Skip to main content

featherbit/mcp/tools/
debug.rs

1//! Read tools over debug mode: trace listing/inspection and the sandbox.
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::ToolError;
8use crate::debug::render::{apply_filter, render_trace, TraceFilter};
9use crate::debug::sandbox::{run_sandbox, SandboxError, SandboxRequest};
10use crate::state::SharedState;
11
12#[derive(Debug, Default, Deserialize, JsonSchema)]
13pub struct ListTracesArgs {
14    /// Only traces recorded for this route name.
15    pub route: Option<String>,
16    /// Only traces of this policy.
17    pub policy: Option<String>,
18    /// Only traces whose final response had this status.
19    pub status: Option<u16>,
20    /// `request` (real traffic) or `sandbox`.
21    pub source: Option<String>,
22    /// Maximum rows (newest first). Default 20.
23    pub limit: Option<usize>,
24}
25
26#[derive(Debug, Deserialize, JsonSchema)]
27pub struct GetTraceArgs {
28    /// Trace id from list_traces.
29    pub id: String,
30    /// Include the full context snapshot after every step (large). Default false;
31    /// use get_trace_step for one node's before/after.
32    #[serde(default)]
33    pub include_snapshots: bool,
34}
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub struct GetTraceStepArgs {
38    /// Trace id from list_traces.
39    pub id: String,
40    /// Node id of the step (as shown in the trace). Either this or `index`.
41    pub node_id: Option<String>,
42    /// Zero-based step index. Either this or `node_id`.
43    pub index: Option<usize>,
44}
45
46fn require_debug(state: &SharedState) -> Result<(), ToolError> {
47    if state.debug.enabled {
48        Ok(())
49    } else {
50        Err(ToolError::debug_disabled())
51    }
52}
53
54pub async fn list_traces(state: &SharedState, a: ListTracesArgs) -> Result<Value, ToolError> {
55    require_debug(state)?;
56    let f = TraceFilter {
57        route: a.route,
58        policy: a.policy,
59        status: a.status,
60        source: a.source,
61        limit: Some(a.limit.unwrap_or(20)),
62    };
63    let traces = apply_filter(state.debug.list(), &f);
64    // Agents read this tool's output as evidence. Without `retention`, an
65    // empty result after a filter reads as "the filter matched nothing",
66    // which has already produced a confident and wrong "the filter is
67    // broken" conclusion when the traces had simply aged out.
68    Ok(serde_json::json!({
69        "traces": traces,
70        "retention": state.debug.retention(),
71    }))
72}
73
74pub async fn get_trace(state: &SharedState, a: GetTraceArgs) -> Result<Value, ToolError> {
75    require_debug(state)?;
76    let trace = state
77        .debug
78        .get(&a.id)
79        .ok_or_else(|| ToolError::not_found("trace", &a.id))?;
80    let mut v = render_trace(&trace);
81    if !a.include_snapshots {
82        if let Some(obj) = v.as_object_mut() {
83            obj.remove("initial");
84        }
85        if let Some(steps) = v["steps"].as_array_mut() {
86            for s in steps {
87                if let Some(o) = s.as_object_mut() {
88                    o.remove("after");
89                }
90            }
91        }
92        v["snapshots_omitted"] = Value::Bool(true);
93    }
94    Ok(v)
95}
96
97pub async fn get_trace_step(state: &SharedState, a: GetTraceStepArgs) -> Result<Value, ToolError> {
98    require_debug(state)?;
99    let trace = state
100        .debug
101        .get(&a.id)
102        .ok_or_else(|| ToolError::not_found("trace", &a.id))?;
103    let idx = match (&a.node_id, a.index) {
104        (Some(id), _) => trace
105            .steps
106            .iter()
107            .position(|s| &s.node_id == id)
108            .ok_or_else(|| ToolError::not_found("step for node", id))?,
109        (None, Some(i)) if i < trace.steps.len() => i,
110        (None, Some(i)) => return Err(ToolError::not_found("step index", &i.to_string())),
111        (None, None) => return Err(ToolError::invalid_input("provide node_id or index")),
112    };
113    let rendered = render_trace(&trace);
114    let step = rendered["steps"][idx].clone();
115    let before = if idx == 0 {
116        &trace.initial
117    } else {
118        &trace.steps[idx - 1].after
119    };
120    let node_config = {
121        let gw = state.gateway.read().await;
122        gw.policies
123            .iter()
124            .find(|p| p.name == trace.policy)
125            .and_then(|p| p.nodes.iter().find(|n| n.id == trace.steps[idx].node_id))
126            .map(|n| serde_json::to_value(n).unwrap_or(Value::Null))
127            .unwrap_or(Value::Null)
128    };
129    Ok(serde_json::json!({
130        "trace_id": trace.id,
131        "policy": trace.policy,
132        "step": step,
133        "before": serde_json::to_value(before).map_err(|e| ToolError::internal(e.to_string()))?,
134        "after": serde_json::to_value(&trace.steps[idx].after).map_err(|e| ToolError::internal(e.to_string()))?,
135        "node_config": node_config,
136    }))
137}
138
139/// `run_sandbox` takes the same body as `POST /api/debug/sandbox`. Shape
140/// errors come back with a hint showing the accepted payload, because a model
141/// that guessed wrong needs the correct shape, not just the field name.
142pub async fn run_sandbox_tool(state: &SharedState, a: Value) -> Result<Value, ToolError> {
143    let req: SandboxRequest = serde_json::from_value(a)
144        .map_err(|e| ToolError::sandbox_bad_request(format!("invalid sandbox request: {e}")))?;
145    match run_sandbox(state, req).await {
146        Ok(r) => Ok(serde_json::json!({
147            "mode": r.mode,
148            "policy": r.policy,
149            "warning": "plugins executed for real: outbound calls were made and shared rate-limit/breaker state was mutated",
150            "stored_trace_id": r.stored_trace_id,
151            "trace": r.trace,
152        })),
153        Err(SandboxError::Disabled) => Err(ToolError::debug_disabled()),
154        Err(SandboxError::SandboxDisabled) => Err(ToolError::sandbox_disabled()),
155        Err(SandboxError::BadRequest(m)) => Err(ToolError::sandbox_bad_request(m)),
156        Err(SandboxError::UnknownPolicy(n)) => Err(ToolError::not_found("policy", &n)),
157        Err(SandboxError::Timeout(s)) => Err(ToolError::internal(format!(
158            "run exceeded debug.sandbox_timeout_seconds ({s}s)"
159        ))),
160    }
161}
162
163/// Schema for `run_sandbox`. Typed and documented field by field so an agent
164/// sees the exact payload shape; the tool still hands the raw JSON to the
165/// sandbox runner, which validates (and forgives a few common variants:
166/// `query`/`uri` aliases, JSON bodies written as objects, numeric header
167/// values, `response.status`).
168// Only ever used through `schema_of::<SandboxArgs>()`.
169#[allow(dead_code)]
170#[derive(Debug, Deserialize, JsonSchema)]
171pub struct SandboxArgs {
172    /// Ad-hoc nodes to run in order, chained through their success ports; a
173    /// `listener` and `client` are added for you. Exclusive with `policy`.
174    pub nodes: Option<Vec<SandboxNodeArgs>>,
175    /// Name of a stored policy to run. Exclusive with `nodes`.
176    pub policy: Option<String>,
177    /// Nodes mode only: `stop` (default) leaves error ports unwired so a
178    /// failing node shows `edge: "unhandled"`; `client` wires them to `client`.
179    pub on_error: Option<SandboxOnError>,
180    /// The synthetic request. Every field is optional; `{}` is `GET /`.
181    /// Flat shape — do NOT nest under `request` (a trace snapshot's nested
182    /// `{request, response, message}` object is also accepted).
183    pub context: Option<SandboxContextArgs>,
184}
185
186#[allow(dead_code)]
187#[derive(Debug, Deserialize, JsonSchema)]
188#[serde(rename_all = "lowercase")]
189pub enum SandboxOnError {
190    Stop,
191    Client,
192}
193
194/// One ad-hoc node: `{ "id": "rw", "type": "proxy-rewrite", "config": {...} }`.
195#[allow(dead_code)]
196#[derive(Debug, Deserialize, JsonSchema)]
197pub struct SandboxNodeArgs {
198    /// Node id; defaults to `<type>-<index>` when omitted.
199    pub id: Option<String>,
200    /// Node type as in YAML `type:` (see list_node_types / get_node_type).
201    #[serde(rename = "type")]
202    pub node_type: String,
203    /// The node's config object; keys per get_node_type(<type>).
204    pub config: Option<Value>,
205}
206
207/// Header / query-parameter values: a string, or a list of strings for
208/// repeated values. Numbers and booleans are accepted and stringified.
209#[allow(dead_code)]
210#[derive(Debug, Deserialize, JsonSchema)]
211#[serde(untagged)]
212pub enum SandboxValue {
213    One(String),
214    Many(Vec<String>),
215}
216
217#[allow(dead_code)]
218#[derive(Debug, Deserialize, JsonSchema)]
219pub struct SandboxContextArgs {
220    /// HTTP method. Default `GET`.
221    pub method: Option<String>,
222    /// Request path without the query string, e.g. `/hello/frenk`. Default `/`.
223    pub path: Option<String>,
224    /// Host header value. Default `sandbox.local`.
225    pub host: Option<String>,
226    /// `http` (default) or `https`.
227    pub scheme: Option<String>,
228    /// Request headers as an object: `{"authorization": "Bearer x", "accept": ["a", "b"]}`.
229    pub headers: Option<std::collections::HashMap<String, SandboxValue>>,
230    /// Query parameters as an object (not a query string): `{"page": "2"}`.
231    pub query_params: Option<std::collections::HashMap<String, SandboxValue>>,
232    /// Request body as text. A JSON body may be passed as a JSON string
233    /// (`"{\"a\":1}"`) or directly as an object — it is serialized for you.
234    /// Add a `content-type` header yourself when a plugin needs it.
235    pub body: Option<Value>,
236    /// Base64 body for binary payloads. Exclusive with `body`.
237    pub body_base64: Option<String>,
238    /// Client address `ip:port`. Default `127.0.0.1:0`.
239    pub remote_addr: Option<String>,
240    /// `http1` (default) or `http2`.
241    pub protocol: Option<String>,
242    /// Pre-seeded `context.message` entries (e.g. what an earlier node would
243    /// have set), keyed by name.
244    pub message: Option<std::collections::HashMap<String, Value>>,
245    /// Seed the response to exercise response-phase plugins (response-rewrite,
246    /// loggers): `{"status_code": 200, "headers": {...}, "body": "..."}`.
247    pub response: Option<SandboxResponseArgs>,
248}
249
250#[allow(dead_code)]
251#[derive(Debug, Deserialize, JsonSchema)]
252pub struct SandboxResponseArgs {
253    /// Response status, e.g. 200.
254    pub status_code: Option<u16>,
255    /// Response headers, same shape as request headers.
256    pub headers: Option<std::collections::HashMap<String, SandboxValue>>,
257    /// Response body as text (a JSON object is serialized for you).
258    pub body: Option<Value>,
259}
260
261#[cfg(test)]
262mod tests {
263    use crate::mcp::tools::call;
264    use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
265
266    #[tokio::test]
267    async fn debug_off_is_a_tool_error() {
268        let s = state("{}", ECHO_GATEWAY);
269        for (tool, a) in [
270            ("list_traces", serde_json::json!({})),
271            ("get_trace", serde_json::json!({"id": "x"})),
272            ("get_trace_step", serde_json::json!({"id": "x", "index": 0})),
273            (
274                "run_sandbox",
275                serde_json::json!({"policy": "echo-policy", "context": {}}),
276            ),
277        ] {
278            let err = call(&s, tool, obj(a)).await.unwrap_err();
279            assert_eq!(err.code, "debug_disabled", "{tool}");
280            assert!(err.hint.as_deref().unwrap().contains("debug.enabled"));
281        }
282    }
283
284    #[tokio::test]
285    async fn sandbox_then_inspect_trace() {
286        let s = state("debug:\n  enabled: true\n", ECHO_GATEWAY);
287        let run = call(
288            &s,
289            "run_sandbox",
290            obj(serde_json::json!({"policy": "echo-policy", "context": {"path": "/hello"}})),
291        )
292        .await
293        .unwrap();
294        let id = run["stored_trace_id"].as_str().unwrap().to_string();
295
296        let list = call(
297            &s,
298            "list_traces",
299            obj(serde_json::json!({"source": "sandbox"})),
300        )
301        .await
302        .unwrap();
303        assert_eq!(list["traces"][0]["id"], id);
304        let list = call(
305            &s,
306            "list_traces",
307            obj(serde_json::json!({"policy": "other"})),
308        )
309        .await
310        .unwrap();
311        assert!(list["traces"].as_array().unwrap().is_empty());
312
313        // An empty result must arrive with the retention window attached.
314        // This is the agent-facing half of the ambiguity: without it, "no
315        // traces matched this filter" and "the matching traces were evicted"
316        // are the same JSON, and the difference decides whether a reader
317        // concludes the filter is broken.
318        let r = &list["retention"];
319        assert!(!r.is_null(), "a listing must report its retention window");
320        assert_eq!(r["truncated"], false, "nothing was evicted in this test");
321        assert_eq!(r["evicted"], 0);
322        assert!(
323            r["retained"].as_u64().unwrap() >= 1,
324            "the sandbox trace is still held: {r}"
325        );
326
327        // The positive case: filtering by the policy the trace actually ran
328        // must return it. Without this, a filter that always matched nothing
329        // would satisfy the negative assertion above and look correct.
330        let list = call(
331            &s,
332            "list_traces",
333            obj(serde_json::json!({"policy": "echo-policy"})),
334        )
335        .await
336        .unwrap();
337        assert_eq!(
338            list["traces"].as_array().unwrap().len(),
339            1,
340            "policy filter dropped a trace that ran under that policy: {}",
341            list["traces"]
342        );
343        assert_eq!(list["traces"][0]["id"], id);
344
345        let t = call(&s, "get_trace", obj(serde_json::json!({"id": id})))
346            .await
347            .unwrap();
348        assert_eq!(t["snapshots_omitted"], true);
349        assert!(t.get("initial").is_none());
350        assert!(t["steps"][0].get("after").is_none());
351        assert!(t["steps"][0]["changes"].is_array());
352        let t = call(
353            &s,
354            "get_trace",
355            obj(serde_json::json!({"id": id, "include_snapshots": true})),
356        )
357        .await
358        .unwrap();
359        assert!(t["initial"].is_object() && t["steps"][0]["after"].is_object());
360
361        let st = call(
362            &s,
363            "get_trace_step",
364            obj(serde_json::json!({"id": id, "node_id": "e"})),
365        )
366        .await
367        .unwrap();
368        assert_eq!(st["step"]["node_id"], "e");
369        assert_eq!(st["node_config"]["type"], "echo");
370        assert!(st["before"].is_object() && st["after"].is_object());
371        let err = call(
372            &s,
373            "get_trace_step",
374            obj(serde_json::json!({"id": id, "node_id": "zz"})),
375        )
376        .await
377        .unwrap_err();
378        assert_eq!(err.code, "not_found");
379        let err = call(&s, "get_trace_step", obj(serde_json::json!({"id": id})))
380            .await
381            .unwrap_err();
382        assert_eq!(err.code, "invalid_input");
383
384        let err = call(
385            &s,
386            "run_sandbox",
387            obj(serde_json::json!({"policy": "nope", "context": {}})),
388        )
389        .await
390        .unwrap_err();
391        assert_eq!(err.code, "not_found");
392        let err = call(&s, "run_sandbox", obj(serde_json::json!({"context": {}})))
393            .await
394            .unwrap_err();
395        assert_eq!(err.code, "invalid_input");
396        // Shape errors carry the accepted payload so an agent can self-correct.
397        assert!(
398            err.hint
399                .as_deref()
400                .unwrap_or("")
401                .contains("FLAT \"context\""),
402            "{err:?}"
403        );
404    }
405
406    #[tokio::test]
407    async fn run_sandbox_forgives_common_agent_shapes_and_explains_typos() {
408        let s = state("debug:\n  enabled: true\n", ECHO_GATEWAY);
409        // `uri`/`query` aliases, an object body and a numeric header value.
410        let v = call(
411            &s,
412            "run_sandbox",
413            obj(serde_json::json!({
414                "policy": "echo-policy",
415                "context": {"uri": "/hello", "query": {"page": 2}, "headers": {"x-n": 1}, "body": {"a": 1}}
416            })),
417        )
418        .await
419        .unwrap();
420        assert_eq!(v["trace"]["path"], "/hello");
421        assert_eq!(
422            v["trace"]["initial"]["request"]["query_params"]["page"][0],
423            "2"
424        );
425
426        // A genuine typo is still rejected, with the shape hint attached.
427        let err = call(
428            &s,
429            "run_sandbox",
430            obj(serde_json::json!({"policy": "echo-policy", "context": {"paths": "/x"}})),
431        )
432        .await
433        .unwrap_err();
434        assert_eq!(err.code, "invalid_input");
435        assert!(err.message.contains("paths"), "{err:?}");
436        assert!(err.hint.is_some());
437
438        // The tool schema documents the context fields for the model.
439        let schema = serde_json::to_value(super::super::schema_of::<super::SandboxArgs>()).unwrap();
440        let ctx_ref = schema["properties"]["context"].to_string();
441        assert!(
442            ctx_ref.contains("SandboxContextArgs") || ctx_ref.contains("query_params"),
443            "{ctx_ref}"
444        );
445        let defs = schema
446            .get("$defs")
447            .or_else(|| schema.get("definitions"))
448            .cloned()
449            .unwrap_or_default();
450        let all = format!("{schema}{defs}");
451        for key in ["query_params", "status_code", "body_base64", "on_error"] {
452            assert!(all.contains(key), "schema should mention {key}");
453        }
454    }
455}