1use std::collections::HashMap;
8
9use serde_json::Value;
10
11use crate::mcp::tools::{self, JsonObject, ToolError};
12use crate::state::SharedState;
13
14pub struct PromptArg {
15 pub name: &'static str,
16 pub description: &'static str,
17 pub required: bool,
18}
19
20pub struct PromptDef {
21 pub name: &'static str,
22 pub description: &'static str,
23 pub args: &'static [PromptArg],
24}
25
26const fn arg(name: &'static str, description: &'static str, required: bool) -> PromptArg {
27 PromptArg {
28 name,
29 description,
30 required,
31 }
32}
33
34static PROMPTS: [PromptDef; 9] = [
35 PromptDef {
36 name: "troubleshoot_trace",
37 description: "Troubleshoot a request: diagnose the trace and propose a validated fix.",
38 args: &[arg(
39 "trace_id",
40 "Trace id from list_traces or the Debug panel",
41 true,
42 )],
43 },
44 PromptDef {
45 name: "explain_trace",
46 description: "What is happening in this request? Walk through a trace node by node.",
47 args: &[arg(
48 "trace_id",
49 "Trace id from list_traces or the Debug panel",
50 true,
51 )],
52 },
53 PromptDef {
54 name: "why_this_port",
55 description: "Why did a node exit on this port (false/denied/error/limited…)?",
56 args: &[
57 arg("trace_id", "Trace id", true),
58 arg("node_id", "Node id of the step to explain", true),
59 ],
60 },
61 PromptDef {
62 name: "why_this_response",
63 description: "Why did the client receive this status code?",
64 args: &[arg("trace_id", "Trace id", true)],
65 },
66 PromptDef {
67 name: "review_policy",
68 description:
69 "Review a stored policy for dead nodes, ordering problems and missing error handling.",
70 args: &[arg("policy_name", "Policy name", true)],
71 },
72 PromptDef {
73 name: "design_policy",
74 description:
75 "Design a new policy for a goal, validate it, and apply it (or hand back YAML).",
76 args: &[
77 arg("goal", "What the policy must do, in plain words", true),
78 arg("name", "Policy name to create", false),
79 ],
80 },
81 PromptDef {
82 name: "design_supernode",
83 description: "Design a reusable supernode for a goal.",
84 args: &[
85 arg("goal", "What the supernode must do", true),
86 arg("name", "Supernode name to create", false),
87 ],
88 },
89 PromptDef {
90 name: "design_route",
91 description: "Design a route (match rule + policy reference) for a goal.",
92 args: &[arg(
93 "goal",
94 "Which requests should match and which policy should handle them",
95 true,
96 )],
97 },
98 PromptDef {
99 name: "diagnose_route",
100 description: "Which route would match this request, and what would its policy do?",
101 args: &[
102 arg("method", "HTTP method", true),
103 arg("path", "Request path", true),
104 arg("headers", "Optional headers as 'Name: value' lines", false),
105 ],
106 },
107];
108
109pub fn prompt_defs() -> &'static [PromptDef] {
110 &PROMPTS
111}
112
113pub fn prompt_def(name: &str) -> Option<&'static PromptDef> {
114 PROMPTS.iter().find(|p| p.name == name)
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RenderedPrompt {
120 pub description: String,
121 pub text: String,
122}
123
124fn required<'a>(args: &'a HashMap<String, String>, name: &str) -> Result<&'a str, ToolError> {
125 args.get(name)
126 .map(String::as_str)
127 .filter(|s| !s.trim().is_empty())
128 .ok_or_else(|| ToolError::invalid_input(format!("prompt argument '{name}' is required")))
129}
130
131fn obj(v: Value) -> JsonObject {
132 v.as_object().cloned().unwrap_or_default()
133}
134
135fn last_status_setter(trace: &Value) -> &str {
138 trace["steps"]
139 .as_array()
140 .and_then(|steps| {
141 steps.iter().rev().find(|s| {
142 s["changes"]
143 .as_array()
144 .is_some_and(|c| c.iter().any(|ch| ch["path"] == "response.status_code"))
145 })
146 })
147 .and_then(|s| s["node_id"].as_str())
148 .unwrap_or("(no node changed the status — it is the upstream's or the default)")
149}
150
151fn block(title: &str, v: &Value) -> String {
152 format!(
153 "## {title}\n\n```json\n{}\n```\n\n",
154 serde_json::to_string_pretty(v).unwrap_or_default()
155 )
156}
157
158const MCP_HINT: &str = "You are connected to the gateway's `featherbit` MCP server: prefer its tools (get_trace_step, get_node_type, validate_policy, run_sandbox) for anything not inlined below.\n\n";
159
160const WIRING_RULE: &str = "Rules of this gateway: a policy is a node graph. Every node's `success`/`out` port and every declared outcome port (e.g. `denied`, `redirect`, `limited`, `true`/`false`) MUST be wired to another node's `in` port or the policy fails to compile; only `error` ports may be left unwired (they fall back to the policy's `error_handler`). Every policy has exactly one `listener` (entry) and one `client` (exit). Plugin config keys are documented per node type — call get_node_type(type) before configuring a node.\n\n";
161
162pub async fn render(
163 state: &SharedState,
164 name: &str,
165 args: &HashMap<String, String>,
166) -> Result<RenderedPrompt, ToolError> {
167 let def = prompt_def(name).ok_or_else(|| {
168 let mut e = ToolError::unknown_tool(name);
169 e.code = "unknown_prompt";
170 e.message = format!("no prompt named '{name}'");
171 e
172 })?;
173 let text = match name {
174 "troubleshoot_trace" => {
175 let id = required(args, "trace_id")?;
176 let trace = tools::call(state, "get_trace", obj(serde_json::json!({"id": id}))).await?;
177 let status = trace["status"].clone();
178 let setter = last_status_setter(&trace);
179 let step_count = trace["steps"].as_array().map_or(0, Vec::len);
180 format!(
181 "{MCP_HINT}# Troubleshoot `{} {}` → status {status}\n\nThis request went through policy `{}` ({step_count} node steps). The last node to change `response.status_code` was `{setter}`.\n\nWork like an on-call engineer:\n1. Say in one or two sentences what happened to this request and whether the outcome looks intended or like a misconfiguration.\n2. Walk the steps that matter (skip the uneventful ones): for each, what the node did (use its `changes`), which port it exited on, and the exact config keys and context values that decided it. Fetch a step with get_trace_step, the policy with get_policy, or a node's docs with get_node_type when the inlined data is not enough.\n3. Name the root cause. If several are plausible, list them ranked and say which trace evidence would tell them apart.\n4. Propose a concrete fix as YAML (the changed nodes/edges only), validate it with validate_policy, and explain what the client would receive afterwards. Do not apply any change unless the operator asks.\n5. If you need something that is not in the trace (the intended behaviour, an upstream's contract, a header the client should have sent), ask one precise question.\n\n{}",
182 trace["method"].as_str().unwrap_or("?"),
183 trace["path"].as_str().unwrap_or("?"),
184 trace["policy"].as_str().unwrap_or("?"),
185 block("Trace", &trace)
186 )
187 }
188 "explain_trace" => {
189 let id = required(args, "trace_id")?;
190 let trace = tools::call(state, "get_trace", obj(serde_json::json!({"id": id}))).await?;
191 format!(
192 "{MCP_HINT}# What is happening in this request?\n\nBelow is a debug trace of one request through policy `{}`. Walk through it node by node: for each step say what the node did (use its `changes`), which port it exited on and why. Finish with: which node produced the final response (status {}), and whether anything looks wrong.\n\n{}",
193 trace["policy"].as_str().unwrap_or("?"),
194 trace["status"],
195 block("Trace", &trace)
196 )
197 }
198 "why_this_port" => {
199 let id = required(args, "trace_id")?;
200 let node = required(args, "node_id")?;
201 let step = tools::call(
202 state,
203 "get_trace_step",
204 obj(serde_json::json!({"id": id, "node_id": node})),
205 )
206 .await?;
207 let node_type = step["step"]["node_type"].as_str().unwrap_or("").to_string();
208 let port = match step["step"]["port"].as_str() {
213 Some(p) => p.to_string(),
214 None => match step["step"]["edge"].as_str() {
215 Some("error")
216 | Some("catch_all")
217 | Some("unhandled")
218 | Some("node_not_found") => "error".to_string(),
219 _ => "success".to_string(),
220 },
221 };
222 let docs = crate::mcp::docs::plugin_page(&node_type).unwrap_or_default();
223 format!(
224 "{MCP_HINT}# Why did node `{node}` (`{node_type}`) exit on port `{port}`?\n\nExplain, pointing at the exact config keys and context values (headers, query, message, errors) that decided it. If it is an error, name the error code and the fix.\n\n{}{}## Documentation for `{node_type}`\n\n{docs}\n",
225 block("Step (before / after / changes / node_config)", &step),
226 if step["step"]["outcome"]["kind"] == "error" { "The step's `outcome` is an error — start from its `code` and `message`.\n\n" } else { "" }
227 )
228 }
229 "why_this_response" => {
230 let id = required(args, "trace_id")?;
231 let trace = tools::call(state, "get_trace", obj(serde_json::json!({"id": id}))).await?;
232 let status = trace["status"].clone();
233 let setter = last_status_setter(&trace);
234 format!(
235 "{MCP_HINT}# Why did the client receive status {status}?\n\nThe last node to change `response.status_code` was `{setter}`. Explain why it did, using the trace below, and say what would have to change for the request to succeed.\n\n{}",
236 block("Trace", &trace)
237 )
238 }
239 "review_policy" => {
240 let pname = required(args, "policy_name")?;
241 let policy =
242 tools::call(state, "get_policy", obj(serde_json::json!({"name": pname}))).await?;
243 let types: Vec<String> = policy["policy"]["nodes"]
244 .as_array()
245 .map(|n| {
246 n.iter()
247 .filter_map(|x| x["type"].as_str().map(str::to_string))
248 .collect()
249 })
250 .unwrap_or_default();
251 let catalog = tools::call(state, "list_node_types", JsonObject::new()).await?;
252 let used: Vec<&Value> = catalog["node_types"]
253 .as_array()
254 .map(|all| {
255 all.iter()
256 .filter(|e| types.iter().any(|t| e["type"] == *t))
257 .collect()
258 })
259 .unwrap_or_default();
260 format!(
261 "{MCP_HINT}{WIRING_RULE}# Review policy `{pname}`\n\nReview for: unreachable nodes; ordering problems (authentication or rate limiting after `upstream`; request rewrites after the proxy; response rewrites before it); outcome/error ports routed straight to `client` where a proper rejection or error handler is expected; redundant or contradictory nodes; missing `error_handler`. Propose concrete YAML edits.\n\n{}{}",
262 block("Policy", &policy),
263 block("Port declarations of the node types used", &Value::Array(used.into_iter().cloned().collect()))
264 )
265 }
266 "design_policy" | "design_supernode" | "design_route" => {
267 let goal = required(args, "goal")?;
268 let target_name = args.get("name").cloned();
269 let catalog = tools::call(state, "list_node_types", JsonObject::new()).await?;
270 let existing = match name {
271 "design_route" => tools::call(state, "list_routes", JsonObject::new()).await?,
272 _ => tools::call(state, "list_policies", JsonObject::new()).await?,
273 };
274 let (what, put_tool, validate_tool, extra) = match name {
275 "design_policy" => ("policy", "put_policy", "validate_policy", String::new()),
276 "design_supernode" => (
277 "supernode",
278 "put_supernode",
279 "validate_supernode",
280 format!(
281 "## Supernode rules\n\n{}\n",
282 crate::mcp::docs::concept_page("supernodes").unwrap_or_default()
283 ),
284 ),
285 _ => ("route", "put_route", "validate_policy", String::new()),
286 };
287 let named = target_name
288 .map(|n| format!(" named `{n}`"))
289 .unwrap_or_default();
290 format!(
291 "{MCP_HINT}{WIRING_RULE}# Design a {what}{named}\n\nGoal: {goal}\n\nWorkflow: (1) pick node types from the catalog below and call get_node_type for each to learn its config keys and ports; (2) write the {what} as YAML — when a config value must change per request, use a variable (`$http_x_tenant`, `$arg_page`, `{{{{request.path}}}}`, `$msg_<key>` from a `set-vars` node; call list_vars for the catalog and the fields that cannot be templated) instead of a literal; (3) validate with {validate_tool}; (4) call {put_tool} with dry_run=true, fix every reported error, then call it for real — the change is live at once; do NOT call reload_config (it re-reads the file and discards live edits). If your token is read-only (write tools are missing or return `forbidden`), stop after validation and return the YAML for a human to apply.\n\n{extra}{}{}",
292 block("Node type catalog (type, description, ports)", &catalog),
293 block("Existing definitions (avoid name clashes; reuse where sensible)", &existing)
294 )
295 }
296 "diagnose_route" => {
297 let method = required(args, "method")?;
298 let path = required(args, "path")?;
299 let headers = args.get("headers").cloned().unwrap_or_default();
300 let routes = tools::call(state, "list_routes", JsonObject::new()).await?;
301 format!(
302 "{MCP_HINT}# Diagnose `{method} {path}`\n\nDetermine which route matches this request (routes are evaluated in declaration order; the first match wins; `match.path` is a prefix unless the docs say otherwise — check `featherbit://docs/concepts/policies-and-graphs` if unsure). Then call run_sandbox with `policy` set to that route's policy and a `context` of {{method: \"{method}\", path: \"{path}\", headers: …}} and explain what the policy would do to it.\n\nHeaders:\n```\n{headers}\n```\n\n{}",
303 block("Routes", &routes)
304 )
305 }
306 _ => unreachable!("prompt_def guarantees a known name"),
307 };
308 Ok(RenderedPrompt {
309 description: def.description.to_string(),
310 text,
311 })
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
318
319 fn args(pairs: &[(&str, &str)]) -> HashMap<String, String> {
320 pairs
321 .iter()
322 .map(|(k, v)| (k.to_string(), v.to_string()))
323 .collect()
324 }
325
326 #[test]
327 fn defs_are_unique_and_documented() {
328 let mut seen = std::collections::HashSet::new();
329 for p in prompt_defs() {
330 assert!(seen.insert(p.name));
331 assert!(!p.description.is_empty());
332 assert!(!p.args.is_empty());
333 }
334 assert!(prompt_def("explain_trace").is_some());
335 assert!(prompt_def("nope").is_none());
336 }
337
338 #[tokio::test]
339 async fn unknown_and_missing_args() {
340 let s = state("{}", ECHO_GATEWAY);
341 let err = render(&s, "nope", &args(&[])).await.unwrap_err();
342 assert_eq!(err.code, "unknown_prompt");
343 let err = render(&s, "explain_trace", &args(&[])).await.unwrap_err();
344 assert_eq!(err.code, "invalid_input");
345 assert!(err.message.contains("trace_id"));
346 }
347
348 #[tokio::test]
349 async fn trace_prompts_render_from_a_sandbox_run() {
350 let s = state("debug:\n enabled: true\n", ECHO_GATEWAY);
351 let run = tools::call(
352 &s,
353 "run_sandbox",
354 obj(serde_json::json!({"policy": "echo-policy", "context": {"path": "/hello"}})),
355 )
356 .await
357 .unwrap();
358 let id = run["stored_trace_id"].as_str().unwrap();
359
360 let p = render(&s, "explain_trace", &args(&[("trace_id", id)]))
361 .await
362 .unwrap();
363 assert!(p.text.contains("# What is happening in this request?"));
364 assert!(p.text.contains("echo-policy"));
365 assert!(p.text.starts_with("You are connected"));
366
367 let p = render(
368 &s,
369 "why_this_port",
370 &args(&[("trace_id", id), ("node_id", "e")]),
371 )
372 .await
373 .unwrap();
374 assert!(
375 p.text.contains("exit on port `success`"),
376 "{}",
377 &p.text[..200]
378 );
379 assert!(p.text.contains("# echo"), "docs page inlined");
380 assert!(p.text.contains("node_config"));
381
382 let p = render(&s, "why_this_response", &args(&[("trace_id", id)]))
383 .await
384 .unwrap();
385 assert!(p.text.contains("receive status"));
386
387 let p = render(&s, "troubleshoot_trace", &args(&[("trace_id", id)]))
388 .await
389 .unwrap();
390 assert!(
391 p.text.contains("# Troubleshoot `GET /hello`"),
392 "{}",
393 &p.text[..160]
394 );
395 assert!(p.text.contains("policy `echo-policy`"));
396 assert!(p.text.contains("validate it with validate_policy"));
397 assert!(p
398 .text
399 .contains("Do not apply any change unless the operator asks"));
400 assert!(p.text.contains("## Trace"));
401 assert!(p.text.starts_with("You are connected"));
402
403 let err = render(
404 &s,
405 "why_this_port",
406 &args(&[("trace_id", id), ("node_id", "zz")]),
407 )
408 .await
409 .unwrap_err();
410 assert_eq!(err.code, "not_found");
411 }
412
413 #[tokio::test]
414 async fn why_this_port_labels_error_edge_exits_as_error() {
415 use crate::debug::sandbox::SandboxContextInput;
416 use crate::debug::{CaptureOptions, EdgeKind, StepOutcome, TraceRecorder, TraceSource};
417
418 let s = state("debug:\n enabled: true\n", ECHO_GATEWAY);
419 let ctx = SandboxContextInput::default().into_context().unwrap();
420 let mut rec = TraceRecorder::new(&ctx, CaptureOptions::default(), 10);
421 let after = SandboxContextInput::default().into_context().unwrap();
422 rec.record_step(
426 "e",
427 "echo",
428 StepOutcome::Error {
429 code: "BOOM".into(),
430 message: "boom".into(),
431 },
432 std::time::Duration::from_micros(1),
433 EdgeKind::Error,
434 None,
435 None,
436 &after,
437 );
438 let trace = rec.finish(
439 "err-trace".into(),
440 1,
441 TraceSource::Sandbox,
442 None,
443 "echo-policy".into(),
444 &after,
445 std::time::Duration::from_micros(2),
446 );
447 s.debug.record(trace);
448
449 let p = render(
450 &s,
451 "why_this_port",
452 &args(&[("trace_id", "err-trace"), ("node_id", "e")]),
453 )
454 .await
455 .unwrap();
456 assert!(
457 p.text.contains("exit on port `error`"),
458 "{}",
459 &p.text[..200]
460 );
461 assert!(p.text.contains("is an error — start from its"));
462 }
463
464 #[tokio::test]
465 async fn authoring_prompts_render() {
466 let s = state("{}", ECHO_GATEWAY);
467 let p = render(
468 &s,
469 "review_policy",
470 &args(&[("policy_name", "echo-policy")]),
471 )
472 .await
473 .unwrap();
474 assert!(p.text.contains("# Review policy `echo-policy`"));
475 assert!(p.text.contains("\"type\": \"echo\""));
476 let p = render(
477 &s,
478 "design_policy",
479 &args(&[("goal", "rate limit by api key"), ("name", "rl")]),
480 )
481 .await
482 .unwrap();
483 assert!(p.text.contains("# Design a policy named `rl`"));
484 assert!(p.text.contains("put_policy with dry_run=true"));
485 assert!(p.text.contains("limit-count"));
486 let p = render(&s, "design_supernode", &args(&[("goal", "auth guard")]))
487 .await
488 .unwrap();
489 assert!(p.text.contains("## Supernode rules"));
490 let p = render(
491 &s,
492 "design_route",
493 &args(&[("goal", "/v2 to the v2 policy")]),
494 )
495 .await
496 .unwrap();
497 assert!(p.text.contains("\"hello\""), "existing routes inlined");
498 let p = render(
499 &s,
500 "diagnose_route",
501 &args(&[("method", "GET"), ("path", "/hello")]),
502 )
503 .await
504 .unwrap();
505 assert!(p.text.contains("Diagnose `GET /hello`"));
506 assert!(p.text.contains("run_sandbox"));
507 }
508}