Skip to main content

featherbit/mcp/tools/
catalog.rs

1//! Read tools over static knowledge: node types, vars, status, config export.
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::ToolError;
8use crate::state::SharedState;
9
10/// Tools that take no arguments. Only referenced by the `mcp`-only `TOOLS`
11/// registry in `src/mcp/tools/mod.rs`.
12#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
13#[derive(Debug, Default, Deserialize, JsonSchema)]
14pub struct NoArgs {}
15
16/// `{ "type": "<node type>" }`
17#[derive(Debug, Deserialize, JsonSchema)]
18pub struct TypeArgs {
19    /// Node type name as it appears in YAML `type:` (e.g. `limit-count`).
20    #[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    /// get_node_type is what an agent reads before authoring a node. A missing
149    /// docs page returns `docs: null` rather than failing, so assert it directly
150    /// instead of trusting the file-existence guard.
151    #[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    /// An agent must see that store-get has a mandatory `miss` port, or it will
166    /// author a policy that fails to compile.
167    #[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}