Skip to main content

featherbit/mcp/
server.rs

1//! `rmcp` adapter: exposes the tool/resource/prompt layer over MCP and builds
2//! the Streamable HTTP tower service mounted on the admin router.
3
4use std::sync::Arc;
5use std::time::Instant;
6
7use rmcp::handler::server::ServerHandler;
8use rmcp::model::*;
9use rmcp::service::RequestContext;
10use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
11use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
12use rmcp::{ErrorData as McpError, RoleServer};
13
14use crate::mcp::auth::McpPrincipal;
15use crate::mcp::tools::{self, JsonObject, ToolError};
16use crate::mcp::{docs, prompts};
17use crate::state::SharedState;
18
19/// Orientation sent to every client at `initialize`.
20const INSTRUCTIONS: &str = "\
21featherbit is an API gateway whose behavior is declared as node-graph POLICIES referenced by ROUTES.
22- A policy is a graph of typed nodes (plugins) joined by edges `from: node.port` → `to: node.in`. It has exactly one `listener` (entry) and one `client` (exit).
23- Every node's `success`/`out` port AND every declared outcome port (`denied`, `redirect`, `limited`, `broken`, `preflight`, `abort`, `routed`, `hit`, `true`/`false`, …) MUST be wired, or the policy fails to compile. Only `error` ports may be left unwired (they fall back to the policy's `error_handler`).
24- Plugin config keys are documented per node type: call `get_node_type(<type>)` before writing a node's `config`. `list_node_types` lists them all.
25- Variables: any string config value can reference request data, either as legacy `$var` (`$uri`, `$http_x_tenant`, `$arg_page`, `$cookie_sid`, `$msg_<key>`) or as a `{{namespace.path}}` template (`{{request.path}}`, `{{request.headers.x-tenant}}`, `{{request.query.page}}`, `{{message.user}}`, `{{client.ip}}`, `{{env.NAME}}`). Both resolve per request. Conditions use the bare var name: [\"arg_channel\", \"==\", \"beta\"]. To derive a value (a path segment, a JSON body field, a regex capture) add a `set-vars` node — it writes `context.message`, readable as `$msg_<name>`. Call `list_vars` for the full catalog, the template namespaces and the fields that are NOT templated (regexes, IP lists, schemas, Lua, upstream targets, route match rules); the reference pages are `featherbit://docs/reference/templates` and `featherbit://docs/reference/context-vars`.
26- Authoring loop: get_node_type → write YAML → validate_policy → put_*(dry_run=true) → put_*. Payloads may be JSON objects or YAML strings. A successful put_*/delete_* is LIVE immediately — never call reload_config to 'apply' it: reload_config re-reads gateway.yaml from disk and discards live edits (it refuses unless discard_unsaved=true). With the file config source, ask the operator to persist edits to gateway.yaml (export_config gives the YAML) or they vanish on restart.
27- Debugging: list_traces / get_trace / get_trace_step (context before/after a node, its exit port, the diff) and run_sandbox (execute a policy against a synthetic request). They need `debug.enabled` in system.yaml; the tools tell you if it is off. run_sandbox takes `policy` OR `nodes` plus a FLAT `context` {method, path, host, headers{name: value}, query_params{name: value}, body (text or JSON object), message{}, response{status_code, headers, body}} — never nested under `request`, `path` not `uri`, `query_params` not a query string.
28- Supernodes are reusable subgraphs with input/output/error boundary nodes; `featherbit://docs/concepts/supernodes` explains the rules.
29- Writing a `script` node: get_node_type(\"script\") carries the `ctx` table shape, and `featherbit://docs/guides/lua-scripting` the worked examples. Mutate the table you were given and `return ctx`; to answer the request from the script, prepare `ctx.response` and `return ctx, \"respond\"` — the node's `respond` port must be wired (to `client`, usually); headers and query params are maps of name -> array of strings (a bare string is accepted); bodies are strings. A failure names the offending field (`LUA_UNMARSHAL_ERROR`). For plain extraction — a path segment, a header, a JSON body field — prefer a `set-vars` node over a script.
30- Every how-to guide is a resource: `featherbit://docs/guides/{lua-scripting,debugging,routing,configuration,tls,stream,observability,admin-api,web-ui,deployment,mcp}`.
31- `${ENV_VAR}` placeholders in config are intentional and stay unresolved; never replace them with literal secrets.
32- If your token is read-only, write tools are hidden (or return `forbidden`): finish by returning validated YAML for a human to apply.
33Use the prompts (troubleshoot_trace, explain_trace, why_this_port, why_this_response, review_policy, design_policy, design_supernode, design_route, diagnose_route) for the common questions.";
34
35/// The MCP server: a cheap handle over the shared state.
36#[derive(Clone)]
37pub struct McpServer {
38    state: Arc<SharedState>,
39}
40
41impl McpServer {
42    pub fn new(state: Arc<SharedState>) -> Self {
43        Self { state }
44    }
45}
46
47/// Builds the tower service to mount at `admin.mcp.path`. Host validation is
48/// disabled (agents reach the admin listener by any name; our own middleware
49/// enforces `Origin`), sessions are rmcp's default in-memory manager.
50pub fn build_service(
51    state: Arc<SharedState>,
52) -> StreamableHttpService<McpServer, LocalSessionManager> {
53    let server = McpServer::new(state);
54    StreamableHttpService::new(
55        move || Ok(server.clone()),
56        Arc::new(LocalSessionManager::default()),
57        StreamableHttpServerConfig::default()
58            .disable_allowed_hosts()
59            .disable_allowed_origins(),
60    )
61}
62
63/// The principal the bearer middleware attached to this HTTP request.
64fn principal(ctx: &RequestContext<RoleServer>) -> Result<McpPrincipal, McpError> {
65    ctx.extensions
66        .get::<http::request::Parts>()
67        .and_then(|parts| parts.extensions.get::<McpPrincipal>().cloned())
68        .ok_or_else(|| {
69            McpError::invalid_request(
70                "request carries no MCP principal (auth middleware missing)",
71                None,
72            )
73        })
74}
75
76fn tool_from_def(def: &tools::ToolDef) -> Tool {
77    Tool::new(def.name, def.description, Arc::new((def.input_schema)()))
78}
79
80fn error_result(e: &ToolError) -> CallToolResult {
81    CallToolResult::error(vec![ContentBlock::text(e.to_json().to_string())])
82}
83
84fn tools_obj(v: serde_json::Value) -> JsonObject {
85    v.as_object().cloned().unwrap_or_default()
86}
87
88impl ServerHandler for McpServer {
89    fn get_info(&self) -> ServerInfo {
90        ServerInfo::new(
91            ServerCapabilities::builder()
92                .enable_tools()
93                .enable_resources()
94                .enable_prompts()
95                .build(),
96        )
97        .with_server_info(Implementation::new("featherbit", env!("CARGO_PKG_VERSION")))
98        .with_instructions(INSTRUCTIONS)
99    }
100
101    async fn list_tools(
102        &self,
103        _request: Option<PaginatedRequestParams>,
104        ctx: RequestContext<RoleServer>,
105    ) -> Result<ListToolsResult, McpError> {
106        let p = principal(&ctx)?;
107        let tools = tools::tool_defs()
108            .iter()
109            .filter(|d| p.scope.allows(d.scope))
110            .map(tool_from_def)
111            .collect();
112        // SEP-2549 cache hints are REQUIRED on list results for peers on
113        // protocol 2026-07-28+ (Claude Code rejects the list without them).
114        // Every list here is token-scoped (tools filtered by scope, resources
115        // by config), so: private, and `0` = do not cache.
116        Ok(ListToolsResult::with_all_items(tools)
117            .with_ttl_ms(0)
118            .with_cache_scope(CacheScope::Private))
119    }
120
121    async fn call_tool(
122        &self,
123        request: CallToolRequestParams,
124        ctx: RequestContext<RoleServer>,
125    ) -> Result<CallToolResponse, McpError> {
126        let p = principal(&ctx)?;
127        let name = request.name.to_string();
128        let def = tools::tool_def(&name)
129            .ok_or_else(|| McpError::invalid_params(format!("tool not found: {name}"), None))?;
130        let started = Instant::now();
131        let outcome = if !p.scope.allows(def.scope) {
132            Err(ToolError::forbidden(p.scope))
133        } else {
134            let args: JsonObject = request.arguments.unwrap_or_default();
135            tools::call(&self.state, &name, args).await
136        };
137        tracing::info!(
138            "mcp tool call token={} scope={} tool={} outcome={} duration_ms={}",
139            p.name.as_deref().unwrap_or("unnamed"),
140            p.scope.as_str(),
141            name,
142            match &outcome {
143                Ok(_) => "ok".to_string(),
144                Err(e) => format!("error:{}", e.code),
145            },
146            started.elapsed().as_millis()
147        );
148        let result = match outcome {
149            Ok(v) => CallToolResult::success(vec![ContentBlock::text(v.to_string())]),
150            Err(e) => error_result(&e),
151        };
152        Ok(result.into())
153    }
154
155    async fn list_resources(
156        &self,
157        _request: Option<PaginatedRequestParams>,
158        ctx: RequestContext<RoleServer>,
159    ) -> Result<ListResourcesResult, McpError> {
160        principal(&ctx)?;
161        let mut resources: Vec<Resource> = docs::list_pages()
162            .into_iter()
163            .map(|p| {
164                let mut r = Resource::new(p.uri, p.title);
165                if !p.description.is_empty() {
166                    r = r.with_description(p.description);
167                }
168                r
169            })
170            .collect();
171        let gw = self.state.gateway.read().await;
172        for r in &gw.routes {
173            resources.push(Resource::new(
174                format!("featherbit://routes/{}", r.name),
175                format!("route {}", r.name),
176            ));
177        }
178        for p in &gw.policies {
179            resources.push(Resource::new(
180                format!("featherbit://policies/{}", p.name),
181                format!("policy {}", p.name),
182            ));
183        }
184        for s in &gw.supernodes {
185            resources.push(Resource::new(
186                format!("featherbit://supernodes/{}", s.name),
187                format!("supernode {}", s.name),
188            ));
189        }
190        Ok(ListResourcesResult {
191            resources,
192            ttl_ms: Some(0),
193            cache_scope: Some(CacheScope::Private),
194            ..Default::default()
195        })
196    }
197
198    async fn list_resource_templates(
199        &self,
200        _request: Option<PaginatedRequestParams>,
201        ctx: RequestContext<RoleServer>,
202    ) -> Result<ListResourceTemplatesResult, McpError> {
203        principal(&ctx)?;
204        let resource_templates = vec![
205            ResourceTemplate::new(
206                "featherbit://docs/plugins/{type}",
207                "Node type documentation",
208            ),
209            ResourceTemplate::new("featherbit://docs/concepts/{name}", "Concept guide"),
210            ResourceTemplate::new(
211                "featherbit://docs/reference/{name}",
212                "Reference page (context-vars, conditions, templates)",
213            ),
214            ResourceTemplate::new("featherbit://routes/{name}", "Route definition (YAML)"),
215            ResourceTemplate::new("featherbit://policies/{name}", "Policy definition (YAML)"),
216            ResourceTemplate::new(
217                "featherbit://supernodes/{name}",
218                "Supernode definition (YAML)",
219            ),
220            ResourceTemplate::new(
221                "featherbit://traces/{id}",
222                "Debug trace (JSON, with snapshots)",
223            ),
224        ];
225        Ok(ListResourceTemplatesResult {
226            resource_templates,
227            ttl_ms: Some(0),
228            cache_scope: Some(CacheScope::Private),
229            ..Default::default()
230        })
231    }
232
233    async fn read_resource(
234        &self,
235        request: ReadResourceRequestParams,
236        ctx: RequestContext<RoleServer>,
237    ) -> Result<ReadResourceResponse, McpError> {
238        principal(&ctx)?;
239        let uri = request.uri.clone();
240        let not_found = || {
241            McpError::resource_not_found(
242                "resource_not_found",
243                Some(serde_json::json!({"uri": uri})),
244            )
245        };
246
247        let (text, mime) = if let Some(md) = docs::read_uri(&request.uri) {
248            (md, "text/markdown")
249        } else if let Some(name) = request.uri.strip_prefix("featherbit://routes/") {
250            let gw = self.state.gateway.read().await;
251            let r = gw
252                .routes
253                .iter()
254                .find(|r| r.name == name)
255                .ok_or_else(not_found)?;
256            (
257                serde_yaml::to_string(r)
258                    .map_err(|e| McpError::internal_error(e.to_string(), None))?,
259                "application/yaml",
260            )
261        } else if let Some(name) = request.uri.strip_prefix("featherbit://policies/") {
262            let gw = self.state.gateway.read().await;
263            let p = gw
264                .policies
265                .iter()
266                .find(|p| p.name == name)
267                .ok_or_else(not_found)?;
268            (
269                serde_yaml::to_string(p)
270                    .map_err(|e| McpError::internal_error(e.to_string(), None))?,
271                "application/yaml",
272            )
273        } else if let Some(name) = request.uri.strip_prefix("featherbit://supernodes/") {
274            let gw = self.state.gateway.read().await;
275            let s = gw
276                .supernodes
277                .iter()
278                .find(|s| s.name == name)
279                .ok_or_else(not_found)?;
280            (
281                serde_yaml::to_string(s)
282                    .map_err(|e| McpError::internal_error(e.to_string(), None))?,
283                "application/yaml",
284            )
285        } else if let Some(id) = request.uri.strip_prefix("featherbit://traces/") {
286            let v = tools::call(
287                &self.state,
288                "get_trace",
289                tools_obj(serde_json::json!({"id": id, "include_snapshots": true})),
290            )
291            .await
292            .map_err(|e| match e.code {
293                "not_found" => not_found(),
294                _ => {
295                    let body = e.to_json();
296                    McpError::internal_error(e.message, Some(body))
297                }
298            })?;
299            (v.to_string(), "application/json")
300        } else {
301            return Err(not_found());
302        };
303        Ok(ReadResourceResult::new(vec![
304            ResourceContents::text(text, request.uri).with_mime_type(mime)
305        ])
306        .into())
307    }
308
309    async fn list_prompts(
310        &self,
311        _request: Option<PaginatedRequestParams>,
312        ctx: RequestContext<RoleServer>,
313    ) -> Result<ListPromptsResult, McpError> {
314        principal(&ctx)?;
315        let prompts = prompts::prompt_defs()
316            .iter()
317            .map(|d| {
318                Prompt::new(
319                    d.name,
320                    Some(d.description),
321                    Some(
322                        d.args
323                            .iter()
324                            .map(|a| {
325                                PromptArgument::new(a.name)
326                                    .with_description(a.description)
327                                    .with_required(a.required)
328                            })
329                            .collect(),
330                    ),
331                )
332            })
333            .collect();
334        Ok(ListPromptsResult {
335            prompts,
336            ttl_ms: Some(0),
337            cache_scope: Some(CacheScope::Private),
338            ..Default::default()
339        })
340    }
341
342    async fn get_prompt(
343        &self,
344        request: GetPromptRequestParams,
345        ctx: RequestContext<RoleServer>,
346    ) -> Result<GetPromptResponse, McpError> {
347        principal(&ctx)?;
348        // Spec-compliant clients send string values; accept anything and stringify.
349        let args: std::collections::HashMap<String, String> = request
350            .arguments
351            .unwrap_or_default()
352            .into_iter()
353            .map(|(k, v)| {
354                (
355                    k,
356                    match v {
357                        serde_json::Value::String(s) => s,
358                        other => other.to_string(),
359                    },
360                )
361            })
362            .collect();
363        let rendered = prompts::render(&self.state, &request.name, &args)
364            .await
365            .map_err(|e| match e.code {
366                "unknown_prompt" | "invalid_input" => McpError::invalid_params(e.message, None),
367                _ => {
368                    let body = e.to_json();
369                    McpError::internal_error(e.message, Some(body))
370                }
371            })?;
372        Ok(
373            GetPromptResult::new(vec![PromptMessage::new_text(Role::User, rendered.text)])
374                .with_description(rendered.description)
375                .into(),
376        )
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::config::AdminConfig;
384    use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
385    use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
386    use rmcp::transport::StreamableHttpClientTransport;
387    use rmcp::ServiceExt;
388
389    const READ: &str = "read-token-0123456789";
390    const WRITE: &str = "write-token-0123456789";
391
392    fn admin(enabled: bool) -> AdminConfig {
393        serde_yaml::from_str(&format!(
394            "username: u\npassword: p\nui_enabled: false\nmcp:\n  enabled: {enabled}\n  tokens:\n    - token: {READ}\n      scope: read\n      name: reader\n    - token: {WRITE}\n      scope: write\n"
395        ))
396        .unwrap()
397    }
398
399    /// Serves the admin router on a loopback port; returns the MCP URL.
400    async fn serve(enabled: bool, debug: bool) -> (String, Arc<SharedState>) {
401        let sys = if debug {
402            "debug:\n  enabled: true\n  sandbox: true\n"
403        } else {
404            "{}"
405        };
406        let st = state(sys, ECHO_GATEWAY);
407        let app = crate::admin::build_router(&admin(enabled), st.clone());
408        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
409        let addr = listener.local_addr().unwrap();
410        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
411        (format!("http://{addr}/mcp"), st)
412    }
413
414    async fn client(
415        url: &str,
416        token: &str,
417    ) -> rmcp::service::RunningService<rmcp::RoleClient, ClientInfo> {
418        let cfg = StreamableHttpClientTransportConfig::with_uri(url.to_string()).auth_header(token);
419        let transport = StreamableHttpClientTransport::from_config(cfg);
420        ClientInfo::new(
421            ClientCapabilities::default(),
422            Implementation::new("test", "0"),
423        )
424        .serve(transport)
425        .await
426        .expect("initialize")
427    }
428
429    fn text_of(r: &CallToolResult) -> serde_json::Value {
430        let s = r
431            .content
432            .iter()
433            .find_map(|c| c.as_text().map(|t| t.text.clone()))
434            .expect("text content");
435        serde_json::from_str(&s).unwrap()
436    }
437
438    #[tokio::test]
439    async fn initialize_lists_and_filters_tools_by_scope() {
440        let (url, _) = serve(true, false).await;
441        let reader = client(&url, READ).await;
442        let info = reader.peer_info().unwrap();
443        assert_eq!(info.server_info.as_ref().unwrap().name, "featherbit");
444        assert!(info
445            .instructions
446            .as_deref()
447            .unwrap()
448            .contains("MUST be wired"));
449        let names: Vec<String> = reader
450            .list_all_tools()
451            .await
452            .unwrap()
453            .into_iter()
454            .map(|t| t.name.to_string())
455            .collect();
456        assert!(names.contains(&"get_policy".to_string()));
457        assert!(!names.iter().any(|n| n.starts_with("put_")), "{names:?}");
458        reader.cancel().await.unwrap();
459
460        let writer = client(&url, WRITE).await;
461        let names: Vec<String> = writer
462            .list_all_tools()
463            .await
464            .unwrap()
465            .into_iter()
466            .map(|t| t.name.to_string())
467            .collect();
468        assert!(names.contains(&"put_policy".to_string()));
469        writer.cancel().await.unwrap();
470    }
471
472    #[tokio::test]
473    async fn read_parity_and_forbidden_write() {
474        let (url, st) = serve(true, false).await;
475        let reader = client(&url, READ).await;
476        let r = reader
477            .call_tool(
478                CallToolRequestParams::new("get_policy")
479                    .with_arguments(obj(serde_json::json!({"name": "echo-policy"}))),
480            )
481            .await
482            .unwrap();
483        assert_ne!(r.is_error, Some(true));
484        let v = text_of(&r);
485        let expected = serde_json::to_value(st.gateway.read().await.policies[0].clone()).unwrap();
486        assert_eq!(v["policy"], expected);
487
488        let r = reader
489            .call_tool(
490                CallToolRequestParams::new("put_policy")
491                    .with_arguments(obj(serde_json::json!({"name": "x", "definition": {}}))),
492            )
493            .await
494            .unwrap();
495        assert_eq!(r.is_error, Some(true));
496        assert_eq!(text_of(&r)["code"], "forbidden");
497
498        let err = reader
499            .call_tool(CallToolRequestParams::new("no_such_tool"))
500            .await;
501        assert!(err.is_err(), "unknown tool is a protocol error");
502        reader.cancel().await.unwrap();
503    }
504
505    #[tokio::test]
506    async fn write_dry_run_then_apply_is_visible_and_routable() {
507        let (url, st) = serve(true, false).await;
508        let writer = client(&url, WRITE).await;
509        let def = serde_json::json!({
510            "nodes": [
511                {"id": "l", "type": "listener"},
512                {"id": "e", "type": "echo", "config": {"body": "hi"}},
513                {"id": "c", "type": "client"}
514            ],
515            "edges": [{"from": "l.out", "to": "e.in"}, {"from": "e.out", "to": "c.in"}]
516        });
517        let r = writer
518            .call_tool(CallToolRequestParams::new("put_policy").with_arguments(obj(
519                serde_json::json!({"name": "p2", "definition": def, "dry_run": true}),
520            )))
521            .await
522            .unwrap();
523        assert_eq!(text_of(&r)["applied"], false);
524        assert!(st
525            .gateway
526            .read()
527            .await
528            .policies
529            .iter()
530            .all(|p| p.name != "p2"));
531
532        let r = writer
533            .call_tool(
534                CallToolRequestParams::new("put_policy")
535                    .with_arguments(obj(serde_json::json!({"name": "p2", "definition": def}))),
536            )
537            .await
538            .unwrap();
539        assert_eq!(text_of(&r)["applied"], true);
540        writer
541            .call_tool(CallToolRequestParams::new("put_route").with_arguments(obj(
542                serde_json::json!({"name": "r2", "definition": {"match": {"path": "/two"}, "policy": "p2"}}),
543            )))
544            .await
545            .unwrap();
546        assert_eq!(
547            st.routes.read().await.len(),
548            2,
549            "hot-applied to the route table"
550        );
551
552        // key-auth declares a `denied` outcome port; leaving it unwired must
553        // fail compilation rather than silently dropping rejected requests.
554        let bad = serde_json::json!({
555            "nodes": [
556                {"id": "l", "type": "listener"},
557                {"id": "k", "type": "key-auth", "config": {"keys": ["k1"]}},
558                {"id": "c", "type": "client"}
559            ],
560            "edges": [{"from": "l.out", "to": "k.in"}, {"from": "k.out", "to": "c.in"}]
561        });
562        let r = writer
563            .call_tool(
564                CallToolRequestParams::new("put_policy")
565                    .with_arguments(obj(serde_json::json!({"name": "bad", "definition": bad}))),
566            )
567            .await
568            .unwrap();
569        assert_eq!(r.is_error, Some(true));
570        let v = text_of(&r);
571        assert_eq!(v["code"], "invalid_config");
572        assert!(v["errors"][0].as_str().unwrap().contains("bad"));
573        writer.cancel().await.unwrap();
574    }
575
576    #[tokio::test]
577    async fn resources_and_prompts() {
578        let (url, _) = serve(true, true).await;
579        let c = client(&url, READ).await;
580        // 2026-07-28 peers (Claude Code) require SEP-2549 cache hints on every list.
581        let tl = c.list_tools(None).await.unwrap();
582        assert_eq!(
583            (tl.ttl_ms, tl.cache_scope),
584            (Some(0), Some(CacheScope::Private))
585        );
586        let tpl = c.list_resource_templates(None).await.unwrap();
587        assert_eq!(
588            (tpl.ttl_ms, tpl.cache_scope),
589            (Some(0), Some(CacheScope::Private))
590        );
591        let res = c.list_resources(None).await.unwrap();
592        assert_eq!(
593            (res.ttl_ms, res.cache_scope),
594            (Some(0), Some(CacheScope::Private))
595        );
596        assert!(res
597            .resources
598            .iter()
599            .any(|r| r.uri == "featherbit://docs/plugins/limit-count"));
600        assert!(res
601            .resources
602            .iter()
603            .any(|r| r.uri == "featherbit://policies/echo-policy"));
604        let page = c
605            .read_resource(ReadResourceRequestParams::new(
606                "featherbit://docs/plugins/limit-count",
607            ))
608            .await
609            .unwrap();
610        let text = match &page.contents[0] {
611            ResourceContents::TextResourceContents { text, .. } => text.clone(),
612            _ => panic!("text"),
613        };
614        assert!(text.starts_with("# limit-count"));
615        let pol = c
616            .read_resource(ReadResourceRequestParams::new(
617                "featherbit://policies/echo-policy",
618            ))
619            .await
620            .unwrap();
621        let text = match &pol.contents[0] {
622            ResourceContents::TextResourceContents { text, .. } => text.clone(),
623            _ => panic!("text"),
624        };
625        assert!(text.contains("name: echo-policy"));
626        assert!(c
627            .read_resource(ReadResourceRequestParams::new("featherbit://policies/nope"))
628            .await
629            .is_err());
630
631        let prompts = c.list_prompts(None).await.unwrap();
632        assert_eq!(
633            (prompts.ttl_ms, prompts.cache_scope),
634            (Some(0), Some(CacheScope::Private))
635        );
636        assert!(prompts.prompts.iter().any(|p| p.name == "why_this_port"));
637        let run = c
638            .call_tool(
639                CallToolRequestParams::new("run_sandbox").with_arguments(obj(
640                    serde_json::json!({"policy": "echo-policy", "context": {"path": "/hello"}}),
641                )),
642            )
643            .await
644            .unwrap();
645        let id = text_of(&run)["stored_trace_id"]
646            .as_str()
647            .unwrap()
648            .to_string();
649        let p = c
650            .get_prompt(
651                GetPromptRequestParams::new("explain_trace")
652                    .with_arguments(obj(serde_json::json!({"trace_id": id}))),
653            )
654            .await
655            .unwrap();
656        let msg = match &p.messages[0].content {
657            ContentBlock::Text(t) => t.text.clone(),
658            _ => panic!("text"),
659        };
660        assert!(msg.contains("# What is happening in this request?"));
661        assert!(c
662            .get_prompt(GetPromptRequestParams::new("nope"))
663            .await
664            .is_err());
665        c.cancel().await.unwrap();
666    }
667
668    #[tokio::test]
669    async fn debug_disabled_surfaces_as_tool_error() {
670        let (url, _) = serve(true, false).await;
671        let c = client(&url, READ).await;
672        let r = c
673            .call_tool(CallToolRequestParams::new("list_traces"))
674            .await
675            .unwrap();
676        assert_eq!(r.is_error, Some(true));
677        assert_eq!(text_of(&r)["code"], "debug_disabled");
678        c.cancel().await.unwrap();
679    }
680
681    #[tokio::test]
682    async fn raw_http_auth_and_disabled_behaviors() {
683        let (url, _) = serve(true, false).await;
684        let http = reqwest::Client::new();
685        let init = serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize",
686            "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}});
687        let resp = http
688            .post(&url)
689            .header("accept", "application/json, text/event-stream")
690            .json(&init)
691            .send()
692            .await
693            .unwrap();
694        assert_eq!(resp.status(), 401);
695        assert_eq!(
696            resp.headers().get("www-authenticate").unwrap(),
697            "Bearer realm=\"featherbit-mcp\""
698        );
699        let resp = http
700            .post(&url)
701            .header("accept", "application/json, text/event-stream")
702            .header("origin", "http://evil.example")
703            .bearer_auth(READ)
704            .json(&init)
705            .send()
706            .await
707            .unwrap();
708        assert_eq!(resp.status(), 403);
709        // Basic Auth does not open the MCP door.
710        let resp = http
711            .post(&url)
712            .header("accept", "application/json, text/event-stream")
713            .basic_auth("u", Some("p"))
714            .json(&init)
715            .send()
716            .await
717            .unwrap();
718        assert_eq!(resp.status(), 401);
719        // MCP tokens do not open the Admin API.
720        let api = url.replace("/mcp", "/api/policies");
721        let resp = http.get(&api).bearer_auth(WRITE).send().await.unwrap();
722        assert_eq!(resp.status(), 401);
723
724        let (url, _) = serve(false, false).await;
725        let resp = http
726            .post(&url)
727            .bearer_auth(READ)
728            .json(&init)
729            .send()
730            .await
731            .unwrap();
732        assert_eq!(resp.status(), 404);
733        assert_eq!(
734            resp.json::<serde_json::Value>().await.unwrap(),
735            serde_json::json!({"error": "not_found"})
736        );
737    }
738}