OXYGENHUB
◆ main spectra / src / cli / auto.rs
7.7 KiB · Rust · last changed spectra mcp --auto: one MCP entry for every folder 1 day ago source blame history raw
1
//! `spectra mcp --auto` (spec §15.1): one MCP entry for every folder. The
2
//! shim finds the agent for the folder its client started in, from the
3
//! agent homes under `$SPECTRA_AGENTS` (else `~/.spectra-agents`), each of
4
//! which `spectra agent add` labels with an `agent.toml` naming the
5
//! agent, its kind, its project and the project's folder. Each folder
6
//! still signs as its own agent. A folder with no agent gets a quiet
7
//! server that says how to add one, instead of a failure.
8
9
use anyhow::{Context, Result};
10
use serde_json::{json, Value as Json};
11
use spectra_core::keys::KeyPair;
12
use spectra_mcp::client::Client;
13
use std::path::{Path, PathBuf};
14
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15
16
/// What `agent add` writes into an agent's home.
17
#[derive(Clone, Debug)]
18
pub struct Label {
19
    pub name: String,
20
    pub kind: String,
21
    pub folder: Option<PathBuf>,
22
    pub home: PathBuf,
23
}
24
25
pub fn agents_dir() -> PathBuf {
26
    if let Some(d) = std::env::var_os("SPECTRA_AGENTS").filter(|d| !d.is_empty()) {
27
        return PathBuf::from(d);
28
    }
29
    std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default().join(".spectra-agents")
30
}
31
32
pub fn write_label(home: &Path, name: &str, kind: &str, project: &str, folder: Option<&str>) -> Result<()> {
33
    let mut t = toml::Table::new();
34
    t.insert("name".into(), name.into());
35
    t.insert("kind".into(), kind.into());
36
    t.insert("project".into(), project.into());
37
    if let Some(f) = folder.filter(|f| !f.is_empty()) {
38
        t.insert("folder".into(), f.into());
39
    }
40
    let text = format!("# Written by `spectra agent add`: lets `spectra mcp --auto` find this agent.\n{t}");
41
    spectra_core::durable::write_atomic(&home.join("agent.toml"), text.as_bytes())?;
42
    Ok(())
43
}
44
45
fn read_label(home: &Path) -> Option<Label> {
46
    let t: toml::Table = std::fs::read_to_string(home.join("agent.toml")).ok()?.parse().ok()?;
47
    let s = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from);
48
    s("project")?;
49
    Some(Label { name: s("name")?, kind: s("kind")?, folder: s("folder").map(PathBuf::from), home: home.to_path_buf() })
50
}
51
52
/// Label an agent home made before labels existed, by asking Spectra (as
53
/// that agent) which project it works on and where the project lives.
54
async fn learn_label(endpoint: &spectra_mcp::client::Endpoint, home: &Path) -> Option<Label> {
55
    let key = KeyPair::load_home(home).ok()?;
56
    let c = Client::at(endpoint.clone(), Some(key.clone()));
57
    let agents = c.send("GET", "/api/v1/agents", &[], vec![]).await.ok()?.json().ok()?;
58
    let me = agents.as_array()?.iter().find(|a| a["key"] == key.key.to_hex())?.clone();
59
    let projects = c.send("GET", "/api/v1/projects", &[], vec![]).await.ok()?.json().ok()?;
60
    let project = me["project"].as_str()?.to_string();
61
    let folder = projects.as_array()?.iter().find(|p| p["name"] == project.as_str()).and_then(|p| p["path"].as_str().map(String::from));
62
    let (name, kind) = (me["name"].as_str()?.to_string(), me["kind"].as_str()?.to_string());
63
    let _ = write_label(home, &name, &kind, &project, folder.as_deref());
64
    Some(Label { name, kind, folder: folder.map(PathBuf::from), home: home.to_path_buf() })
65
}
66
67
/// Every labelled agent home, labelling older ones on the way.
68
pub async fn labels(endpoint: Option<&spectra_mcp::client::Endpoint>) -> Vec<Label> {
69
    let mut out = Vec::new();
70
    let Ok(dirs) = std::fs::read_dir(agents_dir()) else { return out };
71
    for d in dirs.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.join("keys").is_dir()) {
72
        match read_label(&d) {
73
            Some(l) => out.push(l),
74
            None => {
75
                if let Some(ep) = endpoint {
76
                    if let Some(l) = learn_label(ep, &d).await {
77
                        out.push(l);
78
                    }
79
                }
80
            }
81
        }
82
    }
83
    out
84
}
85
86
/// The agent of `kind` for `cwd`: the one whose project folder holds it,
87
/// the deepest folder first, then by name.
88
pub fn choose<'a>(labels: &'a [Label], cwd: &Path, kind: &str) -> Option<&'a Label> {
89
    let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
90
    labels
91
        .iter()
92
        .filter(|l| l.kind == kind)
93
        .filter_map(|l| {
94
            let f = l.folder.as_ref()?;
95
            let f = f.canonicalize().unwrap_or_else(|_| f.clone());
96
            cwd.starts_with(&f).then(|| (f.components().count(), l))
97
        })
98
        .max_by(|(a, x), (b, y)| a.cmp(b).then_with(|| y.name.cmp(&x.name)))
99
        .map(|(_, l)| l)
100
}
101
102
/// The quiet server for a folder with no agent: it answers the handshake,
103
/// lists no tools, and its instructions say how to join.
104
pub async fn unconnected(cwd: &Path, kind: &str) -> Result<i32> {
105
    let why = format!(
106
        "This folder ({}) has no Spectra agent of kind {kind}, so Spectra's tools are not available here. \
107
         The owner can add one with the Spectra VS Code extension (Spectra: Add Agent) or with \
108
         `spectra agent add NAME --kind {kind} --project PROJECT --home ~/.spectra-agents/NAME`, \
109
         after `spectra project add {}`; then start a new session.",
110
        cwd.display(),
111
        cwd.display()
112
    );
113
    let mut out = tokio::io::stdout();
114
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
115
    while let Some(line) = lines.next_line().await? {
116
        let Ok(req) = serde_json::from_str::<Json>(&line) else { continue };
117
        let Some(id) = req.get("id").cloned() else { continue };
118
        let result = match req.get("method").and_then(Json::as_str).unwrap_or("") {
119
            "initialize" => {
120
                let v = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(super::mcp::STDIO_VERSIONS[0]);
121
                let v = if super::mcp::STDIO_VERSIONS.contains(&v) { v } else { super::mcp::STDIO_VERSIONS[0] };
122
                json!({ "protocolVersion": v, "capabilities": { "tools": {} }, "serverInfo": { "name": "spectra", "version": env!("CARGO_PKG_VERSION") }, "instructions": why })
123
            }
124
            "tools/list" => json!({ "tools": [] }),
125
            "ping" => json!({}),
126
            _ => {
127
                let e = json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": why } });
128
                out.write_all(format!("{e}\n").as_bytes()).await?;
129
                out.flush().await?;
130
                continue;
131
            }
132
        };
133
        out.write_all(format!("{}\n", json!({ "jsonrpc": "2.0", "id": id, "result": result })).as_bytes()).await?;
134
        out.flush().await?;
135
    }
136
    Ok(0)
137
}
138
139
/// Resolve the agent for this session's folder, or None.
140
pub async fn resolve(ctx: &super::Ctx, kind: &str) -> Result<Option<Label>> {
141
    let cwd = std::env::current_dir().context("this session's folder")?;
142
    let endpoint = ctx.endpoint().ok();
143
    let all = labels(endpoint.as_ref()).await;
144
    Ok(choose(&all, &cwd, kind).cloned())
145
}
146
147
#[cfg(test)]
148
mod tests {
149
    use super::*;
150
151
    fn l(name: &str, kind: &str, folder: &str) -> Label {
152
        Label { name: name.into(), kind: kind.into(), folder: Some(PathBuf::from(folder)), home: PathBuf::new() }
153
    }
154
155
    #[test]
156
    fn the_deepest_folder_of_the_right_kind_wins() {
157
        let all = [
158
            l("claude-dev", "claude-code", "/dev"),
159
            l("claude-book", "claude-code", "/dev/book"),
160
            l("kimi-book", "kimi-code", "/dev/book"),
161
        ];
162
        assert_eq!(choose(&all, Path::new("/dev/book/ch3"), "claude-code").unwrap().name, "claude-book");
163
        assert_eq!(choose(&all, Path::new("/dev/other"), "claude-code").unwrap().name, "claude-dev");
164
        assert_eq!(choose(&all, Path::new("/dev/book"), "kimi-code").unwrap().name, "kimi-book");
165
        assert!(choose(&all, Path::new("/elsewhere"), "claude-code").is_none());
166
        assert!(choose(&all, Path::new("/devx"), "claude-code").is_none(), "a prefix of the name is not inside");
167
    }
168
}