diff --git a/CHANGELOG.md b/CHANGELOG.md index e1144d1..e01b8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,30 @@ # Changelog -## [Unreleased] +## [0.2.0] - 2026-06-24 + +### Added + +- CEP-22: oversized payload transfer for chunking MCP messages that exceed the NIP-44 single-event size limit (~65 KB), using a transport-agnostic framing engine (start/accept/chunk/end/abort frames, SHA-256 digest verification, and out-of-order reassembly), enabled by default and negotiated through the `support_oversized_transfer` capability tag so servers only fragment to clients that advertise support (#88, #89, #91) +- CEP-22: progress-aware request timeouts and an in-flight transfer watchdog, providing per-chunk idle-timeout reset, a max-total transfer cap, and receiver-side reaping of stalled transfers, opt-in via `call_tool_with_options` and `progress_aware_options` (#92) +- CEP-17: multi-stage relay resolution with server identity parsing, relay list (NIP-65) fetching, and `fetch_events`, plus transport integration that resolves a server's preferred relays before connecting (#82, #83) +- CEP-6: expanded server announcements with full `InitializeResult` parsing in `ServerAnnouncement`, auto-publishing on `start()`, relay list publishing, and a tool and resource schema mapping table (#77, #78, #79, #81) +- CEP-23: optional server profile metadata published as a NIP-01 kind 0 event, via a new `ProfileMetadata` type, so clients see a human-friendly identity (#77, #79) +- CEP-41: open-ended streaming - a server tool emits ordered chunks back to a + client while a request is in flight via `call_tool_stream`; the client + consumes them as an async `Stream`; the stream supplements the final + JSON-RPC response rather than replacing it, negotiated through the + `support_open_stream` capability tag (#97, #98) +- CI: MSRV and feature-matrix checks (#75) +- `examples/python/`: runnable Python examples using the UniFFI binding — an + offline install sanity check, server/tool discovery (mirrors `discovery.rs`), + and a client `tools/list` caller (mirrors `proxy.rs`). ### Changed +- Upgraded `rmcp` from 0.16.0 to 1.8 to gain progress-aware request timeouts (#86) +- Raised the minimum supported Rust version (MSRV) from 1.70 to 1.88 +- Added `sha2` and `hex` dependencies for CEP-22 payload digests +- Enabled the `missing_docs` lint, closed rustdoc coverage gaps, and added SDK documentation links and a CEP-22 oversized-transfer guide (#67, #73) - Bumped `nostr-sdk` from `0.43` to `0.44` (pulls core `nostr` `0.44.3`). No source changes were required: the breaking removals in the unreleased 0.45 line (`NostrSigner`, `TagKind`, `EventBuilder::sign_with_keys`, `TagStandard`) @@ -16,11 +37,10 @@ `.github/workflows/ffi.yml` to install `uniffi-bindgen-cli` at tag `v0.31.2` and invoke it as `uniffi-bindgen-cli` (renamed from `uniffi-bindgen` in 0.30). -### Added +### Fixed -- `examples/python/`: runnable Python examples using the UniFFI binding — an - offline install sanity check, server/tool discovery (mirrors `discovery.rs`), - and a client `tools/list` caller (mirrors `proxy.rs`). +- `MockRelayPool` live broadcast now respects per-subscription filters instead of echoing every event to every subscriber (#90) +- Made the oversized-transfer e2e timing tests deterministic with virtual paused time and the relay config hermetic, removing CI flakiness and a 30 s real-network discovery hang (#93, #94) ## [0.1.1] - 2026-05-08 diff --git a/Cargo.toml b/Cargo.toml index 1ad8ecc..e90dfd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "contextvm-sdk" -version = "0.1.1" +version = "0.2.0" edition = "2021" rust-version = "1.88" description = "Rust SDK for the ContextVM protocol — MCP over Nostr" diff --git a/README.md b/README.md index a5fa746..e82b6ac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # contextvm-sdk [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/rust-1.88%2B-orange.svg)](https://www.rust-lang.org) Rust SDK for the [ContextVM protocol](https://contextvm.org) — **MCP over Nostr**. @@ -58,11 +58,11 @@ Add to your `Cargo.toml`: contextvm-sdk = { git = "https://github.com/ContextVM/rs-sdk" } ``` -Or clone and use as a path dependency: +Or pin a published release from crates.io: ```toml [dependencies] -contextvm-sdk = { path = "../rust-contextvm-sdk" } +contextvm-sdk = "0.2.0" ``` ## Quick Start @@ -168,13 +168,17 @@ async fn main() -> contextvm_sdk::Result<()> { The in-repo Rust SDK guides live in [`docs/README.md`](docs/README.md): -- For most users, the main pattern is: build an `rmcp` server or client, then attach [`NostrServerTransport`](src/transport/server/mod.rs:87) or [`NostrClientTransport`](src/transport/client/mod.rs:69). +- For most users, the main pattern is: build an `rmcp` server or client, then attach [`NostrServerTransport`](src/transport/server/mod.rs) or [`NostrClientTransport`](src/transport/client/mod.rs). - [`docs/overview.md`](docs/overview.md) - [`docs/server-transport.md`](docs/server-transport.md) - [`docs/client-transport.md`](docs/client-transport.md) +- [`docs/open-stream.md`](docs/open-stream.md) - [`docs/discovery.md`](docs/discovery.md) - [`docs/encryption.md`](docs/encryption.md) +- [`docs/transport-modes.md`](docs/transport-modes.md) +- [`docs/stateless.md`](docs/stateless.md) +- [`docs/oversized-transfer.md`](docs/oversized-transfer.md) - [`docs/rmcp.md`](docs/rmcp.md) - [`docs/transports.md`](docs/transports.md) - [`docs/gateway.md`](docs/gateway.md) @@ -212,12 +216,21 @@ it adds no event kind — frames ride inside `notifications/progress` messages). See [docs/oversized-transfer.md](docs/oversized-transfer.md) for the timeout model and tuning. +Open-ended streaming (CEP-41) lets a server tool emit an ordered sequence of +chunks back to the client while a request is in flight. The client consumes them +as an async `Stream` via `call_tool_stream`. Unlike CEP-22, the stream supplements +the final JSON-RPC response rather than replacing it. Disabled by default; opt in +with `with_open_stream(OpenStreamConfig::enabled())`. See +[docs/open-stream.md](docs/open-stream.md) for the writer and client APIs and the +keepalive timer model. + ### Server Transport Config | Field | Default | Description | |--------------------------|-----------------------|------------------------------------------| | `relay_urls` | `["wss://relay.damus.io"]` | Nostr relays to connect to | | `encryption_mode` | `Optional` | Encryption policy | +| `gift_wrap_mode` | `Optional` | Gift-wrap policy (CEP-19): persistent (1059) vs ephemeral (21059) | | `server_info` | `None` | Server metadata for announcements | | `is_announced_server` | `false` | Auto-publish announcements on start (CEP-6) | | `allowed_public_keys` | `[]` (allow all) | Client pubkey allowlist (hex) | @@ -228,6 +241,7 @@ model and tuning. | `publish_relay_list` | `true` | Whether to publish kind 10002 relay list metadata | | `profile_metadata` | `None` | Profile metadata for kind 0 publication (CEP-23) | | `oversized_transfer` | enabled | CEP-22 oversized payload transfer config ([guide](docs/oversized-transfer.md)) | +| `open_stream` | disabled | CEP-41 open-stream config; opt-in ([guide](docs/open-stream.md)) | ### Client Transport Config @@ -236,11 +250,13 @@ model and tuning. | `relay_urls` | `[]` | Nostr relays to connect to (empty = use relay resolution) | | `server_pubkey` | (required) | Target server's public key (hex, npub, or nprofile) | | `encryption_mode` | `Optional` | Encryption policy | +| `gift_wrap_mode` | `Optional` | Gift-wrap policy (CEP-19): persistent (1059) vs ephemeral (21059) | | `is_stateless` | `false` | Emulate initialize locally | | `timeout` | `30s` | Response timeout | | `discovery_relay_urls` | `None` (bootstrap relays) | Relays for CEP-17 kind 10002 discovery | | `fallback_operational_relay_urls` | `None` | Relays probed in parallel with CEP-17 discovery | | `oversized_transfer` | enabled | CEP-22 oversized payload transfer config ([guide](docs/oversized-transfer.md)) | +| `open_stream` | disabled | CEP-41 open-stream config; opt-in ([guide](docs/open-stream.md)) | When `relay_urls` is empty, `start()` runs automatic relay resolution: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > bootstrap defaults. diff --git a/docs/README.md b/docs/README.md index 6f4439d..92c39a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ For most native Rust applications, the primary entry points are `NostrServerTran - Stateless mode guide: client-side initialize emulation and when to use it - Discovery guide: public discovery helpers and event kinds - Oversized transfer guide: CEP-22 fragmentation, the three-timer model, and progress-aware request options +- Open-stream guide: CEP-41 streaming responses, the writer and `call_tool_stream` APIs, and the keepalive timer model ### Bridging existing MCP applications diff --git a/docs/client-transport.md b/docs/client-transport.md index 4fa01bd..2ff1b86 100644 --- a/docs/client-transport.md +++ b/docs/client-transport.md @@ -99,15 +99,14 @@ async fn main() -> anyhow::Result<()> { } let result = client - .call_tool(CallToolRequestParams { - name: "echo".into(), - arguments: serde_json::from_value(serde_json::json!({ - "message": "hello from native contextvm client" - })) - .ok(), - meta: None, - task: None, - }) + .call_tool( + CallToolRequestParams::new("echo").with_arguments( + serde_json::from_value(serde_json::json!({ + "message": "hello from native contextvm client" + })) + .unwrap(), + ), + ) .await?; println!("Echo result: {}", first_text(&result)); @@ -151,11 +150,23 @@ Start with these fields in `NostrClientTransportConfig`: - `server_pubkey`: the target server's public key (hex, npub, or nprofile with relay hints) - `encryption_mode`: whether plaintext is allowed - `gift_wrap_mode`: whether to use persistent or ephemeral wrapping +- `open_stream`: CEP-41 open-stream settings; disabled by default, opt in with `with_open_stream(OpenStreamConfig::enabled())` - `is_stateless`: whether initialize is emulated locally for stateless workflows - `timeout`: how long request correlation waits for a response - `discovery_relay_urls`: bootstrap relays for CEP-17 kind 10002 relay-list discovery (defaults to `DEFAULT_BOOTSTRAP_RELAY_URLS`) - `fallback_operational_relay_urls`: non-authoritative relays probed in parallel with CEP-17 discovery +## Open-ended streaming (CEP-41) + +For tools that stream output while a call is in flight, use `call_tool_stream` +with a `ClientOpenStreamHandle`. Capture the handle from the transport with +`transport.open_stream_handle()` before `serve()` consumes the transport, then +call `call_tool_stream(peer, &handle, params)` to receive a `ToolStreamCall` +whose `stream` yields chunks and whose `result` resolves to the final +`CallToolResult`. Open-stream is disabled by default; enable it with +`with_open_stream(OpenStreamConfig::enabled())`. See +[open-stream.md](open-stream.md). + ## When to use this instead of the proxy Use this page's approach when you are writing a new Rust MCP client that should speak ContextVM natively. @@ -168,4 +179,4 @@ Use the proxy guide when you want a simpler message-oriented bridge and do not w - The initialize request is sent automatically as part of the running client startup sequence. - Stateless initialization behavior is covered by the conformance tests. - Capability learning and gift-wrap handling happen inside the client transport implementation. -- When `relay_urls` is empty, `start()` runs 6-stage relay resolution before connecting: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > bootstrap defaults. Callers can set `server_pubkey` to an nprofile and omit `relay_urls` entirely. +- When `relay_urls` is empty, `start()` runs 6-stage relay resolution before connecting: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > sequential fallback > bootstrap defaults. Callers can set `server_pubkey` to an nprofile and omit `relay_urls` entirely. diff --git a/docs/open-stream.md b/docs/open-stream.md new file mode 100644 index 0000000..74efeba --- /dev/null +++ b/docs/open-stream.md @@ -0,0 +1,201 @@ +# Open-Stream Guide (CEP-41) + +Some MCP tools do not produce a single result. They produce output as they work: +log lines, partial results, incremental progress the caller wants to see right +away rather than after the tool finishes. CEP-41 open-ended streaming carries +that case. A server tool emits an ordered, unbounded sequence of `chunk` +fragments back to the client while a `tools/call` request is still in flight, and +the client consumes them incrementally as an async `Stream`. The stream +supplements the call rather than replacing it: one `tools/call` produces two +outputs, a live stream of chunks and the normal final `CallToolResult` that still +concludes the request. + +Frames ride inside MCP `notifications/progress` notifications on the existing +ContextVM message kind (25910), discriminated by `params.cvm.type == "open-stream"`. +The stream id is the request `progressToken`, so chunks correlate to the call +that produced them. This is transparent to peers that do not support CEP-41: they +see one extra discovery tag and nothing else. + +## CEP-22 and CEP-41 are different profiles + +CEP-22 (oversized transfer) and CEP-41 (open stream) share the same +`notifications/progress` envelope and the same kind 25910, but they are not +interchangeable. CEP-22 is bounded reassembly of a single oversized message: a +message too large for one relay event is split into ordered frames, reassembled +by the receiver, validated by byte length and SHA-256, and surfaced as one +ordinary message that replaces the final response. CEP-41 is an unbounded live +stream consumed incrementally, and it supplements the final response instead of +replacing it. Each profile carries its own `cvm.type` discriminant +(`oversized-transfer` versus `open-stream`), so a peer routes each frame to the +right engine. Use CEP-22 when you have one large result to deliver atomically; +use CEP-41 when you have a progression of outputs to deliver as they happen. See +[oversized-transfer.md](oversized-transfer.md) for CEP-22. + +## Enabling open-stream + +Open-stream is disabled by default on both transports (opt-in, matching the +TypeScript SDK). Turn it on with `with_open_stream` on either transport config: + +```rust +use contextvm_sdk::transport::open_stream::OpenStreamConfig; + +let server_config = NostrServerTransportConfig::default() + .with_open_stream(OpenStreamConfig::enabled()); + +let client_config = NostrClientTransportConfig::default() + .with_open_stream(OpenStreamConfig::enabled()); +``` + +`OpenStreamConfig` lives in `contextvm_sdk::transport::open_stream`, not at the +crate root. `OpenStreamConfig::enabled()` is the same as +`OpenStreamConfig::default().with_enabled(true)`: enabled with every other knob at +its default. Once enabled the capability is safe for non-CEP-41 peers, because the +server activates a stream only for clients that advertised support, and injects a +writer only when a request carries a `progressToken`. + +## Server side: emitting a stream from a tool + +When open-stream is enabled and an incoming `tools/call` carries a +`progressToken`, the transport constructs an `OpenStreamWriter` for that request +and inserts it into the rmcp request extensions before the handler runs. A tool +handler that wants to stream retrieves the writer from `ctx.extensions`: + +```rust +use contextvm_sdk::transport::open_stream::OpenStreamWriter; +use rmcp::service::RequestContext; +use rmcp::RoleServer; + +#[tool(description = "Stream three chunks then complete")] +async fn stream_demo( + &self, + Parameters(_params): Parameters, + ctx: RequestContext, +) -> Result { + if let Some(writer) = ctx.extensions.get::().cloned() { + let _ = writer.write("first".to_string()).await; + let _ = writer.write("second".to_string()).await; + let _ = writer.write("third".to_string()).await; + let _ = writer.close().await; + } + Ok(CallToolResult::success(vec![Content::text("done")])) +} +``` + +`OpenStreamWriter` lives in `contextvm_sdk::transport::open_stream`. The writer is +`Clone` and `Arc`-backed, so it can be moved into spawned tasks. Calls to `write` +are serialized internally, so call order equals wire order. The `start` frame is +published lazily on the first `write`, or explicitly with `writer.start().await`. +Always finish the stream: call `writer.close().await` for a normal end, or +`writer.abort(reason).await` to terminate early. After the stream closes, the +handler returns its `CallToolResult` as usual and the transport delivers that +final response. + +Retrieving the writer is optional. If `ctx.extensions.get::()` +returns `None`, the request did not carry a `progressToken` or open-stream is not +active for this client; the handler should still return a normal result. + +## Client side: consuming a stream + +The client needs a `ClientOpenStreamHandle`, which binds an inbound stream to the +call that produced it. Capture it from the transport before `serve()` consumes the +transport: + +```rust +use contextvm_sdk::{call_tool_stream, ClientOpenStreamHandle}; +use futures::StreamExt; +use rmcp::model::CallToolRequestParams; + +// Capture the handle BEFORE the transport is moved into `serve`. +let handle: ClientOpenStreamHandle = client_transport.open_stream_handle(); +let client = MyClientHandler.serve(client_transport).await?; + +let mut call = call_tool_stream( + client.peer(), + &handle, + CallToolRequestParams::new("stream_demo"), +) +.await?; + +// Consume chunks as they arrive. +while let Some(item) = call.stream.next().await { + match item { + Ok(chunk) => println!("chunk: {chunk}"), + Err(error) => { + eprintln!("stream error: {error}"); + break; + } + } +} + +// The final result resolves after the stream closes. +let result = call.result.await?; +println!("final: {result:?}"); +``` + +`call_tool_stream` returns a `ToolStreamCall` with four parts: `progress_token` +(the stringified token that correlates the call and its stream), `stream` (an +async `Stream` of `Result` chunks), `result` (a future +resolving to the final `CallToolResult` after the stream closes), and an `abort` +method that cancels the call. `call_tool_stream` and `ClientOpenStreamHandle` are +re-exported at the crate root; the chunk error type `OpenStreamError` lives in +`contextvm_sdk::transport::open_stream`. + +To cancel from the consumer side, call +`call.abort(Some("reason".to_string())).await`. That publishes an `abort` frame to +the server so its writer stops, finalizes the local stream, and frees the reader +slot. + +## The timeout model + +A reader protects itself against a stream that goes silent with three timers, all +configurable on `OpenStreamConfig`. The idle timeout (`idle_timeout_ms`, default +30000) is how long the reader waits without any frame before it probes the peer +with a `ping`. Every inbound frame resets it, so a live stream never trips it. The +probe timeout (`probe_timeout_ms`, default 20000) is how long the reader then +waits for a `pong` before it gives up and aborts the stream. The close grace +period (`close_grace_period_ms`, default 5000) applies after a `close` arrives +with buffered gaps still unresolved: the reader waits this long for the missing +chunks before aborting. + +There is no hard lifetime cap by default; an open stream may legitimately run for +a long time. Set `max_total_timeout_ms` to `Some(ms)` if you want one. +`call_tool_stream` also derives the rmcp request timeout from these values, +summing idle, probe, and close-grace so the rmcp request is never failed before +the keepalive logic would have aborted a genuinely dead stream, and it re-arms +that timeout on every forwarded frame. + +## Known limitation: keep the final response small + +The final `CallToolResult` of a streamed call is delivered on a deferred path that +publishes it as a single relay event. That path does not apply CEP-22 +fragmentation. A normal, non-streamed response at or above the oversized threshold +(48000 bytes by default) is split into frames and reassembled, but the deferred +final response of a streamed call is not, so it must fit within a single relay +event (the same single-event ceiling described in the oversized transfer guide, +roughly 64 KiB on the wire). Keep the final result small and let the bulk of the +payload ride the stream as chunks. The streaming tools in +`tests/open_stream_e2e.rs` follow this pattern: they stream the data and return +only a short completion string. + +## Configuration reference + +`OpenStreamConfig` is attached to both transport configs via +`with_open_stream(..)`. All fields: + +| Field | Default | Description | +|----------------------------------|----------------------|-----------------------------------------------------------------------------| +| `enabled` | `false` | Master gate. When `false` the capability is neither advertised nor activated | +| `max_concurrent_streams` | `64` | Upper bound on concurrently active streams per peer | +| `max_buffered_chunks_per_stream` | `64` | Upper bound on buffered plus queued chunks held for a single stream | +| `max_buffered_bytes_per_stream` | `524288` (512 KiB) | Upper bound on buffered plus queued payload bytes held for a single stream | +| `idle_timeout_ms` | `30000` | Idle interval after which a reader probes the peer with a `ping` | +| `probe_timeout_ms` | `20000` | Time a reader waits for a `pong` after probing before aborting | +| `close_grace_period_ms` | `5000` | Grace period after a `close` with unresolved gaps before aborting | +| `max_total_timeout_ms` | `None` | Optional hard cap on total stream lifetime; read only by `call_tool_stream` | + +Each field has a `with_*` builder (`with_enabled`, +`with_max_concurrent_streams`, `with_max_buffered_chunks_per_stream`, +`with_max_buffered_bytes_per_stream`, `with_idle_timeout_ms`, +`with_probe_timeout_ms`, `with_close_grace_period_ms`, +`with_max_total_timeout_ms`), so you can start from `OpenStreamConfig::enabled()` +and override individual knobs. diff --git a/docs/oversized-transfer.md b/docs/oversized-transfer.md index 9a35362..5b65bce 100644 --- a/docs/oversized-transfer.md +++ b/docs/oversized-transfer.md @@ -126,3 +126,15 @@ consumers with a custom progress handler observe chunk-granular progress for oversized responses — usable as transfer-progress UX. Default rmcp handlers ignore progress for tokens they didn't register, so no action is needed if you don't want it. Nothing extra goes on the wire; the forwarding is in-process. + +## CEP-22 versus CEP-41 + +CEP-22 and CEP-41 are distinct transfer profiles that share the same +`notifications/progress` envelope and the same kind 25910. CEP-22 is for bounded +reassembly of a single oversized message: it replaces the final JSON-RPC response +with the reassembled payload, validated by byte length and SHA-256. CEP-41 +(open-stream) is for unbounded live streaming: it supplements the final response +with an ordered sequence of chunks consumed incrementally, and the final response +still concludes the call. The two profiles are not interchangeable; each carries +its own `cvm.type` discriminant (`oversized-transfer` versus `open-stream`). See +[open-stream.md](open-stream.md) for CEP-41. diff --git a/docs/overview.md b/docs/overview.md index add9879..d3480e0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -50,16 +50,23 @@ ContextVM keeps MCP semantics intact and uses Nostr only as the transport envelo - MCP payloads are represented by `JsonRpcMessage` - direct plaintext ContextVM traffic uses kind `25910` -- encrypted traffic uses gift-wrap kinds `1059` or `21059` -- public discovery uses kinds `11316` through `11320` +- encrypted traffic uses gift-wrap kinds `1059` (persistent) or `21059` (ephemeral, CEP-19), negotiated by `GiftWrapMode` +- public server discovery uses announcement kinds `11316` through `11320` (CEP-6) +- server relay lists are published as NIP-65 kind `10002` events (CEP-17) +- optional server profile metadata is published as a NIP-01 kind `0` event (CEP-23) +- oversized transfers (CEP-22) and open streams (CEP-41) both ride inside `notifications/progress` frames on kind `25910`, separated by a `cvm.type` discriminant (`oversized-transfer` and `open-stream`) - routing is done with `p` tags and request/response correlation with `e` tags, as reflected in the repository root README ## Core types you should know - `EncryptionMode`: `Optional`, `Required`, `Disabled` -- `GiftWrapMode`: `Optional`, `Ephemeral`, `Persistent` +- `GiftWrapMode`: `Optional`, `Ephemeral`, `Persistent` (CEP-19 gift-wrap policy: persistent kind `1059` vs ephemeral kind `21059`) - `contextvm_sdk::ServerInfo`: announcement metadata +- `contextvm_sdk::ServerAnnouncement`: the discovered-server record returned by `discover_servers()` (CEP-6) +- `contextvm_sdk::ProfileMetadata`: optional NIP-01 kind `0` profile metadata for a human-friendly server identity (CEP-23) - `CapabilityExclusion`: allowlist bypass rules for specific methods or capabilities +- `OpenStreamConfig`: CEP-41 open-stream settings (disabled by default; see the open-stream guide) +- `ToolStreamCall`: the paired live chunk stream and final result returned by `call_tool_stream` ## Typical workflows diff --git a/docs/server-transport.md b/docs/server-transport.md index 147ae52..c54ed9a 100644 --- a/docs/server-transport.md +++ b/docs/server-transport.md @@ -38,7 +38,7 @@ use contextvm_sdk::transport::server::{ use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode, ServerInfo}; use rmcp::{ ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + handler::server::wrapper::Parameters, model::*, schemars, tool, tool_handler, tool_router, }; @@ -46,15 +46,11 @@ use rmcp::{ const RELAY_URL: &str = "wss://relay.contextvm.org"; #[derive(Clone)] -struct DemoServer { - tool_router: ToolRouter, -} +struct DemoServer {} impl DemoServer { fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } + Self {} } } @@ -79,19 +75,13 @@ impl DemoServer { #[tool_handler] impl ServerHandler for DemoServer { fn get_info(&self) -> rmcp::model::ServerInfo { - rmcp::model::ServerInfo { - protocol_version: ProtocolVersion::LATEST, - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation { - name: "contextvm-native-echo".to_string(), - title: Some("ContextVM Native Echo Server".to_string()), - version: "0.1.0".to_string(), - description: Some("Native rmcp echo server over ContextVM/Nostr".to_string()), - icons: None, - website_url: None, - }, - instructions: Some("Call the echo tool with a message string".to_string()), - } + rmcp::model::ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info( + Implementation::new("contextvm-native-echo", "0.1.0") + .with_title("ContextVM Native Echo Server") + .with_description("Native rmcp echo server over ContextVM/Nostr"), + ) + .with_instructions("Call the echo tool with a message string") } } @@ -156,6 +146,7 @@ Start with these fields in `NostrServerTransportConfig`: - `is_announced_server`: whether the server should participate in public discovery - `encryption_mode`: plaintext vs encrypted policy - `gift_wrap_mode`: persistent vs ephemeral wrapping policy +- `open_stream`: CEP-41 open-stream settings; disabled by default, opt in with `with_open_stream(OpenStreamConfig::enabled())` - `allowed_public_keys`: allowlist for private or restricted servers - `excluded_capabilities`: allow specific methods without fully opening the server - `relay_list_urls`: relay URLs advertised in kind 10002 (CEP-17); defaults to `relay_urls` @@ -163,6 +154,18 @@ Start with these fields in `NostrServerTransportConfig`: - `publish_relay_list`: whether to publish kind 10002 relay list metadata; default `true` - `profile_metadata`: optional profile metadata for kind 0 publication (CEP-23) +## Streaming responses with open-stream (CEP-41) + +When open-stream is enabled and a `tools/call` request carries a `progressToken`, +the transport injects an `OpenStreamWriter` into the request extensions before +dispatch. Tool handlers retrieve it with +`ctx.extensions.get::()` (imported from +`contextvm_sdk::transport::open_stream`), write chunks with `writer.write(..)`, +and finish with `writer.close()`. The final `CallToolResult` is returned normally +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. + ## When to use this instead of the gateway Use this page's approach when you are writing a new Rust MCP server. @@ -175,4 +178,5 @@ Use the gateway guide when you already have a request loop or existing local MCP - `rmcp` accepts pre-init ping and enters the main loop immediately after initialization completes. - ContextVM response routing depends on request event ids. - Encryption mirroring and announcement behavior are covered by the integration tests. -- When `is_announced_server` is `true`, the transport auto-publishes all announcement events on `start()` via synthetic MCP requests: kind 11316 (server announcement), kinds 11317-11320 (tools, resources, templates, prompts), kind 10002 (relay list), and kind 0 (profile metadata if configured). +- Announcement publishing is started by the rmcp worker just after `start()` (not by `transport.start()` itself, because it injects synthetic MCP requests that need an rmcp handler to answer). When `is_announced_server` is `true`, the transport publishes the gated announcement events via those synthetic requests: kind 11316 (server announcement) and kinds 11317-11320 (tools, resources, templates, prompts). +- Independently of `is_announced_server`, it also publishes kind 10002 (relay list, when `publish_relay_list` is true (the default) and the advertised relay URLs are non-empty) and kind 0 (profile metadata, when `profile_metadata` is configured).