Skip to main content

featherbit/mcp/tools/
config.rs

1//! Read tools over the stored gateway config, plus standalone validation.
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::{parse_payload, ToolError};
8use crate::config::{PolicyConfig, SupernodeConfig};
9use crate::state::SharedState;
10
11/// `{ "name": "<resource name>" }`
12#[derive(Debug, Deserialize, JsonSchema)]
13pub struct NameArgs {
14    pub name: String,
15}
16
17/// `{ "policy": <object | YAML string> }`
18#[derive(Debug, Deserialize, JsonSchema)]
19pub struct ValidatePolicyArgs {
20    /// The policy definition `{nodes: [...], edges: [...], error_handler?}`, as a
21    /// JSON object or a YAML document string. `name` inside it is optional.
22    #[serde(default, alias = "definition")]
23    pub policy: Option<Value>,
24    /// Optional policy name (only used in messages; the definition is not saved).
25    #[serde(default)]
26    pub name: Option<String>,
27}
28
29/// `{ "definition": <object | YAML string> }`
30#[derive(Debug, Deserialize, JsonSchema)]
31pub struct ValidateSupernodeArgs {
32    /// The supernode definition, as a JSON object or a YAML document string.
33    pub definition: Value,
34}
35
36fn json<T: serde::Serialize>(v: &T) -> Result<Value, ToolError> {
37    serde_json::to_value(v).map_err(|e| ToolError::internal(e.to_string()))
38}
39
40pub async fn list_routes(state: &SharedState) -> Result<Value, ToolError> {
41    let gw = state.gateway.read().await;
42    Ok(serde_json::json!({ "routes": json(&gw.routes)? }))
43}
44
45pub async fn get_route(state: &SharedState, a: NameArgs) -> Result<Value, ToolError> {
46    let gw = state.gateway.read().await;
47    let r = gw
48        .routes
49        .iter()
50        .find(|r| r.name == a.name)
51        .ok_or_else(|| ToolError::not_found("route", &a.name))?;
52    json(r)
53}
54
55pub async fn list_policies(state: &SharedState) -> Result<Value, ToolError> {
56    let gw = state.gateway.read().await;
57    Ok(serde_json::json!({ "policies": json(&gw.policies)? }))
58}
59
60pub async fn get_policy(state: &SharedState, a: NameArgs) -> Result<Value, ToolError> {
61    let gw = state.gateway.read().await;
62    let p = gw
63        .policies
64        .iter()
65        .find(|p| p.name == a.name)
66        .ok_or_else(|| ToolError::not_found("policy", &a.name))?;
67    let referenced_by: Vec<&str> = gw
68        .routes
69        .iter()
70        .filter(|r| r.policy == a.name)
71        .map(|r| r.name.as_str())
72        .collect();
73    Ok(serde_json::json!({ "policy": json(p)?, "referenced_by_routes": referenced_by }))
74}
75
76pub async fn list_supernodes(state: &SharedState) -> Result<Value, ToolError> {
77    let gw = state.gateway.read().await;
78    Ok(serde_json::json!({ "supernodes": json(&gw.supernodes)? }))
79}
80
81pub async fn get_supernode(state: &SharedState, a: NameArgs) -> Result<Value, ToolError> {
82    let gw = state.gateway.read().await;
83    let s = gw
84        .supernodes
85        .iter()
86        .find(|s| s.name == a.name)
87        .ok_or_else(|| ToolError::not_found("supernode", &a.name))?;
88    let used_by: Vec<&str> = gw
89        .policies
90        .iter()
91        .filter(|p| {
92            p.nodes.iter().any(|n| {
93                n.node_type == "supernode"
94                    && n.config.get("name").and_then(Value::as_str) == Some(a.name.as_str())
95            })
96        })
97        .map(|p| p.name.as_str())
98        .collect();
99    Ok(serde_json::json!({ "supernode": json(s)?, "used_by_policies": used_by }))
100}
101
102pub async fn list_plugin_configs(state: &SharedState) -> Result<Value, ToolError> {
103    let gw = state.gateway.read().await;
104    Ok(serde_json::json!({ "plugin_configs": json(&gw.plugin_configs)? }))
105}
106
107pub async fn get_plugin_config(state: &SharedState, a: NameArgs) -> Result<Value, ToolError> {
108    let gw = state.gateway.read().await;
109    let pc = gw
110        .plugin_configs
111        .iter()
112        .find(|p| p.name == a.name)
113        .ok_or_else(|| ToolError::not_found("plugin config", &a.name))?;
114    json(pc)
115}
116
117pub async fn list_stores(state: &SharedState) -> Result<Value, ToolError> {
118    let gw = state.gateway.read().await;
119    Ok(serde_json::json!({ "stores": json(&gw.stores)? }))
120}
121
122pub async fn list_consumers(state: &SharedState) -> Result<Value, ToolError> {
123    let gw = state.gateway.read().await;
124    let masked: Vec<_> = gw
125        .consumers
126        .iter()
127        .map(crate::consumers::mask_credentials)
128        .collect();
129    Ok(serde_json::json!({ "consumers": json(&masked)?, "note": "credential secrets are masked" }))
130}
131
132pub async fn get_consumer(state: &SharedState, a: NameArgs) -> Result<Value, ToolError> {
133    let gw = state.gateway.read().await;
134    let c = gw
135        .consumers
136        .iter()
137        .find(|c| c.name == a.name)
138        .ok_or_else(|| ToolError::not_found("consumer", &a.name))?;
139    json(&crate::consumers::mask_credentials(c))
140}
141
142/// Validates + compiles a policy against the live supernodes, plugin configs
143/// and stores, without persisting. Mirrors what `put_policy(dry_run)` checks
144/// for the policy itself (cross-references from routes are not included).
145///
146/// The result's `buffering` array names every upstream the policy forces to
147/// buffer instead of stream, and the node responsible for each — the same
148/// information the Admin API's `POST /api/policies/validate` reports to a
149/// human, so an agent driving the gateway sees it too. A policy that forces
150/// buffering is still `valid`; `buffering` is informational, not an error.
151pub async fn validate_policy(
152    state: &SharedState,
153    a: ValidatePolicyArgs,
154) -> Result<Value, ToolError> {
155    // Accept the definition under `policy` or `definition`, as an object or a
156    // YAML string, with or without a `name` — validation does not save it, so
157    // forcing a name only made agents trip on "missing field `name`".
158    let mut raw = a.policy.ok_or_else(|| {
159        ToolError::invalid_payload("validate_policy needs `policy` (or `definition`): the policy as a JSON object or YAML string")
160    })?;
161    if let Value::String(yaml) = &raw {
162        raw = serde_yaml::from_str(yaml)
163            .map_err(|e| ToolError::invalid_payload(format!("policy: YAML did not parse: {e}")))?;
164    }
165    if let Some(obj) = raw.as_object_mut() {
166        if !obj.contains_key("name") {
167            let name = a
168                .name
169                .clone()
170                .unwrap_or_else(|| "unsaved-policy".to_string());
171            obj.insert("name".to_string(), Value::String(name));
172        }
173    }
174    let policy: PolicyConfig = parse_payload(raw, "policy")?;
175    let (supernodes, plugin_configs) = {
176        let gw = state.gateway.read().await;
177        (gw.supernodes.clone(), gw.plugin_configs.clone())
178    };
179    let compiled = crate::graph::prepare_policy(policy, &supernodes, &plugin_configs)
180        .and_then(|p| crate::graph::compile_policy(&p, state.resources.clone()));
181    let cache_pairs: Value = match &compiled {
182        Ok(graph) => serde_json::to_value(graph.cache_pair_warnings())
183            .expect("CachePairWarning always serializes"),
184        Err(_) => serde_json::json!([]),
185    };
186    let (errors, buffering): (Vec<String>, Value) = match compiled {
187        Ok(graph) => (
188            Vec::new(),
189            serde_json::to_value(graph.buffering_reasons())
190                .expect("BufferingReason always serializes"),
191        ),
192        Err(e) => (
193            e.split("; ").map(str::to_string).collect(),
194            serde_json::json!([]),
195        ),
196    };
197    Ok(serde_json::json!({
198        "valid": errors.is_empty(),
199        "errors": errors,
200        "buffering": buffering,
201        "cache_pairs": cache_pairs
202    }))
203}
204
205/// Structural validation of a supernode definition.
206pub async fn validate_supernode(a: ValidateSupernodeArgs) -> Result<Value, ToolError> {
207    let def: SupernodeConfig = parse_payload(a.definition, "definition")?;
208    let errors = crate::graph::validate_supernode(&def)
209        .err()
210        .unwrap_or_default();
211    Ok(serde_json::json!({ "valid": errors.is_empty(), "errors": errors }))
212}
213
214#[cfg(test)]
215mod tests {
216    use crate::mcp::tools::call;
217    use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
218
219    #[tokio::test]
220    async fn validate_policy_accepts_agent_shapes() {
221        let s = state("{}", ECHO_GATEWAY);
222        let nameless = serde_json::json!({
223            "nodes": [{"id": "l", "type": "listener"}, {"id": "e", "type": "echo", "config": {"body": "hi"}}, {"id": "c", "type": "client"}],
224            "edges": [{"from": "l.out", "to": "e.in"}, {"from": "e.out", "to": "c.in"}]
225        });
226        // No `name` inside the definition.
227        let v = call(
228            &s,
229            "validate_policy",
230            obj(serde_json::json!({"policy": nameless})),
231        )
232        .await
233        .unwrap();
234        assert_eq!(v["valid"], true, "{v}");
235        // `definition` as the argument key, YAML string payload.
236        let yaml = serde_yaml::to_string(&nameless).unwrap();
237        let v = call(
238            &s,
239            "validate_policy",
240            obj(serde_json::json!({"definition": yaml})),
241        )
242        .await
243        .unwrap();
244        assert_eq!(v["valid"], true, "{v}");
245        // Neither key: an invalid_input with the shape hint.
246        let err = call(&s, "validate_policy", obj(serde_json::json!({"nodes": []})))
247            .await
248            .unwrap_err();
249        assert_eq!(err.code, "invalid_input");
250        assert!(
251            err.hint.as_deref().unwrap_or("").contains("definition"),
252            "{err:?}"
253        );
254    }
255
256    #[tokio::test]
257    async fn reads_mirror_config_and_report_not_found() {
258        let s = state("{}", ECHO_GATEWAY);
259        let v = call(&s, "list_routes", obj(serde_json::json!({})))
260            .await
261            .unwrap();
262        assert_eq!(v["routes"][0]["name"], "hello");
263        assert_eq!(v["routes"][0]["match"]["path"], "/hello");
264        let v = call(
265            &s,
266            "get_policy",
267            obj(serde_json::json!({"name": "echo-policy"})),
268        )
269        .await
270        .unwrap();
271        assert_eq!(v["referenced_by_routes"][0], "hello");
272        assert_eq!(v["policy"]["nodes"].as_array().unwrap().len(), 3);
273        let err = call(&s, "get_route", obj(serde_json::json!({"name": "nope"})))
274            .await
275            .unwrap_err();
276        assert_eq!(err.code, "not_found");
277        assert!(err.message.contains("route 'nope'"));
278    }
279
280    #[tokio::test]
281    async fn consumers_are_masked() {
282        let gw = format!(
283            "{ECHO_GATEWAY}\nconsumers:\n  - name: alice\n    credentials:\n      key-auth: {{ key: topsecret }}\n"
284        );
285        let s = state("{}", &gw);
286        let v = call(&s, "list_consumers", obj(serde_json::json!({})))
287            .await
288            .unwrap();
289        assert_eq!(
290            v["consumers"][0]["credentials"]["key-auth"]["key"],
291            "<masked>"
292        );
293        let v = call(
294            &s,
295            "get_consumer",
296            obj(serde_json::json!({"name": "alice"})),
297        )
298        .await
299        .unwrap();
300        assert_eq!(v["credentials"]["key-auth"]["key"], "<masked>");
301    }
302
303    #[tokio::test]
304    async fn validate_policy_reports_unwired_port_and_accepts_yaml() {
305        let s = state("{}", ECHO_GATEWAY);
306        let bad = "name: p\nnodes:\n  - {id: l, type: listener}\n  - {id: k, type: key-auth, config: {keys: [k1]}}\n  - {id: c, type: client}\nedges:\n  - {from: l.out, to: k.in}\n  - {from: k.out, to: c.in}\n";
307        let v = call(
308            &s,
309            "validate_policy",
310            obj(serde_json::json!({"policy": bad})),
311        )
312        .await
313        .unwrap();
314        assert_eq!(v["valid"], false);
315        let errors = v["errors"].as_array().unwrap();
316        assert!(
317            errors
318                .iter()
319                .any(|e| e.as_str().unwrap().contains("denied")),
320            "{errors:?}"
321        );
322
323        let good = serde_json::json!({"name": "p", "nodes": [
324            {"id": "l", "type": "listener"}, {"id": "e", "type": "echo", "config": {"body": "hi"}}, {"id": "c", "type": "client"}],
325            "edges": [{"from": "l.out", "to": "e.in"}, {"from": "e.out", "to": "c.in"}]});
326        let v = call(
327            &s,
328            "validate_policy",
329            obj(serde_json::json!({"policy": good})),
330        )
331        .await
332        .unwrap();
333        assert_eq!(v["valid"], true);
334    }
335
336    /// Same shape the Admin API's `POST /api/policies/validate` reports:
337    /// an agent driving the gateway must see which node forces an upstream
338    /// to buffer, not just a bare `valid: true`.
339    #[tokio::test]
340    async fn validate_policy_reports_forced_buffering() {
341        let s = state("{}", ECHO_GATEWAY);
342        let policy = serde_json::json!({
343            "nodes": [
344                { "id": "listener", "type": "listener", "config": {} },
345                { "id": "up", "type": "upstream",
346                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
347                { "id": "rw", "type": "response-rewrite",
348                  "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
349                { "id": "client", "type": "client", "config": {} }
350            ],
351            "edges": [
352                { "from": "listener.out", "to": "up.in" },
353                { "from": "up.success", "to": "rw.in" },
354                { "from": "rw.success", "to": "client.in" }
355            ]
356        });
357        let v = call(
358            &s,
359            "validate_policy",
360            obj(serde_json::json!({"policy": policy})),
361        )
362        .await
363        .unwrap();
364        assert_eq!(v["valid"], true, "{v}");
365        assert_eq!(v["buffering"][0]["upstream"], "up");
366        assert_eq!(v["buffering"][0]["blocked_by"], "rw");
367        assert_eq!(v["buffering"][0]["node_type"], "response-rewrite");
368    }
369
370    #[tokio::test]
371    async fn validate_supernode_structural() {
372        let s = state("{}", "{}");
373        let def = "name: sn\nnodes:\n  - {id: input, type: input}\n  - {id: e, type: echo, config: {body: hi}}\nedges:\n  - {from: input.out, to: e.in}\n";
374        let v = call(
375            &s,
376            "validate_supernode",
377            obj(serde_json::json!({"definition": def})),
378        )
379        .await
380        .unwrap();
381        assert_eq!(v["valid"], false, "{v}");
382        assert!(!v["errors"].as_array().unwrap().is_empty());
383    }
384}