systemprompt_api/routes/proxy/
mcp.rs1use 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);