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