Skip to main content

featherbit/mcp/
prompts.rs

1//! Precompiled prompts: the "what is happening here?" / "why did this node
2//! exit on `false`?" questions, rendered with live data so the agent's model
3//! starts from the facts. The same renderer backs MCP `prompts/get` and the
4//! Admin API's `GET /api/mcp/prompts/{name}` (the UI's "copy as agent
5//! prompt"), so the two are byte-identical.
6
7use 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/// A rendered prompt: one user message.
118#[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
135/// The node that last set `response.status_code` in a trace, or a note that
136/// none did (the status is the upstream's or the default).
137fn 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            // `port` is only present when the step took a named outcome port
209            // (`denied`, `redirect`, …). When it is absent, the exit was
210            // either the plain success edge or the error edge — tell them
211            // apart from `edge`, which is always present.
212            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        // A step that exited on the error edge: `port` is always `None` here
423        // (see `src/graph/engine.rs`'s `Err` branch), so the prompt must not
424        // fall back to labeling it `success`.
425        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}