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_mcp::repository::ToolUsageRepository;
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, 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
36impl ProxyEngine {
37 pub(super) async fn proxy_external_mcp(
38 &self,
39 service_name: &str,
40 request: Request<Body>,
41 ctx: AppContext,
42 server_config: McpServerConfig,
43 ) -> Result<Response<Body>, ProxyError> {
44 let req_ctx = request
45 .extensions()
46 .get::<RequestContext>()
47 .cloned()
48 .ok_or_else(|| ProxyError::MissingContext {
49 message: "external MCP proxy requires an authenticated request context".to_owned(),
50 })?;
51
52 let requirement = mcp_oauth_requirement(&ctx, service_name).await?;
53 AccessValidator::validate_with_requirement(
54 request.headers(),
55 service_name,
56 &requirement,
57 &ctx,
58 Some(&req_ctx),
59 )?;
60
61 let target = McpClient::resolve_external_proxy_target(&server_config, &req_ctx)
62 .await
63 .map_err(|e| map_resolve_error(service_name, e))?;
64
65 let method_str = request.method().to_string();
66 let incoming_headers = request.headers().clone();
67 let sessions = super::external_sessions::SessionGuard::new(
68 &self.identities,
69 service_name,
70 &req_ctx,
71 &target.headers,
72 );
73 if let Some(session) = incoming_headers.get("mcp-session-id")
74 && !sessions.accepts(session).await?
75 {
76 return Ok((
77 StatusCode::NOT_FOUND,
78 "MCP session expired; initialize again",
79 )
80 .into_response());
81 }
82 let body = RequestBuilder::extract_body(request.into_body())
83 .await
84 .map_err(|source| ProxyError::BodyExtractionFailed { source })?;
85
86 super::external_governance::enforce(&ctx, &req_ctx, service_name, &body).await?;
87 let audit = build_audit(self.tool_usage_repo.as_ref(), &req_ctx, service_name, &body);
88 let outbound = outbound_headers(&incoming_headers, target.headers);
89
90 let method = RequestBuilder::parse_method(&method_str)
91 .map_err(|reason| ProxyError::InvalidMethod { reason })?;
92 let client = self.client_pool.get_default_client();
93 let response = RequestBuilder::build_request(&client, method, &target.url, &outbound, body)
94 .send()
95 .await
96 .map_err(|source| ProxyError::ConnectionFailed {
97 service: service_name.to_owned(),
98 url: target.url.clone(),
99 source,
100 })?;
101
102 if method_str == "DELETE" {
103 if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
104 sessions.forget(&incoming_headers).await?;
105 }
106 } else if response.status().is_success() {
107 sessions.remember(response.headers()).await?;
108 }
109 let to_invalid = |reason| ProxyError::InvalidResponse {
110 service: service_name.to_owned(),
111 reason,
112 };
113 match audit {
114 Some(audit) => audit::record(response, audit).await.map_err(to_invalid),
115 None => ResponseHandler::build_response(response).map_err(to_invalid),
116 }
117 }
118}
119
120pub fn outbound_headers<S: std::hash::BuildHasher>(
121 incoming: &HeaderMap,
122 provider: HashMap<HeaderName, HeaderValue, S>,
123) -> HeaderMap {
124 let mut headers = HeaderMap::new();
125 for name in MCP_PASSTHROUGH_HEADERS {
126 if let Some(value) = incoming.get(name) {
127 headers.insert(HeaderName::from_static(name), value.clone());
128 }
129 }
130 for (name, value) in provider {
131 headers.insert(name, value);
132 }
133 headers
134}
135
136fn build_audit(
137 repo: Option<&std::sync::Arc<ToolUsageRepository>>,
138 req_ctx: &RequestContext,
139 service_name: &str,
140 body: &[u8],
141) -> Option<McpAudit> {
142 let invocation = parse_tool_call(body)?;
143 let Some(repo) = repo else {
144 tracing::warn!(service = %service_name, "Tool-usage repository unavailable; external MCP call not audited");
145 return None;
146 };
147 Some(McpAudit::new(
148 std::sync::Arc::clone(repo),
149 req_ctx.clone(),
150 service_name.to_owned(),
151 invocation,
152 ))
153}
154
155pub fn map_resolve_error(service_name: &str, error: McpDomainError) -> ProxyError {
156 match error {
157 McpDomainError::AuthRequired(_) => ProxyError::AuthenticationRequired {
158 service: service_name.to_owned(),
159 },
160 McpDomainError::ExternalAuthUnavailable { message, .. } => ProxyError::ServiceNotRunning {
161 service: service_name.to_owned(),
162 status: message,
163 },
164 other => ProxyError::InvalidResponse {
165 service: service_name.to_owned(),
166 reason: other.to_string(),
167 },
168 }
169}