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_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
36// Why: takes the headers and the context rather than the request — a
37// `Body` is not `Sync`, so holding `&Request` across the await would make
38// the proxy future `!Send` and the router refuse it.
39async 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}