OXYGENHUB
◆ v0.1.0 spectra / src / cli / mcp.rs
4.3 KiB · Rust · last changed spectra connect: trust a server through the owner's delegation, pin it, claim it (#21) 1 day ago source blame history raw
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
22
pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> {
23
    let home = match home {
24
        Some(h) => h,
25
        None if ctx.remote()?.is_some() => {
26
            bail!("with Spectra on another machine, name the agent's home: spectra mcp --agent {agent} --home DIR")
27
        }
28
        None => {
29
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
30
            let h: Option<String> =
31
                sqlx::query_scalar("SELECT home FROM agents WHERE name = ?").bind(agent).fetch_optional(ledger.pool()).await?;
32
            PathBuf::from(h.with_context(|| format!("no agent named {agent} (spectra agent list)"))?)
33
        }
34
    };
35
    let key = KeyPair::load_home(&home).with_context(|| format!("reading {agent}'s key in {}", home.display()))?;
36
    if key.identity.name != agent {
37
        bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name);
38
    }
39
    let client = Arc::new(Client::at(ctx.endpoint()?, Some(key)));
40
    let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string()));
41
    let out = Arc::new(Mutex::new(tokio::io::stdout()));
42
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
43
    let mut tasks = tokio::task::JoinSet::new();
44
    while let Some(line) = lines.next_line().await? {
45
        if line.trim().is_empty() {
46
            continue;
47
        }
48
        let (client, version, out) = (client.clone(), version.clone(), out.clone());
49
        // Each request on its own: a `wait` must not hold up the others.
50
        tasks.spawn(async move {
51
            if let Some(reply) = forward(&client, &version, &line).await {
52
                let mut o = out.lock().await;
53
                let _ = o.write_all(format!("{reply}\n").as_bytes()).await;
54
                let _ = o.flush().await;
55
            }
56
        });
57
    }
58
    while tasks.join_next().await.is_some() {}
59
    Ok(0)
60
}
61
62
/// Forward one message; the reply to write, if it was a request.
63
async fn forward(client: &Client, version: &Mutex<String>, line: &str) -> Option<Json> {
64
    let req: Json = match serde_json::from_str(line) {
65
        Ok(v) => v,
66
        Err(e) => return Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })),
67
    };
68
    let id = req.get("id").cloned();
69
    let method = req.get("method").and_then(Json::as_str).unwrap_or("");
70
    let asked_2026 = method == "server/discover"
71
        || req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str) == Some("2026-07-28");
72
    let v = if asked_2026 {
73
        "2026-07-28".to_string()
74
    } else if method == "initialize" {
75
        let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(STDIO_VERSIONS[2]);
76
        let chosen = if STDIO_VERSIONS.contains(&asked) { asked } else { STDIO_VERSIONS[0] };
77
        *version.lock().await = chosen.to_string();
78
        chosen.to_string()
79
    } else {
80
        version.lock().await.clone()
81
    };
82
    let r = match client.mcp(&v, &req).await {
83
        Ok(r) => r,
84
        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
    };
86
    let id = id?;
87
    if r.status == 202 {
88
        return None;
89
    }
90
    match rpc_result(&r) {
91
        Ok(v) => Some(v),
92
        Err(e) => Some(
93
            json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra answered {}: {e:#}", r.status) } }),
94
        ),
95
    }
96
}