Skip to main content

featherbit/mcp/tools/
mod.rs

1//! The MCP tool layer: typed functions over [`SharedState`] plus the registry
2//! the server advertises. Transport-agnostic — the `rmcp` adapter in
3//! `server.rs` and the Admin API's prompt renderer both call into here.
4
5pub mod cache;
6pub mod catalog;
7pub mod config;
8pub mod debug;
9pub mod writes;
10
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13
14use crate::config::McpScope;
15use crate::state::SharedState;
16
17/// A JSON object, as MCP tool arguments arrive.
18pub type JsonObject = serde_json::Map<String, Value>;
19
20/// A domain failure surfaced to the agent as an MCP tool error (never a
21/// JSON-RPC error): `{code, message, errors?, hint?}`.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ToolError {
24    pub code: &'static str,
25    pub message: String,
26    pub errors: Vec<String>,
27    pub hint: Option<String>,
28}
29
30impl ToolError {
31    fn new(code: &'static str, message: impl Into<String>) -> Self {
32        Self {
33            code,
34            message: message.into(),
35            errors: Vec::new(),
36            hint: None,
37        }
38    }
39    fn with_hint(mut self, hint: impl Into<String>) -> Self {
40        self.hint = Some(hint.into());
41        self
42    }
43    pub fn not_found(what: &str, name: &str) -> Self {
44        Self::new("not_found", format!("{what} '{name}' does not exist"))
45    }
46    pub fn invalid_input(msg: impl Into<String>) -> Self {
47        Self::new("invalid_input", msg)
48    }
49    pub fn invalid_config(errors: Vec<String>) -> Self {
50        let mut e = Self::new("invalid_config", "the configuration failed validation");
51        e.errors = errors;
52        e.with_hint(
53            "Every success/outcome port must be wired. Use get_node_type(<type>) to see a node's ports and config keys.",
54        )
55    }
56    pub fn debug_disabled() -> Self {
57        Self::new(
58            "debug_disabled",
59            "debug mode is off; traces and the sandbox are unavailable",
60        )
61        .with_hint("set debug.enabled: true in system.yaml (or FEATHERBIT_DEBUG=true) and restart")
62    }
63    pub fn sandbox_disabled() -> Self {
64        Self::new("sandbox_disabled", "the plugin sandbox is disabled")
65            .with_hint("set debug.sandbox: true in system.yaml and restart")
66    }
67    // Only raised by the scope check in `src/mcp/server.rs`.
68    #[cfg_attr(not(feature = "mcp"), allow(dead_code))]
69    pub fn forbidden(have: McpScope) -> Self {
70        Self::new("forbidden", "this token may not use write tools").with_hint(format!(
71            "this token has scope {}; write tools need a token with scope write. Return the YAML for a human to apply instead.",
72            have.as_str()
73        ))
74    }
75    /// A definition that did not parse (validate_*/put_*): the hint spells out
76    /// the argument shape every write tool shares.
77    pub fn invalid_payload(msg: impl Into<String>) -> Self {
78        Self::new("invalid_input", msg).with_hint(
79            "Write/validate tools take {\"name\": \"<resource name>\", \"definition\": <object | YAML string>, \"dry_run\": bool}; `name` may instead be given inside the definition. A policy definition is {\"nodes\": [{\"id\": \"...\", \"type\": \"...\", \"config\": {...}}], \"edges\": [{\"from\": \"node.port\", \"to\": \"node.in\"}], \"error_handler\"?: \"node id\"}; a route is {\"match\": {\"path\": \"/x/*\", \"methods\"?: [...], \"host\"?: \"...\", \"headers\"?: {...}}, \"policy\": \"<policy name>\"}. validate_policy accepts the definition under `policy` or `definition`, with or without a name.",
80        )
81    }
82    /// A malformed `run_sandbox` payload: the message says what failed, the
83    /// hint shows the accepted shape so the caller can fix it in one step.
84    pub fn sandbox_bad_request(msg: impl Into<String>) -> Self {
85        Self::new("invalid_input", msg).with_hint(
86            "run_sandbox payload: {\"policy\": \"<name>\"} or {\"nodes\": [{\"id\": \"n1\", \"type\": \"<node type>\", \"config\": {...}}]} (exactly one), plus a FLAT \"context\": {\"method\": \"GET\", \"path\": \"/x\", \"host\": \"...\", \"headers\": {\"name\": \"value\" | [\"v1\", \"v2\"]}, \"query_params\": {\"name\": \"value\"}, \"body\": \"text\" | {json object}, \"message\": {\"key\": value}, \"response\": {\"status_code\": 200, \"headers\": {...}, \"body\": \"...\"}}. Every context field is optional; do not nest fields under \"request\"; use query_params (not a query string) and path (not uri/url).",
87        )
88    }
89    /// `reload_config` would revert live edits that were never written to
90    /// gateway.yaml; `errors` lists them.
91    pub fn unsaved_changes(pending: Vec<String>) -> Self {
92        let mut e = Self::new(
93            "unsaved_changes",
94            "the live config has edits that are not in gateway.yaml; reloading would discard them",
95        );
96        e.errors = pending;
97        e.with_hint(
98            "put_*/delete_* changes are already live — no reload is needed. To really revert to the file, call reload_config with discard_unsaved=true; to keep the edits, ask the operator to write them to gateway.yaml (export_config gives the YAML).",
99        )
100    }
101    pub fn store_error(msg: impl Into<String>) -> Self {
102        Self::new("store_error", msg)
103    }
104    pub fn unknown_tool(name: &str) -> Self {
105        Self::new("unknown_tool", format!("no tool named '{name}'"))
106    }
107    pub fn internal(msg: impl Into<String>) -> Self {
108        Self::new("internal", msg)
109    }
110    /// The body of the `isError` tool result. Only called from
111    /// `src/mcp/server.rs` (outside `#[cfg(test)]`, which uses it too).
112    #[cfg_attr(not(feature = "mcp"), allow(dead_code))]
113    pub fn to_json(&self) -> Value {
114        let mut v = serde_json::json!({"code": self.code, "message": self.message});
115        if !self.errors.is_empty() {
116            v["errors"] = serde_json::json!(self.errors);
117        }
118        if let Some(h) = &self.hint {
119            v["hint"] = Value::String(h.clone());
120        }
121        v
122    }
123}
124
125/// Deserializes tool arguments, reporting schema mismatches as `invalid_input`.
126pub fn args<T: DeserializeOwned>(a: JsonObject) -> Result<T, ToolError> {
127    serde_json::from_value(Value::Object(a))
128        .map_err(|e| ToolError::invalid_input(format!("invalid arguments: {e}")))
129}
130
131/// Accepts a payload either as a JSON object or as a YAML document in a
132/// string — agents naturally write the YAML the docs show.
133pub fn parse_payload<T: DeserializeOwned>(v: Value, what: &str) -> Result<T, ToolError> {
134    match v {
135        Value::String(yaml) => serde_yaml::from_str(&yaml)
136            .map_err(|e| ToolError::invalid_payload(format!("{what}: YAML did not parse: {e}"))),
137        other => serde_json::from_value(other).map_err(|e| {
138            ToolError::invalid_payload(format!("{what}: JSON did not deserialize: {e}"))
139        }),
140    }
141}
142
143/// JSON Schema (draft 2020-12, as schemars 1 emits) for a tool's arguments,
144/// with the `$schema`/`title` noise removed. Only used to build `TOOLS`
145/// below, which is `mcp`-only (the Admin API exposes prompts, not tools).
146#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
147pub fn schema_of<T: schemars::JsonSchema>() -> JsonObject {
148    let schema = schemars::schema_for!(T);
149    let mut v = serde_json::to_value(schema).expect("schema serializes");
150    let obj = v.as_object_mut().expect("schema is an object");
151    obj.remove("$schema");
152    obj.remove("title");
153    obj.clone()
154}
155
156/// Static description of one tool. The MCP tool registry, advertised only
157/// by the `mcp` transport (`src/mcp/server.rs`) — the Admin API exposes
158/// prompts, not tools.
159#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
160pub struct ToolDef {
161    pub name: &'static str,
162    pub scope: McpScope,
163    pub description: &'static str,
164    pub input_schema: fn() -> JsonObject,
165}
166
167/// Every tool, in the order clients see them.
168#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
169pub fn tool_defs() -> &'static [ToolDef] {
170    &TOOLS
171}
172
173/// Looks up a tool by name.
174#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
175pub fn tool_def(name: &str) -> Option<&'static ToolDef> {
176    TOOLS.iter().find(|t| t.name == name)
177}
178
179/// Executes a tool. Scope is **not** checked here (see `server.rs`).
180pub async fn call(state: &SharedState, name: &str, a: JsonObject) -> Result<Value, ToolError> {
181    match name {
182        "list_node_types" => catalog::list_node_types().await,
183        "get_node_type" => catalog::get_node_type(args(a)?).await,
184        "list_vars" => catalog::list_vars().await,
185        "get_status" => catalog::get_status(state).await,
186        "export_config" => catalog::export_config(state).await,
187        "list_routes" => config::list_routes(state).await,
188        "get_route" => config::get_route(state, args(a)?).await,
189        "list_policies" => config::list_policies(state).await,
190        "get_policy" => config::get_policy(state, args(a)?).await,
191        "list_supernodes" => config::list_supernodes(state).await,
192        "get_supernode" => config::get_supernode(state, args(a)?).await,
193        "list_plugin_configs" => config::list_plugin_configs(state).await,
194        "get_plugin_config" => config::get_plugin_config(state, args(a)?).await,
195        "list_stores" => config::list_stores(state).await,
196        "list_consumers" => config::list_consumers(state).await,
197        "get_consumer" => config::get_consumer(state, args(a)?).await,
198        "validate_policy" => config::validate_policy(state, args(a)?).await,
199        "validate_supernode" => config::validate_supernode(args(a)?).await,
200        "list_traces" => debug::list_traces(state, args(a)?).await,
201        "get_trace" => debug::get_trace(state, args(a)?).await,
202        "get_trace_step" => debug::get_trace_step(state, args(a)?).await,
203        "run_sandbox" => debug::run_sandbox_tool(state, Value::Object(a)).await,
204        "put_route" => writes::put_route(state, args(a)?).await,
205        "delete_route" => writes::delete_route(state, args(a)?).await,
206        "put_policy" => writes::put_policy(state, args(a)?).await,
207        "delete_policy" => writes::delete_policy(state, args(a)?).await,
208        "put_supernode" => writes::put_supernode(state, args(a)?).await,
209        "delete_supernode" => writes::delete_supernode(state, args(a)?).await,
210        "put_plugin_config" => writes::put_plugin_config(state, args(a)?).await,
211        "delete_plugin_config" => writes::delete_plugin_config(state, args(a)?).await,
212        "put_store" => writes::put_store(state, args(a)?).await,
213        "delete_store" => writes::delete_store(state, args(a)?).await,
214        "purge_cache" => cache::purge_cache(state, args(a)?).await,
215        "reload_config" => writes::reload_config(state, args(a)?).await,
216        _ => Err(ToolError::unknown_tool(name)),
217    }
218}
219
220use McpScope::Read;
221use McpScope::Write;
222
223#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
224static TOOLS: [ToolDef; 34] = [
225    ToolDef { name: "list_node_types", scope: Read, description: "List every node (plugin) type with its description and declared ports. Start here when designing a policy.", input_schema: schema_of::<catalog::NoArgs> },
226    ToolDef { name: "get_node_type", scope: Read, description: "Full reference for one node type: description, input/output ports (which must be wired), and its documentation page with every config key and a YAML example.", input_schema: schema_of::<catalog::TypeArgs> },
227    ToolDef { name: "list_vars", scope: Read, description: "How to reference request data inside plugin config: the `$var` catalog ($uri, $http_<header>, $arg_<query>, $cookie_<name>, $msg_<key>, …), the `{{namespace.path}}` template namespaces (request.*, response.*, message.*, client.*, env.*), which config fields accept them and which do not, and how to derive new variables with `set-vars`. Call this before writing any config value that should change per request.", input_schema: schema_of::<catalog::NoArgs> },
228    ToolDef { name: "get_status", scope: Read, description: "Gateway version, route/policy counts, and whether debug mode and the sandbox are on.", input_schema: schema_of::<catalog::NoArgs> },
229    ToolDef { name: "export_config", scope: Read, description: "The whole gateway.yaml as YAML text (routes, policies, supernodes, plugin_configs, stores, consumers). ${ENV} placeholders stay unresolved. Consumer credential secrets are masked (as in list_consumers/get_consumer).", input_schema: schema_of::<catalog::NoArgs> },
230    ToolDef { name: "list_routes", scope: Read, description: "All routes: name, match rule (path/methods/host/headers) and the policy each references.", input_schema: schema_of::<catalog::NoArgs> },
231    ToolDef { name: "get_route", scope: Read, description: "One route by name.", input_schema: schema_of::<config::NameArgs> },
232    ToolDef { name: "list_policies", scope: Read, description: "All policies (node graphs) with their nodes and edges.", input_schema: schema_of::<catalog::NoArgs> },
233    ToolDef { name: "get_policy", scope: Read, description: "One policy by name, plus the routes that reference it.", input_schema: schema_of::<config::NameArgs> },
234    ToolDef { name: "list_supernodes", scope: Read, description: "All supernode definitions (reusable subgraphs with input/output/error boundary nodes).", input_schema: schema_of::<catalog::NoArgs> },
235    ToolDef { name: "get_supernode", scope: Read, description: "One supernode definition by name, plus the policies that use it.", input_schema: schema_of::<config::NameArgs> },
236    ToolDef { name: "list_plugin_configs", scope: Read, description: "Named shared plugin config profiles referenced by nodes via config_ref.", input_schema: schema_of::<catalog::NoArgs> },
237    ToolDef { name: "get_plugin_config", scope: Read, description: "One plugin config profile by name.", input_schema: schema_of::<config::NameArgs> },
238    ToolDef { name: "list_stores", scope: Read, description: "Named redis/valkey stores referenced by plugin config (`store:`) and sessions.", input_schema: schema_of::<catalog::NoArgs> },
239    ToolDef { name: "list_consumers", scope: Read, description: "API consumers (name, group, labels, credential kinds). Credential secrets are masked.", input_schema: schema_of::<catalog::NoArgs> },
240    ToolDef { name: "get_consumer", scope: Read, description: "One consumer by name; credential secrets are masked.", input_schema: schema_of::<config::NameArgs> },
241    ToolDef { name: "validate_policy", scope: Read, description: "Validate and compile an unsaved policy against the live gateway: structure, port wiring, config_ref/store references, and every node's config. Returns {valid, errors}. Persists nothing. Args: {\"policy\": {\"nodes\": [{\"id\": \"listener\", \"type\": \"listener\"}, {\"id\": \"c\", \"type\": \"client\"}, ...], \"edges\": [{\"from\": \"listener.out\", \"to\": \"c.in\"}]}} — a JSON object or a YAML string, `name` optional; `definition` is accepted as an alias of `policy`. Pass the SAME object to put_policy afterwards as its `definition`.", input_schema: schema_of::<config::ValidatePolicyArgs> },
242    ToolDef { name: "validate_supernode", scope: Read, description: "Structurally validate an unsaved supernode definition (boundary nodes, reserved ids, inner wiring). Node config errors surface when a policy using it is validated or saved with dry_run.", input_schema: schema_of::<config::ValidateSupernodeArgs> },
243    ToolDef { name: "list_traces", scope: Read, description: "Recent debug traces (newest first): id, route, policy, method, path, status, step and error counts. Filter by route/policy/status/source. Requires debug.enabled.", input_schema: schema_of::<debug::ListTracesArgs> },
244    ToolDef { name: "get_trace", scope: Read, description: "One trace: the request, final response, and every node step with outcome, exit port, edge taken and the context changes it made. Snapshots omitted unless include_snapshots.", input_schema: schema_of::<debug::GetTraceArgs> },
245    ToolDef { name: "get_trace_step", scope: Read, description: "One step of a trace in full: context before and after the node, the diff, outcome/port, and the node's stored config. Use to answer 'why did this node exit on this port?'.", input_schema: schema_of::<debug::GetTraceStepArgs> },
246    ToolDef { name: "run_sandbox", scope: Read, description: "Run a stored policy or an ad-hoc node list against a synthetic request, for real (outbound calls happen), and get the resulting trace. Requires debug.enabled and debug.sandbox. Give exactly one of `policy` or `nodes`. `context` is a FLAT object — e.g. {\"policy\": \"hello-policy\", \"context\": {\"method\": \"GET\", \"path\": \"/hello/frenk\", \"headers\": {\"x-tenant\": \"acme\"}, \"query_params\": {\"page\": \"2\"}, \"body\": {\"order\": {\"id\": 42}}}} — headers/query_params are objects (string or list values), body is text or a JSON object, seed `response` {status_code, headers, body} for response-phase plugins. Nodes mode: \"nodes\": [{\"id\": \"v\", \"type\": \"set-vars\", \"config\": {...}}].", input_schema: schema_of::<debug::SandboxArgs> },
247    ToolDef { name: "put_route", scope: Write, description: "Create or replace a route. Args: {\"name\": \"hello\", \"definition\": {\"match\": {\"path\": \"/hello/*\", \"methods\": [\"GET\"]}, \"policy\": \"hello-policy\"}, \"dry_run\": false}. The policy must already exist (put_policy first). Set dry_run=true first to validate the whole resulting config without applying; a successful write is live immediately.", input_schema: schema_of::<writes::PutArgs> },
248    ToolDef { name: "delete_route", scope: Write, description: "Delete a route by name (dry_run supported).", input_schema: schema_of::<writes::DeleteArgs> },
249    ToolDef { name: "put_policy", scope: Write, description: "Create or replace a policy. Args: {\"name\": \"hello-policy\", \"definition\": {\"nodes\": [{\"id\": \"listener\", \"type\": \"listener\"}, {\"id\": \"greet\", \"type\": \"mocking\", \"config\": {...}}, {\"id\": \"client\", \"type\": \"client\"}], \"edges\": [{\"from\": \"listener.out\", \"to\": \"greet.in\"}, {\"from\": \"greet.success\", \"to\": \"client.in\"}]}, \"dry_run\": false}. `definition` may be a YAML string; `name` may live inside it instead. Every success/outcome port must be wired. Use dry_run=true first; a successful write is live immediately (no reload).", input_schema: schema_of::<writes::PutArgs> },
250    ToolDef { name: "delete_policy", scope: Write, description: "Delete a policy by name; fails while a route still references it (dry_run supported).", input_schema: schema_of::<writes::DeleteArgs> },
251    ToolDef { name: "put_supernode", scope: Write, description: "Create or replace a supernode definition. Args: {\"name\": \"<supernode>\", \"definition\": {\"nodes\": [... including type: input / output / error boundary nodes ...], \"edges\": [...], \"description\"?: \"...\"}, \"dry_run\": false}. Use dry_run=true first; live immediately on success.", input_schema: schema_of::<writes::PutArgs> },
252    ToolDef { name: "delete_supernode", scope: Write, description: "Delete a supernode by name; fails while a policy still uses it (dry_run supported).", input_schema: schema_of::<writes::DeleteArgs> },
253    ToolDef { name: "put_plugin_config", scope: Write, description: "Create or replace a shared plugin config profile referenced by nodes via config_ref. Args: {\"name\": \"<profile>\", \"definition\": {\"type\": \"<node type>\", \"config\": {...}, \"description\"?: \"...\"}, \"dry_run\": false}. Live immediately on success.", input_schema: schema_of::<writes::PutArgs> },
254    ToolDef { name: "delete_plugin_config", scope: Write, description: "Delete a plugin config profile by name; fails while referenced (dry_run supported).", input_schema: schema_of::<writes::DeleteArgs> },
255    ToolDef { name: "put_store", scope: Write, description: "Create or replace a redis/valkey store. Args: {\"name\": \"<store>\", \"definition\": {\"type\": \"redis\", \"url\": \"redis://host:6379\", \"password\"?: \"${ENV_VAR}\", \"key_prefix\"?: \"...\", \"tls\"?: {...}}, \"dry_run\": false}. Keep secrets as ${ENV_VAR} placeholders. Live immediately on success.", input_schema: schema_of::<writes::PutArgs> },
256    ToolDef { name: "delete_store", scope: Write, description: "Delete a store by name; fails with the list of referrers while in use (dry_run supported).", input_schema: schema_of::<writes::DeleteArgs> },
257    ToolDef { name: "purge_cache", scope: Write, description: "Purge everything a proxy-cache pair has cached, by its id, on every backend that holds it. A policy: local purge clears this instance only. dry_run lists the backends without deleting.", input_schema: schema_of::<cache::PurgeCacheArgs> },
258    ToolDef { name: "reload_config", scope: Write, description: "Re-read gateway.yaml from disk and apply it (file config source only). NOT needed after put_*/delete_* — those are live immediately. Use it only when the file was edited by hand: it DISCARDS every API/MCP edit that was never written to the file, so it refuses with `unsaved_changes` (listing what would be lost) unless discard_unsaved=true.", input_schema: schema_of::<writes::ReloadArgs> },
259];
260
261#[cfg(test)]
262pub(crate) mod test_support {
263    use std::sync::Arc;
264
265    use super::*;
266    use crate::config::{GatewayConfig, SystemConfig};
267    use crate::config_store::FileConfigStore;
268
269    /// A state with the given system/gateway YAML (both default to `{}`).
270    pub fn state(system_yaml: &str, gateway_yaml: &str) -> Arc<SharedState> {
271        let system: SystemConfig = serde_yaml::from_str(system_yaml).unwrap();
272        let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
273        Arc::new(
274            SharedState::new(
275                system,
276                gateway,
277                None,
278                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
279                    "gateway.yaml",
280                ))),
281            )
282            .unwrap(),
283        )
284    }
285
286    /// A minimal valid gateway: one route → one echo policy.
287    pub const ECHO_GATEWAY: &str = r#"
288routes:
289  - name: hello
290    match: { path: /hello }
291    policy: echo-policy
292policies:
293  - name: echo-policy
294    nodes:
295      - { id: l, type: listener }
296      - { id: e, type: echo, config: { body: hi } }
297      - { id: c, type: client }
298    edges:
299      - { from: l.out, to: e.in }
300      - { from: e.out, to: c.in }
301"#;
302
303    pub fn obj(v: Value) -> JsonObject {
304        v.as_object().cloned().unwrap()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn tool_names_are_unique_and_scopes_follow_naming() {
314        let mut seen = std::collections::HashSet::new();
315        for t in tool_defs() {
316            assert!(seen.insert(t.name), "duplicate tool {}", t.name);
317            let mutating = t.name.starts_with("put_")
318                || t.name.starts_with("delete_")
319                || t.name.starts_with("purge_")
320                || t.name == "reload_config";
321            assert_eq!(
322                t.scope == Write,
323                mutating,
324                "scope/name mismatch for {}",
325                t.name
326            );
327            assert!(!t.description.is_empty());
328            let schema = (t.input_schema)();
329            assert_eq!(
330                schema.get("type").and_then(Value::as_str),
331                Some("object"),
332                "{}",
333                t.name
334            );
335        }
336    }
337
338    #[tokio::test]
339    async fn unknown_tool_is_reported() {
340        let state = test_support::state("{}", "{}");
341        let err = call(&state, "nope", JsonObject::new()).await.unwrap_err();
342        assert_eq!(err.code, "unknown_tool");
343    }
344
345    #[test]
346    fn payload_accepts_yaml_or_json() {
347        let r: crate::config::RouteConfig = parse_payload(
348            Value::String("name: r\nmatch: {path: /x}\npolicy: p\n".into()),
349            "route",
350        )
351        .unwrap();
352        assert_eq!(r.name, "r");
353        let r: crate::config::RouteConfig = parse_payload(
354            serde_json::json!({"name": "r2", "match": {"path": "/y"}, "policy": "p"}),
355            "route",
356        )
357        .unwrap();
358        assert_eq!(r.name, "r2");
359        let err =
360            parse_payload::<crate::config::RouteConfig>(Value::String("name: [".into()), "route")
361                .unwrap_err();
362        assert_eq!(err.code, "invalid_input");
363        assert!(err.message.contains("YAML"));
364    }
365
366    #[test]
367    fn error_json_shape() {
368        let e = ToolError::invalid_config(vec!["a".into(), "b".into()]);
369        let v = e.to_json();
370        assert_eq!(v["code"], "invalid_config");
371        assert_eq!(v["errors"].as_array().unwrap().len(), 2);
372        assert!(v["hint"].as_str().unwrap().contains("get_node_type"));
373        assert!(ToolError::not_found("policy", "x")
374            .to_json()
375            .get("errors")
376            .is_none());
377    }
378}