1use std::sync::Arc;
7
8use anyhow::Result;
9
10use soma_application::SomaApplication;
11use soma_config::{AuthMode, Config, McpConfig, TraceHeaderMode};
12#[cfg(feature = "protected-routes")]
13use soma_gateway::config::ProtectedMcpRouteConfig;
14use soma_gateway::{
15 config::{GatewayConfig, GatewayPaths, UpstreamConfig},
16 gateway::{config_store::FsGatewayConfigStore, manager::GatewayManager},
17};
18pub use soma_mcp_server::ResponsePageStore;
19
20pub type GatewayProductState = Arc<GatewayManager>;
21
22pub fn gateway_product_state_from_config(config: GatewayConfig) -> Result<GatewayProductState> {
23 Ok(Arc::new(GatewayManager::new(config)?))
24}
25
26pub fn gateway_product_state_from_env() -> Result<GatewayProductState> {
27 let paths = if std::env::var_os("MCP_GATEWAY_HOME").is_none() {
28 match std::env::var_os("SOMA_HOME") {
29 Some(home) => GatewayPaths::new(std::path::PathBuf::from(home).join(".mcp-gateway"))?,
30 None => GatewayPaths::from_env()?,
31 }
32 } else {
33 GatewayPaths::from_env()?
34 };
35 gateway_product_state_from_store(FsGatewayConfigStore::from_paths(paths))
36}
37
38pub fn gateway_product_state_from_store(
39 store: FsGatewayConfigStore,
40) -> Result<GatewayProductState> {
41 Ok(Arc::new(GatewayManager::from_store(store)?))
42}
43
44#[must_use]
45pub fn empty_gateway_product_state() -> GatewayProductState {
46 gateway_product_state_from_config(GatewayConfig::default())
47 .expect("empty gateway config should build")
48}
49
50#[derive(Clone)]
55pub enum AuthPolicy {
56 LoopbackDev,
59 TrustedGatewayUnscoped,
62 Mounted {
66 #[cfg(feature = "auth")]
67 auth_state: Option<Arc<soma_auth::state::AuthState>>,
68 },
69}
70
71impl std::fmt::Debug for AuthPolicy {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 AuthPolicy::LoopbackDev => f.write_str("AuthPolicy::LoopbackDev"),
75 AuthPolicy::TrustedGatewayUnscoped => f.write_str("AuthPolicy::TrustedGatewayUnscoped"),
76 #[cfg(feature = "auth")]
77 AuthPolicy::Mounted {
78 auth_state: Some(_),
79 } => f.write_str("AuthPolicy::Mounted { auth_state: Some(<AuthState>) }"),
80 #[cfg(feature = "auth")]
81 AuthPolicy::Mounted { auth_state: None } => {
82 f.write_str("AuthPolicy::Mounted { auth_state: None /* bearer-only */ }")
83 }
84 #[cfg(not(feature = "auth"))]
85 AuthPolicy::Mounted {} => f.write_str("AuthPolicy::Mounted"),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AuthPolicyKind {
92 LoopbackDev,
93 TrustedGatewayUnscoped,
94 MountedBearer,
95 MountedOAuth,
96}
97
98pub fn trusted_gateway_from_env() -> bool {
104 std::env::var("SOMA_NOAUTH")
105 .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
106 .unwrap_or(false)
107}
108
109pub fn resolve_auth_policy_kind(config: &Config, trusted_gateway: bool) -> Result<AuthPolicyKind> {
110 validate_public_url(config)?;
111 let kind = resolve_auth_policy_kind_unchecked(config, trusted_gateway)?;
112 validate_trace_headers_trust(&config.mcp, kind, trusted_gateway)?;
113 Ok(kind)
114}
115
116fn resolve_auth_policy_kind_unchecked(
117 config: &Config,
118 trusted_gateway: bool,
119) -> Result<AuthPolicyKind> {
120 if config.mcp.is_loopback() {
121 return Ok(AuthPolicyKind::LoopbackDev);
122 }
123
124 let has_token = config
125 .mcp
126 .api_token
127 .as_deref()
128 .map(|token| !token.is_empty())
129 .unwrap_or(false);
130 let has_oauth = config.mcp.auth.mode == AuthMode::OAuth;
131
132 if config.mcp.no_auth {
133 if trusted_gateway {
134 return Ok(AuthPolicyKind::TrustedGatewayUnscoped);
135 }
136 anyhow::bail!(
137 "Refusing to bind MCP server to {} with SOMA_MCP_NO_AUTH=true.\n\
138 \n\
139 SOMA_MCP_NO_AUTH is only allowed on loopback binds. For a trusted \
140 upstream proxy deployment, also set SOMA_NOAUTH=true.",
141 config.mcp.host
142 );
143 }
144
145 if has_oauth {
146 #[cfg(not(feature = "auth"))]
147 anyhow::bail!(
148 "SOMA_MCP_AUTH_MODE=oauth requires compiling with the `auth`/`oauth` feature"
149 );
150 #[cfg(feature = "auth")]
151 Ok(AuthPolicyKind::MountedOAuth)
152 } else if has_token {
153 Ok(AuthPolicyKind::MountedBearer)
154 } else if trusted_gateway {
155 Ok(AuthPolicyKind::TrustedGatewayUnscoped)
156 } else {
157 anyhow::bail!(
158 "Refusing to bind MCP server to {} without authentication.\n\
159 \n\
160 Choose one of:\n\
161 1. Bind to loopback: SOMA_MCP_HOST=127.0.0.1\n\
162 2. Set a bearer token: SOMA_MCP_TOKEN=$(openssl rand -hex 32)\n\
163 3. Enable OAuth: SOMA_MCP_AUTH_MODE=oauth (+ OAuth credentials)\n\
164 4. Local no-auth dev: SOMA_MCP_HOST=127.0.0.1 SOMA_MCP_NO_AUTH=true\n\
165 5. Upstream gateway: SOMA_NOAUTH=true (if a proxy handles auth)\n\
166 \n\
167 CUSTOMIZE: Replace SOMA_ prefix with your service's prefix throughout.",
168 config.mcp.host
169 );
170 }
171}
172
173fn validate_trace_headers_trust(
181 mcp: &McpConfig,
182 kind: AuthPolicyKind,
183 trusted_gateway: bool,
184) -> Result<()> {
185 if mcp.trace_headers == TraceHeaderMode::Off || trusted_gateway {
186 return Ok(());
187 }
188 match kind {
189 AuthPolicyKind::LoopbackDev | AuthPolicyKind::TrustedGatewayUnscoped => Ok(()),
190 AuthPolicyKind::MountedBearer | AuthPolicyKind::MountedOAuth => {
191 anyhow::bail!(
192 "Refusing to start with SOMA_MCP_TRACE_HEADERS={} on a {:?} deployment.\n\
193 \n\
194 Bearer/OAuth authentication is not a trace-header trust boundary — a caller \
195 presenting a valid token says nothing about whether an upstream gateway or \
196 proxy stripped or overwrote inbound traceparent/tracestate/baggage headers \
197 from untrusted clients before the request reached this server.\n\
198 \n\
199 Choose one of:\n\
200 1. Set SOMA_MCP_TRACE_HEADERS=off (default; disables HTTP trace-header extraction).\n\
201 2. Bind to loopback: SOMA_MCP_HOST=127.0.0.1\n\
202 3. Deploy behind a trusted proxy that strips/overwrites inbound trace headers \
203 from untrusted clients, and set SOMA_NOAUTH=true.",
204 mcp.trace_headers,
205 kind,
206 );
207 }
208 }
209}
210
211fn validate_public_url(config: &Config) -> Result<()> {
212 let Some(public_url) = config.mcp.auth.public_url.as_deref() else {
213 return Ok(());
214 };
215 let parsed = url::Url::parse(public_url)
216 .map_err(|error| anyhow::anyhow!("SOMA_MCP_PUBLIC_URL is invalid: {error}"))?;
217 let Some(host) = parsed.host_str() else {
218 anyhow::bail!("SOMA_MCP_PUBLIC_URL must include a host");
219 };
220 if host.contains('*') {
221 anyhow::bail!("SOMA_MCP_PUBLIC_URL must not contain wildcard hosts");
222 }
223 Ok(())
224}
225
226#[derive(Clone)]
228pub struct SomaRuntime {
229 application: Arc<SomaApplication>,
230 gateway: GatewayProductState,
231}
232
233impl SomaRuntime {
234 pub fn new(application: Arc<SomaApplication>, gateway: GatewayProductState) -> Self {
235 Self {
236 application,
237 gateway,
238 }
239 }
240
241 pub fn application(&self) -> &SomaApplication {
242 self.application.as_ref()
243 }
244
245 pub fn application_handle(&self) -> Arc<SomaApplication> {
246 self.application.clone()
247 }
248
249 #[cfg(feature = "protected-routes")]
250 pub fn resolve_protected_route(
251 &self,
252 host: &str,
253 path: &str,
254 ) -> Option<ProtectedMcpRouteConfig> {
255 self.gateway.resolve_protected_route(host, path)
256 }
257
258 #[cfg(feature = "protected-routes")]
259 pub fn resolve_protected_route_metadata(
260 &self,
261 host: &str,
262 path: &str,
263 ) -> Option<ProtectedMcpRouteConfig> {
264 self.gateway.resolve_protected_route_metadata(host, path)
265 }
266
267 #[cfg(feature = "protected-routes")]
268 pub fn protected_route_list(&self) -> Vec<ProtectedMcpRouteConfig> {
269 self.gateway.protected_route_list()
270 }
271
272 pub fn upstream_config(&self, name: &str) -> Option<UpstreamConfig> {
273 self.gateway.upstream_config(name)
274 }
275
276 #[cfg(feature = "oauth")]
277 pub async fn upstream_oauth_access_token(
278 &self,
279 upstream: &UpstreamConfig,
280 subject: &str,
281 ) -> Result<Option<String>, soma_gateway::gateway::manager::GatewayManagerError> {
282 self.gateway
283 .upstream_oauth_access_token(upstream, subject)
284 .await
285 }
286}
287
288#[derive(Clone)]
290pub struct AppState {
291 pub config: McpConfig,
292 pub auth_policy: AuthPolicy,
293 runtime: Arc<SomaRuntime>,
294 pub response_pages: ResponsePageStore,
295}
296
297impl AppState {
298 pub fn new(
299 config: McpConfig,
300 auth_policy: AuthPolicy,
301 runtime: Arc<SomaRuntime>,
302 response_pages: ResponsePageStore,
303 ) -> Self {
304 Self {
305 config,
306 auth_policy,
307 runtime,
308 response_pages,
309 }
310 }
311
312 pub fn runtime(&self) -> &SomaRuntime {
313 self.runtime.as_ref()
314 }
315
316 pub fn application(&self) -> &SomaApplication {
317 self.runtime.application()
318 }
319
320 pub fn application_handle(&self) -> Arc<SomaApplication> {
321 self.runtime.application_handle()
322 }
323
324 #[cfg(feature = "protected-routes")]
325 pub fn resolve_protected_route(
326 &self,
327 host: &str,
328 path: &str,
329 ) -> Option<ProtectedMcpRouteConfig> {
330 self.runtime.resolve_protected_route(host, path)
331 }
332
333 #[cfg(feature = "protected-routes")]
334 pub fn resolve_protected_route_metadata(
335 &self,
336 host: &str,
337 path: &str,
338 ) -> Option<ProtectedMcpRouteConfig> {
339 self.runtime.resolve_protected_route_metadata(host, path)
340 }
341
342 #[cfg(feature = "protected-routes")]
343 pub fn protected_route_list(&self) -> Vec<ProtectedMcpRouteConfig> {
344 self.runtime.protected_route_list()
345 }
346
347 pub fn upstream_config(&self, name: &str) -> Option<UpstreamConfig> {
348 self.runtime.upstream_config(name)
349 }
350
351 #[cfg(feature = "oauth")]
352 pub async fn upstream_oauth_access_token(
353 &self,
354 upstream: &UpstreamConfig,
355 subject: &str,
356 ) -> Result<Option<String>, soma_gateway::gateway::manager::GatewayManagerError> {
357 self.runtime
358 .upstream_oauth_access_token(upstream, subject)
359 .await
360 }
361}
362
363#[cfg(feature = "auth")]
372pub fn build_auth_layer(
373 policy: &AuthPolicy,
374 static_token: Option<Arc<str>>,
375 resource_url: Option<Arc<str>>,
376 static_token_write: bool,
377) -> Option<soma_auth::AuthLayer> {
378 match policy {
379 AuthPolicy::LoopbackDev | AuthPolicy::TrustedGatewayUnscoped => None,
380 AuthPolicy::Mounted { auth_state } => {
381 if static_token.is_none() && auth_state.is_none() {
382 tracing::warn!(
383 "auth layer mounted but no static_token or auth_state configured — \
384 all requests will be rejected; set SOMA_MCP_TOKEN or configure OAuth"
385 );
386 }
387 let mut static_token_scopes = vec![soma_domain::actions::READ_SCOPE.to_string()];
388 if static_token_write {
389 static_token_scopes.push(soma_domain::actions::WRITE_SCOPE.to_string());
390 }
391 Some(
392 soma_auth::AuthLayer::new()
393 .with_static_token(static_token)
394 .with_auth_state(auth_state.clone())
395 .with_static_token_scopes(static_token_scopes)
396 .with_resource_url(resource_url)
397 .with_allow_session_cookie(false),
398 )
399 }
400 }
401}
402
403#[cfg(not(feature = "auth"))]
404pub fn build_auth_layer(
405 _policy: &AuthPolicy,
406 _static_token: Option<Arc<str>>,
407 _resource_url: Option<Arc<str>>,
408 _static_token_write: bool,
409) -> Option<()> {
410 None
411}
412
413#[cfg(test)]
414#[path = "server_tests.rs"]
415mod tests;