Skip to main content

systemprompt_api/services/proxy/engine/
external.rs

1//! MCP-over-HTTP forwarding for external servers.
2//!
3//! An external MCP server has no local backend port; instead the gateway mints
4//! a per-user provider bearer server-side and forwards the MCP frames to the
5//! provider endpoint, withholding the systemprompt credential and the provider
6//! URL from the client. A client-mediated `tools/call` is audited under the
7//! calling user via the response tap.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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}