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}