1use std::time::Duration;
2
3use anyhow::Context;
4use axum::response::IntoResponse;
5use axum::{
6 Router,
7 extract::State,
8 routing::{get, post},
9};
10use rmcp::transport::streamable_http_server::{
11 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
12};
13use sqlx::PgPool;
14use tokio_util::sync::CancellationToken;
15use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
16
17use crate::{auth, dashboard, events::EventHub, tools::Bus, webhooks};
18
19pub struct ServeOptions {
20 pub bind: String,
21 pub allowed_hosts: Vec<String>,
24 pub allowed_origins: Vec<String>,
25 pub max_request_bytes: usize,
27 pub rate_limit_per_minute: u32,
29 pub dashboard_secret: Vec<u8>,
32}
33
34pub const DEFAULT_MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
39pub const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 600;
40
41async fn health(
42 State(pool): State<PgPool>,
43) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) {
44 match sqlx::query_scalar::<_, i32>("SELECT 1")
45 .fetch_one(&pool)
46 .await
47 {
48 Ok(_) => (
49 axum::http::StatusCode::OK,
50 axum::Json(serde_json::json!({ "status": "ok", "database": "up" })),
51 ),
52 Err(e) => {
53 tracing::error!(error = %e, "health check failed");
54 (
55 axum::http::StatusCode::SERVICE_UNAVAILABLE,
56 axum::Json(serde_json::json!({ "status": "degraded", "database": "down" })),
57 )
58 }
59 }
60}
61
62async fn explain_payload_too_large(
66 limit: usize,
67 req: axum::extract::Request,
68 next: axum::middleware::Next,
69) -> axum::response::Response {
70 let resp = next.run(req).await;
71 if resp.status() != axum::http::StatusCode::PAYLOAD_TOO_LARGE {
72 return resp;
73 }
74 (
75 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
76 axum::Json(serde_json::json!({
77 "error": format!(
78 "request body is too large; this server accepts up to {limit} bytes. \
79 Send fewer or smaller attachments (256 KiB each, 8 per message), \
80 or split the call into several smaller ones."
81 )
82 })),
83 )
84 .into_response()
85}
86
87pub fn build_router(pool: PgPool, opts: &ServeOptions, ct: CancellationToken) -> Router {
88 let hub = EventHub::new();
91 tokio::spawn(crate::events::run_pg_listener(
92 pool.clone(),
93 hub.clone(),
94 ct.clone(),
95 ));
96 tokio::spawn(webhooks::run_dispatcher(
97 pool.clone(),
98 hub.clone(),
99 ct.clone(),
100 ));
101
102 let mut config = StreamableHttpServerConfig::default()
103 .with_json_response(true)
104 .with_legacy_session_mode(false)
108 .with_sse_keep_alive(Some(Duration::from_secs(30)))
109 .with_cancellation_token(ct);
110
111 config = if opts.allowed_hosts.is_empty() {
112 config.disable_allowed_hosts()
113 } else {
114 config.with_allowed_hosts(opts.allowed_hosts.clone())
115 };
116 if !opts.allowed_origins.is_empty() {
117 config = config.with_allowed_origins(opts.allowed_origins.clone());
118 }
119
120 let mcp: StreamableHttpService<Bus, LocalSessionManager> = StreamableHttpService::new(
121 {
122 let pool = pool.clone();
123 let hub = hub.clone();
124 move || Ok(Bus::new(pool.clone(), hub.clone()))
125 },
126 Default::default(),
127 config,
128 );
129
130 let limiter = crate::ratelimit::RateLimiter::new(opts.rate_limit_per_minute);
131 if limiter.is_none() {
132 tracing::warn!("in-process rate limiting is disabled (BUS_RATE_LIMIT_PER_MINUTE=0)");
133 }
134 let auth_state = auth::AuthState {
135 pool: pool.clone(),
136 limiter,
137 };
138
139 let mcp_routes = Router::new()
140 .nest_service("/mcp", mcp)
141 .layer(axum::middleware::from_fn_with_state(
145 auth_state,
146 auth::require_bearer,
147 ))
148 .layer(RequestBodyLimitLayer::new(opts.max_request_bytes))
153 .layer(axum::middleware::from_fn({
154 let limit = opts.max_request_bytes;
155 move |req, next| explain_payload_too_large(limit, req, next)
156 }));
157
158 let dashboard_state = dashboard::DashboardState {
159 pool: pool.clone(),
160 secret: std::sync::Arc::new(opts.dashboard_secret.clone()),
161 };
162 let dashboard_routes = Router::new()
163 .route("/dashboard", get(dashboard::render))
164 .route("/dashboard/login", post(dashboard::login))
165 .with_state(dashboard_state);
166
167 Router::new()
168 .route("/health", get(health))
169 .merge(dashboard_routes)
170 .merge(mcp_routes)
171 .layer(
175 TraceLayer::new_for_http().make_span_with(|req: &axum::http::Request<_>| {
176 tracing::info_span!(
177 "http",
178 method = %req.method(),
179 path = %req.uri().path(),
180 )
181 }),
182 )
183 .with_state(pool)
184}
185
186pub async fn run(pool: PgPool, opts: ServeOptions) -> anyhow::Result<()> {
187 let ct = CancellationToken::new();
188 let app = build_router(pool, &opts, ct.child_token());
189
190 let listener = tokio::net::TcpListener::bind(&opts.bind)
191 .await
192 .with_context(|| format!("failed to bind {}", opts.bind))?;
193 let addr = listener.local_addr()?;
194 tracing::info!(%addr, "ai-crew-sync listening; MCP endpoint at /mcp");
195
196 let shutdown = {
197 let ct = ct.clone();
198 async move {
199 let ctrl_c = async {
200 tokio::signal::ctrl_c().await.ok();
201 };
202 #[cfg(unix)]
203 let term = async {
204 if let Ok(mut s) =
205 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
206 {
207 s.recv().await;
208 }
209 };
210 #[cfg(not(unix))]
211 let term = std::future::pending::<()>();
212
213 tokio::select! {
214 _ = ctrl_c => {},
215 _ = term => {},
216 }
217 tracing::info!("shutdown signal received");
218 ct.cancel();
219 }
220 };
221
222 axum::serve(listener, app)
223 .with_graceful_shutdown(shutdown)
224 .await
225 .context("server error")?;
226 Ok(())
227}