Skip to main content

atd_server_http/
server.rs

1//! `Server` — the HTTP listener and accept loop.
2//!
3//! SP-streamable-http §4.1 + §4.5: the listener exposes a single `POST
4//! /mcp` route by default, but `ServerBuilder::build` returns the
5//! `axum::Router` so adopters can extend it with their own routes
6//! (Celia's `/chat/stream`, healthkit's bulk-export progress, etc.)
7//! before serving. The bound `Server` carries the shutdown handle and
8//! the `local_addr` so tests can dial in.
9
10use std::net::SocketAddr;
11use std::sync::Arc;
12
13use atd_runtime::dispatch::ServerState;
14use atd_runtime::registry::Registry;
15use atd_runtime::secrets::BearerIdentity;
16use axum::Json;
17use axum::extract::{DefaultBodyLimit, State};
18use axum::http::{HeaderMap, StatusCode};
19use axum::response::IntoResponse;
20use axum::routing::post;
21use axum::{Router, serve};
22use serde_json::{Value, json};
23use tokio::net::TcpListener;
24use tokio::sync::oneshot;
25
26use crate::bearer::{BearerOutcome, resolve_bearer};
27use crate::config::HttpServerConfig;
28use crate::error::HttpServerError;
29use crate::mcp::{
30    JsonRpcRequest, error_response, error_response_with_headers, handle_initialize,
31    handle_initialized_notification, handle_tools_call, handle_tools_list,
32};
33use crate::origin::origin_allowed;
34
35/// Per-request state shared with each axum handler. Holds the dispatch
36/// state, the operator policy fields the route handlers read directly
37/// (origin extras, bearer requirement) and the route-handler-local copy
38/// of the operator allow-list pulled out of the shared config so
39/// handlers don't have to chain `.config.shared.*` paths at every call
40/// site.
41#[derive(Clone)]
42pub(crate) struct HttpAppState {
43    pub state: Arc<ServerState>,
44    pub extra_origins: Arc<Vec<String>>,
45    pub require_bearer: bool,
46    pub server_version: Arc<String>,
47}
48
49pub struct ServerBuilder {
50    registry: Registry,
51    config: HttpServerConfig,
52    tier_policy: atd_runtime::TierPolicy,
53    middleware: Vec<Arc<dyn atd_runtime::Middleware>>,
54}
55
56impl ServerBuilder {
57    pub fn config(mut self, cfg: HttpServerConfig) -> Self {
58        self.config = cfg;
59        self
60    }
61
62    pub fn tier_policy(mut self, policy: atd_runtime::TierPolicy) -> Self {
63        self.tier_policy = policy;
64        self
65    }
66
67    pub fn middleware(mut self, middleware: Vec<Arc<dyn atd_runtime::Middleware>>) -> Self {
68        self.middleware = middleware;
69        self
70    }
71
72    /// Finalise the builder into a `(Router, Server)` pair. Adopters who
73    /// want to mount additional routes call `.route(...)` on the
74    /// returned `Router` before invoking `Server::serve`. The default
75    /// router only carries `POST /mcp` and its CORS preflight.
76    pub fn build(self) -> (Router, Server) {
77        let HttpServerConfig {
78            listen,
79            extra_origins,
80            require_bearer,
81            max_body_bytes,
82            shared,
83        } = self.config;
84
85        let server_version = Arc::new(shared.server_version.clone());
86        // SP-pagination-v1 — capture the cursor key before Arc::try_unwrap
87        // consumes `shared`. The issuer is built once per server instance
88        // so the session_nonce stays stable across paginated round-trips.
89        let cursor_signing_key = shared.cursor_signing_key;
90
91        // SP-streamable-http §4.3: HTTP and UDS share the *same*
92        // `ServerState` shape. The HTTP listener owns the `Arc` directly
93        // here; tests that drive UDS + HTTP in one process build one
94        // `ServerState` and pass clones to both transports.
95        let server_state = Arc::new(ServerState {
96            registry: self.registry,
97            config: Arc::try_unwrap(shared).unwrap_or_else(|arc| {
98                // If the `Arc` was already cloned elsewhere, fall back to a
99                // by-value copy via the public fields. SharedServerConfig
100                // does not impl Clone (audit_sink / token_broker are
101                // trait objects), so we rebuild it field by field.
102                atd_runtime::dispatch::SharedServerConfig {
103                    cwd: arc.cwd.clone(),
104                    max_output_bytes: arc.max_output_bytes,
105                    default_call_timeout_ms: arc.default_call_timeout_ms,
106                    granted_capabilities: arc.granted_capabilities.clone(),
107                    audit_sink: arc.audit_sink.clone(),
108                    server_version: arc.server_version.clone(),
109                    token_broker: arc.token_broker.clone(),
110                    max_ucan_chain_depth: arc.max_ucan_chain_depth,
111                    ucan_revocation_store: arc.ucan_revocation_store.clone(),
112                    frame_deadline_active_ms: arc.frame_deadline_active_ms,
113                    frame_deadline_handshake_ms: arc.frame_deadline_handshake_ms,
114                    cursor_signing_key: arc.cursor_signing_key,
115                    cursor_ttl_seconds: arc.cursor_ttl_seconds,
116                }
117            }),
118            tier_policy: self.tier_policy,
119            middleware: self.middleware,
120            metrics: Arc::new(atd_runtime::MetricsCounters::default()),
121            cursor_issuer: Arc::new(atd_runtime::cursor::CursorIssuer::new(cursor_signing_key)),
122        });
123
124        let app_state = HttpAppState {
125            state: server_state,
126            extra_origins: Arc::new(extra_origins),
127            require_bearer,
128            server_version,
129        };
130
131        let router = Router::new()
132            .route("/mcp", post(handle_mcp_post))
133            // Body-size cap — requests larger than this short-circuit
134            // with HTTP 413. SP-streamable-http §5.6.
135            .layer(DefaultBodyLimit::max(max_body_bytes))
136            .with_state(app_state);
137
138        let server = Server {
139            listen,
140            shutdown_tx: None,
141            local_addr: None,
142        };
143        (router, server)
144    }
145}
146
147pub struct Server {
148    listen: SocketAddr,
149    /// Sender side of the shutdown oneshot. `Some` between `serve` start
150    /// and shutdown; `None` before / after.
151    shutdown_tx: Option<oneshot::Sender<()>>,
152    local_addr: Option<SocketAddr>,
153}
154
155impl Server {
156    /// Begin a builder chain for an HTTP server backed by `registry`.
157    pub fn builder(registry: Registry) -> ServerBuilder {
158        ServerBuilder {
159            registry,
160            config: HttpServerConfig::default(),
161            tier_policy: atd_runtime::TierPolicy::defaults(),
162            middleware: Vec::new(),
163        }
164    }
165
166    /// Address the server is bound to. `None` until the listener has
167    /// successfully bound (i.e. after `serve` has set it). Tests use
168    /// this to discover the kernel-chosen port when `listen` was
169    /// `127.0.0.1:0`.
170    pub fn local_addr(&self) -> Option<SocketAddr> {
171        self.local_addr
172    }
173
174    /// Trigger a graceful shutdown. Returns `false` if the server was
175    /// never started or has already been signalled.
176    pub fn shutdown(&mut self) -> bool {
177        match self.shutdown_tx.take() {
178            Some(tx) => tx.send(()).is_ok(),
179            None => false,
180        }
181    }
182
183    /// Bind + serve. Returns when the listener stops accepting (typically
184    /// triggered by `Server::shutdown` or a fatal accept error).
185    pub async fn serve(mut self, router: Router) -> Result<(), HttpServerError> {
186        let listener =
187            TcpListener::bind(self.listen)
188                .await
189                .map_err(|source| HttpServerError::Bind {
190                    addr: self.listen,
191                    source,
192                })?;
193        let local = listener
194            .local_addr()
195            .map_err(|source| HttpServerError::Bind {
196                addr: self.listen,
197                source,
198            })?;
199        self.local_addr = Some(local);
200
201        let (tx, rx) = oneshot::channel::<()>();
202        self.shutdown_tx = Some(tx);
203
204        serve(listener, router)
205            .with_graceful_shutdown(async move {
206                let _ = rx.await;
207            })
208            .await?;
209        Ok(())
210    }
211
212    /// Bind only — returns the bound `TcpListener` + the resolved local
213    /// address. Tests use this to learn the kernel-chosen port before
214    /// driving `serve` on a separate task.
215    pub async fn bind(&mut self) -> Result<TcpListener, HttpServerError> {
216        let listener =
217            TcpListener::bind(self.listen)
218                .await
219                .map_err(|source| HttpServerError::Bind {
220                    addr: self.listen,
221                    source,
222                })?;
223        let local = listener
224            .local_addr()
225            .map_err(|source| HttpServerError::Bind {
226                addr: self.listen,
227                source,
228            })?;
229        self.local_addr = Some(local);
230        Ok(listener)
231    }
232
233    /// Serve on a pre-bound listener (paired with `bind`). Useful for
234    /// tests that need the local address before the serve future starts.
235    pub async fn serve_with_listener(
236        mut self,
237        listener: TcpListener,
238        router: Router,
239    ) -> Result<(), HttpServerError> {
240        let (tx, rx) = oneshot::channel::<()>();
241        self.shutdown_tx = Some(tx);
242        serve(listener, router)
243            .with_graceful_shutdown(async move {
244                let _ = rx.await;
245            })
246            .await?;
247        Ok(())
248    }
249}
250
251/// `POST /mcp` axum handler. Implements the three-step pipeline:
252/// origin gate → bearer resolution → method dispatch. SP-streamable-http
253/// §4.6 / §4.4 / §4.2.
254async fn handle_mcp_post(
255    State(app): State<HttpAppState>,
256    headers: HeaderMap,
257    body: Result<Json<JsonRpcRequest>, axum::extract::rejection::JsonRejection>,
258) -> axum::response::Response {
259    // Step 0: parse the JSON-RPC body. axum returns a typed rejection
260    // for malformed JSON / wrong content-type / body too large; we
261    // surface those as JSON-RPC invalid-request / parse errors per
262    // SP-streamable-http §5.6.
263    let Json(req) = match body {
264        Ok(j) => j,
265        Err(rej) => {
266            // Body-size rejection → 413 + -32600; everything else → 400
267            // + -32700 (parse) for malformed JSON.
268            let (status, code, msg) = body_rejection_status(&rej);
269            return error_response(status, None, code, msg);
270        }
271    };
272
273    // Step 1: Origin gate.
274    if !origin_allowed(&headers, &app.extra_origins) {
275        return error_response(StatusCode::FORBIDDEN, req.id, -32001, "origin not allowed");
276    }
277
278    // Step 2: Bearer resolution.
279    // SP-token-broker-phase2 §4.4: per-outcome HTTP status (401/400/500/501/503)
280    // + headers (WWW-Authenticate, Retry-After) come from BearerOutcome's
281    // accessor methods. The JSON-RPC envelope's `code` stays at -32002
282    // (auth) for all non-admitted outcomes; the HTTP-layer distinctions
283    // are the load-bearing signal for adopter clients.
284    let outcome = resolve_bearer(
285        &headers,
286        app.state.config.token_broker.as_ref(),
287        app.require_bearer,
288    )
289    .await;
290
291    let identity: Option<BearerIdentity> = match outcome {
292        BearerOutcome::Anonymous => None,
293        BearerOutcome::Validated(id) => Some(id),
294        rejection => {
295            let status = rejection.http_status();
296            let message = rejection
297                .rejection_message()
298                .unwrap_or_else(|| "bearer auth failed".into());
299            let mut headers: Vec<(&str, String)> = Vec::new();
300            if let Some(www) = rejection.www_authenticate() {
301                headers.push(("WWW-Authenticate", www.to_string()));
302            }
303            if let Some(retry) = rejection.retry_after() {
304                headers.push(("Retry-After", retry.to_string()));
305            }
306            return error_response_with_headers(status, req.id, -32002, message, &headers);
307        }
308    };
309
310    // Step 3: Method dispatch.
311    match req.method.as_str() {
312        "initialize" => handle_initialize(
313            req.id,
314            &app.server_version,
315            app.state.config.token_broker.as_ref(),
316        ),
317        "notifications/initialized" => handle_initialized_notification(req.id),
318        "tools/list" => handle_tools_list(req.id, &app.state),
319        "tools/call" => handle_tools_call(req.id, &app.state, identity.as_ref(), req.params).await,
320        other => error_response(
321            StatusCode::OK,
322            req.id,
323            -32601,
324            format!("method not found: {other}"),
325        ),
326    }
327}
328
329/// Map axum's `JsonRejection` into `(status, jsonrpc_code, message)`.
330fn body_rejection_status(
331    rej: &axum::extract::rejection::JsonRejection,
332) -> (StatusCode, i32, String) {
333    use axum::extract::rejection::JsonRejection;
334    match rej {
335        JsonRejection::JsonDataError(e) => (StatusCode::BAD_REQUEST, -32600, e.to_string()),
336        JsonRejection::JsonSyntaxError(e) => (StatusCode::BAD_REQUEST, -32700, e.to_string()),
337        JsonRejection::MissingJsonContentType(_) => (
338            StatusCode::UNSUPPORTED_MEDIA_TYPE,
339            -32600,
340            "expected application/json".into(),
341        ),
342        JsonRejection::BytesRejection(_) => (
343            StatusCode::PAYLOAD_TOO_LARGE,
344            -32600,
345            "body too large".into(),
346        ),
347        other => (
348            StatusCode::BAD_REQUEST,
349            -32600,
350            format!("invalid request body: {other}"),
351        ),
352    }
353}
354
355#[allow(dead_code)]
356fn body_too_large_response(id: Option<Value>) -> axum::response::Response {
357    (
358        StatusCode::PAYLOAD_TOO_LARGE,
359        Json(json!({
360            "jsonrpc": "2.0",
361            "id": id,
362            "error": {"code": -32600, "message": "body too large"},
363        })),
364    )
365        .into_response()
366}