| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 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 | |
|
| 19 | |
|
| 20 | | pub const STDIO_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"]; |
| 21 | |
|
| 22 | 2× | pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> { |
| 23 | 2× | let home = match home { |
| 24 | 1× | Some(h) => h, |
| 25 | | None => { |
| 26 | 1× | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 27 | 1× | let h: Option<String> = |
| 28 | 1× | sqlx::query_scalar("SELECT home FROM agents WHERE name = ?").bind(agent).fetch_optional(ledger.pool()).await?; |
| 29 | 1× | PathBuf::from(h.with_context(|| format!("no agent named {agent} (spectra agent list)"))?) |
| 30 | | } |
| 31 | | }; |
| 32 | 1× | let key = KeyPair::load_home(&home).with_context(|| format!("reading {agent}'s key in {}", home.display()))?; |
| 33 | 1× | if key.identity.name != agent { |
| 34 | 1× | bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name); |
| 35 | 0× | } |
| 36 | 0× | let client = Arc::new(Client::new(&ctx.socket(), Some(key))); |
| 37 | 0× | let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string())); |
| 38 | 0× | let out = Arc::new(Mutex::new(tokio::io::stdout())); |
| 39 | 0× | let mut lines = BufReader::new(tokio::io::stdin()).lines(); |
| 40 | 0× | let mut tasks = tokio::task::JoinSet::new(); |
| 41 | 0× | while let Some(line) = lines.next_line().await? { |
| 42 | 0× | if line.trim().is_empty() { |
| 43 | 0× | continue; |
| 44 | 0× | } |
| 45 | 0× | let (client, version, out) = (client.clone(), version.clone(), out.clone()); |
| 46 | | |
| 47 | 0× | tasks.spawn(async move { |
| 48 | 0× | if let Some(reply) = forward(&client, &version, &line).await { |
| 49 | 0× | let mut o = out.lock().await; |
| 50 | 0× | let _ = o.write_all(format!("{reply}\n").as_bytes()).await; |
| 51 | 0× | let _ = o.flush().await; |
| 52 | 0× | } |
| 53 | 0× | }); |
| 54 | | } |
| 55 | 0× | while tasks.join_next().await.is_some() {} |
| 56 | 0× | Ok(0) |
| 57 | 2× | } |
| 58 | |
|
| 59 | |
|
| 60 | 0× | async fn forward(client: &Client, version: &Mutex<String>, line: &str) -> Option<Json> { |
| 61 | 0× | let req: Json = match serde_json::from_str(line) { |
| 62 | 0× | Ok(v) => v, |
| 63 | 0× | Err(e) => return Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })), |
| 64 | | }; |
| 65 | 0× | let id = req.get("id").cloned(); |
| 66 | 0× | let method = req.get("method").and_then(Json::as_str).unwrap_or(""); |
| 67 | 0× | let asked_2026 = method == "server/discover" |
| 68 | 0× | || req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str) == Some("2026-07-28"); |
| 69 | 0× | let v = if asked_2026 { |
| 70 | 0× | "2026-07-28".to_string() |
| 71 | 0× | } else if method == "initialize" { |
| 72 | 0× | let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(STDIO_VERSIONS[2]); |
| 73 | 0× | let chosen = if STDIO_VERSIONS.contains(&asked) { asked } else { STDIO_VERSIONS[0] }; |
| 74 | 0× | *version.lock().await = chosen.to_string(); |
| 75 | 0× | chosen.to_string() |
| 76 | | } else { |
| 77 | 0× | version.lock().await.clone() |
| 78 | | }; |
| 79 | 0× | let r = match client.mcp(&v, &req).await { |
| 80 | 0× | Ok(r) => r, |
| 81 | 0× | 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?") } })), |
| 82 | | }; |
| 83 | 0× | let id = id?; |
| 84 | 0× | if r.status == 202 { |
| 85 | 0× | return None; |
| 86 | 0× | } |
| 87 | 0× | match rpc_result(&r) { |
| 88 | 0× | Ok(v) => Some(v), |
| 89 | 0× | Err(e) => Some( |
| 90 | 0× | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra answered {}: {e:#}", r.status) } }), |
| 91 | 0× | ), |
| 92 | | } |
| 93 | 0× | } |