featherbit/mcp/mod.rs
1//! Model Context Protocol server for AI agents.
2//!
3//! Layout: [`auth`] (bearer tokens → scope), [`tools`] (the typed tool
4//! functions over [`crate::state::SharedState`]), [`docs`] (documentation
5//! pages embedded in the binary), [`prompts`] (precompiled debugging and
6//! authoring prompts). Everything here compiles in every build — the Admin
7//! API's `/api/mcp/prompts` uses the renderer even without a transport. Only
8//! [`server`] (the `rmcp` adapter and the mounted Streamable HTTP service)
9//! sits behind the `mcp` cargo feature.
10
11// `auth` (bearer tokens → scope) is reached only through the `mcp`
12// transport below; without the feature nothing calls into it.
13#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
14pub mod auth;
15// `docs`, `prompts` and `tools` are transport-agnostic and compile in every
16// build: the Admin API's `/api/mcp/*` endpoints (`src/admin/mcp.rs`) reach
17// them directly, whether or not the `mcp` feature/transport is present.
18// Individual items still unreachable in a headless build carry their own
19// per-item `allow(dead_code)`.
20pub mod docs;
21pub mod prompts;
22pub mod tools;
23
24#[cfg(feature = "mcp")]
25pub mod server;
26
27#[cfg(feature = "mcp")]
28use std::sync::Arc;
29
30use axum::response::IntoResponse;
31use axum::routing::any;
32use axum::Router;
33
34/// Router fragment answering the MCP path with `404` when the feature is off
35/// or `admin.mcp.enabled` is false (the `/api/debug/*` convention: never
36/// advertise a disabled surface).
37///
38/// The warning naming the key fires **once per process**, not once per
39/// request: this route answers before any credential is checked, so a
40/// per-request log would let anyone who can reach the admin listener drive
41/// unbounded WARN volume. The first hit carries the whole diagnosis (and
42/// `build_router` already logs the disabled state at startup); the rest is
43/// noise.
44pub fn disabled_router(path: &str) -> Router {
45 static WARNED: std::sync::Once = std::sync::Once::new();
46 async fn not_found() -> axum::response::Response {
47 WARNED.call_once(|| {
48 tracing::warn!(
49 "MCP endpoint was requested but is disabled; set `admin.mcp.enabled: true` \
50 (FEATHERBIT_MCP_ENABLED=true) with at least one token in system.yaml and restart"
51 );
52 });
53 (
54 axum::http::StatusCode::NOT_FOUND,
55 axum::Json(serde_json::json!({"error": "not_found"})),
56 )
57 .into_response()
58 }
59 Router::new().route(path, any(not_found))
60}
61
62/// The live MCP endpoint: bearer auth → rmcp Streamable HTTP service.
63#[cfg(feature = "mcp")]
64pub fn router(cfg: &crate::config::McpConfig, state: Arc<crate::state::SharedState>) -> Router {
65 let auth_state = Arc::new(auth::McpAuthState::from_config(cfg));
66 Router::new()
67 .route_service(&cfg.path, server::build_service(state))
68 .route_layer(axum::middleware::from_fn_with_state(
69 auth_state,
70 auth::bearer_middleware,
71 ))
72}