1use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::ToolError;
8use crate::state::SharedState;
9
10#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
13#[derive(Debug, Default, Deserialize, JsonSchema)]
14pub struct NoArgs {}
15
16#[derive(Debug, Deserialize, JsonSchema)]
18pub struct TypeArgs {
19 #[serde(rename = "type")]
21 pub node_type: String,
22}
23
24pub async fn list_node_types() -> Result<Value, ToolError> {
25 Ok(serde_json::json!({ "node_types": crate::admin::policies::plugin_catalog() }))
26}
27
28pub async fn get_node_type(a: TypeArgs) -> Result<Value, ToolError> {
29 let entry = crate::admin::policies::plugin_catalog()
30 .into_iter()
31 .find(|e| e["type"] == a.node_type)
32 .ok_or_else(|| ToolError::not_found("node type", &a.node_type))?;
33 let mut out = entry;
34 out["docs"] = match crate::mcp::docs::plugin_page(&a.node_type) {
35 Some(md) => Value::String(md),
36 None => Value::Null,
37 };
38 Ok(out)
39}
40
41pub async fn list_vars() -> Result<Value, ToolError> {
42 Ok(serde_json::json!({
43 "syntax": {
44 "summary": "Two interchangeable ways to reference request data inside any string value of traffic-bound plugin config: legacy `$name` vars (the `vars` list below) and universal `{{namespace.path}}` templates. Both resolve per request; an unknown reference passes through literally. Conditions (`match`/`vars` triple-arrays) use the bare var name without `$`, e.g. [\"arg_channel\", \"==\", \"beta\"].",
45 "legacy": {
46 "form": "$name or ${name}",
47 "examples": ["$uri", "$http_x_tenant", "$arg_page", "$cookie_session", "$msg_user", "${msg_label.tier}"],
48 "note": "Families: http_<header> (dashes as underscores), arg_<query>, cookie_<name>, post_arg_<field>, msg_<message key>, sent_http_<response header>."
49 },
50 "templates": {
51 "form": "{{ namespace.path }}",
52 "namespaces": {
53 "request.method": "HTTP method",
54 "request.path": "request path, no query string",
55 "request.host": "Host header",
56 "request.scheme": "http or https",
57 "request.body": "request body (lossy UTF-8)",
58 "request.headers.<name>": "first value of a request header, name with dashes (request.headers.x-user-id)",
59 "request.query.<name>": "first value of a query parameter",
60 "request.cookies.<name>": "a cookie value",
61 "response.status": "response status code",
62 "response.body": "response body (lossy UTF-8)",
63 "response.headers.<name>": "first value of a response header",
64 "message.<key>": "any context.message key (dotted keys allowed) — where set-vars, traffic-label (label.<key>) and scripts put derived values",
65 "client.ip": "client IP without port",
66 "client.port": "client port",
67 "env.<NAME>": "process environment variable, substituted once at policy compile time (no default syntax)"
68 },
69 "examples": ["hello {{request.query.name}}", "{{request.headers.x-tenant}}", "user={{message.user}}"]
70 },
71 "where": "Every string config value of traffic-bound plugins: header values (proxy-rewrite/response-rewrite set_headers), mocking response_example and response_headers, limit-count/limit-conn key, redirect uri, fault-injection abort body, traffic-label set_headers/set_labels, forward-auth extra_headers, logger log_format… Not templated: regex/CIDR/IP lists, JSON-Schema/OpenAPI documents, Lua sources, upstream targets, TLS paths, logger endpoints, route match rules; body-transformer and error-handler keep their own {{...}} dialects.",
72 "derive": "To compute a new variable (a path segment, a JSON body field, a regex capture of a header) add a `set-vars` node before the consumer; it stores results in context.message → $msg_<name> / {{message.<name>}}. Example: set-vars {vars: [{name: user, from: $uri, regex: '^/hello/([^/]+)'}]} then mocking response_example: 'hello $msg_user'.",
73 "docs": ["featherbit://docs/reference/templates", "featherbit://docs/reference/context-vars", "featherbit://docs/reference/conditions", "featherbit://docs/plugins/set-vars"]
74 },
75 "vars": crate::vars::catalog::var_catalog()
76 }))
77}
78
79pub async fn get_status(state: &SharedState) -> Result<Value, ToolError> {
80 let routes = state.routes.read().await.len();
81 let gw = state.gateway.read().await;
82 Ok(serde_json::json!({
83 "version": env!("CARGO_PKG_VERSION"),
84 "routes": routes,
85 "policies": gw.policies.len(),
86 "supernodes": gw.supernodes.len(),
87 "stores": gw.stores.len(),
88 "debug": { "enabled": state.debug.enabled, "sandbox": state.debug.sandbox_enabled },
89 }))
90}
91
92pub async fn export_config(state: &SharedState) -> Result<Value, ToolError> {
93 let mut gw = state.gateway.read().await.clone();
94 gw.consumers = gw
95 .consumers
96 .iter()
97 .map(crate::consumers::mask_credentials)
98 .collect();
99 let yaml = serde_yaml::to_string(&gw).map_err(|e| ToolError::internal(e.to_string()))?;
100 Ok(serde_json::json!({
101 "format": "yaml",
102 "content": yaml,
103 "note": "consumer credential secrets are masked",
104 }))
105}
106
107#[cfg(test)]
108mod tests {
109 use crate::mcp::tools::call;
110 use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
111
112 #[tokio::test]
113 async fn node_types_and_lookup() {
114 let s = state("{}", "{}");
115 let v = call(&s, "list_node_types", obj(serde_json::json!({})))
116 .await
117 .unwrap();
118 let types = v["node_types"].as_array().unwrap();
119 assert!(types.iter().any(|t| t["type"] == "limit-count"));
120 assert!(types[0]["ports"].is_object());
121
122 let v = call(
123 &s,
124 "get_node_type",
125 obj(serde_json::json!({"type": "condition"})),
126 )
127 .await
128 .unwrap();
129 let outs = v["ports"]["outputs"].as_array().unwrap();
130 assert!(
131 outs.iter().any(|p| p["name"] == "true") && outs.iter().any(|p| p["name"] == "false")
132 );
133
134 let err = call(
135 &s,
136 "get_node_type",
137 obj(serde_json::json!({"type": "nope"})),
138 )
139 .await
140 .unwrap_err();
141 assert_eq!(err.code, "not_found");
142 let err = call(&s, "get_node_type", obj(serde_json::json!({})))
143 .await
144 .unwrap_err();
145 assert_eq!(err.code, "invalid_input");
146 }
147
148 #[tokio::test]
152 async fn store_nodes_expose_their_docs_to_agents() {
153 let s = state("{}", "{}");
154 for t in ["store-get", "store-set", "store-incr", "store-delete"] {
155 let v = call(&s, "get_node_type", obj(serde_json::json!({ "type": t })))
156 .await
157 .unwrap();
158 assert!(
159 v["docs"].as_str().is_some_and(|d| !d.is_empty()),
160 "{t} must serve a docs page to agents"
161 );
162 }
163 }
164
165 #[tokio::test]
168 async fn store_get_exposes_its_miss_port() {
169 let s = state("{}", "{}");
170 let v = call(
171 &s,
172 "get_node_type",
173 obj(serde_json::json!({ "type": "store-get" })),
174 )
175 .await
176 .unwrap();
177 let outs = v["ports"]["outputs"].as_array().unwrap();
178 let miss = outs
179 .iter()
180 .find(|p| p["name"] == "miss")
181 .expect("miss port");
182 assert_eq!(miss["kind"], "outcome");
183 }
184
185 #[tokio::test]
186 async fn list_vars_explains_both_syntaxes_and_where_they_apply() {
187 let s = state("{}", ECHO_GATEWAY);
188 let v = call(&s, "list_vars", obj(serde_json::json!({})))
189 .await
190 .unwrap();
191 assert!(v["vars"].as_array().is_some_and(|a| !a.is_empty()));
192 let syntax = &v["syntax"];
193 assert!(syntax["summary"]
194 .as_str()
195 .unwrap()
196 .contains("{{namespace.path}}"));
197 assert_eq!(syntax["legacy"]["form"], "$name or ${name}");
198 for ns in [
199 "request.path",
200 "request.headers.<name>",
201 "message.<key>",
202 "env.<NAME>",
203 ] {
204 assert!(syntax["templates"]["namespaces"][ns].is_string(), "{ns}");
205 }
206 assert!(syntax["derive"].as_str().unwrap().contains("set-vars"));
207 assert!(syntax["docs"]
208 .as_array()
209 .unwrap()
210 .iter()
211 .any(|d| d == "featherbit://docs/reference/templates"));
212 }
213
214 #[tokio::test]
215 async fn vars_status_export() {
216 let gw = format!(
217 "{ECHO_GATEWAY}\nconsumers:\n - name: alice\n credentials:\n key-auth: {{ key: topsecret }}\n"
218 );
219 let s = state("debug:\n enabled: true\n", &gw);
220 let v = call(&s, "list_vars", obj(serde_json::json!({})))
221 .await
222 .unwrap();
223 assert!(v["vars"]
224 .as_array()
225 .unwrap()
226 .iter()
227 .any(|e| e["name"] == "remote_addr"));
228 let v = call(&s, "get_status", obj(serde_json::json!({})))
229 .await
230 .unwrap();
231 assert_eq!(v["routes"], 1);
232 assert_eq!(v["debug"]["enabled"], true);
233 let v = call(&s, "export_config", obj(serde_json::json!({})))
234 .await
235 .unwrap();
236 let content = v["content"].as_str().unwrap();
237 assert!(content.contains("echo-policy"));
238 assert!(content.contains("<masked>"));
239 assert!(!content.contains("topsecret"));
240 assert_eq!(v["note"], "consumer credential secrets are masked");
241 }
242}