Skip to main content

systemprompt_api/services/middleware/context/middleware/
flavours.rs

1//! Route-specific context middleware flavours.
2//!
3//! Each flavour decides how a `RequestContext` is established for a class of
4//! route: [`PublicContextMiddleware`] admits anonymous traffic,
5//! [`UserOnlyContextMiddleware`] requires a real user from headers,
6//! [`A2AContextMiddleware`] recovers the context id from the JSON-RPC body, and
7//! [`McpContextMiddleware`] falls back to the session context so the MCP proxy
8//! can issue an OAuth challenge.
9
10use std::sync::Arc;
11
12use axum::extract::Request;
13use axum::middleware::Next;
14use axum::response::Response;
15use systemprompt_identifiers::{AgentName, ContextId};
16use systemprompt_models::execution::context::RequestContext;
17use systemprompt_security::HeaderExtractor;
18use tracing::Instrument;
19
20use super::super::extractors::ContextExtractor;
21use super::error::log_error_response;
22use super::support::{DynExtractor, create_request_span, session_context_required_error};
23
24/// Public route flavour: admits `UserType::Anon`.
25///
26/// Forwards the session-derived [`RequestContext`] minted by
27/// `POST /oauth/session`, merging optional `x-context-id` / `x-agent-name`
28/// headers on top. Never touches the request body, and never invokes the
29/// extractor — the public gate has nothing to extract from anonymous traffic.
30#[derive(Clone, Copy, Debug, Default)]
31pub struct PublicContextMiddleware;
32
33impl PublicContextMiddleware {
34    #[must_use]
35    pub const fn new() -> Self {
36        Self
37    }
38
39    pub async fn handle(&self, mut request: Request, next: Next) -> Response {
40        let Some(mut req_ctx) = request.extensions().get::<RequestContext>().cloned() else {
41            let trace_id = HeaderExtractor::extract_trace_id(request.headers());
42            let path = request.uri().path().to_owned();
43            let method = request.method().to_string();
44            return session_context_required_error(&trace_id, &path, &method);
45        };
46
47        let headers = request.headers();
48        if let Some(context_id) = headers.get("x-context-id")
49            && let Ok(id) = context_id.to_str()
50        {
51            req_ctx.execution.context_id = ContextId::new(id.to_owned());
52        }
53
54        if let Some(agent_name) = headers.get("x-agent-name")
55            && let Ok(name) = agent_name.to_str()
56        {
57            req_ctx.execution.agent_name = AgentName::new(name.to_owned());
58        }
59
60        let span = create_request_span(&req_ctx);
61        request.extensions_mut().insert(req_ctx);
62        next.run(request).instrument(span).await
63    }
64}
65
66/// Authenticated-headers flavour. Requires a real user from request headers
67/// and rejects the request on extraction failure. Use this for any route
68/// whose handler may not run anonymously.
69#[derive(Clone)]
70pub struct UserOnlyContextMiddleware {
71    extractor: DynExtractor,
72}
73
74impl std::fmt::Debug for UserOnlyContextMiddleware {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("UserOnlyContextMiddleware").finish()
77    }
78}
79
80impl UserOnlyContextMiddleware {
81    pub fn new<E>(extractor: E) -> Self
82    where
83        E: ContextExtractor + Send + Sync + 'static,
84    {
85        Self {
86            extractor: Arc::new(extractor),
87        }
88    }
89
90    pub async fn handle(&self, mut request: Request, next: Next) -> Response {
91        let trace_id = HeaderExtractor::extract_trace_id(request.headers());
92        let path = request.uri().path().to_owned();
93        let method = request.method().to_string();
94
95        match self.extractor.extract_from_headers(request.headers()).await {
96            Ok(context) => {
97                let span = create_request_span(&context);
98                request.extensions_mut().insert(context);
99                next.run(request).instrument(span).await
100            },
101            Err(e) => log_error_response(&e, &trace_id, &path, &method),
102        }
103    }
104}
105
106/// A2A flavour: requires a real user.
107///
108/// Parses the JSON-RPC body to recover `contextId` (the A2A wire spec carries
109/// it in the body, not headers). The body is read and rebuilt so downstream
110/// handlers can deserialise it again.
111#[derive(Clone)]
112pub struct A2AContextMiddleware {
113    extractor: DynExtractor,
114}
115
116impl std::fmt::Debug for A2AContextMiddleware {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("A2AContextMiddleware").finish()
119    }
120}
121
122impl A2AContextMiddleware {
123    pub fn new<E>(extractor: E) -> Self
124    where
125        E: ContextExtractor + Send + Sync + 'static,
126    {
127        Self {
128            extractor: Arc::new(extractor),
129        }
130    }
131
132    pub async fn handle(&self, request: Request, next: Next) -> Response {
133        let trace_id = HeaderExtractor::extract_trace_id(request.headers());
134        let path = request.uri().path().to_owned();
135        let method = request.method().to_string();
136
137        match self.extractor.extract_from_request(request).await {
138            Ok((context, reconstructed_request)) => {
139                let span = create_request_span(&context);
140                let mut req = reconstructed_request;
141                req.extensions_mut().insert(context);
142                next.run(req).instrument(span).await
143            },
144            Err(e) => log_error_response(&e, &trace_id, &path, &method),
145        }
146    }
147}
148
149/// MCP flavour: headers-only extraction with session fallback.
150///
151/// Extracts a real user from headers when an `Authorization` header is present;
152/// otherwise forwards the session-derived [`RequestContext`] (Anon) so the
153/// downstream MCP proxy handler can emit an RFC 9728 `WWW-Authenticate` 401
154/// challenge to start the OAuth dance.
155///
156/// The session-context fallback is load-bearing: MCP clients (Cowork,
157/// Claude Code, etc.) only begin OAuth discovery on a 401 carrying the
158/// challenge — collapsing this to a 4xx-without-challenge breaks them. See
159/// `crates/tests/integration/api/routes_mcp_unauth_challenge.rs`.
160#[derive(Clone)]
161pub struct McpContextMiddleware {
162    extractor: DynExtractor,
163}
164
165impl std::fmt::Debug for McpContextMiddleware {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("McpContextMiddleware").finish()
168    }
169}
170
171impl McpContextMiddleware {
172    pub fn new<E>(extractor: E) -> Self
173    where
174        E: ContextExtractor + Send + Sync + 'static,
175    {
176        Self {
177            extractor: Arc::new(extractor),
178        }
179    }
180
181    pub async fn handle(&self, request: Request, next: Next) -> Response {
182        let trace_id = HeaderExtractor::extract_trace_id(request.headers());
183        let path = request.uri().path().to_owned();
184        let method = request.method().to_string();
185
186        match self.extractor.extract_from_headers(request.headers()).await {
187            Ok(context) => {
188                let span = create_request_span(&context);
189                let mut req = request;
190                req.extensions_mut().insert(context);
191                next.run(req).instrument(span).await
192            },
193            Err(e) => {
194                if let Some(ctx) = request.extensions().get::<RequestContext>().cloned() {
195                    tracing::debug!(
196                        error = %e,
197                        trace_id = %trace_id,
198                        "MCP header extraction failed, using session context"
199                    );
200                    let span = create_request_span(&ctx);
201                    next.run(request).instrument(span).await
202                } else {
203                    session_context_required_error(&trace_id, &path, &method)
204                }
205            },
206        }
207    }
208}