Skip to main content

rustango/mcp/
router.rs

1//! The axum routers you can mount, and the state their handlers
2//! share.
3//!
4//! - [`router`] for one tenant: it holds the app's
5//!   [`crate::sql::Pool`].
6//! - [`tenant_router`] for many: it holds no pool, because the
7//!   [`crate::extractors::Tenant`] extractor resolves one per
8//!   request.
9//! - [`secure_tenant_router`], and
10//!   `secure_tenant_router_from_settings` behind the `config`
11//!   feature, do the same but require an agent token.
12//!
13//! Each returns a `Router<()>` to `.merge(...)` into your API router
14//! and pass to `Cli::api(...)` or the tenancy `Builder::api(...)`.
15
16use std::sync::Arc;
17
18use axum::extract::FromRequestParts;
19use axum::routing::{get, post};
20use axum::Router;
21
22use crate::extractors::Tenant;
23use crate::sql::sqlx::Database;
24use crate::tenancy::jwt_lifecycle::JwtLifecycle;
25use crate::tenancy::DefaultTenantDb;
26
27use super::auth::{agent_token, default_jwt, post_authed};
28use super::transport::{post_handler, sse_handler};
29
30/// Shared state for the MCP handlers.
31#[derive(Clone)]
32pub(crate) struct McpState {
33    /// The app's pool. `None` under [`tenant_router`], where the
34    /// `Tenant` extractor supplies one per request.
35    #[allow(dead_code)]
36    pub(crate) pool: Option<crate::sql::Pool>,
37    /// Page size for the `*/list` methods, from
38    /// `[mcp].max_tools_listed`. `None` or 0 means one page with
39    /// everything in it.
40    pub(crate) page_size: Option<usize>,
41}
42
43impl McpState {
44    fn new(pool: Option<crate::sql::Pool>) -> Self {
45        Self {
46            pool,
47            page_size: None,
48        }
49    }
50}
51
52/// State of the routers that authenticate: the token lifecycle is
53/// always there, so no handler has an "auth not configured" case.
54/// `pub` in a private module, so the `*_for::<DB>` bounds can name it
55/// while callers cannot.
56#[derive(Clone)]
57pub struct AuthedMcpState {
58    pub(crate) mcp: McpState,
59    pub(crate) jwt: Arc<JwtLifecycle>,
60}
61
62/// JSON-RPC only: the SSE stream needs an agent token, which these
63/// routers never check (#1802).
64fn routes(state: McpState) -> Router {
65    Router::new()
66        .route("/", post(post_handler))
67        .with_state(state)
68}
69
70/// The MCP router for a single-tenant app. Mount it under the prefix
71/// you want: `Router::new().nest("/mcp", rustango::mcp::router(pool))`.
72#[must_use]
73pub fn router(pool: crate::sql::Pool) -> Router {
74    routes(McpState::new(Some(pool)))
75}
76
77/// The MCP router for a multi-tenant app, mounted through the
78/// tenancy `Builder::api(...)`. Each request resolves its own pool
79/// with the `Tenant` extractor.
80///
81/// This one does **not** authenticate. Use [`secure_tenant_router`]
82/// or [`tenant_router_authed`] to require an agent token.
83#[must_use]
84pub fn tenant_router() -> Router {
85    routes(McpState::new(None))
86}
87
88/// [`tenant_router`] **plus agent auth.** It adds
89/// `POST {prefix}/token`, which trades a `{name, secret}` pair for a
90/// scoped JWT, and puts the JSON-RPC endpoint behind a token pinned
91/// to one tenant. Tokens are signed with `RUSTANGO_SESSION_SECRET`;
92/// see [`default_jwt`].
93#[must_use]
94pub fn secure_tenant_router() -> Router {
95    tenant_router_authed(default_jwt())
96}
97
98/// [`secure_tenant_router`] for a `Tenant<DB>` other than the default,
99/// so a build without `config` keeps the same key handling.
100#[must_use]
101pub fn secure_tenant_router_for<DB>() -> Router
102where
103    DB: Database,
104    Tenant<DB>: FromRequestParts<AuthedMcpState> + Send,
105{
106    tenant_router_authed_for::<DB>(default_jwt())
107}
108
109/// [`secure_tenant_router`], configured from the `[mcp]` settings
110/// section. Every field there applies: `token_ttl_secs` sets the
111/// token lifetime, `max_tools_listed` the `*/list` page size,
112/// `enable_sse` whether the notification stream is mounted,
113/// `allowed_origins` a CORS layer, `rate_limit_per_minute` a per-IP
114/// limit, and `max_body_bytes` a body cap, which defaults to 1 MiB.
115#[cfg(feature = "config")]
116#[must_use]
117pub fn secure_tenant_router_from_settings(settings: &crate::config::McpSettings) -> Router {
118    secure_tenant_router_from_settings_for::<DefaultTenantDb>(settings)
119}
120
121/// [`secure_tenant_router_from_settings`] for a `Tenant<DB>` other than
122/// the default, e.g. SQLite in a build that also enables `postgres`.
123#[cfg(feature = "config")]
124#[must_use]
125pub fn secure_tenant_router_from_settings_for<DB>(settings: &crate::config::McpSettings) -> Router
126where
127    DB: Database,
128    Tenant<DB>: FromRequestParts<AuthedMcpState> + Send,
129{
130    let jwt = Arc::new(
131        JwtLifecycle::new(super::auth::jwt_secret()).with_access_ttl(settings.token_ttl_secs()),
132    );
133    let state = AuthedMcpState {
134        mcp: McpState {
135            page_size: settings.max_tools_listed,
136            ..McpState::new(None)
137        },
138        jwt,
139    };
140    let mut router = authed_routes::<DB>(state, settings.sse_enabled());
141
142    // An empty allow-list means no CORS layer, so same-origin only.
143    if !settings.allowed_origins.is_empty() {
144        use crate::cors::{CorsLayer, CorsRouterExt};
145        router = router.cors(CorsLayer::new().allow_origins(settings.allowed_origins.clone()));
146    }
147    // Per-IP limit, counted over a 60-second window.
148    if let Some(rpm) = settings.rate_limit() {
149        use crate::rate_limit::{RateLimitLayer, RateLimitRouterExt};
150        router = router.rate_limit(RateLimitLayer::per_ip(
151            rpm.get(),
152            std::time::Duration::from_secs(60),
153        ));
154    }
155    // Body cap on every MCP route.
156    {
157        use crate::body_limit::{BodyLimitLayer, BodyLimitRouterExt};
158        router = router.body_limit(BodyLimitLayer::new(settings.max_body_bytes()));
159    }
160    router
161}
162
163/// [`secure_tenant_router`] with a [`JwtLifecycle`] you supply, for a
164/// stable secret, your own TTLs, or a shared `JtiStore` so revoking a
165/// token affects every instance. Tests issue and revoke through this.
166///
167/// The notification stream is always mounted here. Use the settings
168/// variant to gate it.
169#[must_use]
170pub fn tenant_router_authed(jwt: Arc<JwtLifecycle>) -> Router {
171    tenant_router_authed_for::<DefaultTenantDb>(jwt)
172}
173
174/// [`tenant_router_authed`] for a `Tenant<DB>` other than the default,
175/// e.g. SQLite in a build that also enables `postgres` (#1787).
176#[must_use]
177pub fn tenant_router_authed_for<DB>(jwt: Arc<JwtLifecycle>) -> Router
178where
179    DB: Database,
180    Tenant<DB>: FromRequestParts<AuthedMcpState> + Send,
181{
182    let state = AuthedMcpState {
183        mcp: McpState::new(None),
184        jwt,
185    };
186    authed_routes::<DB>(state, true)
187}
188
189/// The authenticated route set: JSON-RPC on `/`, the notification
190/// stream when `enable_sse`, `/token`, and the OAuth 2.1 discovery
191/// and `client_credentials` endpoints.
192fn authed_routes<DB>(state: AuthedMcpState, enable_sse: bool) -> Router
193where
194    DB: Database,
195    Tenant<DB>: FromRequestParts<AuthedMcpState> + Send,
196{
197    use super::oauth;
198    let root = if enable_sse {
199        post(post_authed::<DB>).get(sse_handler::<DB>)
200    } else {
201        post(post_authed::<DB>)
202    };
203    Router::new()
204        .route("/", root)
205        .route("/token", post(agent_token::<DB>))
206        .route("/oauth/token", post(oauth::oauth_token::<DB>))
207        .route(
208            "/.well-known/oauth-protected-resource",
209            get(oauth::well_known_protected_resource),
210        )
211        .route(
212            "/.well-known/oauth-authorization-server",
213            get(oauth::well_known_authorization_server),
214        )
215        .with_state(state)
216}