1use axum::Router;
24use axum::extract::State;
25use axum::http::{StatusCode, header};
26use axum::response::{IntoResponse, Response};
27use axum::routing::post;
28use yaml_rust2::Yaml;
29
30use mira_core::json::Json;
31use mira_core::query::{Op, Search, Signal, Target, Term, Value};
32use mira_core::series;
33
34use crate::api::{self, Api};
35
36const PROTOCOL: &str = "2025-06-18";
38
39pub fn router(api: Api) -> Router {
40 Router::new().route("/mcp", post(handler)).with_state(api)
41}
42
43const TOOLS: &str = r#"[
50{"name":"query_records",
51 "description":"Search logs or spans. Returns matching records newest-first with all attributes merged in, plus the blocks and rows the scan touched. Terms are AND-ed. Use `attr` for OpenTelemetry attributes (service.name, http.route, k8s.pod.name) and `field` for columns of the record itself (severity_text, severity_number, body, name, duration_nano, status_code, trace_id, span_id). Attributes are searched at all three levels - record, resource and scope - so you do not need to know where the SDK put them. Time bounds default to the last hour; widening them costs blocks scanned. `rows_matched` above `limit` means you are seeing a page: narrow the filter, or page with `after`.",
52 "inputSchema":{"type":"object","properties":{
53 "signal":{"type":"string","enum":["logs","traces"],"description":"default logs"},
54 "from":{"type":"string","description":"'-15m', 'now', or absolute nanoseconds. Default -1h."},
55 "to":{"type":"string","description":"same forms. Default now."},
56 "where":{"type":"array","description":"AND-ed terms, each {attr|field: <name>, <op>: <value>} where op is one of eq ne lt lte gt gte contains","items":{"type":"object"}},
57 "limit":{"type":"integer","description":"default 100, max 10000"},
58 "after":{"type":"string","description":"the `next` value from a previous response, verbatim, to continue where it stopped. Absent `next` means that was the last page. There is no offset: a store still being written to shifts under one."}}}},
59
60{"name":"get_trace",
61 "description":"Every span of one trace, by trace id, over all of retention. Prefer this to query_records with a trace_id filter: blocks carry a trace-id index, and this is the call that uses it. Returns spans newest-first; parent_span_id links them into the tree.",
62 "inputSchema":{"type":"object","required":["trace_id"],"properties":{
63 "trace_id":{"type":"string","description":"32 hex characters, as returned in any span or log record"},
64 "limit":{"type":"integer","description":"default 1000, max 10000"}}}},
65
66{"name":"query_metric",
67 "description":"One metric as time series, grouped by attribute set. Each series carries its identifying attributes, its `temporality` as the OTLP enum (0 unspecified, 1 delta, 2 cumulative), whether it is `monotonic`, and its points. Points are the values as stored: no step, no aggregation and no rate, so a cumulative sum is the running counter and a per-second rate is yours to derive by subtracting consecutive points and dividing by the gap between their timestamps. Omit `name` at your peril - it scans every metric in the window.",
68 "inputSchema":{"type":"object","properties":{
69 "name":{"type":"string","description":"exact metric name, from list_metrics"},
70 "from":{"type":"string"},"to":{"type":"string"},
71 "where":{"type":"array","description":"same term grammar as query_records","items":{"type":"object"}},
72 "max_series":{"type":"integer","description":"default 200"},
73 "max_points":{"type":"integer","description":"default 5000"}}}},
74
75{"name":"list_metrics",
76 "description":"Metric names present in a time window, with unit and kind. Call this before query_metric rather than guessing a name.",
77 "inputSchema":{"type":"object","properties":{
78 "from":{"type":"string"},"to":{"type":"string"}}}},
79
80{"name":"correlate",
81 "description":"The frame around what a search matched: the time window, the traces those records belong to, and the services that took part. This is the tool for 'what else was happening', and it replaces the usual three round trips of query, read a trace id, query again. `expand` is an ordered walk, and the order matters: 'traces' widens the window to the real start and end of the traces found, which you almost always want first because a log line is written after the request it describes; 'peers' then adds every service that appears in those traces; 'around:<duration>' pads the window by hand. Every field of the answer is an input to another call - feed a trace to get_trace, a service name to query_records as {attr: service.name, eq: <name>}, and from/to to anything. `truncated` true means the frame hit its cap and is a sample, so narrow the search before drawing conclusions.",
82 "inputSchema":{"type":"object","properties":{
83 "signal":{"type":"string","enum":["logs","traces"],"description":"what to anchor on, default logs"},
84 "from":{"type":"string"},"to":{"type":"string"},
85 "where":{"type":"array","description":"same term grammar as query_records","items":{"type":"object"}},
86 "expand":{"type":"array","description":"ordered steps, each one of: traces, peers, around:<duration> such as around:30s","items":{"type":"string"}}}}},
87
88{"name":"service_map",
89 "description":"Who calls whom in a time window, computed from parent_span_id at read time. Nodes are services with their span and error counts; edges carry calls, errors, average and max duration in nanoseconds. The edge from \"entry\" is traffic arriving from outside the traced system. `unresolved` counts spans whose parent was not in the sample - if it is a large fraction of the spans, raise max_spans or narrow the window before trusting a thin edge. Use this to find the failing dependency before querying its records.",
90 "inputSchema":{"type":"object","properties":{
91 "from":{"type":"string"},"to":{"type":"string"},
92 "max_spans":{"type":"integer","description":"span budget, default 1000000. Newest blocks are read first, so a small budget is a recent sample, not a truncated one."}}}},
93
94{"name":"list_services",
95 "description":"Every service that emitted anything in a time window, with the stable entity key Mira identifies it by. Call this before filtering on service.name rather than guessing at the spelling. Two entries with the same name and different keys are two distinct instances or deployments.",
96 "inputSchema":{"type":"object","properties":{
97 "from":{"type":"string"},"to":{"type":"string"}}}},
98
99{"name":"list_alerts",
100 "description":"Every alerting rule this node evaluates and what it is currently doing: state is one of ok, pending (breaching but has not held for `for_nano` yet) and firing. `value` is the last evaluation, `threshold` and `op` are what it is compared against, and `matched`/`total` are the record counts behind it - a ratio rule counts `matched` of `total`, a count rule counts `matched`. `link` opens the same records in Mira's UI. An empty list means this node has no rules file, not that everything is healthy. `error` non-null means the rule could not be evaluated, which is not the same as not firing. Rules are static KYAML in a file, so this tool reads and never writes.",
101 "inputSchema":{"type":"object","properties":{}}}
102]"#;
103
104async fn handler(State(api): State<Api>, body: String) -> Response {
110 let doc = match api::parse(&body) {
111 Ok(d) => d,
112 Err(e) => return rpc_error(&Yaml::Null, -32700, &e),
113 };
114 let id = doc["id"].clone();
115 let method = doc["method"].as_str().unwrap_or_default();
116 let params = &doc["params"];
117
118 match method {
119 "initialize" => result(
120 &id,
121 &format!(
122 "{{\"protocolVersion\":\"{PROTOCOL}\",\"capabilities\":{{\"tools\":{{}}}},\
123 \"serverInfo\":{{\"name\":\"mira\",\"version\":\"{}\"}}}}",
124 env!("CARGO_PKG_VERSION")
125 ),
126 ),
127 "tools/list" => result(&id, &format!("{{\"tools\":{TOOLS}}}")),
128 "tools/call" => call(api, &id, params).await,
129 "ping" => result(&id, "{}"),
130 _ if id.is_badvalue() || id.is_null() => StatusCode::ACCEPTED.into_response(),
134 other => rpc_error(&id, -32601, &format!("unknown method {other:?}")),
135 }
136}
137
138async fn call(api: Api, id: &Yaml, params: &Yaml) -> Response {
139 let name = params["name"].as_str().unwrap_or_default();
140 let args = ¶ms["arguments"];
141 let now = api::now_nanos();
142 let dir = api.data_dir.clone();
143
144 let out = match name {
148 "query_records" => match api::search_doc(args, now) {
149 Ok(q) => {
150 let open = api.open(q.signal.dir()).await;
151 blocking("rows", move || {
152 mira_core::query::search_open(&dir, &q, &open)
153 })
154 .await
155 }
156 Err(e) => Err(e),
157 },
158 "get_trace" => match trace_search(args) {
159 Ok(q) => {
160 let open = api.open(q.signal.dir()).await;
161 blocking("rows", move || {
162 mira_core::query::search_open(&dir, &q, &open)
163 })
164 .await
165 }
166 Err(e) => Err(e),
167 },
168 "query_metric" => match api::series_doc(args, now) {
169 Ok(q) => {
170 let open = api.open("metrics").await;
171 blocking("series", move || series::series_open(&dir, &q, &open)).await
172 }
173 Err(e) => Err(e),
174 },
175 "list_metrics" => match api::window_doc(args, now) {
176 Ok((from, to)) => {
177 let open = api.open("metrics").await;
178 blocking("names", move || series::names_open(&dir, from, to, &open)).await
179 }
180 Err(e) => Err(e),
181 },
182 "correlate" => match api::correlate_doc(args, now) {
183 Ok((q, ops)) => {
184 let anchored = api.open(q.signal.dir()).await;
185 let all = api.open_all().await;
186 blocking("frame", move || {
187 api::correlate(&dir, &q, &ops, &anchored, &all)
188 })
189 .await
190 }
191 Err(e) => Err(e),
192 },
193 "service_map" => match api::map_doc(args, now) {
194 Ok((from, to, max_spans)) => {
195 let open = api.open("traces").await;
196 blocking("map", move || {
197 mira_core::frame::map(&dir, from, to, max_spans, &open)
198 })
199 .await
200 }
201 Err(e) => Err(e),
202 },
203 "list_services" => match api::window_doc(args, now) {
204 Ok((from, to)) => {
205 let open = api.open_all().await;
206 blocking("entities", move || {
207 mira_core::frame::entities(&dir, from, to, &open)
208 })
209 .await
210 }
211 Err(e) => Err(e),
212 },
213 "list_alerts" => match api::known(args, &[]) {
216 Ok(()) => Ok(api.alerts.json()),
217 Err(e) => Err(e),
218 },
219 other => Err(format!("unknown tool {other:?}")),
220 };
221
222 let mut j = Json::new();
223 j.obj(|j| {
224 j.key("content");
225 j.arr(|j| {
226 j.obj(|j| {
227 j.key("type");
228 j.str("text");
229 j.key("text");
230 match &out {
231 Ok(text) => j.str(text),
232 Err(e) => j.str(e),
233 }
234 });
235 });
236 j.key("isError");
237 j.bool(out.is_err());
238 });
239 result(id, &j.into_string())
240}
241
242fn trace_search(args: &Yaml) -> Result<Search, String> {
247 api::known(args, &["trace_id", "limit"])?;
251 let id = args["trace_id"]
252 .as_str()
253 .ok_or("trace_id is required, as 32 hex characters")?
254 .trim();
255 if mira_core::query::unhex(id).is_none_or(|b| b.len() != 16) {
256 return Err(format!("{id:?} is not a 16-byte hex trace id"));
257 }
258 let limit = match &args["limit"] {
259 Yaml::Integer(n) if *n > 0 => (*n as usize).min(10_000),
260 _ => 1_000,
261 };
262 Ok(Search {
263 signal: Signal::Traces,
264 from: 0,
265 to: i64::MAX,
266 terms: vec![Term {
267 target: Target::Field("trace_id".into()),
268 op: Op::Eq,
269 value: Value::Str(id.to_owned()),
270 }],
271 limit,
272 after: None,
276 })
277}
278
279async fn blocking(
284 field: &'static str,
285 f: impl FnOnce() -> mira_core::error::Result<mira_core::query::Results> + Send + 'static,
286) -> Result<String, String> {
287 let t = std::time::Instant::now();
288 match api::scan(f).await {
289 Ok(Ok(r)) => Ok(api::envelope(field, &r, t.elapsed())),
290 Ok(Err(e)) => Err(e.to_string()),
291 Err(e) => Err(format!("query task panicked: {e}")),
292 }
293}
294
295fn result(id: &Yaml, payload: &str) -> Response {
296 json(format!(
297 "{{\"jsonrpc\":\"2.0\",\"id\":{},\"result\":{payload}}}",
298 rpc_id(id)
299 ))
300}
301
302fn rpc_error(id: &Yaml, code: i32, message: &str) -> Response {
303 let mut j = Json::new();
304 j.str(message);
305 json(format!(
306 "{{\"jsonrpc\":\"2.0\",\"id\":{},\"error\":{{\"code\":{code},\"message\":{}}}}}",
307 rpc_id(id),
308 j.into_string()
309 ))
310}
311
312fn rpc_id(id: &Yaml) -> String {
315 match id {
316 Yaml::Integer(n) => n.to_string(),
317 Yaml::String(s) => {
318 let mut j = Json::new();
319 j.str(s);
320 j.into_string()
321 }
322 _ => "null".into(),
323 }
324}
325
326fn json(body: String) -> Response {
330 (
331 StatusCode::OK,
332 [(header::CONTENT_TYPE, "application/json")],
333 body,
334 )
335 .into_response()
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::path::PathBuf;
342 use std::sync::Arc;
343
344 fn api(name: &str) -> (Api, PathBuf) {
347 let dir = std::env::temp_dir().join(format!("mira-mcp-{name}-{}", std::process::id()));
348 let _ = std::fs::remove_dir_all(&dir);
349 std::fs::create_dir_all(&dir).unwrap();
350 let mut b = mira_core::logs::LogsBuilder::new();
351 b.append_request(&crate::e2e::logs_export(
352 "checkout",
353 api::now_nanos() as u64,
354 4,
355 ))
356 .unwrap();
357 let sealed = b.finish().unwrap();
358 mira_core::block::publish(&dir, "logs", mira_core::block::node_id("a"), 0, 0, &sealed)
359 .unwrap();
360 (
361 Api {
362 data_dir: Arc::new(dir.clone()),
363 ..Default::default()
364 },
365 dir,
366 )
367 }
368
369 async fn rpc(api: &Api, body: &str) -> (StatusCode, String) {
370 let res = handler(State(api.clone()), body.to_owned()).await;
371 let status = res.status();
372 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
373 .await
374 .unwrap();
375 (status, String::from_utf8(bytes.to_vec()).unwrap())
376 }
377
378 #[tokio::test]
383 async fn a_tool_that_cannot_answer_says_so_in_its_result_not_in_the_protocol() {
384 let (api, _dir) = api("tools");
385 let call = |name: &str, args: &str| {
386 format!(
387 r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call",
388 "params":{{"name":"{name}","arguments":{args}}}}}"#
389 )
390 };
391 let ok = [
392 ("query_records", r#"{"limit":2}"#),
393 (
394 "get_trace",
395 r#"{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","limit":5}"#,
396 ),
397 ("query_metric", r#"{"name":"http.server.duration"}"#),
398 ("list_metrics", "{}"),
399 (
400 "correlate",
401 r#"{"expand":["traces","around:30s","peers"],"limit":2}"#,
402 ),
403 ("service_map", r#"{"max_spans":100}"#),
404 ("list_services", "{}"),
405 ("list_alerts", "{}"),
406 ];
407 for (name, args) in ok {
408 let (s, body) = rpc(&api, &call(name, args)).await;
409 assert_eq!(s, StatusCode::OK, "{name}");
410 assert!(body.contains(r#""isError":false"#), "{name}: {body}");
411 }
412
413 let (_, body) = rpc(
416 &api,
417 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_metrics"}}"#,
418 )
419 .await;
420 assert!(body.contains(r#""isError":false"#), "{body}");
421
422 let bad = [
425 ("query_records", r#"{"signal":"metrics"}"#, "unknown signal"),
426 ("get_trace", r#"{"trace_id":"nope"}"#, "hex trace id"),
427 ("query_metric", r#"{"where":"errors"}"#, "list of terms"),
428 ("list_metrics", r#"{"from":"yesterday"}"#, "yesterday"),
429 ("teleport", "{}", "unknown tool"),
430 ("query_records", r#"{"filters":[]}"#, "unknown query key"),
433 ("query_metric", r#"{"step":"1m"}"#, "unknown query key"),
434 ("list_metrics", r#"{"name":"http"}"#, "unknown query key"),
435 ("correlate", r#"{"expand":["sideways"]}"#, "sideways"),
436 ("correlate", r#"{"expand":"traces"}"#, "list of steps"),
437 ("correlate", r#"{"signal":"metrics"}"#, "unknown signal"),
438 ("service_map", r#"{"max_spans":0}"#, "max_spans"),
439 ("service_map", r#"{"limit":5}"#, "unknown query key"),
440 ("list_services", r#"{"to":"soon"}"#, "soon"),
441 ("list_alerts", r#"{"from":"-1h"}"#, "unknown query key"),
445 (
446 "get_trace",
447 r#"{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","limitt":5}"#,
448 "unknown query key",
449 ),
450 ];
451 for (name, args, want) in bad {
452 let (s, body) = rpc(&api, &call(name, args)).await;
453 assert_eq!(s, StatusCode::OK, "{name}");
454 assert!(body.contains(r#""isError":true"#), "{name}: {body}");
455 assert!(body.contains(want), "{name}: {body}");
456 }
457 }
458
459 #[tokio::test]
468 async fn a_broken_store_is_reported_to_the_model_rather_than_thrown() {
469 let panicked = blocking("rows", || panic!("a page fault, say"))
470 .await
471 .unwrap_err();
472 assert!(panicked.contains("query task panicked"), "{panicked}");
473
474 let dir = std::env::temp_dir().join(format!("mira-mcp-broken-{}", std::process::id()));
475 let _ = std::fs::remove_dir_all(&dir);
476 std::fs::create_dir_all(&dir).unwrap();
477 std::fs::write(dir.join("logs"), b"not a directory").unwrap();
478 let api = Api {
479 data_dir: Arc::new(dir),
480 ..Default::default()
481 };
482 let (s, body) = rpc(
483 &api,
484 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call",
485 "params":{"name":"query_records","arguments":{}}}"#,
486 )
487 .await;
488 assert_eq!(s, StatusCode::OK);
489 assert!(body.contains(r#""isError":true"#), "{body}");
490 }
491
492 #[tokio::test]
497 async fn the_protocol_surface_echoes_ids_and_stays_quiet_when_there_is_none() {
498 let (api, _dir) = api("proto");
499 let (_, body) = rpc(
500 &api,
501 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
502 )
503 .await;
504 assert!(body.contains(PROTOCOL), "{body}");
505 assert!(body.contains(env!("CARGO_PKG_VERSION")), "{body}");
506
507 let (_, body) = rpc(&api, r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#).await;
508 for tool in [
509 "query_records",
510 "get_trace",
511 "query_metric",
512 "list_metrics",
513 "correlate",
514 "service_map",
515 "list_services",
516 ] {
517 assert!(body.contains(tool), "{tool} missing from tools/list");
518 }
519 assert!(!body.contains("as rates"), "{body}");
524 assert!(body.contains("2 cumulative"), "{body}");
525
526 let (_, body) = rpc(&api, r#"{"jsonrpc":"2.0","id":"a\"b","method":"ping"}"#).await;
529 assert!(
530 body.starts_with(r#"{"jsonrpc":"2.0","id":"a\"b","result":{}}"#),
531 "{body}"
532 );
533
534 let (s, body) = rpc(
535 &api,
536 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
537 )
538 .await;
539 assert_eq!(s, StatusCode::ACCEPTED);
540 assert!(body.is_empty(), "{body}");
541
542 let (_, body) = rpc(&api, r#"{"jsonrpc":"2.0","id":true,"method":"levitate"}"#).await;
545 assert!(body.contains(r#""id":null"#), "{body}");
546 assert!(
547 body.contains("-32601") && body.contains("levitate"),
548 "{body}"
549 );
550
551 let (_, body) = rpc(&api, "{not: [kyaml").await;
552 assert!(body.contains("-32700"), "{body}");
553 }
554
555 #[test]
559 fn a_trace_lookup_insists_on_a_whole_trace_id() {
560 let doc = |s: &str| api::parse(s).unwrap();
561 let q = trace_search(&doc(r#"{"trace_id":" 4BF92F3577B34DA6A3CE929D0E0E4736 "}"#)).unwrap();
562 assert_eq!(q.signal, Signal::Traces);
563 assert_eq!((q.from, q.to), (0, i64::MAX));
564 assert_eq!(q.limit, 1_000);
565 assert!(q.after.is_none());
566 assert_eq!(q.terms.len(), 1);
567
568 let short = trace_search(&doc(r#"{"trace_id":"ab","limit":7}"#)).unwrap_err();
569 assert!(short.contains("16-byte"), "{short}");
570 assert!(trace_search(&doc(r#"{"trace_id":"0102030405060708"}"#)).is_err());
572 assert!(trace_search(&doc("{}")).unwrap_err().contains("required"));
573 let typo = trace_search(&doc(
576 r#"{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","limitt":5}"#,
577 ))
578 .unwrap_err();
579 assert!(
580 typo.contains("unknown query key") && typo.contains("limitt"),
581 "{typo}"
582 );
583
584 let limit = |s: &str| {
585 trace_search(&doc(&format!(
586 r#"{{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","limit":{s}}}"#
587 )))
588 .unwrap()
589 .limit
590 };
591 assert_eq!(limit("7"), 7);
592 assert_eq!(limit("99999"), 10_000);
593 assert_eq!(limit("0"), 1_000);
595 assert_eq!(limit(r#""lots""#), 1_000);
596 }
597}