OXYGENHUB

Coverage / crates/spectra-mcp/src/tools.rs 86.8%

59 of 68 lines run, 13 of 32 functions · run #8 on main · run missed

1
//! The tools agents call (spec §15.2). Each is a plain function, so the
2
//! list is fixed and deterministic, which keeps clients' prompt caches warm.
3
//! A failure is a result with `isError`, never a protocol error, so the
4
//! model sees why.
5
6
use futures::future::BoxFuture;
7
use serde_json::{json, Value as Json};
8
use spectra_core::authn::Caller;
9
use spectra_core::daemon::Daemon;
10
use spectra_core::principals::Principal;
11
use std::sync::Arc;
12
13
/// What a tool sees: the daemon and who called it.
14
pub struct Ctx {
15
    pub daemon: Arc<Daemon>,
16
    pub caller: Caller,
17
    pub session: Option<String>,
18
}
19
20
/// A tool's result: text for the model, the same as JSON, and whether it
21
/// failed.
22
#[derive(Clone, Debug, Default)]
23
pub struct Output {
24
    pub text: String,
25
    pub structured: Option<Json>,
26
    pub is_error: bool,
27
}
28
29
impl Output {
301×
    pub fn ok(text: impl Into<String>, structured: Json) -> Output {
311×
        Output { text: text.into(), structured: Some(structured), is_error: false }
321×
    }
330×
    pub fn err(text: impl Into<String>) -> Output {
340×
        Output { text: text.into(), structured: None, is_error: true }
350×
    }
36
}
37
38
pub type Run = for<'a> fn(&'a Ctx, Json) -> BoxFuture<'a, Output>;
39
40
/// One tool as `tools/list` describes it.
41
pub struct Tool {
42
    pub name: &'static str,
43
    pub description: &'static str,
44
    pub schema: fn() -> Json,
45
    pub read_only: bool,
46
    pub destructive: bool,
47
    /// Answered as an SSE stream with keep-alives (`wait`).
48
    pub long: bool,
49
    pub run: Run,
50
}
51
52
impl Tool {
532×
    pub fn describe(&self) -> Json {
542×
        json!({
552×
            "name": self.name,
562×
            "description": self.description,
572×
            "inputSchema": (self.schema)(),
582×
            "annotations": { "readOnlyHint": self.read_only, "destructiveHint": self.destructive },
59
        })
602×
    }
61
}
62
63
/// Every tool, in the order `tools/list` gives them.
642×
pub fn all() -> &'static [Tool] {
652×
    TOOLS
662×
}
67
681×
pub fn find(name: &str) -> Option<&'static Tool> {
691×
    TOOLS.iter().find(|t| t.name == name)
701×
}
71
72
static TOOLS: &[Tool] = &[Tool {
73
    name: "agents",
74
    description: "Every agent with its owner, kind, project, presence and status.",
752×
    schema: || json!({ "type": "object", "properties": { "project": { "type": "string", "description": "Only this project's agents" } }, "additionalProperties": false }),
76
    read_only: true,
77
    destructive: false,
78
    long: false,
79
    run: agents,
80
}];
81
82
/// A principal as the model reads it.
830×
pub fn who(p: &Principal) -> String {
840×
    p.display()
850×
}
86
871×
fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
881×
    Box::pin(async move {
891×
        let project = args.get("project").and_then(Json::as_str);
901×
        let rows = sqlx_rows(&ctx.daemon, project).await;
911×
        let rows = match rows {
921×
            Ok(r) => r,
930×
            Err(e) => return Output::err(format!("could not list agents: {e}")),
94
        };
951×
        let list: Vec<Json> = rows
961×
            .iter()
971×
            .map(|(name, owner, kind, project, presence, status)| {
981×
                json!({ "name": name, "owner": owner, "kind": kind, "project": project, "presence": presence.clone().unwrap_or_else(|| "offline".into()), "status": status })
991×
            })
1001×
            .collect();
1011×
        let text = if rows.is_empty() {
1020×
            "No agents.".to_string()
103
        } else {
1041×
            rows.iter()
1051×
                .map(|(name, owner, kind, project, presence, status)| {
1061×
                    let st = status.as_deref().map(|s| format!(" — \"{s}\"")).unwrap_or_default();
1071×
                    format!("- {name} · {owner}'s agent ({kind}, #{project}): {}{st}", presence.as_deref().unwrap_or("offline"))
1081×
                })
1091×
                .collect::<Vec<_>>()
1101×
                .join("\n")
111
        };
1121×
        Output::ok(text, json!({ "agents": list }))
1131×
    })
1141×
}
115
116
#[allow(clippy::type_complexity)]
1171×
async fn sqlx_rows(
1181×
    d: &Daemon,
1191×
    project: Option<&str>,
1201×
) -> Result<Vec<(String, String, String, String, Option<String>, Option<String>)>, sqlx::Error> {
1211×
    sqlx::query_as(
1221×
        "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \
1231×
         WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name",
1241×
    )
1251×
    .bind(project)
1261×
    .bind(project)
1271×
    .fetch_all(d.ledger.pool())
1281×
    .await
1291×
}