contextvm-sdk 0.2.1

Rust SDK for the ContextVM protocol — MCP over Nostr
Documentation
# Oversized Transfer Guide (CEP-22)

Nostr relays cap event sizes (commonly around 64 KiB on the wire, with a
65,535-byte NIP-44 plaintext ceiling for encrypted payloads), while MCP
results — file contents, tool output, resource reads — routinely exceed that.
CEP-22 closes the gap: a JSON-RPC message whose published form would not fit
in a single event is split into an ordered sequence of frames carried inside
MCP `notifications/progress` events, then reassembled and validated
(byte-length + SHA-256) by the receiver before it surfaces as one ordinary
message. Fragmentation is transparent to MCP consumers in both directions:
client→server requests and server→client responses.

## Enabled by default

Oversized transfer is **enabled by default** (matching the TypeScript SDK).
The negotiation gates make this safe with peers that don't support it:

- the **client** fragments only requests that carry a `progressToken` (rmcp
  stamps one into every outgoing request), and advertises
  `support_oversized_transfer` on its first message;
- the **server** fragments responses only for clients that advertised the
  capability, and advertises it on announcements and its first response.

A peer without CEP-22 support just sees one extra discovery tag. To opt out:

```rust
// Whole-config form:
let config = NostrClientTransportConfig::default()
    .with_oversized_enabled(false);
// Same builder exists on NostrServerTransportConfig.
```

## Configuration

`OversizedTransferConfig` is attached to both transport configs via
`with_oversized_transfer(..)` (or the `with_oversized_enabled(..)` shorthand):

| Field                      | Default      | Description                                                                 |
|----------------------------|--------------|-----------------------------------------------------------------------------|
| `enabled`                  | `true`       | Master gate: advertise + activate the capability                            |
| `threshold`                | `48_000`     | Published byte size at/above which the sender fragments                     |
| `chunk_size`               | `48_000`     | Upper bound on per-chunk payload bytes (shrunk automatically so every published frame stays under `threshold`) |
| `max_transfer_bytes`       | `104_857_600` (100 MiB) | Receiver cap on a reassembled payload                            |
| `max_transfer_chunks`      | `10_000`     | Receiver cap on chunk count                                                  |
| `max_concurrent_transfers` | `64`         | Receiver cap on simultaneously active transfers                              |
| `transfer_timeout_ms`      | `300_000`    | Receiver-side hard deadline per transfer, from admission; `0` disables the watchdog |
| `max_out_of_order_window`  | `21`         | How far ahead of the contiguous frontier a chunk may arrive and still be buffered |
| `max_out_of_order_chunks`  | `42`         | Cap on buffered out-of-order chunks                                          |
| `accept_timeout_ms`        | `30_000`     | How long an uploading client waits for the server's `accept` handshake       |

The decision to fragment is made on the **final published event size**
(signed, JSON-escaped, and gift-wrapped when encryption is on), so `threshold`
is a real wire budget, not a payload-length heuristic.

## The three timers

Three independent timers govern a transfer; knowing who owns each one makes
timeout behavior predictable:

1. **Requester idle timeout** (rmcp, per request — opt-in). Fails the call if
   no progress arrives for `idle`. The transports forward every inbound
   transfer frame to the requester as a plain progress notification, so a
   *live* transfer resets this timer chunk by chunk while a *stalled* one
   fails after `idle`.
2. **Requester max-total timeout** (rmcp, per request — opt-in). Hard cap on
   the whole call regardless of progress — a trickling transfer cannot hold a
   request open forever.
3. **Receiver watchdog** (`transfer_timeout_ms`, transport-owned). A hard
   memory bound on inbound reassembly state, measured from `start` admission
   and never refreshed by activity. Reaping is local-only — no abort frame is
   emitted; the requester's own timers fail the other side. A reaped token is
   re-admittable by a fresh `start`.

The first two exist only when you pass request options — **a plain rmcp
`call_tool` has no timeout at all** (infinite await). That is the main
consumer footgun this SDK papers over:

## Recommended MCP client usage

```rust
use std::time::Duration;
use contextvm_sdk::{progress_aware_options, PeerRequestOptionsExt};

let result = running_client
    .peer()
    .call_tool_with_options(
        params,
        progress_aware_options(Duration::from_secs(60), Duration::from_secs(300)),
    )
    .await?;
```

`progress_aware_options(idle, max_total)` builds
`PeerRequestOptions::with_timeout(idle).reset_timeout_on_progress().with_max_total_timeout(max_total)`.
The defaults `DEFAULT_OVERSIZED_IDLE_TIMEOUT` (60 s) and
`DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT` (300 s) intentionally mirror the
transport's numbers (60 s covers the worst-case accept wait; 300 s matches the
receiver watchdog so both sides give up in the same window) — but the peer
layer cannot read transport config, so re-align them manually if you tune
`OversizedTransferConfig`.

Unlike the TypeScript SDK's low-level `client.request()` path, rmcp's timer
reset is not tied to registering an `onprogress` callback — the reset is wired
through request options alone.

For request types beyond `call_tool`, the generic form is two lines on public
rmcp API:

```rust
let handle = peer.send_cancellable_request(request, options).await?;
let response = handle.await_response().await?;
```

### Upload (client→server) caveat

A fragmented *request* receives at most **one** inbound reset: the server's
`accept` handshake frame — and it reaches the rmcp service loop only after the
whole upload send returns. Size `idle` and `max_total` to cover the full
upload duration, not just inter-frame gaps.

## Synthetic progress notifications

Because transfer frames are forwarded to the requester (stripped of their
`cvm` payload, with the request's original `progressToken` restored),
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.