1pub 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
17pub type JsonObject = serde_json::Map<String, Value>;
19
20#[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 #[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 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 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 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 #[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
125pub 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
131pub 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#[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#[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#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
169pub fn tool_defs() -> &'static [ToolDef] {
170 &TOOLS
171}
172
173#[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
179pub 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 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 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}