systemprompt_api/services/proxy/engine/
external.rs1use std::collections::HashMap;
13
14use axum::body::Body;
15use axum::extract::Request;
16use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
17use axum::response::{IntoResponse, Response};
18use systemprompt_identifiers::{McpServerId, ServiceName};
19use systemprompt_mcp::services::client::McpClient;
20use systemprompt_mcp::{McpDomainError, McpServerConfig};
21use systemprompt_models::RequestContext;
22use systemprompt_runtime::AppContext;
23
24use super::super::audit::{self, AuditSinks, McpAudit, parse_tool_call};
25use super::super::auth::{AccessValidator, mcp_oauth_requirement};
26use super::super::backend::{ProxyError, RequestBuilder, ResponseHandler};
27use super::ProxyEngine;
28
29const MCP_PASSTHROUGH_HEADERS: [&str; 4] = [
30 "content-type",
31 "accept",
32 "mcp-session-id",
33 "mcp-protocol-version",
34];
35
36async fn authorised_context(
40 ctx: &AppContext,
41 service_name: &ServiceName,
42 headers: &HeaderMap,
43 req_ctx: Option<RequestContext>,
44) -> Result<RequestContext, ProxyError> {
45 let req_ctx = req_ctx.ok_or_else(|| ProxyError::MissingContext {
46 message: "external MCP proxy requires an authenticated request context".to_owned(),
47 })?;
48 let requirement = mcp_oauth_requirement(ctx, service_name).await?;
49 AccessValidator::validate_with_requirement(
50 headers,
51 service_name,
52 &requirement,
53 ctx,
54 Some(&req_ctx),
55 )?;
56 Ok(req_ctx)
57}
58
59impl ProxyEngine {
60 pub(super) async fn proxy_external_mcp(
61 &self,
62 service_name: &ServiceName,
63 request: Request<Body>,
64 ctx: AppContext,
65 server_config: McpServerConfig,
66 ) -> Result<Response<Body>, ProxyError> {
67 let req_ctx = authorised_context(
68 &ctx,
69 service_name,
70 request.headers(),
71 request.extensions().get::<RequestContext>().cloned(),
72 )
73 .await?;
74
75 let target = McpClient::resolve_external_proxy_target(&server_config, &req_ctx)
76 .await
77 .map_err(|e| map_resolve_error(service_name, e))?;
78
79 let method_str = request.method().to_string();
80 let incoming_headers = request.headers().clone();
81 let sessions = super::external_sessions::SessionGuard::new(
82 &self.identities,
83 service_name,
84 &req_ctx,
85 &target.headers,
86 );
87 if let Some(session) = incoming_headers.get("mcp-session-id")
88 && !sessions.accepts(session).await?
89 {
90 return Ok((
91 StatusCode::NOT_FOUND,
92 "MCP session expired; initialize again",
93 )
94 .into_response());
95 }
96 let mut body = RequestBuilder::extract_body(request.into_body())
97 .await
98 .map_err(|source| ProxyError::BodyExtractionFailed { source })?;
99 super::fixed_arguments::apply(&server_config.tools, &mut body);
100
101 super::external_governance::enforce(&ctx, &req_ctx, service_name, &body).await?;
102 let sinks = self.intent_claims.clone().map(|intent_claims| AuditSinks {
103 intent_claims,
104 ingest: self.artifact_ingest.clone(),
105 background: ctx.background_tasks().clone(),
106 });
107 let audit = build_audit(sinks, &req_ctx, service_name, &body);
108 let outbound = outbound_headers(&incoming_headers, target.headers);
109
110 let method = RequestBuilder::parse_method(&method_str)?;
111 let client = self.client_pool.get_default_client();
112 let response = RequestBuilder::build_request(&client, method, &target.url, &outbound, body)
113 .send()
114 .await
115 .map_err(|source| ProxyError::ConnectionFailed {
116 service: service_name.to_string(),
117 url: target.url.clone(),
118 source,
119 })?;
120
121 if method_str == "DELETE" {
122 if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
123 sessions.forget(&incoming_headers).await?;
124 }
125 } else if response.status().is_success() {
126 sessions.remember(response.headers()).await?;
127 }
128 let to_invalid = |source| ProxyError::InvalidResponse {
129 service: service_name.to_string(),
130 source,
131 };
132 match audit {
133 Some(audit) => audit::record(response, audit).await.map_err(to_invalid),
134 None => ResponseHandler::build_response(response).map_err(to_invalid),
135 }
136 }
137}
138
139pub fn outbound_headers<S: std::hash::BuildHasher>(
140 incoming: &HeaderMap,
141 provider: HashMap<HeaderName, HeaderValue, S>,
142) -> HeaderMap {
143 let mut headers = HeaderMap::new();
144 for name in MCP_PASSTHROUGH_HEADERS {
145 if let Some(value) = incoming.get(name) {
146 headers.insert(HeaderName::from_static(name), value.clone());
147 }
148 }
149 for (name, value) in provider {
150 headers.insert(name, value);
151 }
152 headers
153}
154
155fn build_audit(
156 sinks: Option<AuditSinks>,
157 req_ctx: &RequestContext,
158 service_name: &ServiceName,
159 body: &[u8],
160) -> Option<McpAudit> {
161 let invocation = parse_tool_call(body)?;
162 let Some(sinks) = sinks else {
163 tracing::warn!(service = %service_name, "Tool-usage repository unavailable; external MCP call not audited");
164 return None;
165 };
166 Some(McpAudit::new(
167 sinks,
168 req_ctx.clone(),
169 McpServerId::new(service_name.as_str()),
170 invocation,
171 ))
172}
173
174pub fn map_resolve_error(service_name: &ServiceName, error: McpDomainError) -> ProxyError {
175 match error {
176 McpDomainError::AuthRequired(_) => ProxyError::AuthenticationRequired {
177 service: service_name.to_string(),
178 },
179 McpDomainError::ExternalAccountNotConnected { .. } => ProxyError::ProviderNotConnected {
180 service: service_name.to_string(),
181 },
182 McpDomainError::ExternalAuthUnavailable { message, .. } => ProxyError::ServiceNotRunning {
183 service: service_name.to_string(),
184 status: message,
185 },
186 other => ProxyError::ExternalResolveFailed {
187 service: service_name.to_string(),
188 source: other,
189 },
190 }
191}