Skip to main content

systemprompt_api/routes/proxy/
mcp.rs

1//! MCP reverse-proxy routes and discovery metadata.
2//!
3//! Builds the router that forwards requests to managed MCP backends, serves the
4//! RFC 9728 protected-resource and authorization-server metadata per service,
5//! and exposes tool-execution lookups.
6
7use crate::services::proxy::ProxyEngine;
8use axum::extract::{Path, State};
9use axum::http::StatusCode;
10use axum::response::IntoResponse;
11use axum::routing::{any, get};
12use axum::{Json, Router};
13use serde::Serialize;
14use std::sync::Arc;
15use systemprompt_identifiers::McpExecutionId;
16use systemprompt_mcp::repository::ToolUsageRepository;
17use systemprompt_models::modules::ApiPaths;
18use systemprompt_models::{ApiError, Config};
19use systemprompt_oauth::{GrantType, PkceMethod, ResponseType, TokenAuthMethod};
20use systemprompt_runtime::{AppContext, ServiceCategory};
21use systemprompt_traits::McpRegistryProvider;
22
23#[derive(Debug, Serialize)]
24pub struct ToolExecutionResponse {
25    pub id: McpExecutionId,
26    pub tool_name: String,
27    pub server_name: String,
28    pub server_endpoint: String,
29    pub input: serde_json::Value,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub output: Option<serde_json::Value>,
32    pub status: String,
33}
34
35#[derive(Clone, Debug)]
36pub struct McpState {
37    pub ctx: AppContext,
38    pub repo: Arc<ToolUsageRepository>,
39}
40
41pub async fn handle_get_execution(
42    Path(execution_id): Path<String>,
43    State(state): State<McpState>,
44) -> impl IntoResponse {
45    tracing::info!(execution_id = %execution_id, "Fetching execution");
46
47    let execution_id_typed = McpExecutionId::new(&execution_id);
48    match state.repo.find_by_id(&execution_id_typed).await {
49        Ok(Some(execution)) => {
50            let server_endpoint = ApiPaths::mcp_server_endpoint(&execution.server_name);
51
52            let input = match serde_json::from_str(&execution.input) {
53                Ok(v) => v,
54                Err(e) => {
55                    tracing::error!(execution_id = %execution_id, error = %e, "Invalid input JSON");
56                    return ApiError::internal_error(format!("Invalid input JSON: {e}"))
57                        .into_response();
58                },
59            };
60
61            let response = ToolExecutionResponse {
62                id: execution.mcp_execution_id,
63                tool_name: execution.tool_name,
64                server_name: execution.server_name.clone(),
65                server_endpoint,
66                input,
67                output: execution.output.as_deref().and_then(|s| {
68                    serde_json::from_str(s)
69                        .map_err(|e| {
70                            tracing::warn!(
71                                execution_id = %execution_id,
72                                error = %e,
73                                "Failed to parse execution output JSON"
74                            );
75                            e
76                        })
77                        .ok()
78                }),
79                status: execution.status,
80            };
81
82            tracing::info!(execution_id = %execution_id, "Execution found");
83            Json(response).into_response()
84        },
85        Ok(None) => {
86            ApiError::not_found(format!("Execution not found: {execution_id}")).into_response()
87        },
88        Err(e) => {
89            tracing::error!(execution_id = %execution_id, error = %e, "Failed to get execution");
90            ApiError::internal_error(format!("Failed to get execution: {e}")).into_response()
91        },
92    }
93}
94
95#[derive(Debug, Serialize)]
96pub struct McpProtectedResourceMetadata {
97    pub resource: String,
98    pub authorization_servers: Vec<String>,
99    pub scopes_supported: Vec<String>,
100    pub bearer_methods_supported: Vec<String>,
101    pub resource_documentation: Option<String>,
102}
103
104#[derive(Debug, Serialize)]
105pub struct McpAuthorizationServerMetadata {
106    pub issuer: String,
107    pub authorization_endpoint: String,
108    pub token_endpoint: String,
109    pub registration_endpoint: Option<String>,
110    pub scopes_supported: Vec<String>,
111    pub response_types_supported: Vec<String>,
112    pub grant_types_supported: Vec<String>,
113    pub code_challenge_methods_supported: Vec<String>,
114    pub token_endpoint_auth_methods_supported: Vec<String>,
115    pub authorization_response_iss_parameter_supported: bool,
116}
117
118pub async fn handle_mcp_protected_resource(
119    State(state): State<McpState>,
120    Path(service_name): Path<String>,
121) -> impl IntoResponse {
122    let base_url = match Config::get() {
123        Ok(c) => c.api_external_url.clone(),
124        Err(e) => {
125            tracing::error!(error = %e, "Failed to get config");
126            return (
127                StatusCode::INTERNAL_SERVER_ERROR,
128                Json(serde_json::json!({"error": "Configuration unavailable"})),
129            )
130                .into_response();
131        },
132    };
133
134    let scopes = get_mcp_server_scopes(state.ctx.mcp_registry(), &service_name)
135        .await
136        .unwrap_or_else(|| vec!["user".to_owned()]);
137
138    let resource_url = format!("{}/api/v1/mcp/{}/mcp", base_url, service_name);
139
140    let metadata = McpProtectedResourceMetadata {
141        resource: resource_url,
142        authorization_servers: vec![base_url.clone()],
143        scopes_supported: scopes,
144        bearer_methods_supported: vec!["header".to_owned()],
145        resource_documentation: Some(base_url.clone()),
146    };
147
148    (StatusCode::OK, Json(metadata)).into_response()
149}
150
151pub async fn handle_mcp_authorization_server(
152    Path(_service_name): Path<String>,
153) -> impl IntoResponse {
154    let base_url = match Config::get() {
155        Ok(c) => c.api_external_url.clone(),
156        Err(e) => {
157            tracing::error!(error = %e, "Failed to get config");
158            return (
159                StatusCode::INTERNAL_SERVER_ERROR,
160                Json(serde_json::json!({"error": "Configuration unavailable"})),
161            )
162                .into_response();
163        },
164    };
165
166    let metadata = McpAuthorizationServerMetadata {
167        issuer: base_url.clone(),
168        authorization_endpoint: format!("{}/api/v1/core/oauth/authorize", base_url),
169        token_endpoint: format!("{}/api/v1/core/oauth/token", base_url),
170        registration_endpoint: Some(format!("{}/api/v1/core/oauth/register", base_url)),
171        scopes_supported: vec!["user".to_owned(), "admin".to_owned()],
172        response_types_supported: vec![ResponseType::Code.to_string()],
173        grant_types_supported: vec![
174            GrantType::AuthorizationCode.to_string(),
175            GrantType::RefreshToken.to_string(),
176        ],
177        code_challenge_methods_supported: vec![PkceMethod::S256.to_string()],
178        token_endpoint_auth_methods_supported: vec![
179            TokenAuthMethod::None.to_string(),
180            TokenAuthMethod::ClientSecretPost.to_string(),
181            TokenAuthMethod::ClientSecretBasic.to_string(),
182        ],
183        authorization_response_iss_parameter_supported: true,
184    };
185
186    (StatusCode::OK, Json(metadata)).into_response()
187}
188
189pub(in crate::routes) async fn get_mcp_server_scopes(
190    registry: &dyn McpRegistryProvider,
191    service_name: &str,
192) -> Option<Vec<String>> {
193    match registry.get_server(service_name).await {
194        Ok(server_info) if server_info.oauth.required => {
195            let scopes: Vec<String> = server_info
196                .oauth
197                .scopes
198                .iter()
199                .map(ToString::to_string)
200                .collect();
201            if scopes.is_empty() {
202                None
203            } else {
204                Some(scopes)
205            }
206        },
207        _ => None,
208    }
209}
210
211pub(in crate::routes) async fn get_mcp_server_scopes_from_resource(
212    registry: &dyn McpRegistryProvider,
213    resource_uri: &str,
214) -> Option<Vec<String>> {
215    let url = reqwest::Url::parse(resource_uri).ok()?;
216    let path = url.path();
217    let parts: Vec<&str> = path.split('/').collect();
218    if parts.len() < 6 || parts[1] != "api" || parts[3] != "mcp" || parts[5] != "mcp" {
219        return None;
220    }
221    let server_name = parts[4];
222    get_mcp_server_scopes(registry, server_name).await
223}
224
225pub fn router(ctx: &AppContext) -> Router {
226    let engine = ProxyEngine::new();
227
228    let repo = match ToolUsageRepository::new(ctx.db_pool()) {
229        Ok(r) => Arc::new(r),
230        Err(e) => {
231            tracing::error!(error = %e, "Failed to initialize MCP tool usage repository");
232            return Router::new();
233        },
234    };
235
236    let state = McpState {
237        ctx: ctx.clone(),
238        repo,
239    };
240
241    Router::new()
242        .route("/executions/{id}", get(handle_get_execution))
243        .route(
244            "/{service_name}/mcp/.well-known/oauth-protected-resource",
245            get(handle_mcp_protected_resource),
246        )
247        .route(
248            "/{service_name}/mcp/.well-known/oauth-authorization-server",
249            get(handle_mcp_authorization_server),
250        )
251        .route(
252            "/{service_name}/{*path}",
253            any({
254                let ctx_clone = ctx.clone();
255                move |Path((service_name, path)): Path<(String, String)>, request| {
256                    let engine = engine.clone();
257                    let ctx = ctx_clone.clone();
258                    async move {
259                        engine
260                            .handle_mcp_request_with_path(
261                                Path((service_name, path)),
262                                State(ctx),
263                                request,
264                            )
265                            .await
266                    }
267                }
268            }),
269        )
270        .with_state(state)
271}
272
273systemprompt_runtime::register_module_api!(
274    "mcp",
275    ServiceCategory::Mcp,
276    router,
277    true,
278    systemprompt_runtime::ModuleType::Proxy
279);