1use 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 pub route: Option<String>,
16 pub policy: Option<String>,
18 pub status: Option<u16>,
20 pub source: Option<String>,
22 pub limit: Option<usize>,
24}
25
26#[derive(Debug, Deserialize, JsonSchema)]
27pub struct GetTraceArgs {
28 pub id: String,
30 #[serde(default)]
33 pub include_snapshots: bool,
34}
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub struct GetTraceStepArgs {
38 pub id: String,
40 pub node_id: Option<String>,
42 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 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
139pub 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#[allow(dead_code)]
170#[derive(Debug, Deserialize, JsonSchema)]
171pub struct SandboxArgs {
172 pub nodes: Option<Vec<SandboxNodeArgs>>,
175 pub policy: Option<String>,
177 pub on_error: Option<SandboxOnError>,
180 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#[allow(dead_code)]
196#[derive(Debug, Deserialize, JsonSchema)]
197pub struct SandboxNodeArgs {
198 pub id: Option<String>,
200 #[serde(rename = "type")]
202 pub node_type: String,
203 pub config: Option<Value>,
205}
206
207#[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 pub method: Option<String>,
222 pub path: Option<String>,
224 pub host: Option<String>,
226 pub scheme: Option<String>,
228 pub headers: Option<std::collections::HashMap<String, SandboxValue>>,
230 pub query_params: Option<std::collections::HashMap<String, SandboxValue>>,
232 pub body: Option<Value>,
236 pub body_base64: Option<String>,
238 pub remote_addr: Option<String>,
240 pub protocol: Option<String>,
242 pub message: Option<std::collections::HashMap<String, Value>>,
245 pub response: Option<SandboxResponseArgs>,
248}
249
250#[allow(dead_code)]
251#[derive(Debug, Deserialize, JsonSchema)]
252pub struct SandboxResponseArgs {
253 pub status_code: Option<u16>,
255 pub headers: Option<std::collections::HashMap<String, SandboxValue>>,
257 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 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 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 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 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 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 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}