Skip to main content

mira/
mcp.rs

1//! MCP, on the same port as everything else.
2//!
3//! The agentic principle's first reading: a model should be able to point at
4//! Mira and ask, without a translation layer in between. That is one endpoint,
5//! `POST /mcp`, speaking JSON-RPC 2.0 over Streamable HTTP.
6//!
7//! Eight tools, and they are the same eight questions the UI asks —
8//! deliberately. An agent and a human looking at the same incident should be
9//! reading the same numbers out of the same code path; a separate "agent API"
10//! is a second read path to keep correct, and the first thing it does is
11//! drift.
12//!
13//! Stateless, and not by accident. Streamable HTTP lets a server hand out an
14//! `Mcp-Session-Id` and then requires every later request to carry it, which
15//! makes the server a thing with memory that a load balancer has to route back
16//! to the same replica. We issue none: every request carries everything it
17//! needs, any replica can answer it, and killing one loses nothing. That is
18//! principle 4 applied to the agent surface.
19//!
20//! No SSE either. The responses here are single JSON documents that arrive when
21//! the scan finishes, so a stream would be one event and a teardown.
22
23use 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
36/// The revision of the MCP spec these messages conform to.
37const PROTOCOL: &str = "2025-06-18";
38
39pub fn router(api: Api) -> Router {
40    Router::new().route("/mcp", post(handler)).with_state(api)
41}
42
43/// Tool definitions, verbatim.
44///
45/// Written by hand rather than generated, because this text is the prompt. A
46/// model chooses a tool and fills its arguments from these descriptions alone,
47/// so what belongs here is the shape of the data and the mistake to avoid —
48/// not a restatement of the parameter names.
49const 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
104/// The MCP endpoint: JSON-RPC in, one of the eight tools in [`TOOLS`] out.
105///
106/// Streamable HTTP with no session and no SSE, because every tool here is a
107/// single request and a single response. An agent points at this URL and has
108/// the same reads the UI does.
109async 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        // A notification has no id and takes no reply. `notifications/initialized`
131        // is the one every client sends, and answering it with an error is how a
132        // session fails on its second message.
133        _ 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 = &params["arguments"];
141    let now = api::now_nanos();
142    let dir = api.data_dir.clone();
143
144    // Tool failures are results, not protocol errors: a model that asked for a
145    // metric that does not exist needs to read the reason and try again, and a
146    // JSON-RPC error is something its client may swallow before it ever sees it.
147    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        // No window and no scan: this is in-memory state, so it does not go
214        // through `blocking` and has nothing to report as blocks scanned.
215        "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
242/// "Every span of this trace", which is the one query with no useful time
243/// bound: you look a trace up because you do not know when it happened. The
244/// window is therefore all of it, and the block-level trace-id filter is what
245/// makes that affordable.
246fn trace_search(args: &Yaml) -> Result<Search, String> {
247    // The other three tools get this from `search_doc`/`series_doc`/`window_doc`;
248    // this one builds its query by hand, so it has to ask. A misspelled `limit`
249    // is a page size the model believes it set.
250    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        // A trace is one page or it is a broken trace. 10,000 spans is already
273        // past what any waterfall can show, and an agent handed "here is a
274        // third of a trace, ask again" will reason about the third.
275        after: None,
276    })
277}
278
279/// Same rule as the HTTP API, through the same door: a cold mmap fault stalls
280/// the OS thread it lands on, and tokio has no way to see that happen. Sharing
281/// `api::scan` also shares its permit, so an agent's reads and a browser's are
282/// bounded together rather than each getting the whole pool.
283async 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
312/// JSON-RPC ids are a number, a string, or absent. Echoed back exactly, because
313/// that is how the client matches the reply to the call.
314fn 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
326/// Always 200 with a JSON body, even for an error object: in JSON-RPC the
327/// failure is in the payload, and a client that sees a 4xx may never parse far
328/// enough to find out what went wrong.
329fn 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    /// A data directory with one logs block in it, so the tools that succeed
345    /// have something to have succeeded on.
346    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    /// A tool call answers with `isError` inside a 200, never a JSON-RPC error.
379    /// This is the distinction the module header makes and the one a client is
380    /// most likely to get wrong, so every tool is driven through both sides of
381    /// it: the argument document that parses, and the one that does not.
382    #[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        // A tool that needs no arguments may be called with no `arguments`
414        // member at all, and that is a request, not a malformed document.
415        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        // Each tool parses its arguments with a different function, so each one
423        // needs its own way of being wrong.
424        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            // A misspelled key is a filter that was silently dropped, which the
431            // model cannot see in a page of unfiltered rows.
432            ("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` takes no arguments at all, so every key is a
442            // misspelling — including a window, which it does not honour and
443            // must not appear to.
444            ("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    /// A read that fails — or panics — underneath the tool is still the tool's
460    /// answer, not a dead connection: the model is the one that has to decide
461    /// what to do next.
462    ///
463    /// `spawn_blocking` catches the unwind and hands it back as a `JoinError`.
464    /// Dropping it would leave the request hanging with the server otherwise
465    /// healthy, and a hung tool call is the one failure an agent cannot reason
466    /// about at all.
467    #[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    /// The handshake, and the three ways a request can not be a tool call. The
493    /// notification case is the one that breaks sessions: every client sends
494    /// `notifications/initialized` immediately after `initialize`, and a reply
495    /// to it — even a correct-looking error — ends the session on message two.
496    #[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        // These descriptions are the model's only documentation of the data, so
520        // they may not promise arithmetic the engine does not do: points come
521        // back as stored, and the temporality legend is what lets the model do
522        // the subtraction itself.
523        assert!(!body.contains("as rates"), "{body}");
524        assert!(body.contains("2 cumulative"), "{body}");
525
526        // A string id comes back quoted and escaped, because the client matches
527        // on it byte for byte.
528        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        // A method nobody knows, with an id, is a real JSON-RPC error — and a
543        // null id there is still a reply, since the client is waiting for one.
544        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    /// `get_trace` is the one tool with no time bound, so the id is the only
556    /// thing narrowing it. A malformed id has to be refused before the scan,
557    /// not turned into a filter that matches nothing after reading retention.
558    #[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        // A span id is 8 bytes and looks like a trace id to anyone not counting.
571        assert!(trace_search(&doc(r#"{"trace_id":"0102030405060708"}"#)).is_err());
572        assert!(trace_search(&doc("{}")).unwrap_err().contains("required"));
573        // The key check the other three tools get from their `*_doc` parser: a
574        // dropped `limit` is a page size the model thinks it set.
575        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        // Zero and "all of them" both mean the default rather than an empty page.
594        assert_eq!(limit("0"), 1_000);
595        assert_eq!(limit(r#""lots""#), 1_000);
596    }
597}