1use 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#[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 pub account_error: Option<&'a str>,
33}
34
35pub 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; };
48 if let Some(response) = handle(&msg, &settings) {
49 if writeln!(out, "{response}").is_err() {
50 break; }
52 out.flush().ok();
53 }
54 }
55}
56
57pub 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 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 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 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 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 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 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 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 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 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}