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, models, daily,
4// hourly, session, model — the same seven cmduse exposes.
5//
6// Read-only: it reads opencode.db and the generated catalogue. The Go usage
7// endpoint's meters are not fetched here — the caller resolves them once (the
8// CLI's own rule: one fetch per run, not one per tool call) and hands them in,
9// so a `usage` call shows the same window rows the dashboard does.
10use crate::ocuse::api::Usage;
11use crate::ocuse::{db, render, window, zen};
12use serde_json::{json, Value};
13
14const PROTOCOL_VERSION: &str = "2025-06-18";
15
16/// What every tool call needs, resolved once by the caller — the per-run
17/// settings plus the account meters it already fetched. Bundled rather than
18/// passed positionally so adding a source (as `account` did) does not grow every
19/// signature in the file.
20#[derive(Clone, Copy)]
21pub struct Settings<'a> {
22    pub db_path: &'a str,
23    pub days: i64,
24    pub tz_secs: i64,
25    pub colour: bool,
26    pub plan: &'a str,
27    pub period_start: Option<i64>,
28    pub account: Option<&'a Usage>,
29    /// Why the account's meters are missing, when the CLI resolved it — the same
30    /// line the dashboard prints, so a `usage` call through MCP explains itself
31    /// too instead of showing bare local estimates.
32    pub account_error: Option<&'a str>,
33}
34
35/// Serve stdin -> stdout until the host closes the pipe.
36pub fn run(settings: Settings) {
37    use std::io::{BufRead, Write};
38    let stdin = std::io::stdin();
39    let mut out = std::io::stdout().lock();
40    for line in stdin.lock().lines() {
41        let Ok(line) = line else { break };
42        if line.trim().is_empty() {
43            continue;
44        }
45        let Ok(msg) = serde_json::from_str::<Value>(&line) else {
46            continue; // not JSON: ignore, a host bug shouldn't kill the server
47        };
48        if let Some(response) = handle(&msg, &settings) {
49            if writeln!(out, "{response}").is_err() {
50                break; // stdout closed: host is gone
51            }
52            out.flush().ok();
53        }
54    }
55}
56
57/// One message in, one response out; `None` for notifications (no `id`).
58pub fn handle(msg: &Value, settings: &Settings) -> Option<Value> {
59    let method = msg.get("method")?.as_str()?;
60    let id = msg.get("id")?;
61    let result = match method {
62        "initialize" => Ok(json!({
63            "protocolVersion": msg
64                .pointer("/params/protocolVersion")
65                .and_then(|v| v.as_str())
66                .unwrap_or(PROTOCOL_VERSION),
67            "capabilities": { "tools": {} },
68            "serverInfo": { "name": "ocuse", "version": env!("CARGO_PKG_VERSION") },
69        })),
70        "ping" => Ok(json!({})),
71        "tools/list" => Ok(json!({ "tools": tools() })),
72        "tools/call" => tool_call(msg, settings),
73        other => Err((-32601, format!("method not found: {other}"))),
74    };
75    Some(match result {
76        Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
77        Err((code, message)) => json!({
78            "jsonrpc": "2.0", "id": id,
79            "error": { "code": code, "message": message },
80        }),
81    })
82}
83
84fn tool_call(msg: &Value, settings: &Settings) -> Result<Value, (i64, String)> {
85    let Settings {
86        db_path,
87        days,
88        tz_secs,
89        colour,
90        plan,
91        period_start,
92        account,
93        account_error,
94    } = *settings;
95    let name = msg
96        .pointer("/params/name")
97        .and_then(|v| v.as_str())
98        .unwrap_or_default();
99    let args = msg
100        .pointer("/params/arguments")
101        .cloned()
102        .unwrap_or(json!({}));
103    let number =
104        |key: &str, fallback: i64| args.get(key).and_then(|v| v.as_i64()).unwrap_or(fallback);
105    // `--tz` per call: the bucket offset is the one thing a caller routinely
106    // wants different from the server's flag.
107    let tz = match args.get("tz").and_then(|v| v.as_str()) {
108        None => tz_secs,
109        Some(s) => crate::cli::parse_tz(s).ok_or((
110            -32602,
111            "tz needs an offset like +05:30 or -08:00".to_string(),
112        ))?,
113    };
114    // Rows to list: `rows` (the CLI flag's name), `limit` still accepted because
115    // it shipped first, and the server returns every row when neither is given.
116    let rows_cap = |fallback: usize| -> usize {
117        match args
118            .get("rows")
119            .or_else(|| args.get("limit"))
120            .and_then(|v| v.as_u64())
121        {
122            Some(0) | None => fallback,
123            Some(n) => n as usize,
124        }
125    };
126    // `since` pins the read window exactly, as in the CLI.
127    let since_ms = match args.get("since").and_then(|v| v.as_str()) {
128        None => None,
129        Some(s) => Some(cmduse_core::dates::parse_iso_utc(s).ok_or((
130            -32602,
131            format!("since needs an ISO-8601 instant, got '{s}'"),
132        ))? as i64),
133    };
134    // The plan may be overridden per call; it defaults to the server's flag.
135    let plan_id = args
136        .get("plan")
137        .and_then(|v| v.as_str())
138        .unwrap_or(plan)
139        .to_string();
140
141    let rows = || {
142        let cutoff = since_ms
143            .unwrap_or_else(|| crate::ocuse::window::now_ms() - number("days", days) * 86_400_000);
144        db::read_all(db_path, cutoff)
145    };
146    let text = match name {
147        "usage" => rows().map(|rows| {
148            let mut report = window::build(
149                &rows,
150                &zen::catalog(),
151                &plan_id,
152                window::now_ms(),
153                None,
154                account,
155            );
156            // The account's own meters when the caller resolved them, so a `usage`
157            // call renders the same window rows the dashboard does — and the same
158            // reason when a key was refused.
159            report.account = account.cloned();
160            report.account_error = account_error.map(str::to_string);
161            render::render_text(&report, db_path, 15, colour, render::DEFAULT_BAR_WIDTH)
162        }),
163        "plans" => Ok(render::plans_text(colour)),
164        "models" => {
165            // The catalogue is static, but a per-call plan override must be real.
166            let catalog = zen::catalog();
167            if catalog.plan(&plan_id).is_none() {
168                return Err((
169                    -32602,
170                    format!("plan must be one of: {}", catalog.plan_ids().join(", ")),
171                ));
172            }
173            let gated = args.get("gated").and_then(|v| v.as_bool()).unwrap_or(false);
174            Ok(render::models_text(&plan_id, gated, colour))
175        }
176        "daily" => rows().map(|rows| {
177            render::bucket_text(
178                &rows,
179                window::now_ms(),
180                number("days", days) * 86_400_000,
181                86_400_000,
182                tz,
183                colour,
184                crate::report_render::Fmt::Table,
185            )
186        }),
187        "hourly" => rows().map(|rows| {
188            render::bucket_text(
189                &rows,
190                window::now_ms(),
191                number("hours", 24) * 3_600_000,
192                3_600_000,
193                tz,
194                colour,
195                crate::report_render::Fmt::Table,
196            )
197        }),
198        "session" => rows().map(|rows| {
199            render::session_text(
200                &rows,
201                rows_cap(usize::MAX),
202                colour,
203                crate::report_render::Fmt::Table,
204            )
205        }),
206        "model" => rows().map(|rows| {
207            let report = window::build(
208                &rows,
209                &zen::catalog(),
210                &plan_id,
211                window::now_ms(),
212                period_start,
213                account,
214            );
215            match args.get("id").and_then(|v| v.as_str()) {
216                Some(wanted) => render::model_text(&report, wanted, colour),
217                None => render::render_text(
218                    &report,
219                    db_path,
220                    rows_cap(usize::MAX),
221                    colour,
222                    render::DEFAULT_BAR_WIDTH,
223                ),
224            }
225        }),
226        _ => {
227            return Ok(json!({
228                "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
229                "isError": true,
230            }))
231        }
232    };
233    Ok(match text {
234        Ok(body) => json!({ "content": [{ "type": "text", "text": body }] }),
235        Err(error) => json!({
236            "content": [{ "type": "text", "text": format!("error: {error}") }],
237            "isError": true,
238        }),
239    })
240}
241
242fn tools() -> Value {
243    json!([
244        { "name": "usage", "description": "OpenCode Go/Zen usage: period totals, rolling windows, per-model spend against each model's monthly limit. Defaults to the Go plan.", "inputSchema": { "type": "object", "properties": { "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" } } } },
245        { "name": "plans", "description": "The Go/Zen catalogue from the docs: both Go plans' per-model monthly limits and the shared $/1M rates.", "inputSchema": { "type": "object", "properties": {} } },
246        { "name": "models", "description": "The Go catalogue for a plan: every model's $/1M in/out/cache-read rates and its monthly limit, marked granted or not.", "inputSchema": { "type": "object", "properties": { "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" }, "gated": { "type": "boolean", "description": "Only models the plan grants" } } } },
247        { "name": "daily", "description": "Spend, requests and tokens per day.", "inputSchema": { "type": "object", "properties": { "days": { "type": "integer", "description": "days of history (default 120)" }, "tz": { "type": "string", "description": "UTC offset for the day buckets, e.g. +05:30" } } } },
248        { "name": "hourly", "description": "Spend, requests and tokens per hour.", "inputSchema": { "type": "object", "properties": { "hours": { "type": "integer", "description": "hours of history (default 24)" }, "tz": { "type": "string", "description": "UTC offset for the hour buckets, e.g. -08:00" } } } },
249        { "name": "session", "description": "Spend per OpenCode session, newest first.", "inputSchema": { "type": "object", "properties": { "since": { "type": "string", "description": "ISO-8601 instant to count from, e.g. 2026-08-27T00:00:00Z" }, "days": { "type": "integer", "description": "days back (used when since is absent)" }, "rows": { "type": "integer", "description": "sessions to list (0 = every row)" } } } },
250        { "name": "model", "description": "One model's limit, spend and window figures against the given Go plan; without id, the whole period table.", "inputSchema": { "type": "object", "properties": { "id": { "type": "string", "description": "model id, e.g. glm-5.2 (omit for the table)" }, "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" }, "since": { "type": "string", "description": "ISO-8601 instant to count from" }, "days": { "type": "integer", "description": "days back (used when since is absent)" }, "rows": { "type": "integer", "description": "table rows to list (0 = every row)" } } } },
251    ])
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn call(method: &str, params: Value, id: i64) -> Value {
259        handle(
260            &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }),
261            &Settings {
262                db_path: "/nonexistent/opencode.db",
263                days: 120,
264                tz_secs: 0,
265                colour: false,
266                plan: crate::ocuse::zen::DEFAULT_PLAN,
267                period_start: None,
268                // No account: the tests never reach the network, and the local
269                // estimate is what the fixtures below are written against.
270                account: None,
271                account_error: None,
272            },
273        )
274        .expect("notification")
275    }
276
277    #[test]
278    fn initialize_negotiates_the_client_version() {
279        let r = call("initialize", json!({ "protocolVersion": "2025-03-26" }), 1);
280        assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
281        assert_eq!(r["result"]["serverInfo"]["name"], "ocuse");
282    }
283
284    #[test]
285    fn unknown_method_is_jsonrpc_error() {
286        let r = call("resources/list", json!({}), 2);
287        assert_eq!(r["error"]["code"], -32601);
288    }
289
290    #[test]
291    fn unknown_tool_is_iserror_result_not_rpc_error() {
292        let r = call("tools/call", json!({ "name": "nope", "arguments": {} }), 3);
293        assert!(r.get("error").is_none());
294        assert_eq!(r["result"]["isError"], true);
295    }
296
297    #[test]
298    fn a_missing_store_reports_an_error_result() {
299        let r = call("tools/call", json!({ "name": "usage", "arguments": {} }), 4);
300        assert_eq!(r["result"]["isError"], true, "{r}");
301        assert!(r["result"]["content"][0]["text"]
302            .as_str()
303            .unwrap()
304            .contains("error"));
305    }
306
307    #[test]
308    fn tools_list_covers_every_command() {
309        let r = call("tools/list", json!({}), 5);
310        // Sorted set: fails if either MCP server's tool list drifts (the cmduse
311        // side asserts the same seven).
312        let mut names: Vec<&str> = r["result"]["tools"]
313            .as_array()
314            .unwrap()
315            .iter()
316            .map(|t| t["name"].as_str().unwrap())
317            .collect();
318        names.sort_unstable();
319        assert_eq!(
320            names,
321            ["daily", "hourly", "model", "models", "plans", "session", "usage"]
322        );
323    }
324
325    #[test]
326    fn tool_arguments_are_the_family_surface() {
327        let r = call("tools/list", json!({}), 40);
328        let tools = &r["result"]["tools"];
329        let params = |name: &str| crate::mcp_tests::tool_params(tools, name);
330        // The Go source forces `plan` on the tools that read the catalogue; the
331        // rest is the family surface both other servers carry.
332        assert_eq!(params("usage"), "plan");
333        assert_eq!(params("plans"), "");
334        assert_eq!(params("models"), "gated plan");
335        assert_eq!(params("daily"), "days tz");
336        assert_eq!(params("hourly"), "hours tz");
337        assert_eq!(params("session"), "days rows since");
338        assert_eq!(params("model"), "days id plan rows since");
339    }
340
341    #[test]
342    fn models_tool_is_the_catalogue_and_needs_no_store() {
343        let r = call(
344            "tools/call",
345            json!({ "name": "models", "arguments": {} }),
346            6,
347        );
348        assert!(r.get("error").is_none(), "{r}");
349        let text = r["result"]["content"][0]["text"].as_str().unwrap();
350        assert!(text.contains("glm-5.3-flash"), "{text}");
351        assert!(text.contains("GRANTED"), "{text}");
352    }
353
354    #[test]
355    fn models_tool_rejects_an_unknown_plan() {
356        let r = call(
357            "tools/call",
358            json!({ "name": "models", "arguments": { "plan": "nope" } }),
359            7,
360        );
361        assert_eq!(r["error"]["code"], -32602, "{r}");
362    }
363}