Skip to main content

cmd_usage/ocuse/
mcp.rs

1// MCP stdio server for ocuse — the same JSON-RPC profile cmduse speaks
2// (initialize / ping / tools/list / tools/call), so a host that already drives
3// cmduse can drive this. Tools mirror the CLI: usage, plans, daily, hourly,
4// session, model.
5//
6// Read-only, like everything else here: it reads opencode.db and the generated
7// catalogue, never the network.
8use crate::ocuse::{db, render, window, zen};
9use serde_json::{json, Value};
10
11const PROTOCOL_VERSION: &str = "2025-06-18";
12
13/// Serve stdin -> stdout until the host closes the pipe.
14pub fn run(db_path: &str, days: i64, tz_secs: i64, colour: bool, period_start: Option<i64>) {
15    use std::io::{BufRead, Write};
16    let stdin = std::io::stdin();
17    let mut out = std::io::stdout().lock();
18    for line in stdin.lock().lines() {
19        let Ok(line) = line else { break };
20        if line.trim().is_empty() {
21            continue;
22        }
23        let Ok(msg) = serde_json::from_str::<Value>(&line) else {
24            continue; // not JSON: ignore, a host bug shouldn't kill the server
25        };
26        if let Some(response) = handle(&msg, db_path, days, tz_secs, colour, period_start) {
27            if writeln!(out, "{response}").is_err() {
28                break; // stdout closed: host is gone
29            }
30            out.flush().ok();
31        }
32    }
33}
34
35/// One message in, one response out; `None` for notifications (no `id`).
36pub fn handle(
37    msg: &Value,
38    db_path: &str,
39    days: i64,
40    tz_secs: i64,
41    colour: bool,
42    period_start: Option<i64>,
43) -> Option<Value> {
44    let method = msg.get("method")?.as_str()?;
45    let id = msg.get("id")?;
46    let result = match method {
47        "initialize" => Ok(json!({
48            "protocolVersion": msg
49                .pointer("/params/protocolVersion")
50                .and_then(|v| v.as_str())
51                .unwrap_or(PROTOCOL_VERSION),
52            "capabilities": { "tools": {} },
53            "serverInfo": { "name": "ocuse", "version": env!("CARGO_PKG_VERSION") },
54        })),
55        "ping" => Ok(json!({})),
56        "tools/list" => Ok(json!({ "tools": tools() })),
57        "tools/call" => tool_call(msg, db_path, days, tz_secs, colour, period_start),
58        other => Err((-32601, format!("method not found: {other}"))),
59    };
60    Some(match result {
61        Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
62        Err((code, message)) => json!({
63            "jsonrpc": "2.0", "id": id,
64            "error": { "code": code, "message": message },
65        }),
66    })
67}
68
69fn tool_call(
70    msg: &Value,
71    db_path: &str,
72    days: i64,
73    tz_secs: i64,
74    colour: bool,
75    period_start: Option<i64>,
76) -> Result<Value, (i64, String)> {
77    let name = msg
78        .pointer("/params/name")
79        .and_then(|v| v.as_str())
80        .unwrap_or_default();
81    let args = msg
82        .pointer("/params/arguments")
83        .cloned()
84        .unwrap_or(json!({}));
85    let number =
86        |key: &str, fallback: i64| args.get(key).and_then(|v| v.as_i64()).unwrap_or(fallback);
87
88    let rows = || db::read_rows(db_path, crate::ocuse::window::now_ms() - days * 86_400_000);
89    let text = match name {
90        "usage" => rows().map(|rows| {
91            render::render_text(
92                &window::build(&rows, &zen::catalog(), window::now_ms(), None),
93                db_path,
94                15,
95                colour,
96            )
97        }),
98        "plans" => Ok(render::plans_text(colour)),
99        "daily" => rows().map(|rows| {
100            render::bucket_text(
101                &rows,
102                window::now_ms(),
103                number("days", days),
104                86_400_000,
105                "day",
106                tz_secs,
107                colour,
108            )
109        }),
110        "hourly" => rows().map(|rows| {
111            render::bucket_text(
112                &rows,
113                window::now_ms(),
114                number("hours", 24),
115                3_600_000,
116                "hour",
117                tz_secs,
118                colour,
119            )
120        }),
121        "session" => {
122            rows().map(|rows| render::session_text(&rows, number("limit", 20) as usize, colour))
123        }
124        "model" => {
125            let wanted = args
126                .get("id")
127                .and_then(|v| v.as_str())
128                .unwrap_or_default()
129                .to_string();
130            rows().map(|rows| {
131                let report = window::build(&rows, &zen::catalog(), window::now_ms(), period_start);
132                render::model_text(&report, &wanted, colour)
133            })
134        }
135        _ => {
136            return Ok(json!({
137                "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
138                "isError": true,
139            }))
140        }
141    };
142    Ok(match text {
143        Ok(body) => json!({ "content": [{ "type": "text", "text": body }] }),
144        Err(error) => json!({
145            "content": [{ "type": "text", "text": format!("error: {error}") }],
146            "isError": true,
147        }),
148    })
149}
150
151fn tools() -> Value {
152    json!([
153        { "name": "usage", "description": "OpenCode Go/Zen usage: period totals, rolling windows, per-model spend against each model's monthly limit.", "inputSchema": { "type": "object", "properties": {} } },
154        { "name": "plans", "description": "The Go/Zen catalogue from the docs: per-model monthly limits and $/1M rates.", "inputSchema": { "type": "object", "properties": {} } },
155        { "name": "daily", "description": "Spend, requests and tokens per day.", "inputSchema": { "type": "object", "properties": { "days": { "type": "integer", "description": "days of history (default 120)" } } } },
156        { "name": "hourly", "description": "Spend, requests and tokens per hour.", "inputSchema": { "type": "object", "properties": { "hours": { "type": "integer", "description": "hours of history (default 24)" } } } },
157        { "name": "session", "description": "Spend per OpenCode session, newest first.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "description": "sessions to list (default 20)" } } } },
158        { "name": "model", "description": "One model's limit, spend and window figures.", "inputSchema": { "type": "object", "properties": { "id": { "type": "string", "description": "model id, e.g. glm-5.2" } }, "required": ["id"] } },
159    ])
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn call(method: &str, params: Value, id: i64) -> Value {
167        handle(
168            &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }),
169            "/nonexistent/opencode.db",
170            120,
171            0,
172            false,
173            None,
174        )
175        .expect("notification")
176    }
177
178    #[test]
179    fn initialize_negotiates_the_client_version() {
180        let r = call("initialize", json!({ "protocolVersion": "2025-03-26" }), 1);
181        assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
182        assert_eq!(r["result"]["serverInfo"]["name"], "ocuse");
183    }
184
185    #[test]
186    fn unknown_method_is_jsonrpc_error() {
187        let r = call("resources/list", json!({}), 2);
188        assert_eq!(r["error"]["code"], -32601);
189    }
190
191    #[test]
192    fn unknown_tool_is_iserror_result_not_rpc_error() {
193        let r = call("tools/call", json!({ "name": "nope", "arguments": {} }), 3);
194        assert!(r.get("error").is_none());
195        assert_eq!(r["result"]["isError"], true);
196    }
197
198    #[test]
199    fn a_missing_store_reports_an_error_result() {
200        let r = call("tools/call", json!({ "name": "usage", "arguments": {} }), 4);
201        assert_eq!(r["result"]["isError"], true, "{r}");
202        assert!(r["result"]["content"][0]["text"]
203            .as_str()
204            .unwrap()
205            .contains("error"));
206    }
207
208    #[test]
209    fn tools_list_covers_every_command() {
210        let r = call("tools/list", json!({}), 5);
211        let names: Vec<&str> = r["result"]["tools"]
212            .as_array()
213            .unwrap()
214            .iter()
215            .map(|t| t["name"].as_str().unwrap())
216            .collect();
217        for expected in ["usage", "plans", "daily", "hourly", "session", "model"] {
218            assert!(
219                names.contains(&expected),
220                "{expected} missing from {names:?}"
221            );
222        }
223    }
224}