1use 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
19const 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#[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
47pub 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
63fn 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 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 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 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 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 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 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 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}