Skip to main content

contextvm_sdk/transport/client/
mod.rs

1//! Client-side Nostr transport for ContextVM.
2//!
3//! Connects to a remote MCP server over Nostr. Sends JSON-RPC requests as
4//! kind 25910 events, correlates responses via `e` tag.
5
6pub mod correlation_store;
7pub mod relay_resolution;
8pub mod server_identity;
9pub mod server_relay_discovery;
10
11pub use correlation_store::ClientCorrelationStore;
12
13use std::collections::{HashMap, VecDeque};
14use std::num::NonZeroUsize;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use lru::LruCache;
20use nostr_sdk::prelude::*;
21use tokio::sync::oneshot;
22use tokio::sync::Mutex as AsyncMutex;
23use tokio_util::sync::CancellationToken;
24
25use crate::core::constants::*;
26use crate::core::error::Result;
27use crate::core::serializers;
28use crate::core::types::*;
29use crate::core::validation;
30use crate::encryption;
31use crate::relay::{RelayPool, RelayPoolTrait};
32use crate::transport::base::BaseTransport;
33use crate::transport::discovery_tags::{parse_discovered_peer_capabilities, PeerCapabilities};
34use crate::transport::open_stream::{
35    FrameOutcome, KeepaliveAction, OpenStreamConfig, OpenStreamFrame, OpenStreamReceiver,
36    OpenStreamRegistry, OpenStreamSession, OpenStreamSessionInit, PublishFrame,
37};
38use crate::transport::oversized_transfer::{
39    build_oversized_frames, progress_token_string, resolve_safe_chunk_size,
40    send_oversized_transfer, OversizedFrame, OversizedSenderOptions, OversizedTransferConfig,
41    OversizedTransferReceiver, NOTIFICATIONS_PROGRESS_METHOD,
42};
43
44const LOG_TARGET: &str = "contextvm_sdk::transport::client";
45
46/// Configuration for the client transport.
47#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub struct NostrClientTransportConfig {
50    /// Relay URLs to connect to.
51    pub relay_urls: Vec<String>,
52    /// The server's public key (hex, npub, or nprofile).
53    ///
54    /// When an nprofile is provided, embedded relay hints are extracted and used
55    /// during CEP-17 relay resolution.
56    pub server_pubkey: String,
57    /// Encryption mode.
58    pub encryption_mode: EncryptionMode,
59    /// Gift-wrap policy for encrypted messages.
60    pub gift_wrap_mode: GiftWrapMode,
61    /// Stateless mode: emulate initialize response locally.
62    pub is_stateless: bool,
63    /// Correlation-retention TTL for pending client requests (default: 30s).
64    ///
65    /// Stale pending entries older than this are swept from the correlation store.
66    /// This prevents leaks -- rmcp owns actual request timeout and cancellation.
67    /// Keep this value above your rmcp request timeout to avoid premature cleanup.
68    pub timeout: Duration,
69    /// Relay URLs used for CEP-17 relay-list discovery when operational relays are not configured.
70    /// Overrides `DEFAULT_BOOTSTRAP_RELAY_URLS` when provided.
71    pub discovery_relay_urls: Option<Vec<String>>,
72    /// Non-authoritative operational relays probed in parallel with CEP-17 discovery.
73    pub fallback_operational_relay_urls: Option<Vec<String>>,
74    /// CEP-22 oversized payload transfer configuration. Enabled by default.
75    pub oversized_transfer: OversizedTransferConfig,
76    /// CEP-41 open-stream configuration. Disabled by default (opt-in).
77    ///
78    /// When enabled, drives capability advertisement/learning, the inbound reader
79    /// engine, the keepalive sweep, and `call_tool_stream`. Opt in with
80    /// `OpenStreamConfig::enabled()` / `with_enabled(true)`.
81    pub open_stream: OpenStreamConfig,
82}
83
84impl Default for NostrClientTransportConfig {
85    fn default() -> Self {
86        Self {
87            relay_urls: vec![],
88            server_pubkey: String::new(),
89            encryption_mode: EncryptionMode::Optional,
90            gift_wrap_mode: GiftWrapMode::Optional,
91            is_stateless: false,
92            timeout: Duration::from_secs(30),
93            discovery_relay_urls: None,
94            fallback_operational_relay_urls: None,
95            oversized_transfer: OversizedTransferConfig::default(),
96            open_stream: OpenStreamConfig::default(),
97        }
98    }
99}
100
101impl NostrClientTransportConfig {
102    /// Set the server's public key (hex, npub, or nprofile).
103    pub fn with_server_pubkey(mut self, pubkey: impl Into<String>) -> Self {
104        self.server_pubkey = pubkey.into();
105        self
106    }
107    /// Set the encryption mode.
108    pub fn with_encryption_mode(mut self, mode: EncryptionMode) -> Self {
109        self.encryption_mode = mode;
110        self
111    }
112    /// Set the gift-wrap mode (CEP-19).
113    pub fn with_gift_wrap_mode(mut self, mode: GiftWrapMode) -> Self {
114        self.gift_wrap_mode = mode;
115        self
116    }
117    /// Enable or disable stateless mode.
118    pub fn with_stateless(mut self, stateless: bool) -> Self {
119        self.is_stateless = stateless;
120        self
121    }
122    /// Set the relay URLs to connect to.
123    pub fn with_relay_urls(mut self, urls: Vec<String>) -> Self {
124        self.relay_urls = urls;
125        self
126    }
127    /// Set the correlation-retention TTL.
128    pub fn with_timeout(mut self, timeout: Duration) -> Self {
129        self.timeout = timeout;
130        self
131    }
132    /// Set relay URLs for CEP-17 relay-list discovery.
133    pub fn with_discovery_relay_urls(mut self, urls: Vec<String>) -> Self {
134        self.discovery_relay_urls = Some(urls);
135        self
136    }
137    /// Set fallback operational relay URLs probed in parallel with discovery.
138    pub fn with_fallback_operational_relay_urls(mut self, urls: Vec<String>) -> Self {
139        self.fallback_operational_relay_urls = Some(urls);
140        self
141    }
142    /// Set the full CEP-22 oversized payload transfer configuration.
143    pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self {
144        self.oversized_transfer = config;
145        self
146    }
147    /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default.
148    pub fn with_oversized_enabled(mut self, enabled: bool) -> Self {
149        self.oversized_transfer.enabled = enabled;
150        self
151    }
152    /// Set the full CEP-41 open-stream configuration (disabled by default; opt in
153    /// with `OpenStreamConfig::enabled()`).
154    pub fn with_open_stream(mut self, config: OpenStreamConfig) -> Self {
155        self.open_stream = config;
156        self
157    }
158}
159
160/// Client-side Nostr transport for sending MCP requests and receiving responses.
161pub struct NostrClientTransport {
162    base: BaseTransport,
163    config: NostrClientTransportConfig,
164    server_pubkey: PublicKey,
165    /// Populated from nprofile relay hints; used by relay resolution in `start()` (CEP-17).
166    hinted_relay_urls: Vec<String>,
167    /// Discovery relay URLs for CEP-17 kind 10002 lookup.
168    discovery_relay_urls: Vec<String>,
169    /// Fallback operational relay URLs probed in parallel with discovery.
170    fallback_operational_relay_urls: Vec<String>,
171    /// Pending request event IDs awaiting responses.
172    pending_requests: ClientCorrelationStore,
173    /// CEP-35: one-shot flag for client discovery tag emission.
174    has_sent_discovery_tags: AtomicBool,
175    /// CEP-35: learned server capabilities from inbound discovery tags.
176    discovered_server_capabilities: Arc<Mutex<PeerCapabilities>>,
177    /// CEP-35: first inbound event carrying discovery tags (session baseline).
178    server_initialize_event: Arc<Mutex<Option<Event>>>,
179    /// Learned support for server-side ephemeral gift wraps.
180    server_supports_ephemeral: Arc<AtomicBool>,
181    /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`).
182    /// Duplicate outer ids are skipped before decrypt; ids are inserted only after success
183    /// so failed decrypt/verify can be retried on redelivery.
184    seen_gift_wrap_ids: Arc<Mutex<LruCache<EventId, ()>>>,
185    /// CEP-22: reassembly engine for inbound oversized responses from the server
186    /// (single peer). Cleared on [`close`](Self::close).
187    oversized_receiver: Arc<Mutex<OversizedTransferReceiver>>,
188    /// CEP-22: outstanding `accept` handshake waiters keyed by `progressToken`. A
189    /// `send()` awaiting the server's `accept` registers a one-shot here before
190    /// publishing `start`; the event loop fires it when the `accept` frame arrives.
191    accept_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
192    /// CEP-22: original `_meta.progressToken` JSON values of sent
193    /// oversized-eligible requests, keyed by their stringified form. Frames
194    /// stringify tokens on the wire (both SDKs), so the original value —
195    /// `Number` for rmcp-issued tokens — survives only here; progress forwarded
196    /// to the requester must restore it for rmcp's watcher lookup to match
197    /// (`Number(5)` ≠ `String("5")`). LRU-bounded; entries are dropped when
198    /// their transfer concludes and cleared on [`close`](Self::close).
199    original_progress_tokens: Arc<Mutex<LruCache<String, serde_json::Value>>>,
200    /// CEP-41: inbound reader engine for server→client streams (single peer).
201    /// Outbound `call_tool_stream` sessions are created here too (each with a
202    /// publish closure for consumer abort). Disposed on [`close`](Self::close).
203    open_stream_registry: Arc<AsyncMutex<OpenStreamRegistry>>,
204    /// CEP-41: FIFO of `call_tool_stream` placeholders awaiting their SDK-stamped
205    /// progress token. [`send`](Self::send) binds the next one when a `tools/call`
206    /// carrying a token is published (mirrors TS `pendingOutboundOpenStreamResolvers`).
207    #[allow(clippy::type_complexity)]
208    pending_outbound_open_stream:
209        Arc<Mutex<VecDeque<oneshot::Sender<Result<(String, OpenStreamSession)>>>>>,
210    /// CEP-41: monotonic `progress` for client→server control frames (`ping`/`pong`
211    /// from the keepalive sweep / inbound handler). The server does not validate
212    /// these, so one shared counter suffices.
213    open_stream_control_progress: Arc<AtomicU64>,
214    /// CEP-41: serializes the placeholder push→bind window of `call_tool_stream`.
215    /// The placeholder FIFO is matched to a request by *order*, but rmcp stamps the
216    /// token (and the worker publishes) in its own order — so two concurrent
217    /// `call_tool_stream` calls could otherwise bind each other's tokens. Holding
218    /// this across one call's push→bind keeps at most one placeholder unbound.
219    open_stream_bind_lock: Arc<AsyncMutex<()>>,
220    /// Channel for receiving processed MCP messages from the event loop.
221    message_tx: Option<tokio::sync::mpsc::UnboundedSender<JsonRpcMessage>>,
222    message_rx: Option<tokio::sync::mpsc::UnboundedReceiver<JsonRpcMessage>>,
223    /// Token used to cancel the spawned event loop on close().
224    cancellation_token: CancellationToken,
225    /// Handle for the spawned event loop task.
226    event_loop_handle: Option<tokio::task::JoinHandle<()>>,
227}
228
229/// CEP-41: a cheap, shareable handle to a client transport's open-stream state,
230/// passed to [`call_tool_stream`](crate::call_tool_stream).
231///
232/// Obtained via [`NostrClientTransport::open_stream_handle`] before the transport
233/// is moved into an rmcp service. It shares the transport's registry + placeholder
234/// `Arc`s, so the served transport's `send` binds the placeholders this handle
235/// pushes.
236///
237/// Only consumed through `call_tool_stream` (the `rmcp` feature); without it the
238/// handle is dead but harmless.
239#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
240#[derive(Clone)]
241pub struct ClientOpenStreamHandle {
242    registry: Arc<AsyncMutex<OpenStreamRegistry>>,
243    #[allow(clippy::type_complexity)]
244    pending: Arc<Mutex<VecDeque<oneshot::Sender<Result<(String, OpenStreamSession)>>>>>,
245    bind_lock: Arc<AsyncMutex<()>>,
246    config: OpenStreamConfig,
247}
248
249#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
250impl ClientOpenStreamHandle {
251    /// Register a placeholder for the next outbound `call_tool_stream` session
252    /// (resolved by the served transport's `send`).
253    pub(crate) fn prepare_outbound(
254        &self,
255    ) -> oneshot::Receiver<Result<(String, OpenStreamSession)>> {
256        let (tx, rx) = oneshot::channel();
257        let mut pending = match self.pending.lock() {
258            Ok(g) => g,
259            Err(p) => p.into_inner(),
260        };
261        pending.push_back(tx);
262        rx
263    }
264
265    /// Drop the most-recently registered (still-unbound) placeholder.
266    ///
267    /// Called by `call_tool_stream` when its request fails before the transport's
268    /// `send` ever binds it — otherwise the orphaned placeholder would be popped
269    /// (and mis-bound) by the next outbound `tools/call`. The bind lock guarantees
270    /// only this call's placeholder can be unbound, so the back of the FIFO is it.
271    pub(crate) fn cancel_outbound(&self) {
272        let mut pending = match self.pending.lock() {
273            Ok(g) => g,
274            Err(p) => p.into_inner(),
275        };
276        pending.pop_back();
277    }
278
279    /// Clone the reader-registry handle (for the consumer `abort` path).
280    pub(crate) fn registry(&self) -> Arc<AsyncMutex<OpenStreamRegistry>> {
281        self.registry.clone()
282    }
283
284    /// The placeholder push→bind serialization lock (see the transport field).
285    pub(crate) fn bind_lock(&self) -> &Arc<AsyncMutex<()>> {
286        &self.bind_lock
287    }
288
289    /// The open-stream config (for the request-timeout options).
290    pub(crate) fn config(&self) -> &OpenStreamConfig {
291        &self.config
292    }
293}
294
295impl std::fmt::Debug for ClientOpenStreamHandle {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.debug_struct("ClientOpenStreamHandle")
298            .finish_non_exhaustive()
299    }
300}
301
302impl NostrClientTransport {
303    /// Create a new client transport.
304    pub async fn new<T>(signer: T, config: NostrClientTransportConfig) -> Result<Self>
305    where
306        T: IntoNostrSigner,
307    {
308        let (server_pubkey, hinted_relay_urls) =
309            server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| {
310                tracing::error!(
311                    target: LOG_TARGET,
312                    error = %error,
313                    server_pubkey = %config.server_pubkey,
314                    "Invalid server pubkey"
315                );
316                error
317            })?;
318
319        let relay_pool: Arc<dyn RelayPoolTrait> =
320            Arc::new(RelayPool::new(signer).await.map_err(|error| {
321                tracing::error!(
322                    target: LOG_TARGET,
323                    error = %error,
324                    "Failed to initialize relay pool for client transport"
325                );
326                error
327            })?);
328        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
329        let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new(
330            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
331        )));
332
333        tracing::info!(
334            target: LOG_TARGET,
335            relay_count = config.relay_urls.len(),
336            stateless = config.is_stateless,
337            encryption_mode = ?config.encryption_mode,
338            "Created client transport"
339        );
340        let discovery_relay_urls = config.discovery_relay_urls.clone().unwrap_or_else(|| {
341            DEFAULT_BOOTSTRAP_RELAY_URLS
342                .iter()
343                .map(|s| s.to_string())
344                .collect()
345        });
346        let fallback_operational_relay_urls = config
347            .fallback_operational_relay_urls
348            .clone()
349            .unwrap_or_default();
350
351        let oversized_receiver = Arc::new(Mutex::new(OversizedTransferReceiver::with_policy(
352            (&config.oversized_transfer).into(),
353        )));
354        let accept_waiters = Arc::new(Mutex::new(HashMap::new()));
355        let original_progress_tokens = Arc::new(Mutex::new(LruCache::new(
356            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
357        )));
358        let open_stream_registry = Arc::new(AsyncMutex::new(OpenStreamRegistry::with_policy(
359            (&config.open_stream).into(),
360        )));
361
362        Ok(Self {
363            base: BaseTransport {
364                relay_pool,
365                encryption_mode: config.encryption_mode,
366                is_connected: false,
367            },
368            oversized_receiver,
369            accept_waiters,
370            original_progress_tokens,
371            open_stream_registry,
372            pending_outbound_open_stream: Arc::new(Mutex::new(VecDeque::new())),
373            open_stream_control_progress: Arc::new(AtomicU64::new(0)),
374            open_stream_bind_lock: Arc::new(AsyncMutex::new(())),
375            config,
376            server_pubkey,
377            hinted_relay_urls,
378            discovery_relay_urls,
379            fallback_operational_relay_urls,
380            pending_requests: ClientCorrelationStore::new(),
381            has_sent_discovery_tags: AtomicBool::new(false),
382            discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
383            server_initialize_event: Arc::new(Mutex::new(None)),
384            server_supports_ephemeral: Arc::new(AtomicBool::new(false)),
385            seen_gift_wrap_ids,
386            message_tx: Some(tx),
387            message_rx: Some(rx),
388            cancellation_token: CancellationToken::new(),
389            event_loop_handle: None,
390        })
391    }
392
393    /// Like [`new`](Self::new) but accepts an existing relay pool.
394    pub async fn with_relay_pool(
395        config: NostrClientTransportConfig,
396        relay_pool: Arc<dyn RelayPoolTrait>,
397    ) -> Result<Self> {
398        let (server_pubkey, hinted_relay_urls) =
399            server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| {
400                tracing::error!(
401                    target: LOG_TARGET,
402                    error = %error,
403                    server_pubkey = %config.server_pubkey,
404                    "Invalid server pubkey"
405                );
406                error
407            })?;
408
409        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
410        let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new(
411            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
412        )));
413
414        let discovery_relay_urls = config.discovery_relay_urls.clone().unwrap_or_else(|| {
415            DEFAULT_BOOTSTRAP_RELAY_URLS
416                .iter()
417                .map(|s| s.to_string())
418                .collect()
419        });
420        let fallback_operational_relay_urls = config
421            .fallback_operational_relay_urls
422            .clone()
423            .unwrap_or_default();
424
425        tracing::info!(
426            target: LOG_TARGET,
427            relay_count = config.relay_urls.len(),
428            stateless = config.is_stateless,
429            encryption_mode = ?config.encryption_mode,
430            "Created client transport (with_relay_pool)"
431        );
432        let oversized_receiver = Arc::new(Mutex::new(OversizedTransferReceiver::with_policy(
433            (&config.oversized_transfer).into(),
434        )));
435        let accept_waiters = Arc::new(Mutex::new(HashMap::new()));
436        let original_progress_tokens = Arc::new(Mutex::new(LruCache::new(
437            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
438        )));
439        let open_stream_registry = Arc::new(AsyncMutex::new(OpenStreamRegistry::with_policy(
440            (&config.open_stream).into(),
441        )));
442
443        Ok(Self {
444            base: BaseTransport {
445                relay_pool,
446                encryption_mode: config.encryption_mode,
447                is_connected: false,
448            },
449            oversized_receiver,
450            accept_waiters,
451            original_progress_tokens,
452            open_stream_registry,
453            pending_outbound_open_stream: Arc::new(Mutex::new(VecDeque::new())),
454            open_stream_control_progress: Arc::new(AtomicU64::new(0)),
455            open_stream_bind_lock: Arc::new(AsyncMutex::new(())),
456            config,
457            server_pubkey,
458            hinted_relay_urls,
459            discovery_relay_urls,
460            fallback_operational_relay_urls,
461            pending_requests: ClientCorrelationStore::new(),
462            has_sent_discovery_tags: AtomicBool::new(false),
463            discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
464            server_initialize_event: Arc::new(Mutex::new(None)),
465            server_supports_ephemeral: Arc::new(AtomicBool::new(false)),
466            seen_gift_wrap_ids,
467            message_tx: Some(tx),
468            message_rx: Some(rx),
469            cancellation_token: CancellationToken::new(),
470            event_loop_handle: None,
471        })
472    }
473
474    /// Connect and start listening for responses.
475    pub async fn start(&mut self) -> Result<()> {
476        let resolved_urls =
477            relay_resolution::resolve_operational_relays(relay_resolution::RelayResolutionConfig {
478                configured_relay_urls: self.config.relay_urls.clone(),
479                hinted_relay_urls: self.hinted_relay_urls.clone(),
480                discovery_relay_urls: self.discovery_relay_urls.clone(),
481                fallback_operational_relay_urls: self.fallback_operational_relay_urls.clone(),
482                server_pubkey: self.server_pubkey,
483                signer: self.base.relay_pool.signer().await?,
484                timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
485            })
486            .await;
487
488        let connect_urls = if resolved_urls.is_empty() {
489            &self.config.relay_urls
490        } else {
491            &resolved_urls
492        };
493
494        self.base.connect(connect_urls).await.map_err(|error| {
495            tracing::error!(
496                target: LOG_TARGET,
497                error = %error,
498                "Failed to connect client transport to relays"
499            );
500            error
501        })?;
502
503        let pubkey = self.base.get_public_key().await.map_err(|error| {
504            tracing::error!(
505                target: LOG_TARGET,
506                error = %error,
507                "Failed to fetch client transport public key"
508            );
509            error
510        })?;
511        tracing::info!(
512            target: LOG_TARGET,
513            pubkey = %pubkey.to_hex(),
514            "Client transport started"
515        );
516
517        self.base
518            .subscribe_for_pubkey(&pubkey)
519            .await
520            .map_err(|error| {
521                tracing::error!(
522                    target: LOG_TARGET,
523                    error = %error,
524                    pubkey = %pubkey.to_hex(),
525                    "Failed to subscribe client transport for pubkey"
526                );
527                error
528            })?;
529
530        // Spawn event loop with cancellation support
531        let relay_pool = Arc::clone(&self.base.relay_pool);
532        let pending = self.pending_requests.clone();
533        let server_pubkey = self.server_pubkey;
534        let tx = self
535            .message_tx
536            .as_ref()
537            .expect("message_tx must exist before start()")
538            .clone();
539        let encryption_mode = self.config.encryption_mode;
540        let gift_wrap_mode = self.config.gift_wrap_mode;
541        let discovered_caps = self.discovered_server_capabilities.clone();
542        let init_event = self.server_initialize_event.clone();
543        let server_supports_ephemeral = self.server_supports_ephemeral.clone();
544        let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone();
545        let oversized_receiver = self.oversized_receiver.clone();
546        let accept_waiters = self.accept_waiters.clone();
547        let original_progress_tokens = self.original_progress_tokens.clone();
548        let oversized_enabled = self.config.oversized_transfer.enabled;
549        let open_stream_registry = self.open_stream_registry.clone();
550        let open_stream_control_progress = self.open_stream_control_progress.clone();
551        let open_stream_enabled = self.config.open_stream.enabled;
552        let timeout = self.config.timeout;
553        let token = self.cancellation_token.child_token();
554
555        self.event_loop_handle = Some(tokio::spawn(async move {
556            Self::event_loop(
557                relay_pool,
558                pending,
559                server_pubkey,
560                tx,
561                encryption_mode,
562                gift_wrap_mode,
563                discovered_caps,
564                init_event,
565                server_supports_ephemeral,
566                seen_gift_wrap_ids,
567                oversized_receiver,
568                accept_waiters,
569                original_progress_tokens,
570                oversized_enabled,
571                open_stream_registry,
572                open_stream_control_progress,
573                open_stream_enabled,
574                timeout,
575                token,
576            )
577            .await;
578        }));
579
580        tracing::info!(
581            target: LOG_TARGET,
582            relay_count = self.config.relay_urls.len(),
583            "Client transport event loop spawned"
584        );
585        Ok(())
586    }
587
588    /// Close the transport — cancels the event loop and disconnects from relays.
589    pub async fn close(&mut self) -> Result<()> {
590        self.cancellation_token.cancel();
591        if let Some(handle) = self.event_loop_handle.take() {
592            let _ = handle.await;
593        }
594        self.message_tx.take();
595        // CEP-22: release reassembly state and drop any accept waiters so an
596        // in-flight `send()` awaiter unblocks (cancelled) instead of hanging to
597        // its accept timeout.
598        {
599            let mut receiver = match self.oversized_receiver.lock() {
600                Ok(g) => g,
601                Err(p) => p.into_inner(),
602            };
603            receiver.clear();
604        }
605        {
606            let mut waiters = match self.accept_waiters.lock() {
607                Ok(g) => g,
608                Err(p) => p.into_inner(),
609            };
610            waiters.clear();
611        }
612        {
613            let mut originals = match self.original_progress_tokens.lock() {
614                Ok(g) => g,
615                Err(p) => p.into_inner(),
616            };
617            originals.clear();
618        }
619        // CEP-41: dispose reader sessions and drop any unbound `call_tool_stream`
620        // placeholders so their awaiters unblock (cancelled) instead of hanging.
621        self.open_stream_registry.lock().await.clear();
622        {
623            let mut pending = match self.pending_outbound_open_stream.lock() {
624                Ok(g) => g,
625                Err(p) => p.into_inner(),
626            };
627            pending.clear();
628        }
629        self.base.disconnect().await
630    }
631
632    /// Send a JSON-RPC message to the server.
633    pub async fn send(&self, message: &JsonRpcMessage) -> Result<()> {
634        // Stateless mode: emulate initialize response
635        if self.config.is_stateless {
636            if let JsonRpcMessage::Request(ref req) = message {
637                if req.method == "initialize" {
638                    self.emulate_initialize_response(&req.id);
639                    return Ok(());
640                }
641            }
642            if let JsonRpcMessage::Notification(ref n) = message {
643                if n.method == "notifications/initialized" {
644                    return Ok(());
645                }
646            }
647        }
648
649        let is_request = message.is_request();
650
651        // CEP-41: bind a pending `call_tool_stream` placeholder to this request's
652        // SDK-stamped progress token, synchronously before publish (mirrors TS), and
653        // record the token's original JSON type. Inbound stream progress is then
654        // forwarded with that type restored so rmcp's reset-on-progress watcher —
655        // keyed by the numeric token rmcp stamped — actually fires.
656        if is_request && self.config.open_stream.enabled {
657            if let JsonRpcMessage::Request(req) = message {
658                if req.method == "tools/call" {
659                    if let Some(original) = req
660                        .params
661                        .as_ref()
662                        .and_then(|p| p.get("_meta"))
663                        .and_then(|m| m.get("progressToken"))
664                    {
665                        if let Some(token) = progress_token_string(original) {
666                            self.bind_pending_outbound_open_stream(&token, original)
667                                .await;
668                        }
669                    }
670                }
671            }
672        }
673
674        let base_tags = BaseTransport::create_recipient_tags(&self.server_pubkey);
675        let discovery_tags = if is_request {
676            self.get_pending_client_discovery_tags()
677        } else {
678            vec![]
679        };
680        let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]);
681        let gift_wrap_kind = self.choose_outbound_gift_wrap_kind();
682        let discovery_sent = !discovery_tags.is_empty();
683
684        // CEP-22: only a request carrying a `progressToken` is eligible for oversized
685        // fragmentation (the token addresses the frames); extract it once up front.
686        // Tokens may be JSON strings or numbers (rmcp issues numbers): the
687        // stringified form keys all transport state, and the original value is
688        // recorded so progress forwarded to the requester can restore the token's
689        // wire type.
690        let oversized_token: Option<String> =
691            if is_request && self.config.oversized_transfer.enabled {
692                let original = match message {
693                    JsonRpcMessage::Request(req) => req
694                        .params
695                        .as_ref()
696                        .and_then(|p| p.get("_meta"))
697                        .and_then(|m| m.get("progressToken")),
698                    _ => None,
699                };
700                let token = original.and_then(progress_token_string);
701                if let (Some(token), Some(original)) = (token.as_deref(), original) {
702                    self.record_original_progress_token(token, original);
703                }
704                token
705            } else {
706                None
707            };
708
709        // CEP-22: fragment when the message would not fit in a single Nostr event.
710        // Relay size limits apply to the *published* event, so the decision is made
711        // on the published byte size — not the raw payload — which is what actually
712        // grows under JSON escaping and gift-wrap encryption (mirrors TS
713        // `measurePublishedMcpMessageSize`). The raw serialized length is a cheap
714        // lower bound: when it already meets the threshold the message is
715        // conclusively oversized and we fragment without building a single event —
716        // an escape-heavy payload could otherwise overflow NIP-44's plaintext limit
717        // while we measure.
718        if let Some(token) = oversized_token.as_deref() {
719            let content = serde_json::to_string(message)?;
720            let threshold = self.config.oversized_transfer.threshold;
721            if content.len() >= threshold {
722                return self
723                    .send_oversized_request(
724                        message,
725                        &content,
726                        token,
727                        base_tags,
728                        tags,
729                        discovery_sent,
730                    )
731                    .await;
732            }
733            // Borderline: a sub-threshold payload can still cross the threshold once
734            // signed, JSON-escaped, and (when enabled) gift-wrapped. Build the single
735            // event once, measure its real published size, and reuse it if it fits.
736            match self
737                .base
738                .prepare_mcp_message(
739                    message,
740                    &self.server_pubkey,
741                    CTXVM_MESSAGES_KIND,
742                    tags.clone(),
743                    None,
744                    Some(gift_wrap_kind),
745                )
746                .await
747            {
748                Ok((event_id, publishable_event)) => {
749                    let published_len = serde_json::to_string(&publishable_event)
750                        .map(|s| s.len())
751                        .unwrap_or(usize::MAX);
752                    if published_len > threshold {
753                        return self
754                            .send_oversized_request(
755                                message,
756                                &content,
757                                token,
758                                base_tags,
759                                tags,
760                                discovery_sent,
761                            )
762                            .await;
763                    }
764                    return self
765                        .publish_single_event(message, event_id, publishable_event, discovery_sent)
766                        .await;
767                }
768                Err(error) => {
769                    // Could not build even one event (e.g. NIP-44 plaintext overflow
770                    // from an escape-heavy payload) → it cannot be sent as a single
771                    // event; fragment it.
772                    tracing::debug!(
773                        target: LOG_TARGET,
774                        error = %error,
775                        "Single-event build failed; sending as oversized transfer"
776                    );
777                    return self
778                        .send_oversized_request(
779                            message,
780                            &content,
781                            token,
782                            base_tags,
783                            tags,
784                            discovery_sent,
785                        )
786                        .await;
787                }
788            }
789        }
790
791        // Single-event path: not oversized-eligible (notification, feature disabled,
792        // or no progressToken).
793        let (event_id, publishable_event) = self
794            .base
795            .prepare_mcp_message(
796                message,
797                &self.server_pubkey,
798                CTXVM_MESSAGES_KIND,
799                tags,
800                None,
801                Some(gift_wrap_kind),
802            )
803            .await
804            .map_err(|error| {
805                tracing::error!(
806                    target: LOG_TARGET,
807                    error = %error,
808                    server_pubkey = %self.server_pubkey.to_hex(),
809                    method = ?message.method(),
810                    "Failed to prepare client message"
811                );
812                error
813            })?;
814
815        self.publish_single_event(message, event_id, publishable_event, discovery_sent)
816            .await
817    }
818
819    /// Register (for requests) and publish one prepared MCP event, flipping the
820    /// one-shot discovery flag after a successful publish. Shared by the
821    /// non-oversized send paths so the event built for the CEP-22 size check is
822    /// reused for publishing rather than re-encrypted.
823    async fn publish_single_event(
824        &self,
825        message: &JsonRpcMessage,
826        event_id: EventId,
827        publishable_event: Event,
828        discovery_sent: bool,
829    ) -> Result<()> {
830        if let JsonRpcMessage::Request(ref req) = message {
831            let is_initialize = req.method == INITIALIZE_METHOD;
832            self.pending_requests
833                .register(event_id.to_hex(), req.id.clone(), is_initialize)
834                .await;
835        }
836
837        if let Err(error) = self.base.relay_pool.publish_event(&publishable_event).await {
838            self.pending_requests.remove(&event_id.to_hex()).await;
839            tracing::error!(
840                target: LOG_TARGET,
841                error = %error,
842                server_pubkey = %self.server_pubkey.to_hex(),
843                method = ?message.method(),
844                "Failed to publish client message"
845            );
846            return Err(error);
847        }
848
849        // Flip one-shot flag only after successful publish
850        if discovery_sent {
851            self.has_sent_discovery_tags.store(true, Ordering::Relaxed);
852        }
853
854        tracing::debug!(
855            target: LOG_TARGET,
856            event_id = %event_id.to_hex(),
857            method = ?message.method(),
858            "Sent client message"
859        );
860        Ok(())
861    }
862
863    /// CEP-22: publish a request as an ordered oversized-transfer sequence.
864    ///
865    /// Builds `start → chunks… → end` frames, registers an `accept` waiter before
866    /// publishing `start` when the server's support is not yet known, drives the
867    /// [`send_oversized_transfer`] sequencer, and registers the pending request
868    /// against the **end** frame's event id (the value the server correlates its
869    /// response to). One-shot discovery tags ride the `start` frame only.
870    async fn send_oversized_request(
871        &self,
872        message: &JsonRpcMessage,
873        content: &str,
874        token: &str,
875        base_tags: Vec<Tag>,
876        start_tags: Vec<Tag>,
877        discovery_sent: bool,
878    ) -> Result<()> {
879        // The handshake is required until the server is known to support oversized
880        // transfer; once learned, chunks start immediately (no accept slot).
881        let needs_accept = !self
882            .discovered_server_capabilities()
883            .supports_oversized_transfer;
884
885        let gift_wrap_kind = self.choose_outbound_gift_wrap_kind();
886        // Effective encryption for these frames (the publish closure passes `None`,
887        // letting `should_encrypt` decide from the mode — resolve the same boolean
888        // here so the sizing measurement matches the real published frames).
889        let is_encrypted = self.base.should_encrypt(CTXVM_MESSAGES_KIND, None);
890
891        // CEP-22: derive a per-chunk payload budget so every published frame stays
892        // under the threshold even after the JSON-RPC envelope, signature, and
893        // (when encrypted) gift-wrap expansion. Mirrors TS `resolveSafeOversizedChunkSize`.
894        // Continuation (chunk) frames carry the bare recipient `p`-tags (`base_tags`),
895        // so size against those.
896        let chunk_size = resolve_safe_chunk_size(
897            self.config.oversized_transfer.chunk_size,
898            &self.base,
899            &self.server_pubkey,
900            &base_tags,
901            is_encrypted,
902            Kind::Custom(gift_wrap_kind),
903            self.config.oversized_transfer.threshold,
904        )
905        .await?;
906
907        let options = OversizedSenderOptions::new(token)
908            .with_chunk_size(chunk_size)
909            .with_accept_handshake(needs_accept);
910        let frames = build_oversized_frames(content, &options)?;
911
912        // Register the accept-waiter BEFORE publishing `start` so an early `accept`
913        // (decoded on the event-loop task) is never lost.
914        let await_accept = if needs_accept {
915            let (accept_tx, accept_rx) = oneshot::channel();
916            {
917                let mut waiters = match self.accept_waiters.lock() {
918                    Ok(g) => g,
919                    Err(p) => p.into_inner(),
920                };
921                waiters.insert(token.to_string(), accept_tx);
922            }
923            Some(accept_rx)
924        } else {
925            None
926        };
927
928        // Per-frame publish: the start frame carries one-shot discovery tags; the
929        // rest carry bare recipient tags. Mirrors the prepare+publish pair in `send`.
930        let base = &self.base;
931        let server_pubkey = self.server_pubkey;
932        let mut start_tags = Some(start_tags);
933        let publish = move |frame: JsonRpcNotification| {
934            let tags = start_tags.take().unwrap_or_else(|| base_tags.clone());
935            async move {
936                let msg = JsonRpcMessage::Notification(frame);
937                let (event_id, publishable) = base
938                    .prepare_mcp_message(
939                        &msg,
940                        &server_pubkey,
941                        CTXVM_MESSAGES_KIND,
942                        tags,
943                        None,
944                        Some(gift_wrap_kind),
945                    )
946                    .await?;
947                base.relay_pool.publish_event(&publishable).await?;
948                Ok::<EventId, crate::core::error::Error>(event_id)
949            }
950        };
951
952        let accept_timeout =
953            Duration::from_millis(self.config.oversized_transfer.accept_timeout_ms);
954        let result =
955            send_oversized_transfer(frames, needs_accept, await_accept, accept_timeout, publish)
956                .await;
957
958        // Drop the accept-waiter entry regardless of outcome.
959        if needs_accept {
960            let mut waiters = match self.accept_waiters.lock() {
961                Ok(g) => g,
962                Err(p) => p.into_inner(),
963            };
964            waiters.remove(token);
965        }
966
967        let end_id = match result {
968            Ok(id) => id,
969            Err(error) => {
970                tracing::error!(
971                    target: LOG_TARGET,
972                    error = %error,
973                    server_pubkey = %self.server_pubkey.to_hex(),
974                    method = ?message.method(),
975                    "Failed to send oversized client request"
976                );
977                return Err(error);
978            }
979        };
980
981        // Register the pending request against the END frame's event id.
982        if let JsonRpcMessage::Request(ref req) = message {
983            let is_initialize = req.method == INITIALIZE_METHOD;
984            self.pending_requests
985                .register(end_id.to_hex(), req.id.clone(), is_initialize)
986                .await;
987        }
988
989        // Flip the one-shot discovery flag after a successful transfer.
990        if discovery_sent {
991            self.has_sent_discovery_tags.store(true, Ordering::Relaxed);
992        }
993
994        tracing::debug!(
995            target: LOG_TARGET,
996            end_event_id = %end_id.to_hex(),
997            method = ?message.method(),
998            "Sent oversized client request"
999        );
1000        Ok(())
1001    }
1002
1003    /// CEP-22: record the original `_meta.progressToken` value of an
1004    /// outbound request under its stringified form, replacing any stale entry
1005    /// for the same key. See [`Self::original_progress_tokens`].
1006    fn record_original_progress_token(&self, token: &str, original: &serde_json::Value) {
1007        let mut originals = match self.original_progress_tokens.lock() {
1008            Ok(g) => g,
1009            Err(p) => p.into_inner(),
1010        };
1011        originals.push(token.to_string(), original.clone());
1012    }
1013
1014    /// CEP-22: drop — and return — the original `progressToken` value
1015    /// recorded for `token`, once its transfer concludes (delivered or failed).
1016    fn remove_original_progress_token(
1017        originals: &Mutex<LruCache<String, serde_json::Value>>,
1018        token: Option<&str>,
1019    ) -> Option<serde_json::Value> {
1020        let token = token?;
1021        let mut originals = match originals.lock() {
1022            Ok(g) => g,
1023            Err(p) => p.into_inner(),
1024        };
1025        originals.pop(token)
1026    }
1027
1028    /// CEP-22: look up — without removing — the original `progressToken`
1029    /// value recorded for `token`, promoting its LRU recency so an in-flight
1030    /// transfer's record outlives idle ones.
1031    fn original_progress_token(
1032        originals: &Mutex<LruCache<String, serde_json::Value>>,
1033        token: &str,
1034    ) -> Option<serde_json::Value> {
1035        let mut originals = match originals.lock() {
1036            Ok(g) => g,
1037            Err(p) => p.into_inner(),
1038        };
1039        originals.get(token).cloned()
1040    }
1041
1042    /// CEP-22: build the plain `notifications/progress` forwarded to the
1043    /// local consumer for an inbound oversized-transfer frame: `progress` is
1044    /// copied verbatim (plus `total`/`message` when present), the `cvm` frame
1045    /// payload is omitted, and `progressToken` is set to `original_token` —
1046    /// the value recorded at send time, NOT the frame's wire token. The
1047    /// wire stringifies every token, but rmcp's progress-watcher map is keyed
1048    /// by exact JSON type (`Number(5)` ≠ `String("5")`), so only the recorded
1049    /// original resets the requester's idle timer. Returns `None` when the
1050    /// frame has no `progress` (malformed; nothing worth forwarding).
1051    fn stripped_progress_notification(
1052        params: &serde_json::Value,
1053        original_token: &serde_json::Value,
1054    ) -> Option<JsonRpcMessage> {
1055        let mut stripped = serde_json::Map::new();
1056        stripped.insert("progressToken".to_string(), original_token.clone());
1057        stripped.insert("progress".to_string(), params.get("progress")?.clone());
1058        for key in ["total", "message"] {
1059            if let Some(value) = params.get(key) {
1060                stripped.insert(key.to_string(), value.clone());
1061            }
1062        }
1063        Some(JsonRpcMessage::Notification(JsonRpcNotification {
1064            jsonrpc: "2.0".to_string(),
1065            method: NOTIFICATIONS_PROGRESS_METHOD.to_string(),
1066            params: Some(serde_json::Value::Object(stripped)),
1067        }))
1068    }
1069
1070    /// CEP-22: forward one stripped progress notification for
1071    /// the oversized frame `notif` onto the consumer channel, restoring the
1072    /// token recorded at send time. Falls back to the wire token for
1073    /// transfers with no record (e.g. a transfer addressed to a token this
1074    /// transport never sent); rmcp ignores tokens it never issued, so the
1075    /// fallback forward is harmless.
1076    fn forward_stripped_progress(
1077        notif: &JsonRpcNotification,
1078        token: &str,
1079        originals: &Mutex<LruCache<String, serde_json::Value>>,
1080        tx: &tokio::sync::mpsc::UnboundedSender<JsonRpcMessage>,
1081    ) {
1082        let Some(params) = notif.params.as_ref() else {
1083            return;
1084        };
1085        let Some(original) = Self::original_progress_token(originals, token)
1086            .or_else(|| params.get("progressToken").cloned())
1087        else {
1088            return;
1089        };
1090        if let Some(stripped) = Self::stripped_progress_notification(params, &original) {
1091            let _ = tx.send(stripped);
1092        }
1093    }
1094
1095    /// Take the message receiver for consuming incoming messages.
1096    pub fn take_message_receiver(
1097        &mut self,
1098    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<JsonRpcMessage>> {
1099        self.message_rx.take()
1100    }
1101
1102    fn emulate_initialize_response(&self, request_id: &serde_json::Value) {
1103        let response = JsonRpcMessage::Response(JsonRpcResponse {
1104            jsonrpc: "2.0".to_string(),
1105            id: request_id.clone(),
1106            result: serde_json::json!({
1107                "protocolVersion": crate::core::constants::mcp_protocol_version(),
1108                "serverInfo": {
1109                    "name": "Emulated-Stateless-Server",
1110                    "version": "1.0.0"
1111                },
1112                "capabilities": {
1113                    "tools": { "listChanged": true },
1114                    "prompts": { "listChanged": true },
1115                    "resources": { "subscribe": true, "listChanged": true }
1116                }
1117            }),
1118        });
1119        if let Some(ref tx) = self.message_tx {
1120            let _ = tx.send(response);
1121        }
1122    }
1123
1124    #[allow(clippy::too_many_arguments)]
1125    async fn event_loop(
1126        relay_pool: Arc<dyn RelayPoolTrait>,
1127        pending: ClientCorrelationStore,
1128        server_pubkey: PublicKey,
1129        tx: tokio::sync::mpsc::UnboundedSender<JsonRpcMessage>,
1130        encryption_mode: EncryptionMode,
1131        gift_wrap_mode: GiftWrapMode,
1132        discovered_caps: Arc<Mutex<PeerCapabilities>>,
1133        init_event: Arc<Mutex<Option<Event>>>,
1134        server_supports_ephemeral: Arc<AtomicBool>,
1135        seen_gift_wrap_ids: Arc<Mutex<LruCache<EventId, ()>>>,
1136        oversized_receiver: Arc<Mutex<OversizedTransferReceiver>>,
1137        accept_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
1138        original_progress_tokens: Arc<Mutex<LruCache<String, serde_json::Value>>>,
1139        oversized_enabled: bool,
1140        open_stream_registry: Arc<AsyncMutex<OpenStreamRegistry>>,
1141        open_stream_control_progress: Arc<AtomicU64>,
1142        open_stream_enabled: bool,
1143        timeout: Duration,
1144        cancel: CancellationToken,
1145    ) {
1146        let mut notifications = relay_pool.notifications();
1147        // Sweep interval: half the timeout, clamped to [1s, 30s].
1148        let sweep_interval = (timeout / 2).clamp(Duration::from_secs(1), Duration::from_secs(30));
1149        let mut sweep_timer =
1150            tokio::time::interval_at(tokio::time::Instant::now() + sweep_interval, sweep_interval);
1151
1152        loop {
1153            tokio::select! {
1154                _ = cancel.cancelled() => {
1155                    tracing::info!(
1156                        target: LOG_TARGET,
1157                        "Client event loop cancelled"
1158                    );
1159                    break;
1160                }
1161                result = notifications.recv() => {
1162                    let notification = match result {
1163                        Ok(n) => n,
1164                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1165                            tracing::warn!(
1166                                target: LOG_TARGET,
1167                                skipped = n,
1168                                "Relay broadcast lagged, skipping missed events"
1169                            );
1170                            continue;
1171                        }
1172                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1173                    };
1174                    Self::handle_notification(
1175                        &notification,
1176                        &pending,
1177                        server_pubkey,
1178                        &tx,
1179                        encryption_mode,
1180                        gift_wrap_mode,
1181                        &discovered_caps,
1182                        &init_event,
1183                        &server_supports_ephemeral,
1184                        &seen_gift_wrap_ids,
1185                        &oversized_receiver,
1186                        &accept_waiters,
1187                        &original_progress_tokens,
1188                        &open_stream_registry,
1189                        &open_stream_control_progress,
1190                        open_stream_enabled,
1191                        &relay_pool,
1192                    )
1193                    .await;
1194                }
1195                _ = sweep_timer.tick() => {
1196                    let swept = pending.sweep_expired(timeout).await;
1197                    if swept > 0 {
1198                        tracing::warn!(
1199                            target: LOG_TARGET,
1200                            swept,
1201                            timeout_ms = timeout.as_millis() as u64,
1202                            "Swept stale pending requests (rmcp handles timeout errors)"
1203                        );
1204                    }
1205                    // CEP-22: reap inbound transfers past their hard deadline.
1206                    // Local-only (no abort frame is emitted): the requester's
1207                    // own timeout fails the call, and late frames are
1208                    // orphan-ignored. `remove_expired` no-ops when
1209                    // `transfer_timeout_ms` is 0; the sync guard is dropped
1210                    // before anything awaits.
1211                    if oversized_enabled {
1212                        let reaped = {
1213                            let mut receiver = match oversized_receiver.lock() {
1214                                Ok(g) => g,
1215                                Err(p) => p.into_inner(),
1216                            };
1217                            receiver.remove_expired()
1218                        };
1219                        for token in reaped {
1220                            tracing::warn!(
1221                                target: LOG_TARGET,
1222                                token = %token,
1223                                "Oversized transfer reaped by watchdog"
1224                            );
1225                        }
1226                    }
1227                    // CEP-41: drive the open-stream keepalive (idle → ping; probe /
1228                    // close-grace deadline → abort) for every active reader session.
1229                    if open_stream_enabled {
1230                        let gift_wrap_kind = outbound_gift_wrap_kind(
1231                            gift_wrap_mode,
1232                            server_supports_ephemeral.load(Ordering::Relaxed),
1233                        );
1234                        Self::sweep_client_open_stream_sessions(
1235                            &open_stream_registry,
1236                            &open_stream_control_progress,
1237                            &relay_pool,
1238                            server_pubkey,
1239                            encryption_mode,
1240                            gift_wrap_kind,
1241                            Instant::now(),
1242                        )
1243                        .await;
1244                    }
1245                }
1246            }
1247        }
1248    }
1249
1250    // ── CEP-35 discovery tag helpers ──────────────────────────────
1251
1252    /// Constructs client capability tags based on config.
1253    fn get_client_capability_tags(&self) -> Vec<Tag> {
1254        let mut tags = Vec::new();
1255        if self.config.encryption_mode != EncryptionMode::Disabled {
1256            tags.push(Tag::custom(
1257                TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()),
1258                Vec::<String>::new(),
1259            ));
1260            if self.config.gift_wrap_mode != GiftWrapMode::Persistent {
1261                tags.push(Tag::custom(
1262                    TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
1263                    Vec::<String>::new(),
1264                ));
1265            }
1266        }
1267        // CEP-22: advertise oversized-transfer support when enabled.
1268        if self.config.oversized_transfer.enabled {
1269            tags.push(Tag::custom(
1270                TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()),
1271                Vec::<String>::new(),
1272            ));
1273        }
1274        // CEP-41: advertise open-stream support when enabled.
1275        if self.config.open_stream.enabled {
1276            tags.push(Tag::custom(
1277                TagKind::Custom(tags::SUPPORT_OPEN_STREAM.into()),
1278                Vec::<String>::new(),
1279            ));
1280        }
1281        tags
1282    }
1283
1284    /// One-shot: returns capability tags if not yet sent, empty otherwise.
1285    fn get_pending_client_discovery_tags(&self) -> Vec<Tag> {
1286        if self.has_sent_discovery_tags.load(Ordering::Relaxed) {
1287            vec![]
1288        } else {
1289            self.get_client_capability_tags()
1290        }
1291    }
1292
1293    /// Parses inbound event tags and updates learned server capabilities.
1294    fn learn_server_discovery(
1295        discovered_caps: &Mutex<PeerCapabilities>,
1296        init_event: &Mutex<Option<Event>>,
1297        event: &Event,
1298    ) {
1299        let tag_vec: Vec<Tag> = event.tags.clone().to_vec();
1300        let discovered = parse_discovered_peer_capabilities(&tag_vec);
1301        if discovered.discovery_tags.is_empty() {
1302            return;
1303        }
1304
1305        {
1306            let mut caps = match discovered_caps.lock() {
1307                Ok(g) => g,
1308                Err(p) => p.into_inner(),
1309            };
1310            caps.supports_encryption |= discovered.capabilities.supports_encryption;
1311            caps.supports_ephemeral_encryption |=
1312                discovered.capabilities.supports_ephemeral_encryption;
1313            caps.supports_oversized_transfer |= discovered.capabilities.supports_oversized_transfer;
1314            // CEP-41: OR-learn the server's open-stream support (never downgrades).
1315            caps.supports_open_stream |= discovered.capabilities.supports_open_stream;
1316        }
1317
1318        let mut stored = match init_event.lock() {
1319            Ok(g) => g,
1320            Err(p) => p.into_inner(),
1321        };
1322        match stored.as_ref() {
1323            // First discovery-tag-carrying event becomes the session baseline.
1324            None => *stored = Some(event.clone()),
1325            // CEP-35 upgrade (mirrors TS `inbound-coordinator`): if the baseline was
1326            // captured from a non-initialize event (e.g. the first discovery tags
1327            // arrived on a notification) and this event carries a full
1328            // `InitializeResult` (has `protocolVersion`), upgrade the baseline to the
1329            // richer initialize response so `get_server_initialize_event` exposes the
1330            // full server identity/capabilities. Never downgrades.
1331            Some(existing) => {
1332                if !Self::event_has_initialize_result(existing)
1333                    && Self::event_has_initialize_result(event)
1334                {
1335                    *stored = Some(event.clone());
1336                }
1337            }
1338        }
1339    }
1340
1341    /// Returns `true` when the event's `content` parses to a JSON-RPC response
1342    /// whose `result` is a full MCP `InitializeResult` (keyed on `protocolVersion`,
1343    /// matching the TS `InitializeResultSchema` marker).
1344    fn event_has_initialize_result(event: &Event) -> bool {
1345        serde_json::from_str::<serde_json::Value>(&event.content)
1346            .ok()
1347            .as_ref()
1348            .and_then(|content| content.get("result"))
1349            .and_then(|result| result.get("protocolVersion"))
1350            .is_some()
1351    }
1352
1353    /// Returns a clone of the first inbound event that carried server discovery tags.
1354    pub fn get_server_initialize_event(&self) -> Option<Event> {
1355        let guard = match self.server_initialize_event.lock() {
1356            Ok(g) => g,
1357            Err(p) => p.into_inner(),
1358        };
1359        guard.clone()
1360    }
1361
1362    /// Returns a snapshot of the learned server capabilities from discovery tags.
1363    pub fn discovered_server_capabilities(&self) -> PeerCapabilities {
1364        let guard = match self.discovered_server_capabilities.lock() {
1365            Ok(g) => g,
1366            Err(p) => p.into_inner(),
1367        };
1368        *guard
1369    }
1370
1371    // ── CEP-41 open-stream ────────────────────────────────────────
1372
1373    /// CEP-41: register a placeholder for the next outbound `call_tool_stream`
1374    /// session. [`send`](Self::send) binds it to the paired `tools/call`'s
1375    /// SDK-stamped progress token and resolves the returned receiver with
1376    /// `(progress_token, OpenStreamSession)` (or an error on admission failure /
1377    /// transport close).
1378    pub fn prepare_outbound_open_stream_session(
1379        &self,
1380    ) -> oneshot::Receiver<Result<(String, OpenStreamSession)>> {
1381        let (tx, rx) = oneshot::channel();
1382        let mut pending = match self.pending_outbound_open_stream.lock() {
1383            Ok(g) => g,
1384            Err(p) => p.into_inner(),
1385        };
1386        pending.push_back(tx);
1387        rx
1388    }
1389
1390    /// CEP-41: bind the oldest pending placeholder to `token`, creating its reader
1391    /// session. A no-op when no placeholder is waiting (an ordinary `tools/call`).
1392    ///
1393    /// The original token JSON value is recorded **only when a placeholder is
1394    /// actually bound** (i.e. for a `call_tool_stream`), so the inbound handler can
1395    /// restore the numeric type for rmcp's reset-on-progress watcher — an ordinary
1396    /// `tools/call` (no outbound stream) records nothing.
1397    async fn bind_pending_outbound_open_stream(&self, token: &str, original: &serde_json::Value) {
1398        let waiter = {
1399            let mut pending = match self.pending_outbound_open_stream.lock() {
1400                Ok(g) => g,
1401                Err(p) => p.into_inner(),
1402            };
1403            pending.pop_front()
1404        };
1405        if let Some(waiter) = waiter {
1406            self.record_original_progress_token(token, original);
1407            let result = self
1408                .create_outbound_open_stream_session(token)
1409                .await
1410                .map(|session| (token.to_string(), session));
1411            let _ = waiter.send(result);
1412        }
1413    }
1414
1415    /// CEP-41: get-or-create the reader session for `token`, injecting the publish
1416    /// closure used by the consumer `abort` path.
1417    async fn create_outbound_open_stream_session(&self, token: &str) -> Result<OpenStreamSession> {
1418        let mut registry = self.open_stream_registry.lock().await;
1419        if let Some(existing) = registry.get_session(token) {
1420            return Ok(existing);
1421        }
1422        let init = OpenStreamSessionInit {
1423            publish_frame: Some(self.open_stream_publish_closure()),
1424            ..Default::default()
1425        };
1426        Ok(registry.create_session_with(token, init)?)
1427    }
1428
1429    /// CEP-41: the reader session for `token`, if one exists.
1430    pub async fn get_open_stream_session(&self, token: &str) -> Option<OpenStreamSession> {
1431        self.open_stream_registry.lock().await.get_session(token)
1432    }
1433
1434    /// CEP-41: a cheap, shareable handle to this transport's open-stream state for
1435    /// [`call_tool_stream`](crate::call_tool_stream).
1436    ///
1437    /// Obtain it **before** moving the transport into an rmcp service (which
1438    /// consumes it): the handle clones the registry + placeholder `Arc`s, so the
1439    /// served transport's `send` still binds the placeholders the handle pushes.
1440    pub fn open_stream_handle(&self) -> ClientOpenStreamHandle {
1441        ClientOpenStreamHandle {
1442            registry: self.open_stream_registry.clone(),
1443            pending: self.pending_outbound_open_stream.clone(),
1444            bind_lock: self.open_stream_bind_lock.clone(),
1445            config: self.config.open_stream.clone(),
1446        }
1447    }
1448
1449    /// CEP-41: consumer cancel for an outbound stream — publish an `abort` frame to
1450    /// the server (so its writer aborts), finalize the local stream, and free the
1451    /// registry slot. Exposed to consumers via `ToolStreamCall::abort`.
1452    pub async fn abort_open_stream(&self, token: &str, reason: Option<String>) {
1453        let session = { self.open_stream_registry.lock().await.get_session(token) };
1454        if let Some(session) = session {
1455            // Publishes the `abort` frame to the server + finalizes locally.
1456            session.abort(reason.clone()).await;
1457        }
1458        // Free the concurrency slot + run any hook (idempotent re-finalize).
1459        self.open_stream_registry
1460            .lock()
1461            .await
1462            .consumer_abort(token, reason)
1463            .await;
1464    }
1465
1466    /// CEP-41: build the outbound publish closure for an open-stream session —
1467    /// publishes a `notifications/progress` frame to the server (recipient-tagged;
1468    /// the server correlates by `progressToken`, so no `e` tag is needed).
1469    fn open_stream_publish_closure(&self) -> PublishFrame {
1470        let relay_pool = Arc::clone(&self.base.relay_pool);
1471        let encryption_mode = self.config.encryption_mode;
1472        let server_pubkey = self.server_pubkey;
1473        let gift_wrap_kind = self.choose_outbound_gift_wrap_kind();
1474        Arc::new(move |notification: JsonRpcNotification| {
1475            let relay_pool = Arc::clone(&relay_pool);
1476            Box::pin(async move {
1477                let base = BaseTransport {
1478                    relay_pool,
1479                    encryption_mode,
1480                    is_connected: true,
1481                };
1482                let tags = BaseTransport::create_recipient_tags(&server_pubkey);
1483                base.send_mcp_message(
1484                    &JsonRpcMessage::Notification(notification),
1485                    &server_pubkey,
1486                    CTXVM_MESSAGES_KIND,
1487                    tags,
1488                    None,
1489                    Some(gift_wrap_kind),
1490                )
1491                .await
1492            })
1493        })
1494    }
1495
1496    /// CEP-41 inbound interception (beside the oversized branch). Feeds the frame
1497    /// to the reader engine, publishes a `pong` on `SendPong`, forwards a stripped
1498    /// progress with the original token restored (resets rmcp's idle timer),
1499    /// and keeps the request correlation alive via `pending.touch`.
1500    #[allow(clippy::too_many_arguments)]
1501    async fn handle_inbound_open_stream_frame(
1502        open_stream_registry: &Arc<AsyncMutex<OpenStreamRegistry>>,
1503        open_stream_control_progress: &Arc<AtomicU64>,
1504        original_progress_tokens: &Mutex<LruCache<String, serde_json::Value>>,
1505        pending: &ClientCorrelationStore,
1506        relay_pool: &Arc<dyn RelayPoolTrait>,
1507        server_pubkey: PublicKey,
1508        encryption_mode: EncryptionMode,
1509        gift_wrap_kind: u16,
1510        tx: &tokio::sync::mpsc::UnboundedSender<JsonRpcMessage>,
1511        notif: &JsonRpcNotification,
1512        e_tag: Option<&str>,
1513    ) {
1514        let token = notif
1515            .params
1516            .as_ref()
1517            .and_then(|p| p.get("progressToken"))
1518            .and_then(progress_token_string);
1519
1520        // Keep the request correlation alive — chunks don't otherwise refresh it.
1521        if let Some(correlated) = e_tag {
1522            pending.touch(correlated).await;
1523        }
1524
1525        // Feed the reader engine (delivers the chunk to the consumer's stream).
1526        let outcome = {
1527            open_stream_registry
1528                .lock()
1529                .await
1530                .process_frame(Instant::now(), notif)
1531                .await
1532        };
1533
1534        // SendPong → answer the peer's keepalive ping.
1535        if let Ok(FrameOutcome::SendPong(nonce)) = &outcome {
1536            if let Some(token) = token.as_deref() {
1537                let progress = open_stream_control_progress.fetch_add(1, Ordering::SeqCst) + 1;
1538                if let Ok(frame) = (OpenStreamFrame::Pong {
1539                    nonce: nonce.clone(),
1540                })
1541                .into_progress_notification(token, progress, None)
1542                {
1543                    let base = BaseTransport {
1544                        relay_pool: Arc::clone(relay_pool),
1545                        encryption_mode,
1546                        is_connected: true,
1547                    };
1548                    let tags = BaseTransport::create_recipient_tags(&server_pubkey);
1549                    let _ = base
1550                        .send_mcp_message(
1551                            &JsonRpcMessage::Notification(frame),
1552                            &server_pubkey,
1553                            CTXVM_MESSAGES_KIND,
1554                            tags,
1555                            None,
1556                            Some(gift_wrap_kind),
1557                        )
1558                        .await;
1559                }
1560            }
1561        }
1562
1563        // Reset rmcp's idle timer: forward a stripped progress carrying the ORIGINAL
1564        // (numeric) token — the chunk itself already reached the consumer's stream.
1565        // Done before the terminal cleanup so the token is still recorded.
1566        if let Some(token) = token.as_deref() {
1567            Self::forward_stripped_progress(notif, token, original_progress_tokens, tx);
1568        }
1569
1570        // Drop the recorded original token once the stream is terminal.
1571        if matches!(
1572            &outcome,
1573            Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Aborted(_)) | Err(_)
1574        ) {
1575            Self::remove_original_progress_token(original_progress_tokens, token.as_deref());
1576        }
1577    }
1578
1579    /// CEP-41 client keepalive sweep: drive each reader session's pure `tick(now)`;
1580    /// publish a `ping` on `SendPing` (an `Abort` already finalized + removed the
1581    /// session — the consumer sees the terminal error on the stream). `now` is a
1582    /// parameter so tests can drive idle→ping→probe deterministically.
1583    async fn sweep_client_open_stream_sessions(
1584        open_stream_registry: &Arc<AsyncMutex<OpenStreamRegistry>>,
1585        open_stream_control_progress: &Arc<AtomicU64>,
1586        relay_pool: &Arc<dyn RelayPoolTrait>,
1587        server_pubkey: PublicKey,
1588        encryption_mode: EncryptionMode,
1589        gift_wrap_kind: u16,
1590        now: Instant,
1591    ) {
1592        let actions = { open_stream_registry.lock().await.tick_all(now) };
1593        for (token, action) in actions {
1594            if let KeepaliveAction::SendPing(nonce) = action {
1595                let progress = open_stream_control_progress.fetch_add(1, Ordering::SeqCst) + 1;
1596                if let Ok(frame) = (OpenStreamFrame::Ping { nonce })
1597                    .into_progress_notification(&token, progress, None)
1598                {
1599                    let base = BaseTransport {
1600                        relay_pool: Arc::clone(relay_pool),
1601                        encryption_mode,
1602                        is_connected: true,
1603                    };
1604                    let tags = BaseTransport::create_recipient_tags(&server_pubkey);
1605                    let _ = base
1606                        .send_mcp_message(
1607                            &JsonRpcMessage::Notification(frame),
1608                            &server_pubkey,
1609                            CTXVM_MESSAGES_KIND,
1610                            tags,
1611                            None,
1612                            Some(gift_wrap_kind),
1613                        )
1614                        .await;
1615                }
1616            }
1617        }
1618    }
1619
1620    /// CEP-41: run one keepalive sweep at `now`. The event loop drives this on its
1621    /// own timer with `Instant::now()`; it is also exposed so callers (and
1622    /// deterministic tests) can drive idle→ping→probe→abort with an explicit
1623    /// instant — the session clock is `std::time::Instant`, unaffected by
1624    /// `tokio`'s `start_paused`.
1625    pub async fn run_open_stream_keepalive_sweep(&self, now: Instant) {
1626        Self::sweep_client_open_stream_sessions(
1627            &self.open_stream_registry,
1628            &self.open_stream_control_progress,
1629            &self.base.relay_pool,
1630            self.server_pubkey,
1631            self.config.encryption_mode,
1632            self.choose_outbound_gift_wrap_kind(),
1633            now,
1634        )
1635        .await;
1636    }
1637
1638    #[allow(clippy::too_many_arguments)]
1639    async fn handle_notification(
1640        notification: &RelayPoolNotification,
1641        pending: &ClientCorrelationStore,
1642        server_pubkey: PublicKey,
1643        tx: &tokio::sync::mpsc::UnboundedSender<JsonRpcMessage>,
1644        encryption_mode: EncryptionMode,
1645        gift_wrap_mode: GiftWrapMode,
1646        discovered_caps: &Arc<Mutex<PeerCapabilities>>,
1647        init_event: &Arc<Mutex<Option<Event>>>,
1648        server_supports_ephemeral: &Arc<AtomicBool>,
1649        seen_gift_wrap_ids: &Arc<Mutex<LruCache<EventId, ()>>>,
1650        oversized_receiver: &Arc<Mutex<OversizedTransferReceiver>>,
1651        accept_waiters: &Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
1652        original_progress_tokens: &Arc<Mutex<LruCache<String, serde_json::Value>>>,
1653        open_stream_registry: &Arc<AsyncMutex<OpenStreamRegistry>>,
1654        open_stream_control_progress: &Arc<AtomicU64>,
1655        open_stream_enabled: bool,
1656        relay_pool: &Arc<dyn RelayPoolTrait>,
1657    ) {
1658        let event = match notification {
1659            RelayPoolNotification::Event { event, .. } => event,
1660            _ => return,
1661        };
1662
1663        let is_gift_wrap = is_gift_wrap_kind(&event.kind);
1664        let outer_kind = event.kind.as_u16();
1665
1666        // Enforce encryption mode before decrypt/parse.
1667        if violates_encryption_policy(&event.kind, &encryption_mode) {
1668            if is_gift_wrap {
1669                tracing::warn!(
1670                    target: LOG_TARGET,
1671                    event_id = %event.id.to_hex(),
1672                    event_kind = outer_kind,
1673                    configured_mode = ?gift_wrap_mode,
1674                    "Skipping encrypted response because client encryption is disabled"
1675                );
1676            } else {
1677                tracing::warn!(
1678                    target: LOG_TARGET,
1679                    event_id = %event.id.to_hex(),
1680                    "Skipping plaintext response because client encryption is required"
1681                );
1682            }
1683            return;
1684        }
1685
1686        // Enforce CEP-19 gift-wrap-mode policy.
1687        if is_gift_wrap && !gift_wrap_mode.allows_kind(outer_kind) {
1688            tracing::warn!(
1689                target: LOG_TARGET,
1690                event_id = %event.id.to_hex(),
1691                event_kind = outer_kind,
1692                configured_mode = ?gift_wrap_mode,
1693                "Skipping gift wrap due to CEP-19 policy"
1694            );
1695            return;
1696        }
1697
1698        // Handle gift-wrapped events
1699        let (actual_event_content, actual_pubkey, e_tag, verified_tags, source_event) =
1700            if is_gift_wrap {
1701                {
1702                    let guard = match seen_gift_wrap_ids.lock() {
1703                        Ok(g) => g,
1704                        Err(poisoned) => poisoned.into_inner(),
1705                    };
1706                    if guard.contains(&event.id) {
1707                        tracing::debug!(
1708                            target: LOG_TARGET,
1709                            event_id = %event.id.to_hex(),
1710                            "Skipping duplicate gift-wrap (outer id)"
1711                        );
1712                        return;
1713                    }
1714                }
1715                // Single-layer NIP-44 decrypt (matches JS/TS SDK)
1716                let signer = match relay_pool.signer().await {
1717                    Ok(s) => s,
1718                    Err(error) => {
1719                        tracing::error!(
1720                            target: LOG_TARGET,
1721                            error = %error,
1722                            "Failed to get signer"
1723                        );
1724                        return;
1725                    }
1726                };
1727                match encryption::decrypt_gift_wrap_single_layer(&signer, event).await {
1728                    Ok(decrypted_json) => match serde_json::from_str::<Event>(&decrypted_json) {
1729                        Ok(inner) => {
1730                            if let Err(e) = inner.verify() {
1731                                tracing::warn!("Inner event signature verification failed: {e}");
1732                                return;
1733                            }
1734                            {
1735                                let mut guard = match seen_gift_wrap_ids.lock() {
1736                                    Ok(g) => g,
1737                                    Err(poisoned) => poisoned.into_inner(),
1738                                };
1739                                guard.put(event.id, ());
1740                            }
1741                            let e_tag = serializers::get_tag_value(&inner.tags, "e");
1742                            let inner_clone = inner.clone();
1743                            (inner.content, inner.pubkey, e_tag, inner.tags, inner_clone)
1744                        }
1745                        Err(error) => {
1746                            tracing::error!(
1747                                target: LOG_TARGET,
1748                                error = %error,
1749                                "Failed to parse inner event"
1750                            );
1751                            return;
1752                        }
1753                    },
1754                    Err(error) => {
1755                        tracing::error!(
1756                            target: LOG_TARGET,
1757                            error = %error,
1758                            "Failed to decrypt gift wrap"
1759                        );
1760                        return;
1761                    }
1762                }
1763            } else {
1764                let e_tag = serializers::get_tag_value(&event.tags, "e");
1765                let event_clone: Event = (**event).clone();
1766                (
1767                    event.content.clone(),
1768                    event.pubkey,
1769                    e_tag,
1770                    event.tags.clone(),
1771                    event_clone,
1772                )
1773            };
1774
1775        // Verify it's from our server
1776        if actual_pubkey != server_pubkey {
1777            tracing::debug!(
1778                target: LOG_TARGET,
1779                event_pubkey = %actual_pubkey.to_hex(),
1780                expected_pubkey = %server_pubkey.to_hex(),
1781                "Skipping event from unexpected pubkey"
1782            );
1783            return;
1784        }
1785
1786        // CEP-35: learn server capabilities from discovery tags
1787        Self::learn_server_discovery(discovered_caps, init_event, &source_event);
1788
1789        // CEP-19: learn ephemeral support from server
1790        if Self::should_learn_ephemeral_support(
1791            actual_pubkey,
1792            server_pubkey,
1793            if is_gift_wrap { Some(outer_kind) } else { None },
1794            &verified_tags,
1795        ) {
1796            server_supports_ephemeral.store(true, Ordering::Relaxed);
1797        }
1798
1799        // CEP-41: intercept open-stream frames before the correlation gate, beside
1800        // the oversized branch. Type-disjoint (`is_open_stream_frame` vs
1801        // `is_oversized_frame` claim distinct `cvm.type`s). The engine delivers
1802        // chunks to the consumer's `OpenStreamSession`; here we also forward a
1803        // stripped progress to reset rmcp's idle timer and keep correlation alive.
1804        if open_stream_enabled {
1805            if let Ok(notif) = serde_json::from_str::<JsonRpcNotification>(&actual_event_content) {
1806                if notif.method == NOTIFICATIONS_PROGRESS_METHOD
1807                    && OpenStreamReceiver::is_open_stream_frame(&notif)
1808                {
1809                    let gift_wrap_kind = outbound_gift_wrap_kind(
1810                        gift_wrap_mode,
1811                        server_supports_ephemeral.load(Ordering::Relaxed),
1812                    );
1813                    Self::handle_inbound_open_stream_frame(
1814                        open_stream_registry,
1815                        open_stream_control_progress,
1816                        original_progress_tokens,
1817                        pending,
1818                        relay_pool,
1819                        server_pubkey,
1820                        encryption_mode,
1821                        gift_wrap_kind,
1822                        tx,
1823                        &notif,
1824                        e_tag.as_deref(),
1825                    )
1826                    .await;
1827                    return;
1828                }
1829            }
1830        }
1831
1832        // CEP-22: intercept oversized-transfer frames ABOVE the correlation gate
1833        // below. This is mandatory: an `accept` is e-tagged to the start frame
1834        // (not in `pending`), and chunk/end response frames must be reassembled
1835        // rather than delivered raw. Plain `notifications/progress` (no `cvm`) and
1836        // ordinary responses fall through untouched.
1837        if let Ok(notif) = serde_json::from_str::<JsonRpcNotification>(&actual_event_content) {
1838            if notif.method == NOTIFICATIONS_PROGRESS_METHOD
1839                && OversizedTransferReceiver::is_oversized_frame(&notif)
1840            {
1841                // Token extraction accepts string or number — defensive only:
1842                // every known sender stringifies tokens into frames.
1843                let token = notif
1844                    .params
1845                    .as_ref()
1846                    .and_then(|p| p.get("progressToken"))
1847                    .and_then(progress_token_string);
1848
1849                // Route `accept` frames to the waiting sender by progressToken
1850                // (their e-tag is the start-frame id, which is not in `pending`).
1851                let is_accept = notif
1852                    .params
1853                    .as_ref()
1854                    .and_then(|p| p.get("cvm"))
1855                    .and_then(OversizedFrame::from_cvm_value)
1856                    .is_some_and(|f| matches!(f, OversizedFrame::Accept));
1857                if is_accept {
1858                    if let Some(ref token) = token {
1859                        let waiter = {
1860                            let mut waiters = match accept_waiters.lock() {
1861                                Ok(g) => g,
1862                                Err(p) => p.into_inner(),
1863                            };
1864                            waiters.remove(token)
1865                        };
1866                        if let Some(waiter) = waiter {
1867                            let _ = waiter.send(());
1868                            // The accept is the one inbound frame of a
1869                            // client→server upload — forward it (stripped) so
1870                            // the requester's idle timer re-arms for the
1871                            // response-wait phase. Only for a live waiter: a
1872                            // duplicate or stray accept must not poke the timer.
1873                            Self::forward_stripped_progress(
1874                                &notif,
1875                                token,
1876                                original_progress_tokens,
1877                                tx,
1878                            );
1879                        }
1880                    }
1881                    return;
1882                }
1883
1884                // Touch the pending entry so the sweep does not evict the
1885                // request mid-transfer (chunks do not otherwise refresh it).
1886                if let Some(ref correlated_id) = e_tag {
1887                    pending.touch(correlated_id.as_str()).await;
1888                }
1889
1890                // Feed the frame to the reassembler (process_frame is sync; the
1891                // guard is dropped before any await or channel send).
1892                let (outcome, tracked) = {
1893                    let mut receiver = match oversized_receiver.lock() {
1894                        Ok(g) => g,
1895                        Err(p) => p.into_inner(),
1896                    };
1897                    let outcome = receiver.process_frame(&notif);
1898                    // Zombie guard: forward progress only for transfers still
1899                    // tracked after this frame — a late/orphan frame must not
1900                    // keep a dead request's idle timer alive.
1901                    let tracked = token
1902                        .as_deref()
1903                        .is_some_and(|token| receiver.is_tracking(token));
1904                    (outcome, tracked)
1905                };
1906                match outcome {
1907                    // start/chunk consumed — forward a stripped (cvm-less)
1908                    // progress notification carrying the original token so the
1909                    // requester's progress-aware idle timeout resets.
1910                    Ok(None) => {
1911                        if tracked {
1912                            if let Some(ref token) = token {
1913                                Self::forward_stripped_progress(
1914                                    &notif,
1915                                    token,
1916                                    original_progress_tokens,
1917                                    tx,
1918                                );
1919                            }
1920                        }
1921                        return;
1922                    }
1923                    // end frame: deliver the reassembled (already-validated, may
1924                    // exceed 1 MB) message and clear the pending entry. No extra
1925                    // progress forward — the response itself resolves the request.
1926                    Ok(Some(message)) => {
1927                        if let Some(ref correlated_id) = e_tag {
1928                            pending.remove(correlated_id.as_str()).await;
1929                        } else {
1930                            // Matches the TS SDK: an oversized response that
1931                            // reassembles without a correlation `e` tag is still
1932                            // delivered (rmcp matches it by JSON-RPC id), but the
1933                            // missing transport-level correlation is worth a warn.
1934                            tracing::warn!(
1935                                target: LOG_TARGET,
1936                                "Oversized transfer completed without a correlation `e` tag; \
1937                                 delivering the reassembled response uncorrelated"
1938                            );
1939                        }
1940                        Self::remove_original_progress_token(
1941                            original_progress_tokens,
1942                            token.as_deref(),
1943                        );
1944                        let _ = tx.send(message);
1945                        return;
1946                    }
1947                    // Failure: clean up locally, let the request time out.
1948                    Err(error) => {
1949                        tracing::warn!(
1950                            target: LOG_TARGET,
1951                            error = %error,
1952                            "Inbound oversized transfer failed"
1953                        );
1954                        Self::remove_original_progress_token(
1955                            original_progress_tokens,
1956                            token.as_deref(),
1957                        );
1958                        return;
1959                    }
1960                }
1961            }
1962        }
1963
1964        // Correlate response
1965        if let Some(ref correlated_id) = e_tag {
1966            let is_pending = pending.contains(correlated_id.as_str()).await;
1967            if !is_pending {
1968                tracing::warn!(
1969                    target: LOG_TARGET,
1970                    correlated_event_id = %correlated_id,
1971                    "Response for unknown request"
1972                );
1973                return;
1974            }
1975        }
1976
1977        // Parse MCP message
1978        if let Some(mcp_msg) = validation::validate_and_parse(&actual_event_content) {
1979            // Drop uncorrelated responses and server-to-client requests (matches TS SDK).
1980            match &mcp_msg {
1981                JsonRpcMessage::Response(_) | JsonRpcMessage::ErrorResponse(_)
1982                    if e_tag.is_none() =>
1983                {
1984                    tracing::warn!(
1985                        target: LOG_TARGET,
1986                        "Dropping response/error without correlation `e` tag"
1987                    );
1988                    return;
1989                }
1990                JsonRpcMessage::Request(_) => {
1991                    tracing::warn!(
1992                        target: LOG_TARGET,
1993                        method = ?mcp_msg.method(),
1994                        "Dropping server-to-client request (invalid in MCP)"
1995                    );
1996                    return;
1997                }
1998                _ => {}
1999            }
2000
2001            // Clean up pending request
2002            if let Some(ref correlated_id) = e_tag {
2003                pending.remove(correlated_id.as_str()).await;
2004            }
2005            let _ = tx.send(mcp_msg);
2006        }
2007    }
2008
2009    fn choose_outbound_gift_wrap_kind(&self) -> u16 {
2010        outbound_gift_wrap_kind(
2011            self.config.gift_wrap_mode,
2012            self.server_supports_ephemeral.load(Ordering::Relaxed),
2013        )
2014    }
2015
2016    fn has_support_ephemeral_tag(tags: &Tags) -> bool {
2017        tags.iter().any(|tag| {
2018            tag.kind()
2019                == TagKind::Custom(
2020                    crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into(),
2021                )
2022        })
2023    }
2024
2025    fn should_learn_ephemeral_support(
2026        actual_pubkey: PublicKey,
2027        server_pubkey: PublicKey,
2028        event_kind: Option<u16>,
2029        tags: &Tags,
2030    ) -> bool {
2031        actual_pubkey == server_pubkey
2032            && (event_kind == Some(EPHEMERAL_GIFT_WRAP_KIND)
2033                || Self::has_support_ephemeral_tag(tags))
2034    }
2035
2036    /// Returns whether the client has learned ephemeral gift-wrap support from the server.
2037    pub fn server_supports_ephemeral_encryption(&self) -> bool {
2038        self.server_supports_ephemeral.load(Ordering::Relaxed)
2039    }
2040}
2041
2042#[inline]
2043fn is_gift_wrap_kind(kind: &Kind) -> bool {
2044    *kind == Kind::Custom(GIFT_WRAP_KIND) || *kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND)
2045}
2046
2047/// CEP-19: the outbound gift-wrap kind for the client (free fn so the static
2048/// event-loop / sweep contexts can resolve it without `&self`).
2049#[inline]
2050fn outbound_gift_wrap_kind(mode: GiftWrapMode, server_supports_ephemeral: bool) -> u16 {
2051    match mode {
2052        GiftWrapMode::Persistent => GIFT_WRAP_KIND,
2053        GiftWrapMode::Ephemeral => EPHEMERAL_GIFT_WRAP_KIND,
2054        GiftWrapMode::Optional => {
2055            if server_supports_ephemeral {
2056                EPHEMERAL_GIFT_WRAP_KIND
2057            } else {
2058                GIFT_WRAP_KIND
2059            }
2060        }
2061    }
2062}
2063
2064/// Returns `true` when the inbound event kind violates the configured encryption
2065/// policy and must be dropped before any further processing.
2066#[inline]
2067fn violates_encryption_policy(kind: &Kind, mode: &EncryptionMode) -> bool {
2068    let is_gift_wrap = is_gift_wrap_kind(kind);
2069    (is_gift_wrap && *mode == EncryptionMode::Disabled)
2070        || (!is_gift_wrap && *mode == EncryptionMode::Required)
2071}
2072
2073#[cfg(test)]
2074mod tests {
2075    use super::*;
2076
2077    #[test]
2078    fn test_config_defaults() {
2079        let config = NostrClientTransportConfig::default();
2080        assert!(config.relay_urls.is_empty());
2081        assert!(config.server_pubkey.is_empty());
2082        assert_eq!(config.encryption_mode, EncryptionMode::Optional);
2083        assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional);
2084        assert!(!config.is_stateless);
2085        assert_eq!(config.timeout, Duration::from_secs(30));
2086        assert!(config.discovery_relay_urls.is_none());
2087        assert!(config.fallback_operational_relay_urls.is_none());
2088    }
2089
2090    #[test]
2091    fn test_stateless_config() {
2092        let config = NostrClientTransportConfig {
2093            is_stateless: true,
2094            ..Default::default()
2095        };
2096        assert!(config.is_stateless);
2097    }
2098
2099    #[test]
2100    fn test_custom_timeout_config() {
2101        let config = NostrClientTransportConfig {
2102            timeout: Duration::from_secs(60),
2103            ..Default::default()
2104        };
2105        assert_eq!(config.timeout, Duration::from_secs(60));
2106    }
2107
2108    #[test]
2109    fn test_has_support_ephemeral_tag_detects_capability() {
2110        let tags = Tags::from_list(vec![Tag::custom(
2111            TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
2112            Vec::<String>::new(),
2113        )]);
2114        assert!(NostrClientTransport::has_support_ephemeral_tag(&tags));
2115    }
2116
2117    #[test]
2118    fn test_has_support_ephemeral_tag_absent() {
2119        let tags = Tags::from_list(vec![Tag::custom(
2120            TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION.into()),
2121            Vec::<String>::new(),
2122        )]);
2123        assert!(!NostrClientTransport::has_support_ephemeral_tag(&tags));
2124    }
2125
2126    #[test]
2127    fn test_should_learn_ephemeral_support_requires_matching_server_pubkey() {
2128        let server_keys = Keys::generate();
2129        let other_keys = Keys::generate();
2130        let tags = Tags::from_list(vec![Tag::custom(
2131            TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
2132            Vec::<String>::new(),
2133        )]);
2134
2135        assert!(!NostrClientTransport::should_learn_ephemeral_support(
2136            other_keys.public_key(),
2137            server_keys.public_key(),
2138            Some(EPHEMERAL_GIFT_WRAP_KIND),
2139            &tags,
2140        ));
2141        assert!(NostrClientTransport::should_learn_ephemeral_support(
2142            server_keys.public_key(),
2143            server_keys.public_key(),
2144            Some(EPHEMERAL_GIFT_WRAP_KIND),
2145            &tags,
2146        ));
2147    }
2148
2149    #[test]
2150    fn test_should_learn_from_ephemeral_kind_even_without_tag() {
2151        let server_keys = Keys::generate();
2152        let empty_tags = Tags::from_list(vec![]);
2153
2154        assert!(NostrClientTransport::should_learn_ephemeral_support(
2155            server_keys.public_key(),
2156            server_keys.public_key(),
2157            Some(EPHEMERAL_GIFT_WRAP_KIND),
2158            &empty_tags,
2159        ));
2160    }
2161
2162    #[test]
2163    fn test_should_learn_from_tag_without_ephemeral_kind() {
2164        let server_keys = Keys::generate();
2165        let tags = Tags::from_list(vec![Tag::custom(
2166            TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
2167            Vec::<String>::new(),
2168        )]);
2169
2170        assert!(NostrClientTransport::should_learn_ephemeral_support(
2171            server_keys.public_key(),
2172            server_keys.public_key(),
2173            Some(GIFT_WRAP_KIND), // persistent kind, but tag present
2174            &tags,
2175        ));
2176    }
2177
2178    #[test]
2179    fn test_stateless_emulated_initialize_response_shape() {
2180        let request_id = serde_json::json!(1);
2181        let response = JsonRpcMessage::Response(JsonRpcResponse {
2182            jsonrpc: "2.0".to_string(),
2183            id: request_id.clone(),
2184            result: serde_json::json!({
2185                "protocolVersion": crate::core::constants::mcp_protocol_version(),
2186                "serverInfo": {
2187                    "name": "Emulated-Stateless-Server",
2188                    "version": "1.0.0"
2189                },
2190                "capabilities": {
2191                    "tools": { "listChanged": true },
2192                    "prompts": { "listChanged": true },
2193                    "resources": { "subscribe": true, "listChanged": true }
2194                }
2195            }),
2196        });
2197        assert!(response.is_response());
2198        assert_eq!(response.id(), Some(&serde_json::json!(1)));
2199
2200        if let JsonRpcMessage::Response(r) = &response {
2201            assert!(r.result.get("capabilities").is_some());
2202            assert!(r.result.get("serverInfo").is_some());
2203            let server_info = r.result.get("serverInfo").unwrap();
2204            assert_eq!(
2205                server_info.get("name").unwrap().as_str().unwrap(),
2206                "Emulated-Stateless-Server"
2207            );
2208        }
2209    }
2210
2211    #[test]
2212    fn test_stateless_mode_initialize_request_detection() {
2213        let init_req = JsonRpcMessage::Request(JsonRpcRequest {
2214            jsonrpc: "2.0".to_string(),
2215            id: serde_json::json!(1),
2216            method: "initialize".to_string(),
2217            params: None,
2218        });
2219        assert_eq!(init_req.method(), Some("initialize"));
2220
2221        let init_notif = JsonRpcMessage::Notification(JsonRpcNotification {
2222            jsonrpc: "2.0".to_string(),
2223            method: "notifications/initialized".to_string(),
2224            params: None,
2225        });
2226        assert_eq!(init_notif.method(), Some("notifications/initialized"));
2227    }
2228
2229    #[test]
2230    fn test_gift_wrap_kind_detection() {
2231        assert!(is_gift_wrap_kind(&Kind::Custom(GIFT_WRAP_KIND)));
2232        assert!(is_gift_wrap_kind(&Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND)));
2233        assert!(!is_gift_wrap_kind(&Kind::Custom(CTXVM_MESSAGES_KIND)));
2234    }
2235
2236    #[test]
2237    fn test_required_mode_drops_plaintext() {
2238        let plaintext_kind = Kind::Custom(CTXVM_MESSAGES_KIND);
2239        assert!(
2240            violates_encryption_policy(&plaintext_kind, &EncryptionMode::Required),
2241            "Required mode must reject plaintext (non-gift-wrap) events"
2242        );
2243    }
2244
2245    #[test]
2246    fn test_disabled_mode_drops_encrypted() {
2247        assert!(
2248            violates_encryption_policy(&Kind::Custom(GIFT_WRAP_KIND), &EncryptionMode::Disabled),
2249            "Disabled mode must reject gift-wrap events"
2250        );
2251        assert!(
2252            violates_encryption_policy(
2253                &Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND),
2254                &EncryptionMode::Disabled
2255            ),
2256            "Disabled mode must reject ephemeral gift-wrap events"
2257        );
2258    }
2259
2260    #[test]
2261    fn test_optional_mode_accepts_all() {
2262        let plaintext = Kind::Custom(CTXVM_MESSAGES_KIND);
2263        let gift_wrap = Kind::Custom(GIFT_WRAP_KIND);
2264        let ephemeral = Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND);
2265        assert!(!violates_encryption_policy(
2266            &plaintext,
2267            &EncryptionMode::Optional
2268        ));
2269        assert!(!violates_encryption_policy(
2270            &gift_wrap,
2271            &EncryptionMode::Optional
2272        ));
2273        assert!(!violates_encryption_policy(
2274            &ephemeral,
2275            &EncryptionMode::Optional
2276        ));
2277    }
2278
2279    #[test]
2280    fn test_required_mode_accepts_encrypted() {
2281        assert!(
2282            !violates_encryption_policy(&Kind::Custom(GIFT_WRAP_KIND), &EncryptionMode::Required),
2283            "Required mode must accept gift-wrap events"
2284        );
2285        assert!(
2286            !violates_encryption_policy(
2287                &Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND),
2288                &EncryptionMode::Required
2289            ),
2290            "Required mode must accept ephemeral gift-wrap events"
2291        );
2292    }
2293
2294    #[test]
2295    fn test_disabled_mode_accepts_plaintext() {
2296        let plaintext = Kind::Custom(CTXVM_MESSAGES_KIND);
2297        assert!(
2298            !violates_encryption_policy(&plaintext, &EncryptionMode::Disabled),
2299            "Disabled mode must accept plaintext events"
2300        );
2301    }
2302
2303    // ── CEP-35 client discovery tag emission ────────────────────
2304
2305    fn make_transport_for_tags(
2306        encryption_mode: EncryptionMode,
2307        gift_wrap_mode: GiftWrapMode,
2308    ) -> NostrClientTransport {
2309        let keys = Keys::generate();
2310        NostrClientTransport {
2311            base: BaseTransport {
2312                relay_pool: Arc::new(crate::relay::mock::MockRelayPool::new()),
2313                encryption_mode,
2314                is_connected: false,
2315            },
2316            config: NostrClientTransportConfig {
2317                encryption_mode,
2318                gift_wrap_mode,
2319                server_pubkey: Keys::generate().public_key().to_hex(),
2320                ..Default::default()
2321            },
2322            server_pubkey: keys.public_key(),
2323            hinted_relay_urls: vec![],
2324            discovery_relay_urls: vec![],
2325            fallback_operational_relay_urls: vec![],
2326            pending_requests: ClientCorrelationStore::new(),
2327            has_sent_discovery_tags: AtomicBool::new(false),
2328            discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
2329            server_initialize_event: Arc::new(Mutex::new(None)),
2330            server_supports_ephemeral: Arc::new(AtomicBool::new(false)),
2331            seen_gift_wrap_ids: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap()))),
2332            oversized_receiver: Arc::new(Mutex::new(OversizedTransferReceiver::new())),
2333            accept_waiters: Arc::new(Mutex::new(HashMap::new())),
2334            original_progress_tokens: Arc::new(Mutex::new(LruCache::new(
2335                NonZeroUsize::new(10).unwrap(),
2336            ))),
2337            open_stream_registry: Arc::new(AsyncMutex::new(OpenStreamRegistry::new())),
2338            pending_outbound_open_stream: Arc::new(Mutex::new(VecDeque::new())),
2339            open_stream_control_progress: Arc::new(AtomicU64::new(0)),
2340            open_stream_bind_lock: Arc::new(AsyncMutex::new(())),
2341            message_tx: Some(tokio::sync::mpsc::unbounded_channel().0),
2342            message_rx: None,
2343            cancellation_token: CancellationToken::new(),
2344            event_loop_handle: None,
2345        }
2346    }
2347
2348    fn make_tag(parts: &[&str]) -> Tag {
2349        let kind = TagKind::Custom(parts[0].into());
2350        let values: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
2351        Tag::custom(kind, values)
2352    }
2353
2354    fn tag_names(tags: &[Tag]) -> Vec<String> {
2355        tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect()
2356    }
2357
2358    #[test]
2359    fn client_capability_tags_encryption_optional() {
2360        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2361        let tags = t.get_client_capability_tags();
2362        let names = tag_names(&tags);
2363        // The oversized tag (default-on) is pushed last; open-stream is opt-in.
2364        assert_eq!(
2365            names,
2366            vec![
2367                "support_encryption",
2368                "support_encryption_ephemeral",
2369                "support_oversized_transfer"
2370            ]
2371        );
2372    }
2373
2374    #[test]
2375    fn client_capability_tags_encryption_disabled() {
2376        let t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional);
2377        let tags = t.get_client_capability_tags();
2378        // No encryption tags; the default-on oversized tag remains (open-stream is opt-in).
2379        assert_eq!(tag_names(&tags), vec!["support_oversized_transfer"]);
2380    }
2381
2382    #[test]
2383    fn client_capability_tags_persistent_gift_wrap() {
2384        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Persistent);
2385        let tags = t.get_client_capability_tags();
2386        let names = tag_names(&tags);
2387        assert_eq!(
2388            names,
2389            vec!["support_encryption", "support_oversized_transfer"]
2390        );
2391    }
2392
2393    #[test]
2394    fn client_capability_tags_oversized_enabled_by_default() {
2395        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2396        assert!(t.config.oversized_transfer.enabled);
2397        let names = tag_names(&t.get_client_capability_tags());
2398        assert!(
2399            names.contains(&"support_oversized_transfer".to_string()),
2400            "oversized tag must be advertised by default"
2401        );
2402    }
2403
2404    #[test]
2405    fn client_capability_tags_oversized_opt_out() {
2406        // The opt-out gate still works: disabling suppresses the tag.
2407        let mut t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2408        t.config.oversized_transfer = OversizedTransferConfig::default().with_enabled(false);
2409        let names = tag_names(&t.get_client_capability_tags());
2410        assert!(
2411            !names.contains(&"support_oversized_transfer".to_string()),
2412            "oversized tag must not be advertised when disabled"
2413        );
2414    }
2415
2416    #[test]
2417    fn client_capability_tags_oversized_enabled() {
2418        let mut t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2419        t.config.oversized_transfer.enabled = true;
2420        let names = tag_names(&t.get_client_capability_tags());
2421        assert!(
2422            names.contains(&"support_oversized_transfer".to_string()),
2423            "oversized tag must be advertised when enabled"
2424        );
2425    }
2426
2427    #[test]
2428    fn client_capability_tags_oversized_enabled_without_encryption() {
2429        // Tag is emitted independently of the encryption capability tags.
2430        let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional);
2431        t.config.oversized_transfer.enabled = true;
2432        let names = tag_names(&t.get_client_capability_tags());
2433        assert_eq!(names, vec!["support_oversized_transfer"]);
2434    }
2435
2436    #[test]
2437    fn client_capability_tags_open_stream_gate() {
2438        // Open-stream is opt-in: absent by default.
2439        let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional);
2440        assert!(
2441            !tag_names(&t.get_client_capability_tags())
2442                .contains(&"support_open_stream".to_string()),
2443            "open-stream tag must be absent by default (opt-in)"
2444        );
2445        // Enabling it advertises the single-element tag.
2446        t.config.open_stream = OpenStreamConfig::enabled();
2447        assert!(
2448            tag_names(&t.get_client_capability_tags()).contains(&"support_open_stream".to_string()),
2449            "open-stream tag must be advertised when enabled"
2450        );
2451    }
2452
2453    #[test]
2454    fn client_learn_server_discovery_learns_open_stream() {
2455        let caps = Mutex::new(PeerCapabilities::default());
2456        let init = Mutex::new(None);
2457        let event = make_event_with_tags(&[&["support_open_stream"]]);
2458        NostrClientTransport::learn_server_discovery(&caps, &init, &event);
2459        assert!(caps.lock().unwrap().supports_open_stream);
2460    }
2461
2462    #[test]
2463    fn client_config_oversized_builders() {
2464        let cfg = NostrClientTransportConfig::default().with_oversized_enabled(true);
2465        assert!(cfg.oversized_transfer.enabled);
2466        let cfg = NostrClientTransportConfig::default()
2467            .with_oversized_transfer(OversizedTransferConfig::enabled().with_chunk_size(1024));
2468        assert!(cfg.oversized_transfer.enabled);
2469        assert_eq!(cfg.oversized_transfer.chunk_size, 1024);
2470    }
2471
2472    // ── CEP-22 original progressToken record/restore ─────────────
2473
2474    #[test]
2475    fn original_progress_token_roundtrip_preserves_numeric_type() {
2476        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2477        // rmcp stamps numeric tokens; the record keys them by stringified form.
2478        t.record_original_progress_token("7", &serde_json::json!(7));
2479        let restored = NostrClientTransport::remove_original_progress_token(
2480            &t.original_progress_tokens,
2481            Some("7"),
2482        );
2483        assert_eq!(restored, Some(serde_json::json!(7)));
2484        // Dropped on first take — the transfer concluded.
2485        assert_eq!(
2486            NostrClientTransport::remove_original_progress_token(
2487                &t.original_progress_tokens,
2488                Some("7"),
2489            ),
2490            None
2491        );
2492    }
2493
2494    #[test]
2495    fn original_progress_token_string_never_parsed_to_number() {
2496        // A legitimate String("5") token must restore as a string — restoring
2497        // by parsing numeric-looking wire strings would corrupt it.
2498        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2499        t.record_original_progress_token("5", &serde_json::json!("5"));
2500        assert_eq!(
2501            NostrClientTransport::remove_original_progress_token(
2502                &t.original_progress_tokens,
2503                Some("5"),
2504            ),
2505            Some(serde_json::json!("5"))
2506        );
2507    }
2508
2509    #[test]
2510    fn remove_original_progress_token_handles_missing() {
2511        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2512        assert_eq!(
2513            NostrClientTransport::remove_original_progress_token(&t.original_progress_tokens, None,),
2514            None
2515        );
2516        assert_eq!(
2517            NostrClientTransport::remove_original_progress_token(
2518                &t.original_progress_tokens,
2519                Some("unknown"),
2520            ),
2521            None
2522        );
2523    }
2524
2525    /// `send()` must record the original token value for every
2526    /// oversized-eligible request — including sub-threshold ones, whose
2527    /// *responses* may still come back fragmented.
2528    #[tokio::test]
2529    async fn send_records_numeric_progress_token_original() {
2530        let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional);
2531        t.config.oversized_transfer.enabled = true;
2532        let request = JsonRpcMessage::Request(JsonRpcRequest {
2533            jsonrpc: "2.0".to_string(),
2534            id: serde_json::json!(1),
2535            method: "tools/call".to_string(),
2536            params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })),
2537        });
2538        t.send(&request).await.expect("send small request");
2539
2540        let recorded = NostrClientTransport::remove_original_progress_token(
2541            &t.original_progress_tokens,
2542            Some("7"),
2543        );
2544        assert_eq!(
2545            recorded,
2546            Some(serde_json::json!(7)),
2547            "numeric token must be recorded under its stringified form"
2548        );
2549    }
2550
2551    /// With oversized transfer disabled (explicit opt-out) nothing is recorded.
2552    #[tokio::test]
2553    async fn send_records_nothing_when_oversized_disabled() {
2554        let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional);
2555        t.config.oversized_transfer = OversizedTransferConfig::default().with_enabled(false);
2556        let request = JsonRpcMessage::Request(JsonRpcRequest {
2557            jsonrpc: "2.0".to_string(),
2558            id: serde_json::json!(1),
2559            method: "tools/call".to_string(),
2560            params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })),
2561        });
2562        t.send(&request).await.expect("send small request");
2563
2564        assert_eq!(
2565            NostrClientTransport::remove_original_progress_token(
2566                &t.original_progress_tokens,
2567                Some("7"),
2568            ),
2569            None
2570        );
2571    }
2572
2573    // ── CEP-22 stripped progress construction ────────────────────
2574
2575    #[test]
2576    fn stripped_progress_notification_strips_cvm_and_restores_token() {
2577        let params = serde_json::json!({
2578            "progressToken": "7",
2579            "progress": 3,
2580            "total": 5,
2581            "message": "transferring",
2582            "cvm": { "type": "oversized-transfer", "frameType": "chunk", "data": "x" },
2583        });
2584        let stripped =
2585            NostrClientTransport::stripped_progress_notification(&params, &serde_json::json!(7))
2586                .expect("frame carries progress");
2587        let JsonRpcMessage::Notification(n) = stripped else {
2588            panic!("expected a notification");
2589        };
2590        assert_eq!(n.method, NOTIFICATIONS_PROGRESS_METHOD);
2591        let p = n.params.expect("params");
2592        assert_eq!(
2593            p["progressToken"],
2594            serde_json::json!(7),
2595            "token must be the restored original, not the wire string"
2596        );
2597        assert_eq!(p["progress"], serde_json::json!(3));
2598        assert_eq!(p["total"], serde_json::json!(5));
2599        assert_eq!(p["message"], serde_json::json!("transferring"));
2600        assert!(p.get("cvm").is_none(), "cvm payload must be stripped");
2601    }
2602
2603    #[test]
2604    fn stripped_progress_notification_requires_progress_and_omits_absent_fields() {
2605        // No `progress` → nothing worth forwarding.
2606        let malformed = serde_json::json!({ "progressToken": "7", "cvm": {} });
2607        assert!(NostrClientTransport::stripped_progress_notification(
2608            &malformed,
2609            &serde_json::json!(7)
2610        )
2611        .is_none());
2612
2613        // Absent total/message are omitted, not nulled.
2614        let minimal = serde_json::json!({ "progressToken": "7", "progress": 1 });
2615        let stripped =
2616            NostrClientTransport::stripped_progress_notification(&minimal, &serde_json::json!("7"))
2617                .expect("progress present");
2618        let JsonRpcMessage::Notification(n) = stripped else {
2619            panic!("expected a notification");
2620        };
2621        let p = n.params.expect("params");
2622        let keys = p.as_object().expect("object params");
2623        assert_eq!(keys.len(), 2, "only progressToken + progress: {p}");
2624        assert_eq!(p["progressToken"], serde_json::json!("7"));
2625    }
2626
2627    #[test]
2628    fn client_discovery_tags_sent_once() {
2629        let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional);
2630        let first = t.get_pending_client_discovery_tags();
2631        assert!(!first.is_empty());
2632
2633        t.has_sent_discovery_tags.store(true, Ordering::Relaxed);
2634        let second = t.get_pending_client_discovery_tags();
2635        assert!(second.is_empty());
2636    }
2637
2638    // ── CEP-35 client capability learning ───────────────────────
2639
2640    fn make_event_with_tags(tag_parts: &[&[&str]]) -> Event {
2641        make_event_with_content_and_tags("{}", tag_parts)
2642    }
2643
2644    fn make_event_with_content_and_tags(content: &str, tag_parts: &[&[&str]]) -> Event {
2645        let keys = Keys::generate();
2646        let tags: Vec<Tag> = tag_parts.iter().map(|p| make_tag(p)).collect();
2647        let builder = EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), content).tags(tags);
2648        let unsigned = builder.build(keys.public_key());
2649        unsigned.sign_with_keys(&keys).unwrap()
2650    }
2651
2652    /// A JSON-RPC response carrying a full `InitializeResult` (has `protocolVersion`).
2653    fn initialize_result_content() -> String {
2654        serde_json::json!({
2655            "jsonrpc": "2.0",
2656            "id": 1,
2657            "result": {
2658                "protocolVersion": "2025-06-18",
2659                "capabilities": {},
2660                "serverInfo": { "name": "UpgradedServer", "version": "1.0.0" }
2661            }
2662        })
2663        .to_string()
2664    }
2665
2666    #[test]
2667    fn client_learn_server_discovery_sets_baseline() {
2668        let caps = Mutex::new(PeerCapabilities::default());
2669        let init = Mutex::new(None);
2670        let event = make_event_with_tags(&[&["support_encryption"], &["name", "TestServer"]]);
2671
2672        NostrClientTransport::learn_server_discovery(&caps, &init, &event);
2673
2674        let c = caps.lock().unwrap();
2675        assert!(c.supports_encryption);
2676        assert!(!c.supports_ephemeral_encryption);
2677
2678        let stored = init.lock().unwrap();
2679        assert!(stored.is_some());
2680        assert_eq!(stored.as_ref().unwrap().id, event.id);
2681    }
2682
2683    #[test]
2684    fn client_learn_server_discovery_or_assigns() {
2685        let caps = Mutex::new(PeerCapabilities::default());
2686        let init = Mutex::new(None);
2687
2688        let event1 = make_event_with_tags(&[&["support_encryption"]]);
2689        NostrClientTransport::learn_server_discovery(&caps, &init, &event1);
2690
2691        // Second event with different caps does NOT downgrade
2692        let event2 = make_event_with_tags(&[&["support_encryption_ephemeral"]]);
2693        NostrClientTransport::learn_server_discovery(&caps, &init, &event2);
2694
2695        let c = caps.lock().unwrap();
2696        assert!(c.supports_encryption, "must not downgrade");
2697        assert!(c.supports_ephemeral_encryption, "must learn new cap");
2698    }
2699
2700    #[test]
2701    fn client_baseline_not_replaced_on_later_events() {
2702        let caps = Mutex::new(PeerCapabilities::default());
2703        let init = Mutex::new(None);
2704
2705        let event1 = make_event_with_tags(&[&["support_encryption"], &["name", "First"]]);
2706        NostrClientTransport::learn_server_discovery(&caps, &init, &event1);
2707        let first_id = event1.id;
2708
2709        let event2 =
2710            make_event_with_tags(&[&["support_encryption_ephemeral"], &["name", "Second"]]);
2711        NostrClientTransport::learn_server_discovery(&caps, &init, &event2);
2712
2713        let stored = init.lock().unwrap();
2714        assert_eq!(
2715            stored.as_ref().unwrap().id,
2716            first_id,
2717            "baseline must not be replaced"
2718        );
2719    }
2720
2721    #[test]
2722    fn client_baseline_upgraded_to_initialize_result() {
2723        let caps = Mutex::new(PeerCapabilities::default());
2724        let init = Mutex::new(None);
2725
2726        // First discovery tags arrive on a non-initialize event (e.g. a notification).
2727        let baseline = make_event_with_tags(&[&["support_encryption"]]);
2728        NostrClientTransport::learn_server_discovery(&caps, &init, &baseline);
2729        assert_eq!(init.lock().unwrap().as_ref().unwrap().id, baseline.id);
2730
2731        // A later event carries a full InitializeResult → baseline is upgraded.
2732        let init_event = make_event_with_content_and_tags(
2733            &initialize_result_content(),
2734            &[&["support_encryption"]],
2735        );
2736        NostrClientTransport::learn_server_discovery(&caps, &init, &init_event);
2737        assert_eq!(
2738            init.lock().unwrap().as_ref().unwrap().id,
2739            init_event.id,
2740            "baseline must upgrade to the initialize-result event"
2741        );
2742
2743        // A still-later non-initialize event must NOT downgrade the baseline.
2744        let later = make_event_with_tags(&[&["support_encryption_ephemeral"]]);
2745        NostrClientTransport::learn_server_discovery(&caps, &init, &later);
2746        assert_eq!(
2747            init.lock().unwrap().as_ref().unwrap().id,
2748            init_event.id,
2749            "baseline must not downgrade away from the initialize result"
2750        );
2751    }
2752}