use std::{ sync::Arc, time::{Duration, Instant}, }; use axum::{ Json, Router, extract::{Path, State}, http::{ HeaderMap, HeaderValue, StatusCode, header::{self, ACCEPT}, }, response::{IntoResponse, Response}, routing::get, }; use crank_core::{ InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, }; use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool}; use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ catalog::PublishedToolCatalog, jsonrpc::{ CURRENT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION, is_notification, is_request, is_response, jsonrpc_error, jsonrpc_result, method_name, negotiated_protocol_version, params, request_id, }, session::{SessionState, SessionStore}, }; const HEADER_MCP_SESSION_ID: &str = "MCP-Session-Id"; const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; #[derive(Clone)] pub struct AppState { registry: PostgresRegistry, catalog: PublishedToolCatalog, runtime: RuntimeExecutor, sessions: SessionStore, allowed_origins: AllowedOrigins, } #[derive(Clone)] pub struct AllowedOrigins { public_origin: Option, } #[derive(Debug, Serialize, Deserialize)] struct InitializeParams { #[serde(rename = "protocolVersion")] protocol_version: String, } #[derive(Debug, Serialize, Deserialize)] struct ToolCallParams { name: String, #[serde(default)] arguments: Value, } #[derive(Clone, Debug, Deserialize)] struct AgentRoutePath { workspace_slug: String, agent_slug: String, } pub fn build_app( registry: PostgresRegistry, refresh_interval: Duration, public_base_url: Option, ) -> Router { let state = Arc::new(AppState { registry: registry.clone(), catalog: PublishedToolCatalog::new(registry, refresh_interval), runtime: RuntimeExecutor::new(), sessions: SessionStore::new(), allowed_origins: AllowedOrigins::new(public_base_url), }); Router::new() .route("/health", get(health)) .route( "/v1/{workspace_slug}/{agent_slug}", get(mcp_get).post(mcp_post).delete(mcp_delete), ) .with_state(state) } async fn health() -> Json { Json(json!({ "service": "mcp-server", "status": "ok" })) } async fn mcp_get(Path(_path): Path) -> Response { StatusCode::METHOD_NOT_ALLOWED.into_response() } async fn mcp_delete( Path(path): Path, State(state): State>, headers: HeaderMap, ) -> Response { match session_id_from_headers(&headers) { Ok(Some(session_id)) => match state.sessions.get(&session_id).await { Some(session) if session.workspace_slug == path.workspace_slug && session.agent_slug == path.agent_slug => { if state.sessions.delete(&session_id).await { StatusCode::NO_CONTENT.into_response() } else { StatusCode::NOT_FOUND.into_response() } } Some(_) => StatusCode::NOT_FOUND.into_response(), None => StatusCode::NOT_FOUND.into_response(), }, Ok(None) => StatusCode::BAD_REQUEST.into_response(), Err(status) => status.into_response(), } } async fn mcp_post( Path(path): Path, State(state): State>, headers: HeaderMap, Json(message): Json, ) -> Response { if let Err(status) = validate_origin(&state.allowed_origins, &headers) { return status.into_response(); } if let Err(status) = validate_accept_header(&headers) { return status.into_response(); } if is_response(&message) || is_notification(&message) && method_name(&message).is_none() { return StatusCode::ACCEPTED.into_response(); } let protocol_version = match protocol_version_from_headers(&headers) { Ok(value) => value, Err(status) => return status.into_response(), }; match method_name(&message) { Some("initialize") if is_request(&message) => { handle_initialize(state, &path, &message).await } Some("notifications/initialized") if is_notification(&message) => { handle_initialized_notification(state, &path, &headers).await } Some("ping") if is_request(&message) => { let session = match require_initialized_session(&state, &path, &headers, &message).await { Ok(session) => session, Err(response) => return response, }; json_response( StatusCode::OK, jsonrpc_result( request_id(&message), json!({ "protocolVersion": session.protocol_version }), ), None, Some(&protocol_version), ) } Some("tools/list") if is_request(&message) => { let session = match require_initialized_session(&state, &path, &headers, &message).await { Ok(session) => session, Err(response) => return response, }; match state .catalog .list_tools(&session.workspace_slug, &session.agent_slug) .await { Ok(tools) => { let definitions = tools.iter().map(tool_definition).collect::>(); json_response( StatusCode::OK, jsonrpc_result(request_id(&message), json!({ "tools": definitions })), None, Some(&session.protocol_version), ) } Err(error) => internal_jsonrpc_error(&message, error), } } Some("tools/call") if is_request(&message) => { let session = match require_initialized_session(&state, &path, &headers, &message).await { Ok(session) => session, Err(response) => return response, }; let tool_call_params: ToolCallParams = match serde_json::from_value(params(&message)) { Ok(value) => value, Err(error) => { return json_response( StatusCode::OK, jsonrpc_error(request_id(&message), -32602, error.to_string()), None, Some(&session.protocol_version), ); } }; let arguments = if tool_call_params.arguments.is_null() { json!({}) } else { tool_call_params.arguments }; match state .catalog .get_tool( &session.workspace_slug, &session.agent_slug, &tool_call_params.name, ) .await { Ok(Some(tool)) => { let runtime_operation = runtime_operation(&tool); let request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments); let started_at = Instant::now(); match state.runtime.execute(&runtime_operation, &arguments).await { Ok(output) => json_response( StatusCode::OK, { let _ = persist_invocation( &state, &tool, InvocationStatus::Ok, InvocationLevel::Info, "agent tool call completed", None, None, started_at.elapsed(), request_preview, output.clone(), ) .await; jsonrpc_result( request_id(&message), json!({ "content": [ { "type": "text", "text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned()) } ], "structuredContent": output, "isError": false }), ) }, None, Some(&session.protocol_version), ), Err(error) => { let _ = persist_invocation( &state, &tool, InvocationStatus::Error, InvocationLevel::Error, &error.to_string(), None, Some(runtime_error_code(&error)), started_at.elapsed(), request_preview, Value::Null, ) .await; json_response( StatusCode::OK, jsonrpc_result( request_id(&message), json!({ "content": [ { "type": "text", "text": error.to_string() } ], "structuredContent": { "error": { "code": runtime_error_code(&error), "message": error.to_string() } }, "isError": true }), ), None, Some(&session.protocol_version), ) } } } Ok(None) => json_response( StatusCode::OK, jsonrpc_error( request_id(&message), -32602, format!("tool {} was not found", tool_call_params.name), ), None, Some(&session.protocol_version), ), Err(error) => internal_jsonrpc_error(&message, error), } } Some(method) if is_notification(&message) => { let _ = method; StatusCode::ACCEPTED.into_response() } Some(method) => json_response( StatusCode::OK, jsonrpc_error( request_id(&message), -32601, format!("method {method} is not supported"), ), None, Some(&protocol_version), ), None => json_response( StatusCode::BAD_REQUEST, jsonrpc_error(Value::Null, -32600, "invalid JSON-RPC message"), None, Some(&protocol_version), ), } } async fn handle_initialize( state: Arc, path: &AgentRoutePath, message: &Value, ) -> Response { let initialize_params: InitializeParams = match serde_json::from_value(params(message)) { Ok(value) => value, Err(error) => { return json_response( StatusCode::OK, jsonrpc_error(request_id(message), -32602, error.to_string()), None, Some(DEFAULT_PROTOCOL_VERSION), ); } }; let Some(protocol_version) = negotiated_protocol_version(&initialize_params.protocol_version) else { return json_response( StatusCode::OK, jsonrpc_error( request_id(message), -32602, format!( "unsupported protocol version {}", initialize_params.protocol_version ), ), None, Some(DEFAULT_PROTOCOL_VERSION), ); }; let session_id = state .sessions .create(protocol_version, &path.workspace_slug, &path.agent_slug) .await; json_response( StatusCode::OK, jsonrpc_result( request_id(message), json!({ "protocolVersion": protocol_version, "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "crank-mcp-server", "version": env!("CARGO_PKG_VERSION") } }), ), Some(&session_id), Some(protocol_version), ) } async fn handle_initialized_notification( state: Arc, path: &AgentRoutePath, headers: &HeaderMap, ) -> Response { match session_id_from_headers(headers) { Ok(Some(session_id)) => match state.sessions.get(&session_id).await { Some(session) if session.workspace_slug == path.workspace_slug && session.agent_slug == path.agent_slug => { if state.sessions.mark_initialized(&session_id).await { StatusCode::ACCEPTED.into_response() } else { StatusCode::NOT_FOUND.into_response() } } Some(_) => StatusCode::NOT_FOUND.into_response(), None => StatusCode::NOT_FOUND.into_response(), }, Ok(None) => StatusCode::BAD_REQUEST.into_response(), Err(status) => status.into_response(), } } async fn require_initialized_session( state: &Arc, path: &AgentRoutePath, headers: &HeaderMap, message: &Value, ) -> Result { let session_id = match session_id_from_headers(headers) { Ok(Some(session_id)) => session_id, Ok(None) => return Err(StatusCode::BAD_REQUEST.into_response()), Err(status) => return Err(status.into_response()), }; let Some(session) = state.sessions.get(&session_id).await else { return Err(StatusCode::NOT_FOUND.into_response()); }; if session.workspace_slug != path.workspace_slug || session.agent_slug != path.agent_slug { return Err(StatusCode::NOT_FOUND.into_response()); } if !session.initialized { return Err(json_response( StatusCode::OK, jsonrpc_error(request_id(message), -32002, "session is not initialized"), None, Some(&session.protocol_version), )); } Ok(session) } fn validate_origin( allowed_origins: &AllowedOrigins, headers: &HeaderMap, ) -> Result<(), StatusCode> { let Some(origin) = headers.get(header::ORIGIN) else { return Ok(()); }; let Ok(origin) = origin.to_str() else { return Err(StatusCode::FORBIDDEN); }; if allowed_origins.is_allowed(origin) { return Ok(()); } Err(StatusCode::FORBIDDEN) } fn validate_accept_header(headers: &HeaderMap) -> Result<(), StatusCode> { let Some(accept) = headers.get(ACCEPT) else { return Err(StatusCode::BAD_REQUEST); }; let Ok(accept) = accept.to_str() else { return Err(StatusCode::BAD_REQUEST); }; let normalized = accept.to_ascii_lowercase(); if normalized.contains("application/json") && normalized.contains("text/event-stream") { return Ok(()); } Err(StatusCode::NOT_ACCEPTABLE) } fn protocol_version_from_headers(headers: &HeaderMap) -> Result { let Some(version) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { return Ok(DEFAULT_PROTOCOL_VERSION.to_owned()); }; let Ok(version) = version.to_str() else { return Err(StatusCode::BAD_REQUEST); }; if negotiated_protocol_version(version).is_some() { return Ok(version.to_owned()); } Err(StatusCode::BAD_REQUEST) } fn session_id_from_headers(headers: &HeaderMap) -> Result, StatusCode> { let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) else { return Ok(None); }; let Ok(session_id) = session_id.to_str() else { return Err(StatusCode::BAD_REQUEST); }; Ok(Some(session_id.to_owned())) } fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Response { json_response( StatusCode::INTERNAL_SERVER_ERROR, jsonrpc_error(request_id(message), -32603, error.to_string()), None, Some(DEFAULT_PROTOCOL_VERSION), ) } fn build_request_preview( runtime: &RuntimeExecutor, operation: &RuntimeOperation, arguments: &Value, ) -> Value { match runtime.prepare_request(operation, arguments) { Ok(prepared) => json!({ "path": prepared.path_params, "query": prepared.query_params, "headers": prepared.headers, "grpc": prepared.grpc.unwrap_or(Value::Null), "variables": prepared.variables.unwrap_or(Value::Null), "body": prepared.body.unwrap_or(Value::Null) }), Err(_) => Value::Null, } } async fn persist_invocation( state: &Arc, tool: &PublishedAgentTool, status: InvocationStatus, level: InvocationLevel, message: &str, status_code: Option, error_kind: Option<&str>, duration: Duration, request_preview: Value, response_preview: Value, ) -> Result<(), crank_registry::RegistryError> { let created_at = OffsetDateTime::now_utc() .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()); let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); let log = InvocationLog { id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())), workspace_id: tool.workspace_id.clone(), agent_id: Some(tool.agent_id.clone()), operation_id: tool.operation.id.clone(), source: InvocationSource::AgentToolCall, level, status, tool_name: tool.tool_name.clone(), message: message.to_owned(), request_id: None, status_code, duration_ms, error_kind: error_kind.map(ToOwned::to_owned), request_preview, response_preview, created_at, }; state .registry .create_invocation_log(CreateInvocationLogRequest { log: &log }) .await } fn runtime_error_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "schema_validation_error", RuntimeError::Mapping(_) => "mapping_error", RuntimeError::GraphqlAdapter(_) => "adapter_execution_error", RuntimeError::GrpcAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::InvalidPreparedRequest { .. } => "runtime_error", } } fn json_response( status: StatusCode, payload: Value, session_id: Option<&str>, protocol_version: Option<&str>, ) -> Response { let mut response = (status, Json(payload)).into_response(); if let Some(session_id) = session_id { response.headers_mut().insert( HEADER_MCP_SESSION_ID, HeaderValue::from_str(session_id) .unwrap_or_else(|_| HeaderValue::from_static("invalid")), ); } if let Some(protocol_version) = protocol_version { response.headers_mut().insert( HEADER_MCP_PROTOCOL_VERSION, HeaderValue::from_str(protocol_version) .unwrap_or_else(|_| HeaderValue::from_static(CURRENT_PROTOCOL_VERSION)), ); } response } fn tool_definition(tool: &PublishedAgentTool) -> Value { json!({ "name": tool.tool_name, "title": tool.tool_title, "description": tool.tool_description, "inputSchema": schema_to_json_schema(&tool.operation.input_schema) }) } fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { let mut operation = RuntimeOperation::from(tool.operation.clone()); operation.tool_name = tool.tool_name.clone(); operation.tool_description.title = tool.tool_title.clone(); operation.tool_description.description = tool.tool_description.clone(); operation } fn schema_to_json_schema(schema: &crank_schema::Schema) -> Value { match schema.kind { crank_schema::SchemaKind::Object => { let mut properties = serde_json::Map::new(); let mut required = Vec::new(); for (field_name, field_schema) in &schema.fields { properties.insert(field_name.clone(), schema_to_json_schema(field_schema)); if field_schema.required { required.push(Value::String(field_name.clone())); } } json!({ "type": "object", "properties": properties, "required": required }) } crank_schema::SchemaKind::Array => json!({ "type": "array", "items": schema .items .as_deref() .map(schema_to_json_schema) .unwrap_or_else(|| json!({})) }), crank_schema::SchemaKind::String => json!({ "type": "string" }), crank_schema::SchemaKind::Integer => json!({ "type": "integer" }), crank_schema::SchemaKind::Number => json!({ "type": "number" }), crank_schema::SchemaKind::Boolean => json!({ "type": "boolean" }), crank_schema::SchemaKind::Enum => json!({ "type": "string", "enum": schema.enum_values }), crank_schema::SchemaKind::Null => json!({ "type": "null" }), crank_schema::SchemaKind::Oneof => json!({ "anyOf": schema.variants.iter().map(schema_to_json_schema).collect::>() }), } } impl AllowedOrigins { fn new(public_base_url: Option) -> Self { Self { public_origin: public_base_url.and_then(|value| extract_origin(&value)), } } fn is_allowed(&self, origin: &str) -> bool { if origin.starts_with("http://localhost") || origin.starts_with("http://127.0.0.1") || origin.starts_with("https://localhost") || origin.starts_with("https://127.0.0.1") { return true; } match &self.public_origin { Some(public_origin) => public_origin == origin, None => false, } } } fn extract_origin(url: &str) -> Option { let mut parts = url.split('/'); let scheme = parts.next()?; let empty = parts.next()?; let authority = parts.next()?; if empty.is_empty() { return Some(format!("{scheme}//{authority}")); } None }