| 1 | //! Reviewed external MCP imports. Existing runtime bearer auth and body limits |
| 2 | //! apply; preview/apply never spawn a process, connect, or echo credential values. |
| 3 | use super::{ApiError, RuntimeApiState, mcp_expected_revision}; |
| 4 | use crate::mcp::external_import::{ |
| 5 | ImportContext, ImportDecision, ImportPreview, ImportReceipt, apply_reviewed_import, |
| 6 | preview_imports, |
| 7 | }; |
| 8 | use axum::{ |
| 9 | Json, |
| 10 | extract::State, |
| 11 | http::{HeaderMap, StatusCode}, |
| 12 | }; |
| 13 | use serde::Deserialize; |
| 14 | |
| 15 | fn import_error(error: anyhow::Error) -> ApiError { |
| 16 | ApiError { |
| 17 | status: if error.is::<crate::mcp::McpRevisionConflict>() { |
| 18 | StatusCode::PRECONDITION_FAILED |
| 19 | } else { |
| 20 | StatusCode::CONFLICT |
| 21 | }, |
| 22 | message: error.to_string(), |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | pub(super) async fn preview( |
| 27 | State(state): State<RuntimeApiState>, |
| 28 | ) -> Result<Json<ImportPreview>, ApiError> { |
| 29 | #[cfg(test)] |
| 30 | let env_ticket = crate::test_support::env_scope_ticket(); |
| 31 | tokio::task::spawn_blocking(move || { |
| 32 | #[cfg(test)] |
| 33 | let _membership = crate::test_support::join_env_scope(env_ticket); |
| 34 | let path = state.config.read().mcp_config_path(); |
| 35 | let plugins = state |
| 36 | .plugin_discovery |
| 37 | .registry_for_workspace(&state.workspace); |
| 38 | let context = |
| 39 | ImportContext::new(&state.workspace, &path, plugins.as_ref()).map_err(import_error)?; |
| 40 | preview_imports(&context).map(Json).map_err(import_error) |
| 41 | }) |
| 42 | .await |
| 43 | .map_err(|_| ApiError::internal("MCP import preview failed"))? |
| 44 | } |
| 45 | |
| 46 | #[derive(Deserialize)] |
| 47 | #[serde(deny_unknown_fields)] |
| 48 | pub(super) struct ApplyRequest { |
| 49 | id: String, |
| 50 | content_hash: String, |
| 51 | decision: ImportDecision, |
| 52 | } |
| 53 | |
| 54 | impl ApplyRequest { |
| 55 | fn validate(&self) -> Result<(), ApiError> { |
| 56 | if ![&self.id, &self.content_hash] |
| 57 | .iter() |
| 58 | .all(|value| value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())) |
| 59 | || self.decision == ImportDecision::Skip |
| 60 | { |
| 61 | return Err(ApiError::bad_request( |
| 62 | "Choose a reviewed candidate and approve or decline", |
| 63 | )); |
| 64 | } |
| 65 | Ok(()) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | pub(super) async fn apply( |
| 70 | State(state): State<RuntimeApiState>, |
| 71 | headers: HeaderMap, |
| 72 | Json(request): Json<ApplyRequest>, |
| 73 | ) -> Result<Json<ImportReceipt>, ApiError> { |
| 74 | let expected = mcp_expected_revision(&headers)?; |
| 75 | request.validate()?; |
| 76 | #[cfg(test)] |
| 77 | let env_ticket = crate::test_support::env_scope_ticket(); |
| 78 | tokio::task::spawn_blocking(move || { |
| 79 | #[cfg(test)] |
| 80 | let _membership = crate::test_support::join_env_scope(env_ticket); |
| 81 | let path = state.config.read().mcp_config_path(); |
| 82 | let plugins = state |
| 83 | .plugin_discovery |
| 84 | .registry_for_workspace(&state.workspace); |
| 85 | let context = |
| 86 | ImportContext::new(&state.workspace, &path, plugins.as_ref()).map_err(import_error)?; |
| 87 | apply_reviewed_import( |
| 88 | &context, |
| 89 | &request.id, |
| 90 | &request.content_hash, |
| 91 | &expected, |
| 92 | request.decision, |
| 93 | ) |
| 94 | .map(Json) |
| 95 | .map_err(import_error) |
| 96 | }) |
| 97 | .await |
| 98 | .map_err(|_| ApiError::internal("MCP import failed"))? |
| 99 | } |
| 100 | |
| 101 | #[cfg(test)] |
| 102 | mod tests { |
| 103 | use super::*; |
| 104 | |
| 105 | #[test] |
| 106 | fn reviewed_import_request_requires_closed_shape_exact_identity_and_decision() { |
| 107 | let request = serde_json::json!({"id": "a".repeat(64), "content_hash": "b".repeat(64), "decision": "approve"}); |
| 108 | assert!( |
| 109 | serde_json::from_value::<ApplyRequest>(request.clone()) |
| 110 | .unwrap() |
| 111 | .validate() |
| 112 | .is_ok() |
| 113 | ); |
| 114 | for (key, value) in [("id", "x"), ("content_hash", "z"), ("decision", "skip")] { |
| 115 | let mut invalid = request.clone(); |
| 116 | invalid[key] = value.into(); |
| 117 | assert_eq!( |
| 118 | serde_json::from_value::<ApplyRequest>(invalid) |
| 119 | .unwrap() |
| 120 | .validate() |
| 121 | .unwrap_err() |
| 122 | .status, |
| 123 | StatusCode::BAD_REQUEST |
| 124 | ); |
| 125 | } |
| 126 | let mut unknown = request; |
| 127 | unknown["path"] = "/arbitrary/source".into(); |
| 128 | assert!(serde_json::from_value::<ApplyRequest>(unknown).is_err()); |
| 129 | } |
| 130 | |
| 131 | #[test] |
| 132 | fn reviewed_import_requires_concrete_configuration_revision() { |
| 133 | let mut headers = HeaderMap::new(); |
| 134 | assert_eq!( |
| 135 | mcp_expected_revision(&headers).unwrap_err().status, |
| 136 | StatusCode::PRECONDITION_REQUIRED |
| 137 | ); |
| 138 | for invalid in ["*", "W/\"mcp-v1-absent\"", "mcp-v1-invalid"] { |
| 139 | headers.insert(axum::http::header::IF_MATCH, invalid.parse().unwrap()); |
| 140 | assert_eq!( |
| 141 | mcp_expected_revision(&headers).unwrap_err().status, |
| 142 | StatusCode::BAD_REQUEST |
| 143 | ); |
| 144 | } |
| 145 | headers.insert( |
| 146 | axum::http::header::IF_MATCH, |
| 147 | "\"mcp-v1-absent\"".parse().unwrap(), |
| 148 | ); |
| 149 | assert_eq!(mcp_expected_revision(&headers).unwrap(), "mcp-v1-absent"); |
| 150 | } |
| 151 | } |
| 152 |