返回 CodeWhale
mcp_import.rs
根目录 / crates / tui / src / runtime_api / mcp_import.rs
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
152 lines RUST