From f503b3a54431d5c16036822f3c94f2b55edfe877 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 27 May 2026 21:03:39 +0200 Subject: [PATCH 1/2] Add ContextVM FFI bindings --- Cargo.toml | 4 + contextvm-ffi/.gitignore | 3 + contextvm-ffi/Cargo.toml | 20 + contextvm-ffi/README.md | 73 +++ contextvm-ffi/headers/contextvm.h | 252 ++++++++ contextvm-ffi/src/builders.rs | 208 +++++++ contextvm-ffi/src/channel.rs | 883 ++++++++++++++++++++++++++++ contextvm-ffi/src/discovery.rs | 241 ++++++++ contextvm-ffi/src/error.rs | 87 +++ contextvm-ffi/src/handle.rs | 21 + contextvm-ffi/src/kv.rs | 30 + contextvm-ffi/src/lib.rs | 23 + contextvm-ffi/src/runtime.rs | 14 + contextvm-ffi/src/types.rs | 918 ++++++++++++++++++++++++++++++ contextvm-ffi/src/uniffi_types.rs | 721 +++++++++++++++++++++++ 15 files changed, 3498 insertions(+) create mode 100644 contextvm-ffi/.gitignore create mode 100644 contextvm-ffi/Cargo.toml create mode 100644 contextvm-ffi/README.md create mode 100644 contextvm-ffi/headers/contextvm.h create mode 100644 contextvm-ffi/src/builders.rs create mode 100644 contextvm-ffi/src/channel.rs create mode 100644 contextvm-ffi/src/discovery.rs create mode 100644 contextvm-ffi/src/error.rs create mode 100644 contextvm-ffi/src/handle.rs create mode 100644 contextvm-ffi/src/kv.rs create mode 100644 contextvm-ffi/src/lib.rs create mode 100644 contextvm-ffi/src/runtime.rs create mode 100644 contextvm-ffi/src/types.rs create mode 100644 contextvm-ffi/src/uniffi_types.rs diff --git a/Cargo.toml b/Cargo.toml index e0a28b4..5cb1fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,3 +71,7 @@ tokio-test = "0.4" anyhow = "1" schemars = "0.8" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[workspace] +members = [".", "contextvm-ffi"] +resolver = "2" diff --git a/contextvm-ffi/.gitignore b/contextvm-ffi/.gitignore new file mode 100644 index 0000000..e33a057 --- /dev/null +++ b/contextvm-ffi/.gitignore @@ -0,0 +1,3 @@ +/target +__pycache__/ +*.py[cod] diff --git a/contextvm-ffi/Cargo.toml b/contextvm-ffi/Cargo.toml new file mode 100644 index 0000000..1a47b57 --- /dev/null +++ b/contextvm-ffi/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "contextvm-ffi" +version = "0.1.0" +edition = "2021" +description = "FFI bindings for the ContextVM SDK — C header + Python/Swift/Kotlin support" +license = "MIT" + +[lib] +name = "contextvm_ffi" +crate-type = ["cdylib", "staticlib", "lib"] + +[dependencies] +contextvm-sdk = { path = ".." } +nostr-sdk = { version = "0.43", features = ["nip59"] } +tokio = { version = "1", features = ["full"] } +serde_json = "1" +uniffi = "0.29" + +[features] +default = [] diff --git a/contextvm-ffi/README.md b/contextvm-ffi/README.md new file mode 100644 index 0000000..ed74c6b --- /dev/null +++ b/contextvm-ffi/README.md @@ -0,0 +1,73 @@ +# contextvm-ffi + +FFI bindings for the ContextVM Rust SDK. + +This crate exposes two binding surfaces: + +- A flat C ABI in `headers/contextvm.h` +- UniFFI objects for Python, Kotlin, and Swift + +Async SDK operations are driven by an internal Tokio runtime, so foreign callers +use blocking functions and do not need to manage Rust async state. + +## Build + +```bash +cd contextvm-ffi +cargo build --release +``` + +Outputs: + +- Linux: `target/release/libcontextvm_ffi.so` +- macOS: `target/release/libcontextvm_ffi.dylib` +- Windows: `target/release/contextvm_ffi.dll` + +## Generate UniFFI Bindings + +Build the shared library first, then generate bindings from the compiled +library metadata using `uniffi-bindgen` 0.29.x: + +```bash +cd contextvm-ffi +cargo build + +uniffi-bindgen generate target/debug/libcontextvm_ffi.so \ + --library \ + --crate contextvm_ffi \ + --language python \ + --out-dir python/ +``` + +Use `--language kotlin` or `--language swift` for the other supported targets. + +## C API + +Include `headers/contextvm.h` and link against `libcontextvm_ffi`. + +```c +#include "contextvm.h" + +CvmError *error = NULL; +CvmHandle keys = cvm_keys_generate(&error); + +char *public_key = cvm_keys_public_key(keys, &error); +cvm_string_free(public_key); + +cvm_keys_free(keys); +``` + +Errors are opaque. Use `cvm_error_code`, `cvm_error_message`, and +`cvm_error_free` to inspect and release them. + +## Memory Management + +Rust-owned values returned through the C ABI must be released by the caller: + +- Strings: `cvm_string_free` +- Messages: `cvm_message_free` +- Incoming requests: `cvm_incoming_request_free` +- Announcement arrays: `cvm_announcements_free` +- Discovered tool arrays: `cvm_discovered_tools_free` +- Provider profile arrays: `cvm_provider_profiles_free` +- Errors: `cvm_error_free` diff --git a/contextvm-ffi/headers/contextvm.h b/contextvm-ffi/headers/contextvm.h new file mode 100644 index 0000000..6b631e1 --- /dev/null +++ b/contextvm-ffi/headers/contextvm.h @@ -0,0 +1,252 @@ +/* + * contextvm.h — C FFI header for the ContextVM SDK + * + * Usage: + * #include "contextvm.h" + * Link against libcontextvm_ffi.a (static) or libcontextvm_ffi.so (shared) + * + * All functions that return handles use the pattern: + * - On success: returns a valid handle (id > 0) + * - On error: returns handle { id: 0 } and sets *error + * + * Strings returned by this library must be freed with cvm_string_free(). + * Structs with owned strings must be freed with their respective _free functions. + * + * Error pointers should be freed with cvm_error_free(). + */ + +#ifndef CONTEXTVM_FFI_H +#define CONTEXTVM_FFI_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ─── Opaque Handle ────────────────────────────────────────────────── */ + +typedef struct { + uint64_t id; +} CvmHandle; + +/* ─── Error ────────────────────────────────────────────────────────── */ + +typedef enum { + CVM_OK = 0, + CVM_TRANSPORT = 1, + CVM_ENCRYPTION = 2, + CVM_DECRYPTION = 3, + CVM_TIMEOUT = 4, + CVM_VALIDATION = 5, + CVM_UNAUTHORIZED = 6, + CVM_SERIALIZATION = 7, + CVM_OTHER = 99, +} CvmErrorCode; + +typedef struct CvmError CvmError; + +/* ─── Enums ─────────────────────────────────────────────────────────── */ + +typedef enum { + CVM_ENCRYPTION_OPTIONAL = 0, + CVM_ENCRYPTION_REQUIRED = 1, + CVM_ENCRYPTION_DISABLED = 2, +} CvmEncryptionMode; + +typedef enum { + CVM_GIFTWRAP_OPTIONAL = 0, + CVM_GIFTWRAP_EPHEMERAL = 1, + CVM_GIFTWRAP_PERSISTENT = 2, +} CvmGiftWrapMode; + +typedef enum { + CVM_MSG_REQUEST = 0, + CVM_MSG_RESPONSE = 1, + CVM_MSG_ERROR_RESPONSE = 2, + CVM_MSG_NOTIFICATION = 3, +} CvmJsonRpcType; + +/* ─── Structs ──────────────────────────────────────────────────────── */ + +typedef struct { + CvmJsonRpcType msg_type; + char *payload_json; /* owned JSON string */ + char *method; /* owned, may be NULL */ + char *id; /* owned, may be NULL */ +} CvmJsonRpcMessage; + +typedef struct { + CvmJsonRpcMessage message; + char *client_pubkey; /* owned hex string */ + char *event_id; /* owned hex string */ + bool is_encrypted; +} CvmIncomingRequest; + +typedef struct { + char *pubkey; /* owned hex string */ + char *name; /* owned, may be NULL */ + char *version; /* owned, may be NULL */ + char *picture; /* owned, may be NULL */ + char *about; /* owned, may be NULL */ + char *website; /* owned, may be NULL */ + char *event_id; /* owned hex string */ +} CvmServerAnnouncement; + +typedef struct { + char *provider_pubkey; /* owned hex string */ + char *provider_display_name; /* owned, may be NULL */ + char *provider_name; /* owned, may be NULL */ + char *provider_about; /* owned, may be NULL */ + char *provider_picture; /* owned, may be NULL */ + char *provider_nip05; /* owned, may be NULL */ + char *tool_name; /* owned */ + char *description; /* owned */ + char *schema_json; /* owned JSON string */ +} CvmDiscoveredTool; + +typedef struct { + char *pubkey; /* owned hex string */ + char *name; /* owned, may be NULL */ + char *about; /* owned, may be NULL */ + char *picture; /* owned, may be NULL */ + char *nip05; /* owned, may be NULL */ +} CvmProviderProfile; + +typedef struct { + char **relay_urls; + size_t relay_url_count; + CvmEncryptionMode encryption_mode; + CvmGiftWrapMode gift_wrap_mode; + bool is_announced_server; + char *server_name; /* may be NULL */ + char *server_version; /* may be NULL */ + char *server_picture; /* may be NULL */ + char *server_about; /* may be NULL */ + char *server_website; /* may be NULL */ + char **allowed_pubkeys; + size_t allowed_pubkey_count; + uint64_t session_timeout_secs; + uint64_t cleanup_interval_secs; +} CvmServerConfig; + +typedef struct { + char **relay_urls; + size_t relay_url_count; + char *server_pubkey; /* required */ + CvmEncryptionMode encryption_mode; + CvmGiftWrapMode gift_wrap_mode; + bool is_stateless; + uint64_t timeout_secs; +} CvmClientConfig; + +/* ─── Free Functions ───────────────────────────────────────────────── */ + +void cvm_string_free(char *s); +void cvm_message_free(CvmJsonRpcMessage msg); +void cvm_incoming_request_free(CvmIncomingRequest req); +void cvm_announcements_free(CvmServerAnnouncement *announcements, size_t count); +void cvm_discovered_tools_free(CvmDiscoveredTool *tools, size_t count); +void cvm_provider_profiles_free(CvmProviderProfile *profiles, size_t count); +void cvm_error_free(CvmError *e); +char *cvm_error_message(const CvmError *e); +CvmErrorCode cvm_error_code(const CvmError *e); + +/* ─── Version ──────────────────────────────────────────────────────── */ + +char *cvm_version(void); + +/* ─── Keys / Signer ────────────────────────────────────────────────── */ + +CvmHandle cvm_keys_generate(CvmError **error); +CvmHandle cvm_keys_from_secret_key(const char *sk, CvmError **error); +char *cvm_keys_public_key(CvmHandle handle, CvmError **error); +char *cvm_keys_secret_key(CvmHandle handle, CvmError **error); +void cvm_keys_free(CvmHandle handle); + +/* ─── Relay Pool ───────────────────────────────────────────────────── */ + +CvmHandle cvm_relay_pool_new(CvmHandle keys_handle, CvmError **error); +bool cvm_relay_pool_connect(CvmHandle pool_handle, char **urls, size_t count, CvmError **error); +bool cvm_relay_pool_disconnect(CvmHandle pool_handle, CvmError **error); +void cvm_relay_pool_free(CvmHandle handle); + +/* ─── Server (channel-based) ──────────────────────────────────────── */ + +CvmHandle cvm_server_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmError **error); +bool cvm_server_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_server_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); +bool cvm_server_ch_announce(CvmHandle handle, CvmError **error); +bool cvm_server_ch_close(CvmHandle handle, CvmError **error); + +/* ─── Client (channel-based) ──────────────────────────────────────── */ + +CvmHandle cvm_client_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmError **error); +bool cvm_client_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_client_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_client_ch_close(CvmHandle handle, CvmError **error); + +/* ─── Gateway (channel-based) ─────────────────────────────────────── */ + +CvmHandle cvm_gateway_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmError **error); +bool cvm_gateway_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_gateway_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); +bool cvm_gateway_ch_announce(CvmHandle handle, CvmError **error); +bool cvm_gateway_ch_stop(CvmHandle handle, CvmError **error); + +/* ─── Proxy (channel-based) ───────────────────────────────────────── */ + +CvmHandle cvm_proxy_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmError **error); +bool cvm_proxy_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_proxy_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_proxy_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_proxy_ch_stop(CvmHandle handle, CvmError **error); + +/* ─── Discovery ────────────────────────────────────────────────────── */ + +CvmServerAnnouncement *cvm_discover_servers( + CvmHandle pool_handle, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmDiscoveredTool *cvm_discover_tools( + CvmHandle pool_handle, + const char *provider_pubkey_hex, + const char *provider_display_name, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmDiscoveredTool *cvm_discover_all_tools( + CvmHandle pool_handle, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmProviderProfile *cvm_fetch_provider_profiles( + CvmHandle pool_handle, + char **provider_pubkeys, + size_t provider_pubkey_count, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); + +/* ─── Encryption ───────────────────────────────────────────────────── */ + +char *cvm_encrypt_nip44(CvmHandle keys_handle, const char *recipient_hex, const char *plaintext, CvmError **error); +char *cvm_decrypt_nip44(CvmHandle keys_handle, const char *sender_hex, const char *ciphertext, CvmError **error); +char *cvm_pubkey_hex_to_npub(const char *pubkey_hex, CvmError **error); + +#ifdef __cplusplus +} +#endif + +#endif /* CONTEXTVM_FFI_H */ diff --git a/contextvm-ffi/src/builders.rs b/contextvm-ffi/src/builders.rs new file mode 100644 index 0000000..e6c726d --- /dev/null +++ b/contextvm-ffi/src/builders.rs @@ -0,0 +1,208 @@ +//! Helper functions for building SDK types from FFI configs. +//! +//! The SDK's public types are `#[non_exhaustive]`, so we can't construct +//! them with struct literals from outside the crate. These helpers use +//! the builder pattern instead. + +use std::time::Duration; + +use crate::types::{c_str_array_to_vec, c_str_to_string, FfiClientConfig, FfiServerConfig}; + +pub struct ServerConfigParts { + pub relay_urls: Vec, + pub encryption_mode: contextvm_sdk::EncryptionMode, + pub gift_wrap_mode: contextvm_sdk::GiftWrapMode, + pub server_name: Option, + pub server_version: Option, + pub server_picture: Option, + pub server_about: Option, + pub server_website: Option, + pub is_announced_server: bool, + pub allowed_pubkeys: Vec, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, +} + +/// Build a `ServerInfo` from FFI C strings. +pub fn build_server_info( + name: Option, + version: Option, + picture: Option, + about: Option, + website: Option, +) -> Option { + if name.is_none() + && version.is_none() + && picture.is_none() + && about.is_none() + && website.is_none() + { + return None; + } + + let mut info = contextvm_sdk::ServerInfo::default(); + if let Some(n) = name { + info = info.with_name(n); + } + if let Some(v) = version { + info = info.with_version(v); + } + if let Some(p) = picture { + info = info.with_picture(p); + } + if let Some(a) = about { + info = info.with_about(a); + } + if let Some(w) = website { + info = info.with_website(w); + } + Some(info) +} + +/// Extract fields from an FfiServerConfig and build an SDK server config. +pub fn build_sdk_server_config( + config: &FfiServerConfig, +) -> contextvm_sdk::NostrServerTransportConfig { + let relay_urls = c_str_array_to_vec(config.relay_urls, config.relay_url_count); + let allowed = c_str_array_to_vec(config.allowed_pubkeys, config.allowed_pubkey_count); + + let server_info = build_server_info( + c_str_to_string(config.server_name), + c_str_to_string(config.server_version), + c_str_to_string(config.server_picture), + c_str_to_string(config.server_about), + c_str_to_string(config.server_website), + ); + + let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() + .with_relay_urls(if relay_urls.is_empty() { + vec!["wss://relay.damus.io".to_string()] + } else { + relay_urls + }) + .with_encryption_mode(config.encryption_mode.into()) + .with_gift_wrap_mode(config.gift_wrap_mode.into()) + .with_announced_server(config.is_announced_server) + .with_allowed_public_keys(allowed) + .with_session_timeout(Duration::from_secs(config.session_timeout_secs.max(1))) + .with_cleanup_interval(Duration::from_secs(config.cleanup_interval_secs.max(1))); + + if let Some(server_info) = server_info { + sdk_config = sdk_config.with_server_info(server_info); + } + + sdk_config +} + +/// Extract fields from an FfiClientConfig and build an SDK client config. +pub fn build_sdk_client_config( + config: &FfiClientConfig, +) -> Option { + let server_pubkey = c_str_to_string(config.server_pubkey)?; + let relay_urls = c_str_array_to_vec(config.relay_urls, config.relay_url_count); + + Some( + contextvm_sdk::NostrClientTransportConfig::default() + .with_relay_urls(relay_urls) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(config.encryption_mode.into()) + .with_gift_wrap_mode(config.gift_wrap_mode.into()) + .with_stateless(config.is_stateless) + .with_timeout(Duration::from_secs(config.timeout_secs.max(1))), + ) +} + +/// Build a server config from UniFFI record fields. +pub fn build_sdk_server_config_from_fields( + parts: ServerConfigParts, +) -> contextvm_sdk::NostrServerTransportConfig { + let server_info = build_server_info( + parts.server_name, + parts.server_version, + parts.server_picture, + parts.server_about, + parts.server_website, + ); + + let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() + .with_relay_urls(if parts.relay_urls.is_empty() { + vec!["wss://relay.damus.io".to_string()] + } else { + parts.relay_urls + }) + .with_encryption_mode(parts.encryption_mode) + .with_gift_wrap_mode(parts.gift_wrap_mode) + .with_announced_server(parts.is_announced_server) + .with_allowed_public_keys(parts.allowed_pubkeys) + .with_session_timeout(Duration::from_secs(parts.session_timeout_secs.max(1))) + .with_cleanup_interval(Duration::from_secs(parts.cleanup_interval_secs.max(1))); + + if let Some(server_info) = server_info { + sdk_config = sdk_config.with_server_info(server_info); + } + + sdk_config +} + +/// Build a client config from UniFFI record fields. +pub fn build_sdk_client_config_from_fields( + relay_urls: Vec, + server_pubkey: String, + encryption_mode: contextvm_sdk::EncryptionMode, + gift_wrap_mode: contextvm_sdk::GiftWrapMode, + is_stateless: bool, + timeout_secs: u64, +) -> contextvm_sdk::NostrClientTransportConfig { + contextvm_sdk::NostrClientTransportConfig::default() + .with_relay_urls(relay_urls) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(encryption_mode) + .with_gift_wrap_mode(gift_wrap_mode) + .with_stateless(is_stateless) + .with_timeout(Duration::from_secs(timeout_secs.max(1))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn omitted_server_info_matches_sdk_default() { + assert!(build_server_info(None, None, None, None, None).is_none()); + + let config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + is_announced_server: false, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + }); + + assert!(config.server_info.is_none()); + } + + #[test] + fn server_info_preserves_sdk_fields() { + let info = build_server_info( + Some("name".to_string()), + Some("1.2.3".to_string()), + Some("https://example.com/pic.png".to_string()), + Some("about".to_string()), + Some("https://example.com".to_string()), + ) + .expect("server info should be present when any field is supplied"); + + assert_eq!(info.name.as_deref(), Some("name")); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + assert_eq!(info.picture.as_deref(), Some("https://example.com/pic.png")); + assert_eq!(info.about.as_deref(), Some("about")); + assert_eq!(info.website.as_deref(), Some("https://example.com")); + } +} diff --git a/contextvm-ffi/src/channel.rs b/contextvm-ffi/src/channel.rs new file mode 100644 index 0000000..e05638b --- /dev/null +++ b/contextvm-ffi/src/channel.rs @@ -0,0 +1,883 @@ +//! Channel-based wrapper types that allow FFI consumers to receive messages. + +use crate::builders::{build_sdk_client_config, build_sdk_server_config}; +use crate::error::{set_error, FfiError}; +use crate::handle::FfiHandle; +use crate::kv; +use crate::runtime::global_runtime; +use crate::types::*; + +use std::os::raw::c_char; +use std::time::Duration; +use tokio::sync::Mutex; + +// ─── Wrappers that combine transport + receiver ──────────────────────── + +/// Server wrapper holding transport + message receiver. +pub struct ServerChannel { + transport: Mutex, + receiver: Mutex>, +} + +/// Client wrapper holding transport + message receiver. +pub struct ClientChannel { + transport: Mutex, + receiver: Mutex>, +} + +/// Gateway wrapper holding gateway + message receiver. +pub struct GatewayChannel { + gateway: Mutex, + receiver: Mutex>, +} + +/// Proxy wrapper holding proxy + message receiver. +pub struct ProxyChannel { + proxy: Mutex, + receiver: Mutex>, +} + +// ─── Server Channel API ──────────────────────────────────────────────── + +/// Create and start a server transport with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_server_ch_new( + keys_handle: FfiHandle, + config: FfiServerConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = build_sdk_server_config(&config); + + let result = global_runtime().block_on(async { + let mut transport = contextvm_sdk::NostrServerTransport::new(keys, sdk_config).await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(ServerChannel { + transport: Mutex::new(transport), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Receive the next incoming request. Blocks until available. +#[no_mangle] +pub extern "C" fn cvm_server_ch_recv( + handle: FfiHandle, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(incoming) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Send a response through a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_send_response( + handle: FfiHandle, + event_id: *const c_char, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + let eid = match c_str_to_string(event_id) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null event_id".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.send_response(&eid, msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Publish server announcement. +#[no_mangle] +pub extern "C" fn cvm_server_ch_announce(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.announce().await + }) { + Ok(_) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Close a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.close().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Client Channel API ──────────────────────────────────────────────── + +/// Create and start a client transport with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_client_ch_new( + keys_handle: FfiHandle, + config: FfiClientConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_client_config(&config) { + Some(c) => c, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "server_pubkey is required".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + + let result = global_runtime().block_on(async { + let mut transport = contextvm_sdk::NostrClientTransport::new(keys, sdk_config).await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(ClientChannel { + transport: Mutex::new(transport), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Send a message through a client channel. +#[no_mangle] +pub extern "C" fn cvm_client_ch_send( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.send(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Receive the next message. Blocks until available. +#[no_mangle] +pub extern "C" fn cvm_client_ch_recv( + handle: FfiHandle, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(message) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Close a client channel. +#[no_mangle] +pub extern "C" fn cvm_client_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.close().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Gateway Channel API ─────────────────────────────────────────────── + +/// Create and start a gateway with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_new( + keys_handle: FfiHandle, + config: FfiServerConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = build_sdk_server_config(&config); + let gw_config = contextvm_sdk::gateway::GatewayConfig::new(sdk_config); + + let result = global_runtime().block_on(async { + let mut gw = contextvm_sdk::gateway::NostrMCPGateway::new(keys, gw_config).await?; + let receiver = gw.start().await?; + Ok::<_, contextvm_sdk::Error>(GatewayChannel { + gateway: Mutex::new(gw), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Receive the next request from a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_recv( + handle: FfiHandle, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(incoming) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Send a response through a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_send_response( + handle: FfiHandle, + event_id: *const c_char, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + let eid = match c_str_to_string(event_id) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null event_id".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.send_response(&eid, msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Publish announcement through a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_announce(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.announce().await + }) { + Ok(_) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Stop a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut gateway = channel.gateway.lock().await; + gateway.stop().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Proxy Channel API ───────────────────────────────────────────────── + +/// Create and start a proxy with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_new( + keys_handle: FfiHandle, + config: FfiClientConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_client_config(&config) { + Some(c) => c, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "server_pubkey is required".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + + let proxy_config = contextvm_sdk::proxy::ProxyConfig::new(sdk_config); + + let result = global_runtime().block_on(async { + let mut proxy = contextvm_sdk::proxy::NostrMCPProxy::new(keys, proxy_config).await?; + let receiver = proxy.start().await?; + Ok::<_, contextvm_sdk::Error>(ProxyChannel { + proxy: Mutex::new(proxy), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Send a message through a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_send( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let proxy = channel.proxy.lock().await; + proxy.send(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Receive the next message from a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_recv( + handle: FfiHandle, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(message) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Receive the next message from a proxy channel, timing out after `timeout_secs`. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_recv_timeout( + handle: FfiHandle, + timeout_secs: u64, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), receiver.recv()).await + }) { + Ok(Some(message)) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + Ok(None) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + Err(_) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }, + ); + false + } + } +} + +/// Stop a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut proxy = channel.proxy.lock().await; + proxy.stop().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────── + +fn get_keys(handle: FfiHandle, error: *mut *mut FfiError) -> Option { + match kv::get::(handle) { + Some(k) => Some(k.as_ref().clone()), + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + None + } + } +} + +fn parse_json_rpc_message( + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> Option { + let json_str = match c_str_to_string(payload_json) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null payload json".into(), + }, + ); + return None; + } + }; + + match serde_json::from_str(&json_str) { + Ok(m) => Some(m), + Err(e) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }, + ); + None + } + } +} diff --git a/contextvm-ffi/src/discovery.rs b/contextvm-ffi/src/discovery.rs new file mode 100644 index 0000000..d243e4f --- /dev/null +++ b/contextvm-ffi/src/discovery.rs @@ -0,0 +1,241 @@ +//! Shared discovery helpers for C and UniFFI bindings. + +use contextvm_sdk::signer::PublicKey; +use nostr_sdk::prelude::*; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub(crate) struct DiscoveredToolRecord { + pub provider_pubkey: String, + pub provider_display_name: Option, + pub provider_name: Option, + pub provider_about: Option, + pub provider_picture: Option, + pub provider_nip05: Option, + pub tool_name: String, + pub description: String, + pub schema_json: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ProviderProfileRecord { + pub pubkey: String, + pub name: Option, + pub about: Option, + pub picture: Option, + pub nip05: Option, +} + +pub(crate) async fn discover_tools( + client: &Arc, + provider_pubkey: &str, + provider_display_name: Option, + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let parsed_pubkey = PublicKey::from_hex(provider_pubkey) + .map_err(|e| contextvm_sdk::Error::Validation(format!("bad pubkey: {e}")))?; + let raw_tools = + contextvm_sdk::discovery::discover_tools(client, &parsed_pubkey, relay_urls).await?; + + raw_tools + .into_iter() + .map(|tool| tool_record_from_value(provider_pubkey, provider_display_name.clone(), tool)) + .collect() +} + +pub(crate) async fn discover_all_tools( + client: &Arc, + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let servers = contextvm_sdk::discovery::discover_servers(client, relay_urls).await?; + let mut tools = Vec::new(); + + for server in servers { + match discover_tools( + client, + &server.pubkey, + server.server_info.name.clone(), + relay_urls, + ) + .await + { + Ok(mut server_tools) => tools.append(&mut server_tools), + Err(_) => continue, + } + } + + let provider_pubkeys: Vec = tools + .iter() + .map(|tool| tool.provider_pubkey.clone()) + .collect::>() + .into_iter() + .collect(); + if let Ok(profiles) = fetch_provider_profiles(client, &provider_pubkeys, relay_urls).await { + for tool in &mut tools { + if let Some(profile) = profiles.get(&tool.provider_pubkey) { + tool.provider_name = profile.name.clone(); + tool.provider_about = profile.about.clone(); + tool.provider_picture = profile.picture.clone(); + tool.provider_nip05 = profile.nip05.clone(); + } + } + } + + Ok(tools) +} + +pub(crate) async fn fetch_provider_profiles( + client: &Arc, + provider_pubkeys: &[String], + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let parsed_pubkeys: Vec = provider_pubkeys + .iter() + .filter_map(|pubkey| PublicKey::from_hex(pubkey).ok()) + .collect(); + + if parsed_pubkeys.is_empty() { + return Ok(HashMap::new()); + } + + let filter = Filter::new().authors(parsed_pubkeys).kind(Kind::Metadata); + let timeout = Duration::from_secs(10); + let events = if relay_urls.is_empty() { + client.fetch_events(filter, timeout).await + } else { + client.fetch_events_from(relay_urls, filter, timeout).await + } + .map_err(|e| contextvm_sdk::Error::Transport(e.to_string()))?; + + let mut profiles = HashMap::new(); + for event in events { + if let Some(profile) = profile_from_metadata(event.pubkey.to_hex(), &event.content) { + profiles.insert(profile.pubkey.clone(), profile); + } + } + + Ok(profiles) +} + +pub(crate) fn pubkey_hex_to_npub(pubkey_hex: &str) -> contextvm_sdk::Result { + use nostr_sdk::ToBech32; + + let pubkey = PublicKey::from_hex(pubkey_hex) + .map_err(|e| contextvm_sdk::Error::Validation(format!("bad pubkey: {e}")))?; + pubkey + .to_bech32() + .map_err(|e| contextvm_sdk::Error::Other(e.to_string())) +} + +fn tool_record_from_value( + provider_pubkey: &str, + provider_display_name: Option, + value: serde_json::Value, +) -> contextvm_sdk::Result { + let tool_name = value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| contextvm_sdk::Error::Other("tool announcement missing name".into()))? + .to_string(); + let description = value + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let schema = value + .get("inputSchema") + .or_else(|| value.get("input_schema")) + .ok_or_else(|| { + contextvm_sdk::Error::Other(format!( + "tool announcement {tool_name} missing inputSchema" + )) + })?; + let schema_json = serde_json::to_string(schema).map_err(contextvm_sdk::Error::Serialization)?; + + Ok(DiscoveredToolRecord { + provider_pubkey: provider_pubkey.to_string(), + provider_display_name, + provider_name: None, + provider_about: None, + provider_picture: None, + provider_nip05: None, + tool_name, + description, + schema_json, + }) +} + +fn profile_from_metadata(pubkey: String, content: &str) -> Option { + let metadata: serde_json::Value = serde_json::from_str(content).ok()?; + Some(ProviderProfileRecord { + pubkey, + name: metadata + .get("name") + .and_then(|v| v.as_str()) + .map(String::from), + about: metadata + .get("about") + .and_then(|v| v.as_str()) + .map(String::from), + picture: metadata + .get("picture") + .and_then(|v| v.as_str()) + .map(String::from), + nip05: metadata + .get("nip05") + .and_then(|v| v.as_str()) + .map(String::from), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_record_preserves_confidential_app_fields() { + let tool = tool_record_from_value( + "abc", + Some("provider".to_string()), + json!({ + "name": "echo", + "description": "Echo a message", + "inputSchema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + }), + ) + .unwrap(); + + assert_eq!(tool.provider_pubkey, "abc"); + assert_eq!(tool.provider_display_name.as_deref(), Some("provider")); + assert_eq!(tool.tool_name, "echo"); + assert_eq!(tool.description, "Echo a message"); + assert!(tool.schema_json.contains("message")); + } + + #[test] + fn profile_metadata_maps_provider_fields() { + let profile = profile_from_metadata( + "abc".to_string(), + r#"{"name":"Provider","about":"About","picture":"https://pic","nip05":"p@example.com"}"#, + ) + .unwrap(); + + assert_eq!(profile.name.as_deref(), Some("Provider")); + assert_eq!(profile.about.as_deref(), Some("About")); + assert_eq!(profile.picture.as_deref(), Some("https://pic")); + assert_eq!(profile.nip05.as_deref(), Some("p@example.com")); + } + + #[test] + fn pubkey_hex_to_npub_rejects_invalid_hex() { + assert!(pubkey_hex_to_npub("not-hex").is_err()); + } +} diff --git a/contextvm-ffi/src/error.rs b/contextvm-ffi/src/error.rs new file mode 100644 index 0000000..413b331 --- /dev/null +++ b/contextvm-ffi/src/error.rs @@ -0,0 +1,87 @@ +//! FFI-safe error type. + +use std::fmt; + +/// Error codes returned by the FFI layer. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ErrorCode { + /// Success (no error). + Ok = 0, + /// Transport / relay error. + Transport = 1, + /// Encryption (NIP-44) error. + Encryption = 2, + /// Decryption error. + Decryption = 3, + /// Request timed out. + Timeout = 4, + /// Validation error (size/schema). + Validation = 5, + /// Unauthorized (pubkey not in allowlist). + Unauthorized = 6, + /// Serialization/deserialization error. + Serialization = 7, + /// Generic / unknown error. + Other = 99, +} + +impl From<&contextvm_sdk::Error> for ErrorCode { + fn from(e: &contextvm_sdk::Error) -> Self { + match e { + contextvm_sdk::Error::Transport(_) => ErrorCode::Transport, + contextvm_sdk::Error::Encryption(_) => ErrorCode::Encryption, + contextvm_sdk::Error::Decryption(_) => ErrorCode::Decryption, + contextvm_sdk::Error::Timeout => ErrorCode::Timeout, + contextvm_sdk::Error::Validation(_) => ErrorCode::Validation, + contextvm_sdk::Error::Unauthorized(_) => ErrorCode::Unauthorized, + contextvm_sdk::Error::Serialization(_) => ErrorCode::Serialization, + contextvm_sdk::Error::Other(_) => ErrorCode::Other, + } + } +} + +/// An FFI-safe error with a code and human-readable message. +#[derive(Debug, Clone, uniffi::Object)] +pub struct FfiError { + pub code: ErrorCode, + pub message: String, +} + +#[uniffi::export] +impl FfiError { + /// Get the error code. + pub fn code(&self) -> ErrorCode { + self.code + } + + /// Get the error message. + pub fn message(&self) -> String { + self.message.clone() + } +} + +impl std::fmt::Display for FfiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for FfiError {} + +impl From for FfiError { + fn from(e: contextvm_sdk::Error) -> Self { + FfiError { + code: ErrorCode::from(&e), + message: e.to_string(), + } + } +} + +pub(crate) fn set_error(out: *mut *mut FfiError, err: FfiError) { + if !out.is_null() { + unsafe { + *out = Box::into_raw(Box::new(err)); + } + } +} diff --git a/contextvm-ffi/src/handle.rs b/contextvm-ffi/src/handle.rs new file mode 100644 index 0000000..000a612 --- /dev/null +++ b/contextvm-ffi/src/handle.rs @@ -0,0 +1,21 @@ +//! Opaque handle type for FFI consumers. + +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); + +/// An opaque handle returned by the FFI layer. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FfiHandle { + pub id: u64, +} + +impl FfiHandle { + /// Allocate a fresh, unique handle. + pub fn next() -> Self { + Self { + id: NEXT_HANDLE.fetch_add(1, Ordering::Relaxed), + } + } +} diff --git a/contextvm-ffi/src/kv.rs b/contextvm-ffi/src/kv.rs new file mode 100644 index 0000000..ee52a5c --- /dev/null +++ b/contextvm-ffi/src/kv.rs @@ -0,0 +1,30 @@ +//! Global key-value store that maps FFI handles to their underlying Rust objects. + +use std::any::Any; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; + +use crate::handle::FfiHandle; + +static STORE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Insert a value and return its handle. +pub fn insert(value: T) -> FfiHandle { + let handle = FfiHandle::next(); + let mut store = STORE.lock().unwrap(); + store.insert(handle.id, Arc::new(value)); + handle +} + +/// Retrieve a typed handle. +pub fn get(handle: FfiHandle) -> Option> { + let store = STORE.lock().unwrap(); + let value = Arc::clone(store.get(&handle.id)?); + value.downcast::().ok() +} + +/// Remove a handle from the store, dropping the object. +pub fn remove(handle: FfiHandle) -> bool { + STORE.lock().unwrap().remove(&handle.id).is_some() +} diff --git a/contextvm-ffi/src/lib.rs b/contextvm-ffi/src/lib.rs new file mode 100644 index 0000000..d4474e9 --- /dev/null +++ b/contextvm-ffi/src/lib.rs @@ -0,0 +1,23 @@ +// ─── ContextVM FFI — Flat C API + UniFFI for Python/Swift/Kotlin ─── +// +// This crate exposes: +// 1. A flat `#[no_mangle] extern "C"` surface for direct C interop +// (Swift via C headers, Kotlin via JNI/JNA, C/C++ directly) +// 2. UniFFI proc-macro definitions for Python and as an alternative +// to hand-written Swift/Kotlin bindings +// +// All async work is driven on an internal global tokio runtime so +// callers never need to manage an async runtime. + +mod builders; +mod channel; +mod discovery; +mod error; +mod handle; +mod kv; +mod runtime; +mod types; +mod uniffi_types; + +// UniFFI scaffolding — must be at crate root, after all types it references. +uniffi::setup_scaffolding!(); diff --git a/contextvm-ffi/src/runtime.rs b/contextvm-ffi/src/runtime.rs new file mode 100644 index 0000000..3355c0b --- /dev/null +++ b/contextvm-ffi/src/runtime.rs @@ -0,0 +1,14 @@ +//! Global tokio runtime shared across all FFI calls. +//! +// The runtime is lazily initialized on first use and never shut down. +//! This avoids needing the caller to manage a runtime lifecycle. + +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +static RUNTIME: OnceLock = OnceLock::new(); + +/// Return a reference to the global tokio runtime. +pub fn global_runtime() -> &'static Runtime { + RUNTIME.get_or_init(|| Runtime::new().expect("failed to create global tokio runtime for FFI")) +} diff --git a/contextvm-ffi/src/types.rs b/contextvm-ffi/src/types.rs new file mode 100644 index 0000000..a7b4209 --- /dev/null +++ b/contextvm-ffi/src/types.rs @@ -0,0 +1,918 @@ +//! FFI-exposed types and flat C API functions. +//! +//! This module defines the FFI-safe struct mirrors, `#[no_mangle] extern "C"` +//! functions for key management, relay pool, discovery, encryption, and utility +//! operations. The channel-based transport APIs live in `channel.rs`. + +use crate::error::{set_error, ErrorCode, FfiError}; +use crate::handle::FfiHandle; +use crate::kv; +use crate::runtime::global_runtime; + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// ─── FFI-safe struct mirrors ─────────────────────────────────────────── + +/// Encryption mode. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionMode { + Optional = 0, + Required = 1, + Disabled = 2, +} + +impl From for contextvm_sdk::EncryptionMode { + fn from(m: EncryptionMode) -> Self { + match m { + EncryptionMode::Optional => contextvm_sdk::EncryptionMode::Optional, + EncryptionMode::Required => contextvm_sdk::EncryptionMode::Required, + EncryptionMode::Disabled => contextvm_sdk::EncryptionMode::Disabled, + } + } +} + +/// Gift-wrap mode (CEP-19). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GiftWrapMode { + Optional = 0, + Ephemeral = 1, + Persistent = 2, +} + +impl From for contextvm_sdk::GiftWrapMode { + fn from(m: GiftWrapMode) -> Self { + match m { + GiftWrapMode::Optional => contextvm_sdk::GiftWrapMode::Optional, + GiftWrapMode::Ephemeral => contextvm_sdk::GiftWrapMode::Ephemeral, + GiftWrapMode::Persistent => contextvm_sdk::GiftWrapMode::Persistent, + } + } +} + +/// JSON-RPC message type discriminator. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonRpcType { + Request = 0, + Response = 1, + ErrorResponse = 2, + Notification = 3, +} + +/// An FFI-safe JSON-RPC message. +#[repr(C)] +#[derive(Debug)] +pub struct FfiJsonRpcMessage { + pub msg_type: JsonRpcType, + pub payload_json: *mut c_char, + pub method: *mut c_char, + pub id: *mut c_char, +} + +/// An FFI-safe incoming request (server-side). +#[repr(C)] +#[derive(Debug)] +pub struct FfiIncomingRequest { + pub message: FfiJsonRpcMessage, + pub client_pubkey: *mut c_char, + pub event_id: *mut c_char, + pub is_encrypted: bool, +} + +/// A discovered server announcement. +#[repr(C)] +#[derive(Debug)] +pub struct FfiServerAnnouncement { + pub pubkey: *mut c_char, + pub name: *mut c_char, + pub version: *mut c_char, + pub picture: *mut c_char, + pub about: *mut c_char, + pub website: *mut c_char, + pub event_id: *mut c_char, +} + +/// A discovered MCP tool and provider metadata. +#[repr(C)] +#[derive(Debug)] +pub struct FfiDiscoveredTool { + pub provider_pubkey: *mut c_char, + pub provider_display_name: *mut c_char, + pub provider_name: *mut c_char, + pub provider_about: *mut c_char, + pub provider_picture: *mut c_char, + pub provider_nip05: *mut c_char, + pub tool_name: *mut c_char, + pub description: *mut c_char, + pub schema_json: *mut c_char, +} + +/// Nostr profile metadata for a provider. +#[repr(C)] +#[derive(Debug)] +pub struct FfiProviderProfile { + pub pubkey: *mut c_char, + pub name: *mut c_char, + pub about: *mut c_char, + pub picture: *mut c_char, + pub nip05: *mut c_char, +} + +/// Server transport config for FFI. +#[repr(C)] +#[derive(Debug)] +pub struct FfiServerConfig { + pub relay_urls: *mut *mut c_char, + pub relay_url_count: usize, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_announced_server: bool, + pub server_name: *mut c_char, + pub server_version: *mut c_char, + pub server_picture: *mut c_char, + pub server_about: *mut c_char, + pub server_website: *mut c_char, + pub allowed_pubkeys: *mut *mut c_char, + pub allowed_pubkey_count: usize, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, +} + +/// Client transport config for FFI. +#[repr(C)] +#[derive(Debug)] +pub struct FfiClientConfig { + pub relay_urls: *mut *mut c_char, + pub relay_url_count: usize, + pub server_pubkey: *mut c_char, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_stateless: bool, + pub timeout_secs: u64, +} + +// ─── Internal conversion helpers ─────────────────────────────────────── + +pub fn c_str_to_string(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + unsafe { CStr::from_ptr(ptr).to_str().ok().map(String::from) } +} + +pub fn string_to_c(s: String) -> *mut c_char { + CString::new(s).unwrap_or_default().into_raw() +} + +pub fn opt_string_to_c(s: Option) -> *mut c_char { + s.map(string_to_c).unwrap_or(ptr::null_mut()) +} + +pub fn c_str_array_to_vec(ptr: *mut *mut c_char, count: usize) -> Vec { + if ptr.is_null() || count == 0 { + return Vec::new(); + } + unsafe { + let slice = std::slice::from_raw_parts(ptr, count); + slice.iter().filter_map(|&p| c_str_to_string(p)).collect() + } +} + +/// Convert an SDK `JsonRpcMessage` to the FFI representation. +pub fn message_to_ffi(msg: &contextvm_sdk::JsonRpcMessage) -> FfiJsonRpcMessage { + let json_str = serde_json::to_string(msg).unwrap_or_default(); + let msg_type = match msg { + contextvm_sdk::JsonRpcMessage::Request(_) => JsonRpcType::Request, + contextvm_sdk::JsonRpcMessage::Response(_) => JsonRpcType::Response, + contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JsonRpcType::ErrorResponse, + contextvm_sdk::JsonRpcMessage::Notification(_) => JsonRpcType::Notification, + }; + FfiJsonRpcMessage { + msg_type, + payload_json: string_to_c(json_str), + method: opt_string_to_c(msg.method().map(String::from)), + id: opt_string_to_c(msg.id().map(|v| v.to_string())), + } +} + +// ─── Free functions ──────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_string_free(s: *mut c_char) { + if !s.is_null() { + unsafe { + let _ = CString::from_raw(s); + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_message_free(msg: FfiJsonRpcMessage) { + cvm_string_free(msg.payload_json); + cvm_string_free(msg.method); + cvm_string_free(msg.id); +} + +#[no_mangle] +pub extern "C" fn cvm_incoming_request_free(req: FfiIncomingRequest) { + cvm_message_free(req.message); + cvm_string_free(req.client_pubkey); + cvm_string_free(req.event_id); +} + +#[no_mangle] +pub extern "C" fn cvm_announcements_free(announcements: *mut FfiServerAnnouncement, count: usize) { + if announcements.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(announcements, count); + for ann in slice.iter_mut() { + cvm_string_free(ann.pubkey); + cvm_string_free(ann.name); + cvm_string_free(ann.version); + cvm_string_free(ann.picture); + cvm_string_free(ann.about); + cvm_string_free(ann.website); + cvm_string_free(ann.event_id); + } + let _ = Vec::from_raw_parts(announcements, count, count); + } +} + +#[no_mangle] +pub extern "C" fn cvm_discovered_tools_free(tools: *mut FfiDiscoveredTool, count: usize) { + if tools.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(tools, count); + for tool in slice.iter_mut() { + cvm_string_free(tool.provider_pubkey); + cvm_string_free(tool.provider_display_name); + cvm_string_free(tool.provider_name); + cvm_string_free(tool.provider_about); + cvm_string_free(tool.provider_picture); + cvm_string_free(tool.provider_nip05); + cvm_string_free(tool.tool_name); + cvm_string_free(tool.description); + cvm_string_free(tool.schema_json); + } + let _ = Vec::from_raw_parts(tools, count, count); + } +} + +#[no_mangle] +pub extern "C" fn cvm_provider_profiles_free(profiles: *mut FfiProviderProfile, count: usize) { + if profiles.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(profiles, count); + for profile in slice.iter_mut() { + cvm_string_free(profile.pubkey); + cvm_string_free(profile.name); + cvm_string_free(profile.about); + cvm_string_free(profile.picture); + cvm_string_free(profile.nip05); + } + let _ = Vec::from_raw_parts(profiles, count, count); + } +} + +// ─── Signer / Key management ─────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_keys_generate(_error: *mut *mut FfiError) -> FfiHandle { + kv::insert(contextvm_sdk::signer::generate()) +} + +#[no_mangle] +pub extern "C" fn cvm_keys_from_secret_key( + sk: *const c_char, + error: *mut *mut FfiError, +) -> FfiHandle { + let sk_str = match c_str_to_string(sk) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null secret key string".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + match contextvm_sdk::signer::from_sk(&sk_str) { + Ok(keys) => kv::insert(keys), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: e.to_string(), + }, + ); + FfiHandle { id: 0 } + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_public_key(handle: FfiHandle, error: *mut *mut FfiError) -> *mut c_char { + let guard = kv::get::(handle); + match guard { + Some(keys) => string_to_c(keys.public_key().to_hex()), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_secret_key(handle: FfiHandle, error: *mut *mut FfiError) -> *mut c_char { + let guard = kv::get::(handle); + match guard { + Some(keys) => string_to_c(keys.secret_key().to_secret_hex()), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_free(handle: FfiHandle) { + kv::remove(handle); +} + +// ─── Relay Pool ──────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_new( + keys_handle: FfiHandle, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + match global_runtime().block_on(contextvm_sdk::RelayPool::new(keys)) { + Ok(p) => kv::insert(p), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_connect( + pool_handle: FfiHandle, + urls: *mut *mut c_char, + url_count: usize, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return false; + } + }; + let url_vec = c_str_array_to_vec(urls, url_count); + match global_runtime().block_on(pool.connect(&url_vec)) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_disconnect( + pool_handle: FfiHandle, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return false; + } + }; + match global_runtime().block_on(pool.disconnect()) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_free(handle: FfiHandle) { + kv::remove(handle); +} + +// ─── Discovery ───────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_discover_servers( + pool_handle: FfiHandle, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiServerAnnouncement { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime() + .block_on(async { contextvm_sdk::discovery::discover_servers(client, &urls).await }); + + let announcements = match result { + Ok(a) => a, + Err(e) => { + set_error(error, e.into()); + return ptr::null_mut(); + } + }; + + let count = announcements.len(); + let ffi_announcements: Vec = announcements + .into_iter() + .map(|a| FfiServerAnnouncement { + pubkey: string_to_c(a.pubkey), + name: opt_string_to_c(a.server_info.name), + version: opt_string_to_c(a.server_info.version), + picture: opt_string_to_c(a.server_info.picture), + about: opt_string_to_c(a.server_info.about), + website: opt_string_to_c(a.server_info.website), + event_id: string_to_c(a.event_id.to_hex()), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_announcements = ffi_announcements; + let ptr = ffi_announcements.as_mut_ptr(); + std::mem::forget(ffi_announcements); + ptr +} + +#[no_mangle] +pub extern "C" fn cvm_discover_tools( + pool_handle: FfiHandle, + provider_pubkey_hex: *const c_char, + provider_display_name: *const c_char, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiDiscoveredTool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let provider_pubkey = match c_str_to_string(provider_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null provider pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let provider_display_name = c_str_to_string(provider_display_name); + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime().block_on(async { + crate::discovery::discover_tools(client, &provider_pubkey, provider_display_name, &urls) + .await + }); + + match result { + Ok(tools) => tools_to_ffi_array(tools, out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_discover_all_tools( + pool_handle: FfiHandle, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiDiscoveredTool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime() + .block_on(async { crate::discovery::discover_all_tools(client, &urls).await }); + + match result { + Ok(tools) => tools_to_ffi_array(tools, out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_fetch_provider_profiles( + pool_handle: FfiHandle, + provider_pubkeys: *mut *mut c_char, + provider_pubkey_count: usize, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiProviderProfile { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pubkeys = c_str_array_to_vec(provider_pubkeys, provider_pubkey_count); + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime().block_on(async { + crate::discovery::fetch_provider_profiles(client, &pubkeys, &urls).await + }); + + match result { + Ok(profiles) => profiles_to_ffi_array(profiles.into_values().collect(), out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +// ─── Encryption ──────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_encrypt_nip44( + keys_handle: FfiHandle, + recipient_pubkey_hex: *const c_char, + plaintext: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + + let pk_str = match c_str_to_string(recipient_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null recipient pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pk = match contextvm_sdk::signer::PublicKey::from_hex(&pk_str) { + Ok(pk) => pk, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("invalid pubkey: {e}"), + }, + ); + return ptr::null_mut(); + } + }; + let pt = match c_str_to_string(plaintext) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null plaintext".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match global_runtime().block_on(contextvm_sdk::encryption::encrypt_nip44(&keys, &pk, &pt)) { + Ok(ct) => string_to_c(ct), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_decrypt_nip44( + keys_handle: FfiHandle, + sender_pubkey_hex: *const c_char, + ciphertext: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + + let pk_str = match c_str_to_string(sender_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null sender pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pk = match contextvm_sdk::signer::PublicKey::from_hex(&pk_str) { + Ok(pk) => pk, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("invalid pubkey: {e}"), + }, + ); + return ptr::null_mut(); + } + }; + let ct = match c_str_to_string(ciphertext) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null ciphertext".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match global_runtime().block_on(contextvm_sdk::encryption::decrypt_nip44(&keys, &pk, &ct)) { + Ok(pt) => string_to_c(pt), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +// ─── Utility ─────────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_version() -> *mut c_char { + string_to_c(env!("CARGO_PKG_VERSION").to_string()) +} + +#[no_mangle] +pub extern "C" fn cvm_pubkey_hex_to_npub( + pubkey_hex: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let pubkey = match c_str_to_string(pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match crate::discovery::pubkey_hex_to_npub(&pubkey) { + Ok(npub) => string_to_c(npub), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_error_free(e: *mut FfiError) { + if !e.is_null() { + unsafe { + let _ = Box::from_raw(e); + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_error_message(e: *const FfiError) -> *mut c_char { + if e.is_null() { + return ptr::null_mut(); + } + unsafe { string_to_c((*e).message.clone()) } +} + +#[no_mangle] +pub extern "C" fn cvm_error_code(e: *const FfiError) -> ErrorCode { + if e.is_null() { + return ErrorCode::Ok; + } + unsafe { (*e).code } +} + +fn tools_to_ffi_array( + tools: Vec, + out_count: *mut usize, +) -> *mut FfiDiscoveredTool { + let count = tools.len(); + let ffi_tools: Vec = tools + .into_iter() + .map(|tool| FfiDiscoveredTool { + provider_pubkey: string_to_c(tool.provider_pubkey), + provider_display_name: opt_string_to_c(tool.provider_display_name), + provider_name: opt_string_to_c(tool.provider_name), + provider_about: opt_string_to_c(tool.provider_about), + provider_picture: opt_string_to_c(tool.provider_picture), + provider_nip05: opt_string_to_c(tool.provider_nip05), + tool_name: string_to_c(tool.tool_name), + description: string_to_c(tool.description), + schema_json: string_to_c(tool.schema_json), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_tools = ffi_tools; + let ptr = ffi_tools.as_mut_ptr(); + std::mem::forget(ffi_tools); + ptr +} + +fn profiles_to_ffi_array( + profiles: Vec, + out_count: *mut usize, +) -> *mut FfiProviderProfile { + let count = profiles.len(); + let ffi_profiles: Vec = profiles + .into_iter() + .map(|profile| FfiProviderProfile { + pubkey: string_to_c(profile.pubkey), + name: opt_string_to_c(profile.name), + about: opt_string_to_c(profile.about), + picture: opt_string_to_c(profile.picture), + nip05: opt_string_to_c(profile.nip05), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_profiles = ffi_profiles; + let ptr = ffi_profiles.as_mut_ptr(); + std::mem::forget(ffi_profiles); + ptr +} diff --git a/contextvm-ffi/src/uniffi_types.rs b/contextvm-ffi/src/uniffi_types.rs new file mode 100644 index 0000000..3a17f58 --- /dev/null +++ b/contextvm-ffi/src/uniffi_types.rs @@ -0,0 +1,721 @@ +//! UniFFI-compatible types and high-level object-oriented API. +//! +//! These types are exposed via UniFFI proc-macros and provide a more ergonomic +//! interface than the flat C API. They are designed for Python, Swift, and +//! Kotlin consumers. + +use crate::builders::{ + build_sdk_client_config_from_fields, build_sdk_server_config_from_fields, ServerConfigParts, +}; +use crate::error::FfiError; +use crate::runtime::global_runtime; +use std::sync::Arc; +use std::time::Duration; + +// ─── Enum mirrors for UniFFI ─────────────────────────────────────────── + +/// Encryption mode. +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum EncryptionMode { + Optional, + Required, + Disabled, +} + +/// Gift-wrap mode (CEP-19). +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum GiftWrapMode { + Optional, + Ephemeral, + Persistent, +} + +/// JSON-RPC message type. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum JsonRpcMessageType { + Request, + Response, + ErrorResponse, + Notification, +} + +// ─── Record types for UniFFI ─────────────────────────────────────────── + +/// A JSON-RPC message. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JsonRpcMessage { + pub msg_type: JsonRpcMessageType, + pub payload_json: String, + pub method: String, + pub id: String, +} + +/// An incoming MCP request (server-side). +#[derive(Debug, Clone, uniffi::Record)] +pub struct IncomingRequest { + pub message: JsonRpcMessage, + pub client_pubkey: String, + pub event_id: String, + pub is_encrypted: bool, +} + +/// A discovered server announcement. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ServerAnnouncement { + pub pubkey: String, + pub name: Option, + pub version: Option, + pub picture: Option, + pub about: Option, + pub website: Option, + pub event_id: String, +} + +/// Nostr profile metadata for a provider. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderProfile { + pub pubkey: String, + pub name: Option, + pub about: Option, + pub picture: Option, + pub nip05: Option, +} + +/// A discovered MCP tool and provider metadata used by foreign clients. +#[derive(Debug, Clone, uniffi::Record)] +pub struct DiscoveredTool { + pub provider_pubkey: String, + pub provider_display_name: Option, + pub provider_name: Option, + pub provider_about: Option, + pub provider_picture: Option, + pub provider_nip05: Option, + pub tool_name: String, + pub description: String, + pub schema_json: String, +} + +/// Server transport configuration. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ServerConfig { + pub relay_urls: Vec, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_announced_server: bool, + pub server_name: Option, + pub server_version: Option, + pub server_picture: Option, + pub server_about: Option, + pub server_website: Option, + pub allowed_pubkeys: Vec, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + relay_urls: vec!["wss://relay.damus.io".to_string()], + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + is_announced_server: false, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + } + } +} + +/// Client transport configuration. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ClientConfig { + pub relay_urls: Vec, + pub server_pubkey: String, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_stateless: bool, + pub timeout_secs: u64, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + relay_urls: vec![], + server_pubkey: String::new(), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + is_stateless: false, + timeout_secs: 30, + } + } +} + +// ─── Conversion helpers ──────────────────────────────────────────────── + +fn sdk_encryption_mode(m: EncryptionMode) -> contextvm_sdk::EncryptionMode { + match m { + EncryptionMode::Optional => contextvm_sdk::EncryptionMode::Optional, + EncryptionMode::Required => contextvm_sdk::EncryptionMode::Required, + EncryptionMode::Disabled => contextvm_sdk::EncryptionMode::Disabled, + } +} + +fn sdk_gift_wrap_mode(m: GiftWrapMode) -> contextvm_sdk::GiftWrapMode { + match m { + GiftWrapMode::Optional => contextvm_sdk::GiftWrapMode::Optional, + GiftWrapMode::Ephemeral => contextvm_sdk::GiftWrapMode::Ephemeral, + GiftWrapMode::Persistent => contextvm_sdk::GiftWrapMode::Persistent, + } +} + +fn message_to_uniffi(msg: &contextvm_sdk::JsonRpcMessage) -> JsonRpcMessage { + let msg_type = match msg { + contextvm_sdk::JsonRpcMessage::Request(_) => JsonRpcMessageType::Request, + contextvm_sdk::JsonRpcMessage::Response(_) => JsonRpcMessageType::Response, + contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JsonRpcMessageType::ErrorResponse, + contextvm_sdk::JsonRpcMessage::Notification(_) => JsonRpcMessageType::Notification, + }; + + JsonRpcMessage { + msg_type, + payload_json: serde_json::to_string(msg).unwrap_or_default(), + method: msg.method().map(String::from).unwrap_or_default(), + id: msg.id().map(|v| v.to_string()).unwrap_or_default(), + } +} + +fn incoming_to_uniffi(req: &contextvm_sdk::IncomingRequest) -> IncomingRequest { + IncomingRequest { + message: message_to_uniffi(&req.message), + client_pubkey: req.client_pubkey.clone(), + event_id: req.event_id.clone(), + is_encrypted: req.is_encrypted, + } +} + +fn parse_json_rpc(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }) +} + +fn tool_to_uniffi(tool: crate::discovery::DiscoveredToolRecord) -> DiscoveredTool { + DiscoveredTool { + provider_pubkey: tool.provider_pubkey, + provider_display_name: tool.provider_display_name, + provider_name: tool.provider_name, + provider_about: tool.provider_about, + provider_picture: tool.provider_picture, + provider_nip05: tool.provider_nip05, + tool_name: tool.tool_name, + description: tool.description, + schema_json: tool.schema_json, + } +} + +fn profile_to_uniffi(profile: crate::discovery::ProviderProfileRecord) -> ProviderProfile { + ProviderProfile { + pubkey: profile.pubkey, + name: profile.name, + about: profile.about, + picture: profile.picture, + nip05: profile.nip05, + } +} + +// ─── High-level UniFFI objects ───────────────────────────────────────── + +/// A Nostr keypair. +#[derive(uniffi::Object)] +pub struct Keys { + inner: contextvm_sdk::signer::Keys, +} + +#[uniffi::export] +impl Keys { + /// Generate a new random keypair. + #[uniffi::constructor] + pub fn generate() -> Self { + Self { + inner: contextvm_sdk::signer::generate(), + } + } + + /// Create keys from a secret key (hex or nsec/bech32). + #[uniffi::constructor] + pub fn from_secret_key(sk: &str) -> Result { + contextvm_sdk::signer::from_sk(sk) + .map(|inner| Self { inner }) + .map_err(|e| FfiError { + code: crate::error::ErrorCode::Other, + message: e.to_string(), + }) + } + + /// Get the public key (hex). + pub fn public_key(&self) -> String { + self.inner.public_key().to_hex() + } + + /// Get the secret key (hex). + pub fn secret_key(&self) -> String { + self.inner.secret_key().to_secret_hex() + } +} + +/// A relay pool for Nostr connectivity. +#[derive(uniffi::Object)] +pub struct RelayPool { + inner: contextvm_sdk::RelayPool, +} + +#[uniffi::export] +impl RelayPool { + /// Create a new relay pool. + #[uniffi::constructor] + pub fn new(keys: &Keys) -> Result { + global_runtime() + .block_on(contextvm_sdk::RelayPool::new(keys.inner.clone())) + .map(|inner| Self { inner }) + .map_err(FfiError::from) + } + + /// Connect to relays. + pub fn connect(&self, relay_urls: Vec) -> Result<(), FfiError> { + global_runtime() + .block_on(self.inner.connect(&relay_urls)) + .map_err(FfiError::from) + } + + /// Disconnect from all relays. + pub fn disconnect(&self) -> Result<(), FfiError> { + global_runtime() + .block_on(self.inner.disconnect()) + .map_err(FfiError::from) + } +} + +/// A server transport that receives MCP requests over Nostr. +#[derive(uniffi::Object)] +pub struct Server { + transport: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Server { + /// Create and start a server transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ServerConfig) -> Result { + let sdk_config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: config.relay_urls.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + server_name: config.server_name.clone(), + server_version: config.server_version.clone(), + server_picture: config.server_picture.clone(), + server_about: config.server_about.clone(), + server_website: config.server_website.clone(), + is_announced_server: config.is_announced_server, + allowed_pubkeys: config.allowed_pubkeys.clone(), + session_timeout_secs: config.session_timeout_secs, + cleanup_interval_secs: config.cleanup_interval_secs, + }); + + global_runtime() + .block_on(async { + let mut transport = + contextvm_sdk::NostrServerTransport::new(keys.inner.clone(), sdk_config) + .await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(Self { + transport: Arc::new(tokio::sync::Mutex::new(transport)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Receive the next incoming request. Blocks until one is available. + pub fn recv(&self) -> Result { + let rx = self.receiver.clone(); + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|req| incoming_to_uniffi(&req)) + .ok_or_else(|| FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }) + } + + /// Send a response for a given event ID. + pub fn send_response(&self, event_id: &str, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.send_response(event_id, message).await + }) + .map_err(FfiError::from) + } + + /// Publish server announcement. + pub fn announce(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.announce().await + }) + .map(|_| ()) + .map_err(FfiError::from) + } + + /// Close the server transport. + pub fn close(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let mut guard = transport.lock().await; + guard.close().await + }) + .map_err(FfiError::from) + } +} + +/// A client transport that sends MCP requests over Nostr. +#[derive(uniffi::Object)] +pub struct Client { + transport: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Client { + /// Create and start a client transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ClientConfig) -> Result { + let sdk_config = build_sdk_client_config_from_fields( + config.relay_urls.clone(), + config.server_pubkey.clone(), + sdk_encryption_mode(config.encryption_mode), + sdk_gift_wrap_mode(config.gift_wrap_mode), + config.is_stateless, + config.timeout_secs, + ); + + global_runtime() + .block_on(async { + let mut transport = + contextvm_sdk::NostrClientTransport::new(keys.inner.clone(), sdk_config) + .await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(Self { + transport: Arc::new(tokio::sync::Mutex::new(transport)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Send a JSON-RPC message. + pub fn send(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.send(&message).await + }) + .map_err(FfiError::from) + } + + /// Receive the next response. Blocks until one is available. + pub fn recv(&self) -> Result { + let rx = self.receiver.clone(); + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|msg| message_to_uniffi(&msg)) + .ok_or_else(|| FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }) + } + + /// Close the client transport. + pub fn close(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let mut guard = transport.lock().await; + guard.close().await + }) + .map_err(FfiError::from) + } +} + +/// A proxy that connects a local MCP client to a remote Nostr MCP server. +#[derive(uniffi::Object)] +pub struct Proxy { + proxy: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Proxy { + /// Create and start a proxy transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ClientConfig) -> Result { + let sdk_config = build_sdk_client_config_from_fields( + config.relay_urls.clone(), + config.server_pubkey.clone(), + sdk_encryption_mode(config.encryption_mode), + sdk_gift_wrap_mode(config.gift_wrap_mode), + config.is_stateless, + config.timeout_secs, + ); + let proxy_config = contextvm_sdk::proxy::ProxyConfig::new(sdk_config); + + global_runtime() + .block_on(async { + let mut proxy = + contextvm_sdk::proxy::NostrMCPProxy::new(keys.inner.clone(), proxy_config) + .await?; + let receiver = proxy.start().await?; + Ok::<_, contextvm_sdk::Error>(Self { + proxy: Arc::new(tokio::sync::Mutex::new(proxy)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Send a JSON-RPC message through the proxy. + pub fn send(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let proxy = self.proxy.clone(); + global_runtime() + .block_on(async { + let guard = proxy.lock().await; + guard.send(&message).await + }) + .map_err(FfiError::from) + } + + /// Receive the next response or notification. + pub fn recv(&self) -> Result { + let rx = self.receiver.clone(); + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|msg| message_to_uniffi(&msg)) + .ok_or_else(|| FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }) + } + + /// Receive the next response or notification, timing out after `timeout_secs`. + pub fn recv_timeout(&self, timeout_secs: u64) -> Result { + let rx = self.receiver.clone(); + match global_runtime().block_on(async { + let mut guard = rx.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), guard.recv()).await + }) { + Ok(Some(msg)) => Ok(message_to_uniffi(&msg)), + Ok(None) => Err(FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }), + Err(_) => Err(FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }), + } + } + + /// Stop the proxy transport. + pub fn stop(&self) -> Result<(), FfiError> { + let proxy = self.proxy.clone(); + global_runtime() + .block_on(async { + let mut guard = proxy.lock().await; + guard.stop().await + }) + .map_err(FfiError::from) + } +} + +/// Discovery functions. +#[derive(uniffi::Object)] +pub struct Discovery; + +#[uniffi::export] +impl Discovery { + #[uniffi::constructor] + pub fn new() -> Self { + Self + } + + /// Discover MCP servers on the given relay URLs. + pub fn discover_servers( + &self, + pool: &RelayPool, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + contextvm_sdk::discovery::discover_servers(client, &relay_urls).await + }) + .map(|announcements| { + announcements + .into_iter() + .map(|a| ServerAnnouncement { + pubkey: a.pubkey, + name: a.server_info.name, + version: a.server_info.version, + picture: a.server_info.picture, + about: a.server_info.about, + website: a.server_info.website, + event_id: a.event_id.to_hex(), + }) + .collect() + }) + .map_err(FfiError::from) + } + + /// Discover MCP tools published by a specific provider. + pub fn discover_tools( + &self, + pool: &RelayPool, + provider_pubkey: String, + provider_display_name: Option, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + crate::discovery::discover_tools( + client, + &provider_pubkey, + provider_display_name, + &relay_urls, + ) + .await + }) + .map(|tools| tools.into_iter().map(tool_to_uniffi).collect()) + .map_err(FfiError::from) + } + + /// Discover server announcements, tools, and provider profiles in one pass. + pub fn discover_all_tools( + &self, + pool: &RelayPool, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { crate::discovery::discover_all_tools(client, &relay_urls).await }) + .map(|tools| tools.into_iter().map(tool_to_uniffi).collect()) + .map_err(FfiError::from) + } + + /// Fetch Nostr kind-0 provider profiles for a set of provider pubkeys. + pub fn fetch_provider_profiles( + &self, + pool: &RelayPool, + provider_pubkeys: Vec, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + crate::discovery::fetch_provider_profiles(client, &provider_pubkeys, &relay_urls) + .await + }) + .map(|profiles| profiles.into_values().map(profile_to_uniffi).collect()) + .map_err(FfiError::from) + } +} + +impl Default for Discovery { + fn default() -> Self { + Self::new() + } +} + +// ─── Top-level functions ─────────────────────────────────────────────── + +/// Get the library version. +#[uniffi::export] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Convert a hex public key to npub bech32. +#[uniffi::export] +pub fn pubkey_hex_to_npub(pubkey_hex: String) -> Result { + crate::discovery::pubkey_hex_to_npub(&pubkey_hex).map_err(FfiError::from) +} + +/// Helper: build a JSON-RPC request as a JSON string. +#[uniffi::export] +pub fn make_request(id: String, method: String, params: Option) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Request(contextvm_sdk::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + method, + params: params.and_then(|p| serde_json::from_str(&p).ok()), + }); + serde_json::to_string(&msg).unwrap_or_default() +} + +/// Helper: build a JSON-RPC notification as a JSON string. +#[uniffi::export] +pub fn make_notification(method: String, params: Option) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Notification(contextvm_sdk::JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method, + params: params.and_then(|p| serde_json::from_str(&p).ok()), + }); + serde_json::to_string(&msg).unwrap_or_default() +} + +/// Helper: build a JSON-RPC response as a JSON string. +#[uniffi::export] +pub fn make_response(id: String, result: String) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Response(contextvm_sdk::JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + result: serde_json::from_str(&result).unwrap_or(serde_json::json!(null)), + }); + serde_json::to_string(&msg).unwrap_or_default() +} From e6e7625817a8618a8a483ac79f2b60815a62b466 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 29 May 2026 16:24:35 +0200 Subject: [PATCH 2/2] fixing minor issues, features --- contextvm-ffi/README.md | 15 + contextvm-ffi/headers/contextvm.h | 53 ++- contextvm-ffi/src/builders.rs | 467 ++++++++++++++++++++++--- contextvm-ffi/src/channel.rs | 560 ++++++++++++++++++++++++++++-- contextvm-ffi/src/types.rs | 254 +++++++++++--- contextvm-ffi/src/uniffi_types.rs | 428 ++++++++++++++++++++++- src/gateway/mod.rs | 1 + src/transport/server/mod.rs | 47 ++- 8 files changed, 1683 insertions(+), 142 deletions(-) diff --git a/contextvm-ffi/README.md b/contextvm-ffi/README.md index ed74c6b..40accba 100644 --- a/contextvm-ffi/README.md +++ b/contextvm-ffi/README.md @@ -60,6 +60,21 @@ cvm_keys_free(keys); Errors are opaque. Use `cvm_error_code`, `cvm_error_message`, and `cvm_error_free` to inspect and release them. +Mode fields in `CvmServerConfig` and `CvmClientConfig` are raw `int32_t` +values. Set them with the `CVM_ENCRYPTION_*` and `CVM_GIFTWRAP_*` constants; +invalid values are rejected with `CVM_VALIDATION`. + +## JSON Arguments + +Several parity APIs use JSON strings to represent SDK values that are not +portable C structs: + +- `profile_metadata_json`: a `ProfileMetadata` JSON object. +- `*_publish_tools/resources/prompts/resource_templates`: a JSON array of MCP + capability objects. +- `*_set_announcement_*_tags`: a JSON array of Nostr tag arrays, for example + `[["pricing","free"]]`. + ## Memory Management Rust-owned values returned through the C ABI must be released by the caller: diff --git a/contextvm-ffi/headers/contextvm.h b/contextvm-ffi/headers/contextvm.h index 6b631e1..a326cec 100644 --- a/contextvm-ffi/headers/contextvm.h +++ b/contextvm-ffi/headers/contextvm.h @@ -72,7 +72,7 @@ typedef enum { /* ─── Structs ──────────────────────────────────────────────────────── */ typedef struct { - CvmJsonRpcType msg_type; + int32_t msg_type; /* one of CvmJsonRpcType */ char *payload_json; /* owned JSON string */ char *method; /* owned, may be NULL */ char *id; /* owned, may be NULL */ @@ -115,31 +115,56 @@ typedef struct { char *nip05; /* owned, may be NULL */ } CvmProviderProfile; +typedef struct { + char *method; /* required */ + char *name; /* optional */ +} CvmCapabilityExclusion; + +typedef struct { + bool supports_encryption; + bool supports_ephemeral_encryption; + bool supports_oversized_transfer; +} CvmPeerCapabilities; + typedef struct { char **relay_urls; size_t relay_url_count; - CvmEncryptionMode encryption_mode; - CvmGiftWrapMode gift_wrap_mode; + int32_t encryption_mode; /* one of CvmEncryptionMode */ + int32_t gift_wrap_mode; /* one of CvmGiftWrapMode */ bool is_announced_server; char *server_name; /* may be NULL */ char *server_version; /* may be NULL */ char *server_picture; /* may be NULL */ char *server_about; /* may be NULL */ char *server_website; /* may be NULL */ - char **allowed_pubkeys; + char **allowed_pubkeys; /* if count > 0, pointer and entries must be non-NULL UTF-8 */ size_t allowed_pubkey_count; uint64_t session_timeout_secs; uint64_t cleanup_interval_secs; + CvmCapabilityExclusion *excluded_capabilities; + size_t excluded_capability_count; + size_t max_sessions; /* 0 keeps SDK default */ + uint64_t request_timeout_secs; /* 0 keeps SDK default */ + char **relay_list_urls; + size_t relay_list_url_count; + char **bootstrap_relay_urls; + size_t bootstrap_relay_url_count; + bool publish_relay_list; + char *profile_metadata_json; /* optional ProfileMetadata JSON object */ } CvmServerConfig; typedef struct { char **relay_urls; size_t relay_url_count; char *server_pubkey; /* required */ - CvmEncryptionMode encryption_mode; - CvmGiftWrapMode gift_wrap_mode; + int32_t encryption_mode; /* one of CvmEncryptionMode */ + int32_t gift_wrap_mode; /* one of CvmGiftWrapMode */ bool is_stateless; uint64_t timeout_secs; + char **discovery_relay_urls; + size_t discovery_relay_url_count; + char **fallback_operational_relay_urls; + size_t fallback_operational_relay_url_count; } CvmClientConfig; /* ─── Free Functions ───────────────────────────────────────────────── */ @@ -178,7 +203,17 @@ void cvm_relay_pool_free(CvmHandle handle); CvmHandle cvm_server_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmError **error); bool cvm_server_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); bool cvm_server_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); +bool cvm_server_ch_send_notification(CvmHandle handle, const char *client_pubkey, const char *payload_json, const char *correlated_event_id, CvmError **error); +bool cvm_server_ch_broadcast_notification(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_server_ch_set_announcement_extra_tags(CvmHandle handle, const char *tags_json, CvmError **error); +bool cvm_server_ch_set_announcement_pricing_tags(CvmHandle handle, const char *tags_json, CvmError **error); bool cvm_server_ch_announce(CvmHandle handle, CvmError **error); +char *cvm_server_ch_announce_event_id(CvmHandle handle, CvmError **error); +char *cvm_server_ch_publish_tools(CvmHandle handle, const char *tools_json, CvmError **error); +char *cvm_server_ch_publish_resources(CvmHandle handle, const char *resources_json, CvmError **error); +char *cvm_server_ch_publish_prompts(CvmHandle handle, const char *prompts_json, CvmError **error); +char *cvm_server_ch_publish_resource_templates(CvmHandle handle, const char *templates_json, CvmError **error); +bool cvm_server_ch_delete_announcements(CvmHandle handle, const char *reason, CvmError **error); bool cvm_server_ch_close(CvmHandle handle, CvmError **error); /* ─── Client (channel-based) ──────────────────────────────────────── */ @@ -186,6 +221,9 @@ bool cvm_server_ch_close(CvmHandle handle, CvmError **error); CvmHandle cvm_client_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmError **error); bool cvm_client_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); bool cvm_client_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_client_ch_discovered_server_capabilities(CvmHandle handle, CvmPeerCapabilities *out_caps, CvmError **error); +bool cvm_client_ch_server_supports_ephemeral_encryption(CvmHandle handle, CvmError **error); +char *cvm_client_ch_server_initialize_event_json(CvmHandle handle, CvmError **error); bool cvm_client_ch_close(CvmHandle handle, CvmError **error); /* ─── Gateway (channel-based) ─────────────────────────────────────── */ @@ -194,6 +232,8 @@ CvmHandle cvm_gateway_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmE bool cvm_gateway_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); bool cvm_gateway_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); bool cvm_gateway_ch_announce(CvmHandle handle, CvmError **error); +char *cvm_gateway_ch_announce_event_id(CvmHandle handle, CvmError **error); +bool cvm_gateway_ch_is_active(CvmHandle handle, CvmError **error); bool cvm_gateway_ch_stop(CvmHandle handle, CvmError **error); /* ─── Proxy (channel-based) ───────────────────────────────────────── */ @@ -202,6 +242,7 @@ CvmHandle cvm_proxy_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmErr bool cvm_proxy_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); bool cvm_proxy_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); bool cvm_proxy_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_proxy_ch_is_active(CvmHandle handle, CvmError **error); bool cvm_proxy_ch_stop(CvmHandle handle, CvmError **error); /* ─── Discovery ────────────────────────────────────────────────────── */ diff --git a/contextvm-ffi/src/builders.rs b/contextvm-ffi/src/builders.rs index e6c726d..3ec2a6d 100644 --- a/contextvm-ffi/src/builders.rs +++ b/contextvm-ffi/src/builders.rs @@ -6,7 +6,12 @@ use std::time::Duration; -use crate::types::{c_str_array_to_vec, c_str_to_string, FfiClientConfig, FfiServerConfig}; +use crate::error::{ErrorCode, FfiError}; +use crate::types::{ + c_str_array_to_vec_checked, c_str_to_string_checked, ffi_encryption_mode_to_sdk, + ffi_gift_wrap_mode_to_sdk, optional_c_str_to_string_checked, FfiCapabilityExclusion, + FfiClientConfig, FfiServerConfig, +}; pub struct ServerConfigParts { pub relay_urls: Vec, @@ -21,6 +26,30 @@ pub struct ServerConfigParts { pub allowed_pubkeys: Vec, pub session_timeout_secs: u64, pub cleanup_interval_secs: u64, + pub excluded_capabilities: Vec, + pub max_sessions: usize, + pub request_timeout_secs: u64, + pub relay_list_urls: Vec, + pub bootstrap_relay_urls: Vec, + pub publish_relay_list: bool, + pub profile_metadata_json: Option, +} + +pub struct ClientConfigParts { + pub relay_urls: Vec, + pub server_pubkey: String, + pub encryption_mode: contextvm_sdk::EncryptionMode, + pub gift_wrap_mode: contextvm_sdk::GiftWrapMode, + pub is_stateless: bool, + pub timeout_secs: u64, + pub discovery_relay_urls: Vec, + pub fallback_operational_relay_urls: Vec, +} + +#[derive(Clone)] +pub struct CapabilityExclusionParts { + pub method: String, + pub name: Option, } /// Build a `ServerInfo` from FFI C strings. @@ -62,17 +91,42 @@ pub fn build_server_info( /// Extract fields from an FfiServerConfig and build an SDK server config. pub fn build_sdk_server_config( config: &FfiServerConfig, -) -> contextvm_sdk::NostrServerTransportConfig { - let relay_urls = c_str_array_to_vec(config.relay_urls, config.relay_url_count); - let allowed = c_str_array_to_vec(config.allowed_pubkeys, config.allowed_pubkey_count); +) -> Result { + let relay_urls = + c_str_array_to_vec_checked(config.relay_urls, config.relay_url_count, "relay_urls")?; + let allowed = c_str_array_to_vec_checked( + config.allowed_pubkeys, + config.allowed_pubkey_count, + "allowed_pubkeys", + )?; + let excluded = ffi_capability_exclusions_to_parts( + config.excluded_capabilities, + config.excluded_capability_count, + )?; + let relay_list_urls = c_str_array_to_vec_checked( + config.relay_list_urls, + config.relay_list_url_count, + "relay_list_urls", + )?; + let bootstrap_relay_urls = c_str_array_to_vec_checked( + config.bootstrap_relay_urls, + config.bootstrap_relay_url_count, + "bootstrap_relay_urls", + )?; + let encryption_mode = ffi_encryption_mode_to_sdk(config.encryption_mode)?; + let gift_wrap_mode = ffi_gift_wrap_mode_to_sdk(config.gift_wrap_mode)?; let server_info = build_server_info( - c_str_to_string(config.server_name), - c_str_to_string(config.server_version), - c_str_to_string(config.server_picture), - c_str_to_string(config.server_about), - c_str_to_string(config.server_website), + optional_c_str_to_string_checked(config.server_name, "server_name")?, + optional_c_str_to_string_checked(config.server_version, "server_version")?, + optional_c_str_to_string_checked(config.server_picture, "server_picture")?, + optional_c_str_to_string_checked(config.server_about, "server_about")?, + optional_c_str_to_string_checked(config.server_website, "server_website")?, ); + let profile_metadata = parse_profile_metadata_json(optional_c_str_to_string_checked( + config.profile_metadata_json, + "profile_metadata_json", + )?)?; let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() .with_relay_urls(if relay_urls.is_empty() { @@ -80,42 +134,67 @@ pub fn build_sdk_server_config( } else { relay_urls }) - .with_encryption_mode(config.encryption_mode.into()) - .with_gift_wrap_mode(config.gift_wrap_mode.into()) + .with_encryption_mode(encryption_mode) + .with_gift_wrap_mode(gift_wrap_mode) .with_announced_server(config.is_announced_server) .with_allowed_public_keys(allowed) .with_session_timeout(Duration::from_secs(config.session_timeout_secs.max(1))) - .with_cleanup_interval(Duration::from_secs(config.cleanup_interval_secs.max(1))); + .with_cleanup_interval(Duration::from_secs(config.cleanup_interval_secs.max(1))) + .with_publish_relay_list(config.publish_relay_list); if let Some(server_info) = server_info { sdk_config = sdk_config.with_server_info(server_info); } - sdk_config + sdk_config = apply_extended_server_config( + sdk_config, + excluded, + config.max_sessions, + config.request_timeout_secs, + relay_list_urls, + bootstrap_relay_urls, + profile_metadata, + ); + + Ok(sdk_config) } /// Extract fields from an FfiClientConfig and build an SDK client config. pub fn build_sdk_client_config( config: &FfiClientConfig, -) -> Option { - let server_pubkey = c_str_to_string(config.server_pubkey)?; - let relay_urls = c_str_array_to_vec(config.relay_urls, config.relay_url_count); - - Some( - contextvm_sdk::NostrClientTransportConfig::default() - .with_relay_urls(relay_urls) - .with_server_pubkey(server_pubkey) - .with_encryption_mode(config.encryption_mode.into()) - .with_gift_wrap_mode(config.gift_wrap_mode.into()) - .with_stateless(config.is_stateless) - .with_timeout(Duration::from_secs(config.timeout_secs.max(1))), - ) +) -> Result { + let server_pubkey = c_str_to_string_checked(config.server_pubkey, "server_pubkey")?; + let relay_urls = + c_str_array_to_vec_checked(config.relay_urls, config.relay_url_count, "relay_urls")?; + let discovery_relay_urls = c_str_array_to_vec_checked( + config.discovery_relay_urls, + config.discovery_relay_url_count, + "discovery_relay_urls", + )?; + let fallback_operational_relay_urls = c_str_array_to_vec_checked( + config.fallback_operational_relay_urls, + config.fallback_operational_relay_url_count, + "fallback_operational_relay_urls", + )?; + let encryption_mode = ffi_encryption_mode_to_sdk(config.encryption_mode)?; + let gift_wrap_mode = ffi_gift_wrap_mode_to_sdk(config.gift_wrap_mode)?; + + Ok(build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls, + server_pubkey, + encryption_mode, + gift_wrap_mode, + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls, + fallback_operational_relay_urls, + })) } /// Build a server config from UniFFI record fields. pub fn build_sdk_server_config_from_fields( parts: ServerConfigParts, -) -> contextvm_sdk::NostrServerTransportConfig { +) -> Result { let server_info = build_server_info( parts.server_name, parts.server_version, @@ -123,6 +202,7 @@ pub fn build_sdk_server_config_from_fields( parts.server_about, parts.server_website, ); + let profile_metadata = parse_profile_metadata_json(parts.profile_metadata_json)?; let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() .with_relay_urls(if parts.relay_urls.is_empty() { @@ -135,36 +215,183 @@ pub fn build_sdk_server_config_from_fields( .with_announced_server(parts.is_announced_server) .with_allowed_public_keys(parts.allowed_pubkeys) .with_session_timeout(Duration::from_secs(parts.session_timeout_secs.max(1))) - .with_cleanup_interval(Duration::from_secs(parts.cleanup_interval_secs.max(1))); + .with_cleanup_interval(Duration::from_secs(parts.cleanup_interval_secs.max(1))) + .with_publish_relay_list(parts.publish_relay_list); if let Some(server_info) = server_info { sdk_config = sdk_config.with_server_info(server_info); } - sdk_config + sdk_config = apply_extended_server_config( + sdk_config, + parts.excluded_capabilities, + parts.max_sessions, + parts.request_timeout_secs, + parts.relay_list_urls, + parts.bootstrap_relay_urls, + profile_metadata, + ); + + Ok(sdk_config) } /// Build a client config from UniFFI record fields. pub fn build_sdk_client_config_from_fields( - relay_urls: Vec, - server_pubkey: String, - encryption_mode: contextvm_sdk::EncryptionMode, - gift_wrap_mode: contextvm_sdk::GiftWrapMode, - is_stateless: bool, - timeout_secs: u64, + parts: ClientConfigParts, ) -> contextvm_sdk::NostrClientTransportConfig { - contextvm_sdk::NostrClientTransportConfig::default() - .with_relay_urls(relay_urls) - .with_server_pubkey(server_pubkey) - .with_encryption_mode(encryption_mode) - .with_gift_wrap_mode(gift_wrap_mode) - .with_stateless(is_stateless) - .with_timeout(Duration::from_secs(timeout_secs.max(1))) + let mut config = contextvm_sdk::NostrClientTransportConfig::default() + .with_relay_urls(parts.relay_urls) + .with_server_pubkey(parts.server_pubkey) + .with_encryption_mode(parts.encryption_mode) + .with_gift_wrap_mode(parts.gift_wrap_mode) + .with_stateless(parts.is_stateless) + .with_timeout(Duration::from_secs(parts.timeout_secs.max(1))); + + if !parts.discovery_relay_urls.is_empty() { + config = config.with_discovery_relay_urls(parts.discovery_relay_urls); + } + if !parts.fallback_operational_relay_urls.is_empty() { + config = config.with_fallback_operational_relay_urls(parts.fallback_operational_relay_urls); + } + + config +} + +fn apply_extended_server_config( + mut config: contextvm_sdk::NostrServerTransportConfig, + excluded_capabilities: Vec, + max_sessions: usize, + request_timeout_secs: u64, + relay_list_urls: Vec, + bootstrap_relay_urls: Vec, + profile_metadata: Option, +) -> contextvm_sdk::NostrServerTransportConfig { + if !excluded_capabilities.is_empty() { + config = config.with_excluded_capabilities( + excluded_capabilities + .into_iter() + .map(|cap| contextvm_sdk::CapabilityExclusion { + method: cap.method, + name: cap.name, + }) + .collect(), + ); + } + if max_sessions > 0 { + config = config.with_max_sessions(max_sessions); + } + if request_timeout_secs > 0 { + config = config.with_request_timeout(Duration::from_secs(request_timeout_secs)); + } + if !relay_list_urls.is_empty() { + config = config.with_relay_list_urls(relay_list_urls); + } + if !bootstrap_relay_urls.is_empty() { + config = config.with_bootstrap_relay_urls(bootstrap_relay_urls); + } + if let Some(profile_metadata) = profile_metadata { + config = config.with_profile_metadata(profile_metadata); + } + config +} + +fn ffi_capability_exclusions_to_parts( + ptr: *mut FfiCapabilityExclusion, + count: usize, +) -> Result, FfiError> { + if count == 0 { + return Ok(Vec::new()); + } + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("excluded_capabilities has count {count} but null pointer"), + }); + } + + unsafe { + std::slice::from_raw_parts(ptr, count) + .iter() + .map(|cap| { + let method = c_str_to_string_checked(cap.method, "capability_exclusions[].method")?; + Ok(CapabilityExclusionParts { + method, + name: optional_c_str_to_string_checked( + cap.name, + "capability_exclusions[].name", + )?, + }) + }) + .collect() + } +} + +fn parse_profile_metadata_json( + json: Option, +) -> Result, FfiError> { + match json { + Some(json) if !json.trim().is_empty() => { + serde_json::from_str(&json).map(Some).map_err(|e| FfiError { + code: ErrorCode::Serialization, + message: format!("invalid profile_metadata_json: {e}"), + }) + } + _ => Ok(None), + } } #[cfg(test)] mod tests { use super::*; + use crate::types::{ENCRYPTION_MODE_OPTIONAL, GIFT_WRAP_MODE_OPTIONAL}; + use std::ffi::CString; + use std::os::raw::c_char; + use std::ptr; + + fn minimal_ffi_server_config() -> FfiServerConfig { + FfiServerConfig { + relay_urls: ptr::null_mut(), + relay_url_count: 0, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: ptr::null_mut(), + server_version: ptr::null_mut(), + server_picture: ptr::null_mut(), + server_about: ptr::null_mut(), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + } + } + + fn minimal_ffi_client_config(server_pubkey: *mut c_char) -> FfiClientConfig { + FfiClientConfig { + relay_urls: ptr::null_mut(), + relay_url_count: 0, + server_pubkey, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + } + } #[test] fn omitted_server_info_matches_sdk_default() { @@ -183,7 +410,15 @@ mod tests { allowed_pubkeys: vec![], session_timeout_secs: 300, cleanup_interval_secs: 60, - }); + excluded_capabilities: vec![], + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: vec![], + bootstrap_relay_urls: vec![], + publish_relay_list: true, + profile_metadata_json: None, + }) + .expect("config should build"); assert!(config.server_info.is_none()); } @@ -205,4 +440,150 @@ mod tests { assert_eq!(info.about.as_deref(), Some("about")); assert_eq!(info.website.as_deref(), Some("https://example.com")); } + + #[test] + fn server_config_preserves_extended_fields() { + let config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + is_announced_server: false, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: vec![CapabilityExclusionParts { + method: "tools/call".to_string(), + name: Some("get_weather".to_string()), + }], + max_sessions: 42, + request_timeout_secs: 17, + relay_list_urls: vec!["wss://relay-list.example.com".to_string()], + bootstrap_relay_urls: vec!["wss://bootstrap.example.com".to_string()], + publish_relay_list: false, + profile_metadata_json: Some(r#"{"name":"profile","nip05":"bot@example.com"}"#.into()), + }) + .expect("config should build"); + + assert_eq!(config.excluded_capabilities.len(), 1); + assert_eq!(config.max_sessions, 42); + assert_eq!(config.request_timeout.as_secs(), 17); + assert_eq!( + config.relay_list_urls.as_deref(), + Some(&["wss://relay-list.example.com".to_string()][..]) + ); + assert_eq!( + config.bootstrap_relay_urls.as_deref(), + Some(&["wss://bootstrap.example.com".to_string()][..]) + ); + assert!(!config.publish_relay_list); + let profile = config.profile_metadata.expect("profile metadata"); + assert_eq!(profile.name.as_deref(), Some("profile")); + assert_eq!(profile.nip05.as_deref(), Some("bot@example.com")); + } + + #[test] + fn client_config_preserves_discovery_relays() { + let config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + server_pubkey: "abc".to_string(), + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: vec!["wss://discovery.example.com".to_string()], + fallback_operational_relay_urls: vec!["wss://fallback.example.com".to_string()], + }); + + assert_eq!( + config.discovery_relay_urls.as_deref(), + Some(&["wss://discovery.example.com".to_string()][..]) + ); + assert_eq!( + config.fallback_operational_relay_urls.as_deref(), + Some(&["wss://fallback.example.com".to_string()][..]) + ); + } + + #[test] + fn ffi_server_config_rejects_counted_null_allowlist_pointer() { + let mut config = minimal_ffi_server_config(); + config.allowed_pubkey_count = 1; + + let err = build_sdk_server_config(&config).expect_err("counted null allowlist must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys")); + } + + #[test] + fn ffi_server_config_rejects_null_allowlist_entry() { + let mut config = minimal_ffi_server_config(); + let mut entries = [ptr::null_mut()]; + config.allowed_pubkeys = entries.as_mut_ptr(); + config.allowed_pubkey_count = entries.len(); + + let err = build_sdk_server_config(&config).expect_err("null allowlist entry must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys[0]")); + } + + #[test] + fn ffi_server_config_rejects_non_utf8_allowlist_entry() { + let mut config = minimal_ffi_server_config(); + let bad = CString::new(vec![0xff]).expect("no interior nul"); + let mut entries = [bad.as_ptr() as *mut c_char]; + config.allowed_pubkeys = entries.as_mut_ptr(); + config.allowed_pubkey_count = entries.len(); + + let err = + build_sdk_server_config(&config).expect_err("non-UTF-8 allowlist entry must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys[0]")); + } + + #[test] + fn ffi_server_config_rejects_invalid_modes() { + let mut config = minimal_ffi_server_config(); + config.encryption_mode = 99; + + let err = build_sdk_server_config(&config).expect_err("invalid encryption mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("encryption_mode")); + + let mut config = minimal_ffi_server_config(); + config.gift_wrap_mode = 99; + + let err = build_sdk_server_config(&config).expect_err("invalid gift-wrap mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("gift_wrap_mode")); + } + + #[test] + fn ffi_client_config_rejects_missing_pubkey_and_invalid_modes() { + let config = minimal_ffi_client_config(ptr::null_mut()); + + let err = build_sdk_client_config(&config).expect_err("missing server pubkey must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("server_pubkey")); + + let server_pubkey = CString::new("abc").expect("valid c string"); + let mut config = minimal_ffi_client_config(server_pubkey.as_ptr() as *mut c_char); + config.encryption_mode = 99; + + let err = + build_sdk_client_config(&config).expect_err("invalid client encryption mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("encryption_mode")); + } } diff --git a/contextvm-ffi/src/channel.rs b/contextvm-ffi/src/channel.rs index e05638b..5c4c0b5 100644 --- a/contextvm-ffi/src/channel.rs +++ b/contextvm-ffi/src/channel.rs @@ -1,7 +1,7 @@ //! Channel-based wrapper types that allow FFI consumers to receive messages. use crate::builders::{build_sdk_client_config, build_sdk_server_config}; -use crate::error::{set_error, FfiError}; +use crate::error::{set_error, ErrorCode, FfiError}; use crate::handle::FfiHandle; use crate::kv; use crate::runtime::global_runtime; @@ -51,11 +51,18 @@ pub extern "C" fn cvm_server_ch_new( None => return FfiHandle { id: 0 }, }; - let sdk_config = build_sdk_server_config(&config); + let sdk_config = match build_sdk_server_config(&config) { + Ok(config) => config, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; let result = global_runtime().block_on(async { let mut transport = contextvm_sdk::NostrServerTransport::new(keys, sdk_config).await?; transport.start().await?; + transport.spawn_discoverability_publication(); let receiver = transport .take_message_receiver() .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; @@ -179,6 +186,134 @@ pub extern "C" fn cvm_server_ch_send_response( } } +/// Send a notification to a specific client through a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_send_notification( + handle: FfiHandle, + client_pubkey: *const c_char, + payload_json: *const c_char, + correlated_event_id: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + + let client_pubkey = match c_str_to_string(client_pubkey) { + Some(s) => s, + None => { + set_null_arg_error(error, "client_pubkey"); + return false; + } + }; + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + let correlated_event_id = c_str_to_string(correlated_event_id); + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport + .send_notification(&client_pubkey, &msg, correlated_event_id.as_deref()) + .await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Broadcast a notification to all initialized clients. +#[no_mangle] +pub extern "C" fn cvm_server_ch_broadcast_notification( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.broadcast_notification(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Sets extra announcement/discovery tags from a JSON array of tag arrays. +#[no_mangle] +pub extern "C" fn cvm_server_ch_set_announcement_extra_tags( + handle: FfiHandle, + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let tags = match parse_tags_json(tags_json, error) { + Some(tags) => tags, + None => return false, + }; + + global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.set_announcement_extra_tags(tags); + }); + true +} + +/// Sets pricing tags from a JSON array of tag arrays. +#[no_mangle] +pub extern "C" fn cvm_server_ch_set_announcement_pricing_tags( + handle: FfiHandle, + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let tags = match parse_tags_json(tags_json, error) { + Some(tags) => tags, + None => return false, + }; + + global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.set_announcement_pricing_tags(tags); + }); + true +} + /// Publish server announcement. #[no_mangle] pub extern "C" fn cvm_server_ch_announce(handle: FfiHandle, error: *mut *mut FfiError) -> bool { @@ -209,6 +344,122 @@ pub extern "C" fn cvm_server_ch_announce(handle: FfiHandle, error: *mut *mut Ffi } } +/// Publish server announcement and return the Nostr event ID. +#[no_mangle] +pub extern "C" fn cvm_server_ch_announce_event_id( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.announce().await + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_tools( + handle: FfiHandle, + tools_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let tools = match parse_json_value_array(tools_json, "tools_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, tools, ServerPublishListKind::Tools, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_resources( + handle: FfiHandle, + resources_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let resources = match parse_json_value_array(resources_json, "resources_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, resources, ServerPublishListKind::Resources, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_prompts( + handle: FfiHandle, + prompts_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let prompts = match parse_json_value_array(prompts_json, "prompts_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, prompts, ServerPublishListKind::Prompts, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_resource_templates( + handle: FfiHandle, + templates_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let templates = match parse_json_value_array(templates_json, "templates_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values( + handle, + templates, + ServerPublishListKind::ResourceTemplates, + error, + ) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_delete_announcements( + handle: FfiHandle, + reason: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let reason = match c_str_to_string(reason) { + Some(s) => s, + None => { + set_null_arg_error(error, "reason"); + return false; + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.delete_announcements(&reason).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + /// Close a server channel. #[no_mangle] pub extern "C" fn cvm_server_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { @@ -256,15 +507,9 @@ pub extern "C" fn cvm_client_ch_new( }; let sdk_config = match build_sdk_client_config(&config) { - Some(c) => c, - None => { - set_error( - error, - FfiError { - code: crate::error::ErrorCode::Validation, - message: "server_pubkey is required".into(), - }, - ); + Ok(c) => c, + Err(e) => { + set_error(error, e); return FfiHandle { id: 0 }; } }; @@ -375,6 +620,92 @@ pub extern "C" fn cvm_client_ch_recv( } } +/// Return a snapshot of server capabilities learned from discovery tags. +#[no_mangle] +pub extern "C" fn cvm_client_ch_discovered_server_capabilities( + handle: FfiHandle, + out_caps: *mut FfiPeerCapabilities, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return false; + } + }; + if out_caps.is_null() { + set_null_arg_error(error, "out_caps"); + return false; + } + + let caps = global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.discovered_server_capabilities() + }); + unsafe { + *out_caps = peer_capabilities_to_ffi(caps); + } + true +} + +/// Return whether the client has learned ephemeral gift-wrap support. +#[no_mangle] +pub extern "C" fn cvm_client_ch_server_supports_ephemeral_encryption( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return false; + } + }; + + global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.server_supports_ephemeral_encryption() + }) +} + +/// Return the first server event carrying discovery tags as JSON, or NULL if none. +#[no_mangle] +pub extern "C" fn cvm_client_ch_server_initialize_event_json( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return std::ptr::null_mut(); + } + }; + + let event = global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.get_server_initialize_event() + }); + + match event { + Some(event) => match serde_json::to_string(&event) { + Ok(json) => string_to_c(json), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + std::ptr::null_mut() + } + }, + None => std::ptr::null_mut(), + } +} + /// Close a client channel. #[no_mangle] pub extern "C" fn cvm_client_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { @@ -421,7 +752,13 @@ pub extern "C" fn cvm_gateway_ch_new( None => return FfiHandle { id: 0 }, }; - let sdk_config = build_sdk_server_config(&config); + let sdk_config = match build_sdk_server_config(&config) { + Ok(config) => config, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; let gw_config = contextvm_sdk::gateway::GatewayConfig::new(sdk_config); let result = global_runtime().block_on(async { @@ -577,6 +914,49 @@ pub extern "C" fn cvm_gateway_ch_announce(handle: FfiHandle, error: *mut *mut Ff } } +/// Publish gateway server announcement and return the Nostr event ID. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_announce_event_id( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "gateway channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.announce().await + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +/// Check if a gateway channel is active. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_is_active(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "gateway channel"); + return false; + } + }; + + global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.is_active() + }) +} + /// Stop a gateway channel. #[no_mangle] pub extern "C" fn cvm_gateway_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { @@ -624,15 +1004,9 @@ pub extern "C" fn cvm_proxy_ch_new( }; let sdk_config = match build_sdk_client_config(&config) { - Some(c) => c, - None => { - set_error( - error, - FfiError { - code: crate::error::ErrorCode::Validation, - message: "server_pubkey is required".into(), - }, - ); + Ok(c) => c, + Err(e) => { + set_error(error, e); return FfiHandle { id: 0 }; } }; @@ -799,6 +1173,23 @@ pub extern "C" fn cvm_proxy_ch_recv_timeout( } } +/// Check if a proxy channel is active. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_is_active(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "proxy channel"); + return false; + } + }; + + global_runtime().block_on(async { + let proxy = channel.proxy.lock().await; + proxy.is_active() + }) +} + /// Stop a proxy channel. #[no_mangle] pub extern "C" fn cvm_proxy_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { @@ -881,3 +1272,130 @@ fn parse_json_rpc_message( } } } + +fn parse_json_value_array( + payload_json: *const c_char, + name: &str, + error: *mut *mut FfiError, +) -> Option> { + let json_str = match c_str_to_string(payload_json) { + Some(s) => s, + None => { + set_null_arg_error(error, name); + return None; + } + }; + + match serde_json::from_str::>(&json_str) { + Ok(values) => Some(values), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + None + } + } +} + +fn parse_tags_json( + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> Option> { + let json_str = match c_str_to_string(tags_json) { + Some(s) => s, + None => { + set_null_arg_error(error, "tags_json"); + return None; + } + }; + + let parts = match serde_json::from_str::>>(&json_str) { + Ok(parts) => parts, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + return None; + } + }; + + parts + .into_iter() + .map(|tag| { + nostr_sdk::prelude::Tag::parse(tag).map_err(|e| FfiError { + code: ErrorCode::Validation, + message: e.to_string(), + }) + }) + .collect::, _>>() + .map_err(|e| set_error(error, e)) + .ok() +} + +enum ServerPublishListKind { + Tools, + Resources, + Prompts, + ResourceTemplates, +} + +fn publish_server_values( + handle: FfiHandle, + values: Vec, + kind: ServerPublishListKind, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + match kind { + ServerPublishListKind::Tools => transport.publish_tools(values).await, + ServerPublishListKind::Resources => transport.publish_resources(values).await, + ServerPublishListKind::Prompts => transport.publish_prompts(values).await, + ServerPublishListKind::ResourceTemplates => { + transport.publish_resource_templates(values).await + } + } + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +fn set_invalid_handle_error(error: *mut *mut FfiError, handle_type: &str) { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: format!("invalid {handle_type} handle"), + }, + ); +} + +fn set_null_arg_error(error: *mut *mut FfiError, name: &str) { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("null {name}"), + }, + ); +} diff --git a/contextvm-ffi/src/types.rs b/contextvm-ffi/src/types.rs index a7b4209..e70dd52 100644 --- a/contextvm-ffi/src/types.rs +++ b/contextvm-ffi/src/types.rs @@ -15,53 +15,24 @@ use std::ptr; // ─── FFI-safe struct mirrors ─────────────────────────────────────────── -/// Encryption mode. -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncryptionMode { - Optional = 0, - Required = 1, - Disabled = 2, -} - -impl From for contextvm_sdk::EncryptionMode { - fn from(m: EncryptionMode) -> Self { - match m { - EncryptionMode::Optional => contextvm_sdk::EncryptionMode::Optional, - EncryptionMode::Required => contextvm_sdk::EncryptionMode::Required, - EncryptionMode::Disabled => contextvm_sdk::EncryptionMode::Disabled, - } - } -} +/// Raw integer mode value supplied by C callers. +pub type FfiMode = i32; -/// Gift-wrap mode (CEP-19). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GiftWrapMode { - Optional = 0, - Ephemeral = 1, - Persistent = 2, -} +pub const ENCRYPTION_MODE_OPTIONAL: FfiMode = 0; +pub const ENCRYPTION_MODE_REQUIRED: FfiMode = 1; +pub const ENCRYPTION_MODE_DISABLED: FfiMode = 2; -impl From for contextvm_sdk::GiftWrapMode { - fn from(m: GiftWrapMode) -> Self { - match m { - GiftWrapMode::Optional => contextvm_sdk::GiftWrapMode::Optional, - GiftWrapMode::Ephemeral => contextvm_sdk::GiftWrapMode::Ephemeral, - GiftWrapMode::Persistent => contextvm_sdk::GiftWrapMode::Persistent, - } - } -} +pub const GIFT_WRAP_MODE_OPTIONAL: FfiMode = 0; +pub const GIFT_WRAP_MODE_EPHEMERAL: FfiMode = 1; +pub const GIFT_WRAP_MODE_PERSISTENT: FfiMode = 2; /// JSON-RPC message type discriminator. -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum JsonRpcType { - Request = 0, - Response = 1, - ErrorResponse = 2, - Notification = 3, -} +pub type JsonRpcType = i32; + +pub const JSON_RPC_TYPE_REQUEST: JsonRpcType = 0; +pub const JSON_RPC_TYPE_RESPONSE: JsonRpcType = 1; +pub const JSON_RPC_TYPE_ERROR_RESPONSE: JsonRpcType = 2; +pub const JSON_RPC_TYPE_NOTIFICATION: JsonRpcType = 3; /// An FFI-safe JSON-RPC message. #[repr(C)] @@ -122,14 +93,31 @@ pub struct FfiProviderProfile { pub nip05: *mut c_char, } +/// A capability exclusion pattern that bypasses pubkey whitelisting. +#[repr(C)] +#[derive(Debug)] +pub struct FfiCapabilityExclusion { + pub method: *mut c_char, + pub name: *mut c_char, +} + +/// Learned peer capability flags. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct FfiPeerCapabilities { + pub supports_encryption: bool, + pub supports_ephemeral_encryption: bool, + pub supports_oversized_transfer: bool, +} + /// Server transport config for FFI. #[repr(C)] #[derive(Debug)] pub struct FfiServerConfig { pub relay_urls: *mut *mut c_char, pub relay_url_count: usize, - pub encryption_mode: EncryptionMode, - pub gift_wrap_mode: GiftWrapMode, + pub encryption_mode: FfiMode, + pub gift_wrap_mode: FfiMode, pub is_announced_server: bool, pub server_name: *mut c_char, pub server_version: *mut c_char, @@ -140,6 +128,16 @@ pub struct FfiServerConfig { pub allowed_pubkey_count: usize, pub session_timeout_secs: u64, pub cleanup_interval_secs: u64, + pub excluded_capabilities: *mut FfiCapabilityExclusion, + pub excluded_capability_count: usize, + pub max_sessions: usize, + pub request_timeout_secs: u64, + pub relay_list_urls: *mut *mut c_char, + pub relay_list_url_count: usize, + pub bootstrap_relay_urls: *mut *mut c_char, + pub bootstrap_relay_url_count: usize, + pub publish_relay_list: bool, + pub profile_metadata_json: *mut c_char, } /// Client transport config for FFI. @@ -149,10 +147,14 @@ pub struct FfiClientConfig { pub relay_urls: *mut *mut c_char, pub relay_url_count: usize, pub server_pubkey: *mut c_char, - pub encryption_mode: EncryptionMode, - pub gift_wrap_mode: GiftWrapMode, + pub encryption_mode: FfiMode, + pub gift_wrap_mode: FfiMode, pub is_stateless: bool, pub timeout_secs: u64, + pub discovery_relay_urls: *mut *mut c_char, + pub discovery_relay_url_count: usize, + pub fallback_operational_relay_urls: *mut *mut c_char, + pub fallback_operational_relay_url_count: usize, } // ─── Internal conversion helpers ─────────────────────────────────────── @@ -164,6 +166,36 @@ pub fn c_str_to_string(ptr: *const c_char) -> Option { unsafe { CStr::from_ptr(ptr).to_str().ok().map(String::from) } } +pub fn c_str_to_string_checked(ptr: *const c_char, name: &str) -> Result { + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("null {name}"), + }); + } + + unsafe { + CStr::from_ptr(ptr) + .to_str() + .map(String::from) + .map_err(|e| FfiError { + code: ErrorCode::Validation, + message: format!("{name} is not valid UTF-8: {e}"), + }) + } +} + +pub fn optional_c_str_to_string_checked( + ptr: *const c_char, + name: &str, +) -> Result, FfiError> { + if ptr.is_null() { + Ok(None) + } else { + c_str_to_string_checked(ptr, name).map(Some) + } +} + pub fn string_to_c(s: String) -> *mut c_char { CString::new(s).unwrap_or_default().into_raw() } @@ -182,20 +214,102 @@ pub fn c_str_array_to_vec(ptr: *mut *mut c_char, count: usize) -> Vec { } } +pub fn c_str_array_to_vec_checked( + ptr: *mut *mut c_char, + count: usize, + name: &str, +) -> Result, FfiError> { + if count == 0 { + return Ok(Vec::new()); + } + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("{name} has count {count} but null pointer"), + }); + } + + unsafe { + std::slice::from_raw_parts(ptr, count) + .iter() + .enumerate() + .map(|(index, &p)| { + if p.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("{name}[{index}] is null"), + }); + } + + CStr::from_ptr(p) + .to_str() + .map(String::from) + .map_err(|e| FfiError { + code: ErrorCode::Validation, + message: format!("{name}[{index}] is not valid UTF-8: {e}"), + }) + }) + .collect() + } +} + +pub fn ffi_encryption_mode_to_sdk( + mode: FfiMode, +) -> Result { + match mode { + ENCRYPTION_MODE_OPTIONAL => Ok(contextvm_sdk::EncryptionMode::Optional), + ENCRYPTION_MODE_REQUIRED => Ok(contextvm_sdk::EncryptionMode::Required), + ENCRYPTION_MODE_DISABLED => Ok(contextvm_sdk::EncryptionMode::Disabled), + _ => Err(FfiError { + code: ErrorCode::Validation, + message: format!("invalid encryption_mode {mode}"), + }), + } +} + +pub fn ffi_gift_wrap_mode_to_sdk(mode: FfiMode) -> Result { + match mode { + GIFT_WRAP_MODE_OPTIONAL => Ok(contextvm_sdk::GiftWrapMode::Optional), + GIFT_WRAP_MODE_EPHEMERAL => Ok(contextvm_sdk::GiftWrapMode::Ephemeral), + GIFT_WRAP_MODE_PERSISTENT => Ok(contextvm_sdk::GiftWrapMode::Persistent), + _ => Err(FfiError { + code: ErrorCode::Validation, + message: format!("invalid gift_wrap_mode {mode}"), + }), + } +} + +pub fn json_rpc_id_to_string(id: &serde_json::Value) -> String { + match id { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +pub fn peer_capabilities_to_ffi( + caps: contextvm_sdk::transport::discovery_tags::PeerCapabilities, +) -> FfiPeerCapabilities { + FfiPeerCapabilities { + supports_encryption: caps.supports_encryption, + supports_ephemeral_encryption: caps.supports_ephemeral_encryption, + supports_oversized_transfer: caps.supports_oversized_transfer, + } +} + /// Convert an SDK `JsonRpcMessage` to the FFI representation. pub fn message_to_ffi(msg: &contextvm_sdk::JsonRpcMessage) -> FfiJsonRpcMessage { let json_str = serde_json::to_string(msg).unwrap_or_default(); let msg_type = match msg { - contextvm_sdk::JsonRpcMessage::Request(_) => JsonRpcType::Request, - contextvm_sdk::JsonRpcMessage::Response(_) => JsonRpcType::Response, - contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JsonRpcType::ErrorResponse, - contextvm_sdk::JsonRpcMessage::Notification(_) => JsonRpcType::Notification, + contextvm_sdk::JsonRpcMessage::Request(_) => JSON_RPC_TYPE_REQUEST, + contextvm_sdk::JsonRpcMessage::Response(_) => JSON_RPC_TYPE_RESPONSE, + contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JSON_RPC_TYPE_ERROR_RESPONSE, + contextvm_sdk::JsonRpcMessage::Notification(_) => JSON_RPC_TYPE_NOTIFICATION, }; FfiJsonRpcMessage { msg_type, payload_json: string_to_c(json_str), method: opt_string_to_c(msg.method().map(String::from)), - id: opt_string_to_c(msg.id().map(|v| v.to_string())), + id: opt_string_to_c(msg.id().map(json_rpc_id_to_string)), } } @@ -916,3 +1030,39 @@ fn profiles_to_ffi_array( std::mem::forget(ffi_profiles); ptr } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_to_ffi_string_id_is_unquoted() { + let msg = contextvm_sdk::JsonRpcMessage::Request(contextvm_sdk::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::String("request-1".to_string()), + method: "tools/list".to_string(), + params: None, + }); + + let ffi = message_to_ffi(&msg); + let id = unsafe { CStr::from_ptr(ffi.id).to_str().unwrap().to_string() }; + cvm_message_free(ffi); + + assert_eq!(id, "request-1"); + } + + #[test] + fn message_to_ffi_non_string_id_remains_json_encoded() { + let msg = contextvm_sdk::JsonRpcMessage::Response(contextvm_sdk::JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + result: serde_json::json!({}), + }); + + let ffi = message_to_ffi(&msg); + let id = unsafe { CStr::from_ptr(ffi.id).to_str().unwrap().to_string() }; + cvm_message_free(ffi); + + assert_eq!(id, "42"); + } +} diff --git a/contextvm-ffi/src/uniffi_types.rs b/contextvm-ffi/src/uniffi_types.rs index 3a17f58..4b81594 100644 --- a/contextvm-ffi/src/uniffi_types.rs +++ b/contextvm-ffi/src/uniffi_types.rs @@ -5,10 +5,12 @@ //! Kotlin consumers. use crate::builders::{ - build_sdk_client_config_from_fields, build_sdk_server_config_from_fields, ServerConfigParts, + build_sdk_client_config_from_fields, build_sdk_server_config_from_fields, + CapabilityExclusionParts, ClientConfigParts, ServerConfigParts, }; use crate::error::FfiError; use crate::runtime::global_runtime; +use crate::types::json_rpc_id_to_string; use std::sync::Arc; use std::time::Duration; @@ -81,6 +83,21 @@ pub struct ProviderProfile { pub nip05: Option, } +/// A capability exclusion pattern that bypasses pubkey whitelisting. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CapabilityExclusion { + pub method: String, + pub name: Option, +} + +/// Learned peer capability flags. +#[derive(Debug, Clone, Copy, uniffi::Record)] +pub struct PeerCapabilities { + pub supports_encryption: bool, + pub supports_ephemeral_encryption: bool, + pub supports_oversized_transfer: bool, +} + /// A discovered MCP tool and provider metadata used by foreign clients. #[derive(Debug, Clone, uniffi::Record)] pub struct DiscoveredTool { @@ -110,6 +127,13 @@ pub struct ServerConfig { pub allowed_pubkeys: Vec, pub session_timeout_secs: u64, pub cleanup_interval_secs: u64, + pub excluded_capabilities: Vec, + pub max_sessions: u64, + pub request_timeout_secs: u64, + pub relay_list_urls: Vec, + pub bootstrap_relay_urls: Vec, + pub publish_relay_list: bool, + pub profile_metadata_json: Option, } impl Default for ServerConfig { @@ -127,6 +151,13 @@ impl Default for ServerConfig { allowed_pubkeys: vec![], session_timeout_secs: 300, cleanup_interval_secs: 60, + excluded_capabilities: vec![], + max_sessions: 1000, + request_timeout_secs: 60, + relay_list_urls: vec![], + bootstrap_relay_urls: vec![], + publish_relay_list: true, + profile_metadata_json: None, } } } @@ -140,6 +171,8 @@ pub struct ClientConfig { pub gift_wrap_mode: GiftWrapMode, pub is_stateless: bool, pub timeout_secs: u64, + pub discovery_relay_urls: Vec, + pub fallback_operational_relay_urls: Vec, } impl Default for ClientConfig { @@ -151,6 +184,8 @@ impl Default for ClientConfig { gift_wrap_mode: GiftWrapMode::Optional, is_stateless: false, timeout_secs: 30, + discovery_relay_urls: vec![], + fallback_operational_relay_urls: vec![], } } } @@ -185,7 +220,7 @@ fn message_to_uniffi(msg: &contextvm_sdk::JsonRpcMessage) -> JsonRpcMessage { msg_type, payload_json: serde_json::to_string(msg).unwrap_or_default(), method: msg.method().map(String::from).unwrap_or_default(), - id: msg.id().map(|v| v.to_string()).unwrap_or_default(), + id: msg.id().map(json_rpc_id_to_string).unwrap_or_default(), } } @@ -229,6 +264,37 @@ fn profile_to_uniffi(profile: crate::discovery::ProviderProfileRecord) -> Provid } } +fn capabilities_to_uniffi(caps: contextvm_sdk::PeerCapabilities) -> PeerCapabilities { + PeerCapabilities { + supports_encryption: caps.supports_encryption, + supports_ephemeral_encryption: caps.supports_ephemeral_encryption, + supports_oversized_transfer: caps.supports_oversized_transfer, + } +} + +fn parse_json_value_array(json: &str, name: &str) -> Result, FfiError> { + serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: format!("invalid {name}: {e}"), + }) +} + +fn parse_tags_json(json: &str) -> Result, FfiError> { + let parts: Vec> = serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: format!("invalid tags_json: {e}"), + })?; + parts + .into_iter() + .map(|tag| { + nostr_sdk::prelude::Tag::parse(tag).map_err(|e| FfiError { + code: crate::error::ErrorCode::Validation, + message: e.to_string(), + }) + }) + .collect() +} + // ─── High-level UniFFI objects ───────────────────────────────────────── /// A Nostr keypair. @@ -328,7 +394,21 @@ impl Server { allowed_pubkeys: config.allowed_pubkeys.clone(), session_timeout_secs: config.session_timeout_secs, cleanup_interval_secs: config.cleanup_interval_secs, - }); + excluded_capabilities: config + .excluded_capabilities + .iter() + .map(|cap| CapabilityExclusionParts { + method: cap.method.clone(), + name: cap.name.clone(), + }) + .collect(), + max_sessions: config.max_sessions as usize, + request_timeout_secs: config.request_timeout_secs, + relay_list_urls: config.relay_list_urls.clone(), + bootstrap_relay_urls: config.bootstrap_relay_urls.clone(), + publish_relay_list: config.publish_relay_list, + profile_metadata_json: config.profile_metadata_json.clone(), + })?; global_runtime() .block_on(async { @@ -336,6 +416,7 @@ impl Server { contextvm_sdk::NostrServerTransport::new(keys.inner.clone(), sdk_config) .await?; transport.start().await?; + transport.spawn_discoverability_publication(); let receiver = transport .take_message_receiver() .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; @@ -374,6 +455,59 @@ impl Server { .map_err(FfiError::from) } + /// Send a notification to a specific client. + pub fn send_notification( + &self, + client_pubkey: &str, + payload_json: &str, + correlated_event_id: Option, + ) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard + .send_notification(client_pubkey, &message, correlated_event_id.as_deref()) + .await + }) + .map_err(FfiError::from) + } + + /// Broadcast a notification to all initialized clients. + pub fn broadcast_notification(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.broadcast_notification(&message).await + }) + .map_err(FfiError::from) + } + + /// Sets extra announcement/discovery tags from a JSON array of tag arrays. + pub fn set_announcement_extra_tags(&self, tags_json: &str) -> Result<(), FfiError> { + let tags = parse_tags_json(tags_json)?; + let transport = self.transport.clone(); + global_runtime().block_on(async { + let mut guard = transport.lock().await; + guard.set_announcement_extra_tags(tags); + }); + Ok(()) + } + + /// Sets pricing tags from a JSON array of tag arrays. + pub fn set_announcement_pricing_tags(&self, tags_json: &str) -> Result<(), FfiError> { + let tags = parse_tags_json(tags_json)?; + let transport = self.transport.clone(); + global_runtime().block_on(async { + let mut guard = transport.lock().await; + guard.set_announcement_pricing_tags(tags); + }); + Ok(()) + } + /// Publish server announcement. pub fn announce(&self) -> Result<(), FfiError> { let transport = self.transport.clone(); @@ -386,6 +520,81 @@ impl Server { .map_err(FfiError::from) } + /// Publish server announcement and return the Nostr event ID. + pub fn announce_event_id(&self) -> Result { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.announce().await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish tools list and return the Nostr event ID. + pub fn publish_tools(&self, tools_json: &str) -> Result { + let tools = parse_json_value_array(tools_json, "tools_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_tools(tools).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish resources list and return the Nostr event ID. + pub fn publish_resources(&self, resources_json: &str) -> Result { + let resources = parse_json_value_array(resources_json, "resources_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_resources(resources).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish prompts list and return the Nostr event ID. + pub fn publish_prompts(&self, prompts_json: &str) -> Result { + let prompts = parse_json_value_array(prompts_json, "prompts_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_prompts(prompts).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish resource templates list and return the Nostr event ID. + pub fn publish_resource_templates(&self, templates_json: &str) -> Result { + let templates = parse_json_value_array(templates_json, "templates_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_resource_templates(templates).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Delete previously published server announcements. + pub fn delete_announcements(&self, reason: &str) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.delete_announcements(reason).await + }) + .map_err(FfiError::from) + } + /// Close the server transport. pub fn close(&self) -> Result<(), FfiError> { let transport = self.transport.clone(); @@ -412,14 +621,16 @@ impl Client { /// Create and start a client transport. #[uniffi::constructor] pub fn new(keys: &Keys, config: &ClientConfig) -> Result { - let sdk_config = build_sdk_client_config_from_fields( - config.relay_urls.clone(), - config.server_pubkey.clone(), - sdk_encryption_mode(config.encryption_mode), - sdk_gift_wrap_mode(config.gift_wrap_mode), - config.is_stateless, - config.timeout_secs, - ); + let sdk_config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: config.relay_urls.clone(), + server_pubkey: config.server_pubkey.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls: config.discovery_relay_urls.clone(), + fallback_operational_relay_urls: config.fallback_operational_relay_urls.clone(), + }); global_runtime() .block_on(async { @@ -465,6 +676,42 @@ impl Client { }) } + /// Return a snapshot of server capabilities learned from discovery tags. + pub fn discovered_server_capabilities(&self) -> PeerCapabilities { + let transport = self.transport.clone(); + let caps = global_runtime().block_on(async { + let guard = transport.lock().await; + guard.discovered_server_capabilities() + }); + capabilities_to_uniffi(caps) + } + + /// Return whether the client has learned ephemeral gift-wrap support. + pub fn server_supports_ephemeral_encryption(&self) -> bool { + let transport = self.transport.clone(); + global_runtime().block_on(async { + let guard = transport.lock().await; + guard.server_supports_ephemeral_encryption() + }) + } + + /// Return the first server event carrying discovery tags as JSON, if present. + pub fn server_initialize_event_json(&self) -> Result, FfiError> { + let transport = self.transport.clone(); + let event = global_runtime().block_on(async { + let guard = transport.lock().await; + guard.get_server_initialize_event() + }); + event + .map(|event| { + serde_json::to_string(&event).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }) + }) + .transpose() + } + /// Close the client transport. pub fn close(&self) -> Result<(), FfiError> { let transport = self.transport.clone(); @@ -477,6 +724,138 @@ impl Client { } } +/// A gateway that bridges a local MCP server to Nostr. +#[derive(uniffi::Object)] +pub struct Gateway { + gateway: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Gateway { + /// Create and start a gateway transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ServerConfig) -> Result { + let sdk_config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: config.relay_urls.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + server_name: config.server_name.clone(), + server_version: config.server_version.clone(), + server_picture: config.server_picture.clone(), + server_about: config.server_about.clone(), + server_website: config.server_website.clone(), + is_announced_server: config.is_announced_server, + allowed_pubkeys: config.allowed_pubkeys.clone(), + session_timeout_secs: config.session_timeout_secs, + cleanup_interval_secs: config.cleanup_interval_secs, + excluded_capabilities: config + .excluded_capabilities + .iter() + .map(|cap| CapabilityExclusionParts { + method: cap.method.clone(), + name: cap.name.clone(), + }) + .collect(), + max_sessions: config.max_sessions as usize, + request_timeout_secs: config.request_timeout_secs, + relay_list_urls: config.relay_list_urls.clone(), + bootstrap_relay_urls: config.bootstrap_relay_urls.clone(), + publish_relay_list: config.publish_relay_list, + profile_metadata_json: config.profile_metadata_json.clone(), + })?; + let gateway_config = contextvm_sdk::gateway::GatewayConfig::new(sdk_config); + + global_runtime() + .block_on(async { + let mut gateway = contextvm_sdk::gateway::NostrMCPGateway::new( + keys.inner.clone(), + gateway_config, + ) + .await?; + let receiver = gateway.start().await?; + Ok::<_, contextvm_sdk::Error>(Self { + gateway: Arc::new(tokio::sync::Mutex::new(gateway)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Receive the next incoming request. + pub fn recv(&self) -> Result { + let rx = self.receiver.clone(); + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|req| incoming_to_uniffi(&req)) + .ok_or_else(|| FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }) + } + + /// Send a response for a given event ID. + pub fn send_response(&self, event_id: &str, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.send_response(event_id, message).await + }) + .map_err(FfiError::from) + } + + /// Publish server announcement. + pub fn announce(&self) -> Result<(), FfiError> { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.announce().await + }) + .map(|_| ()) + .map_err(FfiError::from) + } + + /// Publish server announcement and return the Nostr event ID. + pub fn announce_event_id(&self) -> Result { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.announce().await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Check if the gateway is active. + pub fn is_active(&self) -> bool { + let gateway = self.gateway.clone(); + global_runtime().block_on(async { + let guard = gateway.lock().await; + guard.is_active() + }) + } + + /// Stop the gateway transport. + pub fn stop(&self) -> Result<(), FfiError> { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let mut guard = gateway.lock().await; + guard.stop().await + }) + .map_err(FfiError::from) + } +} + /// A proxy that connects a local MCP client to a remote Nostr MCP server. #[derive(uniffi::Object)] pub struct Proxy { @@ -491,14 +870,16 @@ impl Proxy { /// Create and start a proxy transport. #[uniffi::constructor] pub fn new(keys: &Keys, config: &ClientConfig) -> Result { - let sdk_config = build_sdk_client_config_from_fields( - config.relay_urls.clone(), - config.server_pubkey.clone(), - sdk_encryption_mode(config.encryption_mode), - sdk_gift_wrap_mode(config.gift_wrap_mode), - config.is_stateless, - config.timeout_secs, - ); + let sdk_config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: config.relay_urls.clone(), + server_pubkey: config.server_pubkey.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls: config.discovery_relay_urls.clone(), + fallback_operational_relay_urls: config.fallback_operational_relay_urls.clone(), + }); let proxy_config = contextvm_sdk::proxy::ProxyConfig::new(sdk_config); global_runtime() @@ -561,6 +942,15 @@ impl Proxy { } } + /// Check if the proxy is active. + pub fn is_active(&self) -> bool { + let proxy = self.proxy.clone(); + global_runtime().block_on(async { + let guard = proxy.lock().await; + guard.is_active() + }) + } + /// Stop the proxy transport. pub fn stop(&self) -> Result<(), FfiError> { let proxy = self.proxy.clone(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index b452d9c..083a643 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -55,6 +55,7 @@ impl NostrMCPGateway { } self.transport.start().await?; + self.transport.spawn_discoverability_publication(); self.is_running = true; self.transport diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs index 008a5a0..81edaec 100644 --- a/src/transport/server/mod.rs +++ b/src/transport/server/mod.rs @@ -768,7 +768,15 @@ impl NostrServerTransport { .spawn_publish_public_announcements(self.cancellation_token.child_token()); self.task_handles.push(handle); } - // Unconditional: publish profile metadata and relay list (guards inside methods) + self.spawn_discoverability_publication(); + } + + /// Spawn profile metadata and relay-list publication for direct transport users. + /// + /// This publishes kind 0 and kind 10002 discoverability events when configured. + /// It intentionally does not spawn CEP-6 capability announcement tasks because + /// those inject synthetic MCP requests that require an rmcp worker. + pub fn spawn_discoverability_publication(&mut self) { let handle = self.announcement_manager.spawn_publish_discoverability(); self.task_handles.push(handle); } @@ -1328,6 +1336,7 @@ impl NostrServerTransport { #[cfg(test)] mod tests { use super::*; + use crate::relay::mock::MockRelayPool; use std::thread; // ── Session management ────────────────────────────────────── @@ -1567,6 +1576,42 @@ mod tests { assert!(config.profile_metadata.is_none()); } + #[tokio::test] + async fn spawn_discoverability_publication_publishes_kind_0_and_10002_only() { + let pool = Arc::new(MockRelayPool::new()); + let relay_pool: Arc = pool.clone(); + let config = NostrServerTransportConfig::default() + .with_relay_urls(vec!["wss://relay.example.com".to_string()]) + .with_profile_metadata(ProfileMetadata::default().with_name("ffi-server")) + .with_publish_relay_list(true); + let mut transport = NostrServerTransport::with_relay_pool(config, relay_pool) + .await + .expect("transport should build"); + + transport.spawn_discoverability_publication(); + for handle in transport.task_handles.drain(..) { + handle.await.expect("discoverability task should not panic"); + } + + let events = pool.stored_events().await; + assert!( + events.iter().any(|e| e.kind == Kind::Custom(0)), + "profile metadata should be published" + ); + assert!( + events + .iter() + .any(|e| e.kind == Kind::Custom(RELAY_LIST_METADATA_KIND)), + "relay list should be published" + ); + assert!( + events + .iter() + .all(|e| e.kind != Kind::Custom(SERVER_ANNOUNCEMENT_KIND)), + "direct discoverability publication must not emit CEP-6 announcements" + ); + } + // ── CEP-19 helper logic ────────────────────────────────────── #[test]