Skip to main content

contextvm_sdk/gateway/
mod.rs

1//! ContextVM Gateway — bridge a local MCP server to Nostr.
2//!
3//! The gateway receives MCP requests via Nostr and forwards them to a local
4//! MCP server, then publishes responses back to Nostr.
5
6use crate::core::error::{Error, Result};
7use crate::core::types::JsonRpcMessage;
8use crate::transport::server::{IncomingRequest, NostrServerTransport, NostrServerTransportConfig};
9
10/// Configuration for the gateway.
11#[non_exhaustive]
12pub struct GatewayConfig {
13    /// Nostr server transport configuration.
14    pub nostr_config: NostrServerTransportConfig,
15}
16
17impl GatewayConfig {
18    /// Create a new gateway configuration.
19    pub fn new(nostr_config: NostrServerTransportConfig) -> Self {
20        Self { nostr_config }
21    }
22}
23
24/// Gateway that bridges a local MCP server to Nostr.
25///
26/// The gateway listens for incoming MCP requests via Nostr, forwards them
27/// to a local MCP handler function, and sends responses back over Nostr.
28pub struct NostrMCPGateway {
29    transport: NostrServerTransport,
30    is_running: bool,
31}
32
33impl NostrMCPGateway {
34    /// Create a new gateway.
35    pub async fn new<T>(signer: T, config: GatewayConfig) -> Result<Self>
36    where
37        T: nostr_sdk::prelude::IntoNostrSigner,
38    {
39        let transport = NostrServerTransport::new(signer, config.nostr_config).await?;
40
41        Ok(Self {
42            transport,
43            is_running: false,
44        })
45    }
46
47    /// Start the gateway. Returns a receiver for incoming requests.
48    ///
49    /// The caller is responsible for processing requests and calling
50    /// `send_response` for each one.
51    pub async fn start(&mut self) -> Result<tokio::sync::mpsc::UnboundedReceiver<IncomingRequest>> {
52        if self.is_running {
53            return Err(Error::Other("Gateway already running".to_string()));
54        }
55
56        self.transport.start().await?;
57        self.is_running = true;
58
59        self.transport
60            .take_message_receiver()
61            .ok_or_else(|| Error::Other("Message receiver already taken".to_string()))
62    }
63
64    /// Send a response back to the client for a given request.
65    pub async fn send_response(&self, event_id: &str, response: JsonRpcMessage) -> Result<()> {
66        self.transport.send_response(event_id, response).await
67    }
68
69    /// Publish server announcement.
70    pub async fn announce(&self) -> Result<nostr_sdk::EventId> {
71        self.transport.announce().await
72    }
73
74    /// Stop the gateway.
75    pub async fn stop(&mut self) -> Result<()> {
76        if !self.is_running {
77            return Ok(());
78        }
79        self.transport.close().await?;
80        self.is_running = false;
81        Ok(())
82    }
83
84    /// Check if the gateway is active.
85    pub fn is_active(&self) -> bool {
86        self.is_running
87    }
88}
89
90#[cfg(feature = "rmcp")]
91impl NostrMCPGateway {
92    /// Start a gateway directly from an rmcp server handler.
93    ///
94    /// This additive API keeps the existing `new/start/send_response` flow intact,
95    /// while also allowing direct `handler.serve(transport)` style usage.
96    pub async fn serve_handler<T, H>(
97        signer: T,
98        config: GatewayConfig,
99        handler: H,
100    ) -> Result<rmcp::service::RunningService<rmcp::RoleServer, H>>
101    where
102        T: nostr_sdk::prelude::IntoNostrSigner,
103        H: rmcp::ServerHandler,
104    {
105        use crate::NostrServerTransport;
106        use rmcp::ServiceExt;
107
108        let transport = NostrServerTransport::new(signer, config.nostr_config).await?;
109        handler
110            .serve(transport)
111            .await
112            .map_err(|e| Error::Other(format!("rmcp server initialization failed: {e}")))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::core::types::*;
120    use crate::transport::server::NostrServerTransportConfig;
121    use std::time::Duration;
122
123    #[test]
124    fn test_gateway_config_construction() {
125        let nostr_config = NostrServerTransportConfig {
126            relay_urls: vec!["wss://relay.example.com".to_string()],
127            encryption_mode: EncryptionMode::Required,
128            gift_wrap_mode: GiftWrapMode::Optional,
129            server_info: Some(ServerInfo {
130                name: Some("Test Gateway".to_string()),
131                version: Some("1.0.0".to_string()),
132                ..Default::default()
133            }),
134            is_announced_server: true,
135            allowed_public_keys: vec!["abc123".to_string()],
136            excluded_capabilities: vec![],
137            max_sessions: 1000,
138            cleanup_interval: Duration::from_secs(120),
139            session_timeout: Duration::from_secs(600),
140            request_timeout: Duration::from_secs(60),
141            log_file_path: None,
142        };
143
144        let config = GatewayConfig { nostr_config };
145
146        assert_eq!(
147            config.nostr_config.relay_urls,
148            vec!["wss://relay.example.com"]
149        );
150        assert_eq!(
151            config.nostr_config.encryption_mode,
152            EncryptionMode::Required
153        );
154        assert!(config.nostr_config.is_announced_server);
155        assert_eq!(config.nostr_config.allowed_public_keys.len(), 1);
156        assert!(
157            config
158                .nostr_config
159                .server_info
160                .as_ref()
161                .unwrap()
162                .name
163                .as_ref()
164                .unwrap()
165                == "Test Gateway"
166        );
167    }
168
169    #[test]
170    fn test_gateway_config_with_defaults() {
171        let config = GatewayConfig {
172            nostr_config: NostrServerTransportConfig::default(),
173        };
174        assert_eq!(
175            config.nostr_config.encryption_mode,
176            EncryptionMode::Optional
177        );
178        assert!(!config.nostr_config.is_announced_server);
179    }
180}