OXYGENHUB

Coverage / src/cli/mcp.rs 20.6%

14 of 68 lines run, 3 of 16 functions · run #22 on main · run missed

1
//! `spectra mcp --agent NAME`: the stdio shim (spec §15.1). A client starts
2
//! it as an ordinary stdio MCP server; it signs every request with the
3
//! agent's key from the agent's own home and forwards it to the daemon over
4
//! the Unix socket, so a client's configuration is one line with nothing
5
//! secret in it. Nothing is written to stdout but protocol.
6
7
use super::Ctx;
8
use anyhow::{bail, Context, Result};
9
use serde_json::{json, Value as Json};
10
use spectra_core::keys::KeyPair;
11
use spectra_core::ledger::Ledger;
12
use spectra_mcp::client::{rpc_result, Client};
13
use std::path::PathBuf;
14
use std::sync::Arc;
15
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16
use tokio::sync::Mutex;
17
18
/// The revisions the shim speaks to its client (spec §15.1); 2026-07-28
19
/// requests are passed through as they are.
20
pub const STDIO_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"];
21
222×
pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> {
231×
    let home = match home {
241×
        Some(h) => h,
251×
        None if ctx.remote()?.is_some() => {
260×
            bail!("with Spectra on another machine, name the agent's home: spectra mcp --agent {agent} --home DIR")
27
        }
28
        None => {
291×
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
301×
            let h: Option<String> =
311×
                sqlx::query_scalar("SELECT home FROM agents WHERE name = ?").bind(agent).fetch_optional(ledger.pool()).await?;
321×
            PathBuf::from(h.with_context(|| format!("no agent named {agent} (spectra agent list)"))?)
33
        }
34
    };
351×
    let key = KeyPair::load_home(&home).with_context(|| format!("reading {agent}'s key in {}", home.display()))?;
361×
    if key.identity.name != agent {
371×
        bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name);
380×
    }
390×
    let client = Arc::new(Client::at(ctx.endpoint()?, Some(key)));
400×
    let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string()));
410×
    let out = Arc::new(Mutex::new(tokio::io::stdout()));
420×
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
430×
    let mut tasks = tokio::task::JoinSet::new();
440×
    while let Some(line) = lines.next_line().await? {
450×
        if line.trim().is_empty() {
460×
            continue;
470×
        }
480×
        let (client, version, out) = (client.clone(), version.clone(), out.clone());
49
        // Each request on its own: a `wait` must not hold up the others.
500×
        tasks.spawn(async move {
510×
            if let Some(reply) = forward(&client, &version, &line).await {
520×
                let mut o = out.lock().await;
530×
                let _ = o.write_all(format!("{reply}\n").as_bytes()).await;
540×
                let _ = o.flush().await;
550×
            }
560×
        });
57
    }
580×
    while tasks.join_next().await.is_some() {}
590×
    Ok(0)
602×
}
61
62
/// Forward one message; the reply to write, if it was a request.
630×
async fn forward(client: &Client, version: &Mutex<String>, line: &str) -> Option<Json> {
640×
    let req: Json = match serde_json::from_str(line) {
650×
        Ok(v) => v,
660×
        Err(e) => return Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })),
67
    };
680×
    let id = req.get("id").cloned();
690×
    let method = req.get("method").and_then(Json::as_str).unwrap_or("");
700×
    let asked_2026 = method == "server/discover"
710×
        || req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str) == Some("2026-07-28");
720×
    let v = if asked_2026 {
730×
        "2026-07-28".to_string()
740×
    } else if method == "initialize" {
750×
        let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(STDIO_VERSIONS[2]);
760×
        let chosen = if STDIO_VERSIONS.contains(&asked) { asked } else { STDIO_VERSIONS[0] };
770×
        *version.lock().await = chosen.to_string();
780×
        chosen.to_string()
79
    } else {
800×
        version.lock().await.clone()
81
    };
820×
    let r = match client.mcp(&v, &req).await {
830×
        Ok(r) => r,
840×
        Err(e) => return id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra is not reachable: {e:#}. Is `spectra serve` running?") } })),
85
    };
860×
    let id = id?;
870×
    if r.status == 202 {
880×
        return None;
890×
    }
900×
    match rpc_result(&r) {
910×
        Ok(v) => Some(v),
920×
        Err(e) => Some(
930×
            json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra answered {}: {e:#}", r.status) } }),
940×
        ),
95
    }
960×
}