Skip to content

Oversized Transfer

Oversized transfer is the SDK feature that transparently switches a message to the CEP-22: Oversized Payload Transfer flow when a normal relay event would be too large to publish in one piece.

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.

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:

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

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

FieldDefaultDescription
enabledtrueMaster gate: advertise + activate the capability
threshold48_000Published byte size at/above which the sender fragments
chunk_size48_000Upper bound on per-chunk payload bytes (shrunk automatically so every published frame stays under threshold)
max_transfer_bytes104_857_600 (100 MiB)Receiver cap on a reassembled payload
max_transfer_chunks10_000Receiver cap on chunk count
max_concurrent_transfers64Receiver cap on simultaneously active transfers
transfer_timeout_ms300_000Receiver-side hard deadline per transfer, from admission; 0 disables the watchdog
max_out_of_order_window21How far ahead of the contiguous frontier a chunk may arrive and still be buffered
max_out_of_order_chunks42Cap on buffered out-of-order chunks
accept_timeout_ms30_000How 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.

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).

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:

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

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.

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 and CEP-41 (open-stream) 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).


This page was ported from the ContextVM Rust SDK repository.