contextvm-sdk 0.2.0

Rust SDK for the ContextVM protocol — MCP over Nostr
Documentation
# Native Client Guide

Use this path when you are building a native ContextVM client in Rust.

The recommended architecture is:

- define an `rmcp` client handler or use a lightweight client info object
- create a `NostrClientTransport`
- attach the transport with `rmcp`'s `ServiceExt`

This follows the same pattern as the standard `rmcp` client examples, except the transport is ContextVM over Nostr instead of HTTP.

## The high-level shape

In `rmcp`, a client is typically started with `client_info.serve(transport)`.

For ContextVM, the transport becomes `NostrClientTransport`. In the current SDK API, you pass that transport directly to `ServiceExt`; there is no extra adapter step in the public workflow.

## Loading an existing private key

The signer helper is not limited to ephemeral keys. If you already have a private key, load it with `from_sk()`.

It accepts either:

- a 64-character hex secret key
- an `nsec` bech32 secret key

```rust
use contextvm_sdk::signer;

let signer = signer::from_sk("<hex-or-nsec-private-key>")?;
println!("client pubkey: {}", signer.public_key().to_hex());
```

Use `generate()` only when you explicitly want a new random identity for a short-lived client or test flow.

## Example

```rust
use anyhow::Context;
use contextvm_sdk::transport::client::{
    NostrClientTransport, NostrClientTransportConfig,
};
use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode};
use rmcp::{
    model::{CallToolRequestParams, CallToolResult},
    ClientHandler, ServiceExt,
};

const RELAY_URL: &str = "wss://relay.contextvm.org";

#[derive(Clone, Default)]
struct DemoClient;

impl ClientHandler for DemoClient {}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let server_pubkey = std::env::args()
        .nth(1)
        .context("Usage: native_echo_client <server_pubkey_hex>")?;

    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("contextvm_sdk=info".parse()?)
                .add_directive("rmcp=warn".parse()?),
        )
        .init();

    let signer = signer::generate();

    println!("Native ContextVM echo client starting");
    println!("Relay: {RELAY_URL}");
    println!("Client pubkey: {}", signer.public_key().to_hex());
    println!("Target server pubkey: {server_pubkey}");

    let transport = NostrClientTransport::new(
        signer,
        NostrClientTransportConfig::default()
            .with_relay_urls(vec![RELAY_URL.to_string()])
            .with_server_pubkey(server_pubkey)
            .with_encryption_mode(EncryptionMode::Optional)
            .with_gift_wrap_mode(GiftWrapMode::Optional),
    )
    .await?;

    let client = DemoClient.serve(transport).await?;

    let peer_info = client
        .peer_info()
        .expect("server did not provide peer info after initialize");
    println!("Connected to: {:?}", peer_info.server_info.name);

    let tools = client.list_all_tools().await?;
    println!("Discovered {} tool(s):", tools.len());
    for tool in &tools {
        println!("- {}", tool.name);
    }

    let result = client
        .call_tool(
            CallToolRequestParams::new("echo").with_arguments(
                serde_json::from_value(serde_json::json!({
                    "message": "hello from native contextvm client"
                }))
                .unwrap(),
            ),
        )
        .await?;

    println!("Echo result: {}", first_text(&result));
    client.cancel().await?;
    Ok(())
}

fn first_text(result: &CallToolResult) -> String {
    result
        .content
        .iter()
        .find_map(|content| {
            if let rmcp::model::RawContent::Text(text) = &content.raw {
                Some(text.text.clone())
            } else {
                None
            }
        })
        .unwrap_or_default()
}
```

This is the ContextVM equivalent of the usual `rmcp` client workflow, but using `NostrClientTransport` directly.

## What the transport adds

`NostrClientTransport` adds ContextVM-specific client behavior on top of `rmcp` client semantics:

- relay connection management via `NostrClientTransport::new()`
- target server selection through `server_pubkey` in `NostrClientTransportConfig`
- request and response correlation via `send()`
- server capability learning from discovery tags
- optional stateless behavior via `is_stateless` in `NostrClientTransportConfig`
- encrypted message reception and gift-wrap deduplication during notification handling

## Configuration fields that matter first

Start with these fields in `NostrClientTransportConfig`:

- `relay_urls`: relays the client uses to reach the server (empty = use relay resolution)
- `server_pubkey`: the target server's public key (hex, npub, or nprofile with relay hints)
- `encryption_mode`: whether plaintext is allowed
- `gift_wrap_mode`: whether to use persistent or ephemeral wrapping
- `open_stream`: CEP-41 open-stream settings; disabled by default, opt in with `with_open_stream(OpenStreamConfig::enabled())`
- `is_stateless`: whether initialize is emulated locally for stateless workflows
- `timeout`: how long request correlation waits for a response
- `discovery_relay_urls`: bootstrap relays for CEP-17 kind 10002 relay-list discovery (defaults to `DEFAULT_BOOTSTRAP_RELAY_URLS`)
- `fallback_operational_relay_urls`: non-authoritative relays probed in parallel with CEP-17 discovery

## Open-ended streaming (CEP-41)

For tools that stream output while a call is in flight, use `call_tool_stream`
with a `ClientOpenStreamHandle`. Capture the handle from the transport with
`transport.open_stream_handle()` before `serve()` consumes the transport, then
call `call_tool_stream(peer, &handle, params)` to receive a `ToolStreamCall`
whose `stream` yields chunks and whose `result` resolves to the final
`CallToolResult`. Open-stream is disabled by default; enable it with
`with_open_stream(OpenStreamConfig::enabled())`. See
[open-stream.md](open-stream.md).

## When to use this instead of the proxy

Use this page's approach when you are writing a new Rust MCP client that should speak ContextVM natively.

Use the proxy guide when you want a simpler message-oriented bridge and do not want the full `rmcp` running client model.

## Behavioral notes

- The client-side `rmcp` handshake is driven by `ServiceExt::serve()` on the client handler.
- The initialize request is sent automatically as part of the running client startup sequence.
- Stateless initialization behavior is covered by the conformance tests.
- Capability learning and gift-wrap handling happen inside the client transport implementation.
- When `relay_urls` is empty, `start()` runs 6-stage relay resolution before connecting: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > sequential fallback > bootstrap defaults. Callers can set `server_pubkey` to an nprofile and omit `relay_urls` entirely.