1#![allow(dead_code)]
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::utils::http::get_user_agent;
10
11use super::fetch_telemetry::{
12 PluginFetchOutcome, PluginFetchSource, classify_fetch_error, log_plugin_fetch,
13};
14
15pub struct McpbLoadResult {
17 pub manifest: McpbManifest,
18 pub mcp_config: HashMap<String, McpServerConfig>,
19 pub extracted_path: String,
20 pub content_hash: String,
21}
22
23pub struct McpbNeedsConfigResult {
25 pub manifest: McpbManifest,
26 pub extracted_path: String,
27 pub content_hash: String,
28 pub config_schema: UserConfigSchema,
29 pub existing_config: UserConfigValues,
30 pub validation_errors: Vec<String>,
31}
32
33#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct McpbManifest {
36 pub name: String,
37 pub version: Option<String>,
38 pub description: Option<String>,
39 #[serde(rename = "user_config")]
40 pub user_config: Option<UserConfigSchema>,
41}
42
43pub type UserConfigSchema = HashMap<String, McpbUserConfigurationOption>;
45
46#[derive(Serialize, Deserialize, Debug, Clone)]
47pub struct McpbUserConfigurationOption {
48 #[serde(rename = "type")]
49 pub option_type: String,
50 pub title: String,
51 pub description: String,
52 pub required: Option<bool>,
53 pub default: Option<serde_json::Value>,
54 pub multiple: Option<bool>,
55 pub sensitive: Option<bool>,
56 pub min: Option<f64>,
57 pub max: Option<f64>,
58}
59
60pub type UserConfigValues = HashMap<String, serde_json::Value>;
62
63#[derive(Serialize, Deserialize, Debug, Clone)]
65pub struct McpServerConfig {
66 #[serde(rename = "type")]
67 pub server_type: Option<String>,
68 pub command: Option<String>,
69 pub args: Option<Vec<String>>,
70 pub env: Option<HashMap<String, String>>,
71 pub url: Option<String>,
72 pub headers: Option<HashMap<String, String>>,
73}
74
75#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
77pub struct McpbCacheMetadata {
78 pub source: String,
79 pub content_hash: String,
80 pub extracted_path: String,
81 pub cached_at: String,
82 pub last_checked: String,
83}
84
85pub fn is_mcpb_source(source: &str) -> bool {
87 source.ends_with(".mcpb") || source.ends_with(".dxt")
88}
89
90fn is_url(source: &str) -> bool {
92 source.starts_with("http://") || source.starts_with("https://")
93}
94
95fn generate_content_hash(data: &[u8]) -> String {
97 use sha2::Digest;
98 let mut hasher = sha2::Sha256::new();
99 hasher.update(data);
100 let result = hasher.finalize();
101 hex::encode(&result[..8])
102}
103
104fn get_mcpb_cache_dir(plugin_path: &Path) -> PathBuf {
106 plugin_path.join(".mcpb-cache")
107}
108
109fn get_metadata_path(cache_dir: &Path, source: &str) -> PathBuf {
111 use sha2::Digest;
112 let mut hasher = sha2::Sha256::new();
114 hasher.update(source.as_bytes());
115 let result = hasher.finalize();
116 let source_hash = hex::encode(&result[..4]);
117 cache_dir.join(format!("{}.metadata.json", source_hash))
118}
119
120pub fn validate_user_config(
122 values: &UserConfigValues,
123 schema: &UserConfigSchema,
124) -> (bool, Vec<String>) {
125 let mut errors = Vec::new();
126
127 for (key, field_schema) in schema {
128 let value = values.get(key);
129
130 if field_schema.required.unwrap_or(false)
132 && (value.is_none()
133 || value
134 .map(|v| v.as_str().map(|s| s.is_empty()).unwrap_or(false))
135 .unwrap_or(false))
136 {
137 errors.push(format!(
138 "{} is required but not provided",
139 field_schema.title
140 ));
141 continue;
142 }
143
144 if value.is_none() {
145 continue;
146 }
147
148 let value = value.unwrap();
149
150 match field_schema.option_type.as_str() {
152 "string" => {
153 if value.is_array() {
154 if !field_schema.multiple.unwrap_or(false) {
155 errors.push(format!(
156 "{} must be a string, not an array",
157 field_schema.title
158 ));
159 }
160 } else if !value.is_string() {
161 errors.push(format!("{} must be a string", field_schema.title));
162 }
163 }
164 "number" => {
165 if !value.is_number() {
166 errors.push(format!("{} must be a number", field_schema.title));
167 } else if let Some(n) = value.as_f64() {
168 if let Some(min) = field_schema.min {
169 if n < min {
170 errors.push(format!("{} must be at least {}", field_schema.title, min));
171 }
172 }
173 if let Some(max) = field_schema.max {
174 if n > max {
175 errors.push(format!("{} must be at most {}", field_schema.title, max));
176 }
177 }
178 }
179 }
180 "boolean" => {
181 if !value.is_boolean() {
182 errors.push(format!("{} must be a boolean", field_schema.title));
183 }
184 }
185 _ => {}
186 }
187 }
188
189 (errors.is_empty(), errors)
190}
191
192pub async fn check_mcpb_changed(source: &str, plugin_path: &Path) -> bool {
194 let cache_dir = get_mcpb_cache_dir(plugin_path);
195 let metadata_path = get_metadata_path(&cache_dir, source);
196
197 let metadata = match tokio::fs::read_to_string(&metadata_path).await {
199 Ok(content) => match serde_json::from_str::<McpbCacheMetadata>(&content) {
200 Ok(m) => m,
201 Err(_) => return true,
202 },
203 Err(_) => return true,
204 };
205
206 if !Path::new(&metadata.extracted_path).exists() {
208 log::debug!("MCPB extraction path missing: {}", metadata.extracted_path);
209 return true;
210 }
211
212 if !is_url(source) {
214 let local_path = plugin_path.join(source);
215 match tokio::fs::metadata(&local_path).await {
216 Ok(meta) => {
217 let cached_time = chrono::DateTime::parse_from_rfc3339(&metadata.cached_at)
218 .map(|dt| dt.timestamp_millis() as u64)
219 .unwrap_or(0);
220 let file_time = meta
221 .modified()
222 .ok()
223 .and_then(|t| {
224 t.duration_since(std::time::UNIX_EPOCH)
225 .ok()
226 .map(|d| d.as_millis() as u64)
227 })
228 .unwrap_or(0);
229
230 if file_time > cached_time {
231 log::debug!("MCPB file modified");
232 return true;
233 }
234 }
235 Err(_) => return true,
236 }
237 }
238
239 false
240}
241
242async fn download_mcpb(
244 url: &str,
245 dest_path: &Path,
246) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
247 log::debug!("Downloading MCPB from {}", url);
248
249 let started = std::time::Instant::now();
250 let client = reqwest::Client::builder()
251 .user_agent(get_user_agent())
252 .timeout(std::time::Duration::from_secs(120))
253 .build()?;
254
255 let response = client.get(url).send().await?;
256 let data = response.bytes().await?.to_vec();
257
258 log_plugin_fetch(
259 PluginFetchSource::Mcpb,
260 Some(url),
261 PluginFetchOutcome::Success,
262 started.elapsed().as_millis() as u64,
263 None,
264 );
265
266 tokio::fs::write(dest_path, &data).await?;
267 log::debug!("Downloaded {} bytes to {:?}", data.len(), dest_path);
268
269 Ok(data)
270}
271
272fn extract_mcpb_zip(
274 data: &[u8],
275 target_dir: &Path,
276) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
277 let cursor = std::io::Cursor::new(data);
278 let mut archive = zip::ZipArchive::new(cursor)?;
279
280 for i in 0..archive.len() {
281 let mut file = archive.by_index(i)?;
282 let out_path = target_dir.join(file.mangled_name());
283
284 if file.is_dir() {
285 std::fs::create_dir_all(&out_path)?;
286 } else {
287 if let Some(parent) = out_path.parent() {
288 std::fs::create_dir_all(parent)?;
289 }
290 let mut outfile = std::fs::File::create(&out_path)?;
291 std::io::copy(&mut file, &mut outfile)?;
292 }
293 }
294 Ok(())
295}
296
297async fn load_cached_mcpb(
299 source: &str,
300 plugin_path: &Path,
301) -> Result<McpbLoadResult, Box<dyn std::error::Error + Send + Sync>> {
302 let cache_dir = get_mcpb_cache_dir(plugin_path);
303 let metadata_path = get_metadata_path(&cache_dir, source);
304
305 let metadata: McpbCacheMetadata =
306 serde_json::from_str(&tokio::fs::read_to_string(&metadata_path).await?)?;
307 let extracted_path = Path::new(&metadata.extracted_path);
308
309 let manifest_path = extracted_path.join("manifest.json");
311 let manifest: McpbManifest =
312 serde_json::from_str(&tokio::fs::read_to_string(&manifest_path).await?).unwrap_or(
313 McpbManifest {
314 name: metadata
315 .source
316 .rsplit('/')
317 .next()
318 .unwrap_or("unknown")
319 .to_string(),
320 version: None,
321 description: None,
322 user_config: None,
323 },
324 );
325
326 let mcp_config_path = extracted_path.join("mcp-config.json");
328 let mcp_config: HashMap<String, McpServerConfig> = if mcp_config_path.exists() {
329 serde_json::from_str(&tokio::fs::read_to_string(&mcp_config_path).await?)?
330 } else {
331 HashMap::new()
332 };
333
334 Ok(McpbLoadResult {
335 manifest,
336 mcp_config,
337 extracted_path: metadata.extracted_path,
338 content_hash: metadata.content_hash,
339 })
340}
341
342async fn save_mcpb_cache(
344 source: &str,
345 plugin_path: &Path,
346 extracted_path: &str,
347 content_hash: &str,
348) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
349 let cache_dir = get_mcpb_cache_dir(plugin_path);
350 let metadata = McpbCacheMetadata {
351 source: source.to_string(),
352 content_hash: content_hash.to_string(),
353 extracted_path: extracted_path.to_string(),
354 cached_at: chrono::Utc::now().to_rfc3339(),
355 last_checked: chrono::Utc::now().to_rfc3339(),
356 };
357 let metadata_path = get_metadata_path(&cache_dir, source);
358 tokio::fs::write(
359 &metadata_path,
360 serde_json::to_string_pretty(&metadata)?,
361 )
362 .await?;
363 Ok(())
364}
365
366pub async fn load_mcpb_file(
368 source: &str,
369 plugin_path: &Path,
370 _plugin_id: &str,
371) -> Result<Result<McpbLoadResult, McpbNeedsConfigResult>, Box<dyn std::error::Error + Send + Sync>>
372{
373 let cache_dir = get_mcpb_cache_dir(plugin_path);
374 tokio::fs::create_dir_all(&cache_dir).await?;
375
376 log::debug!("Loading MCPB from source: {}", source);
377
378 if !check_mcpb_changed(source, plugin_path).await {
380 if let Ok(cached) = load_cached_mcpb(source, plugin_path).await {
382 return Ok(Ok(cached));
383 }
384 log::debug!("Failed to load cached MCPB for {}", source);
386 }
387
388 let data = if is_url(source) {
390 let dest_path = cache_dir.join("download.mcpb");
391 download_mcpb(source, &dest_path).await?
392 } else {
393 tokio::fs::read(plugin_path.join(source)).await?
394 };
395
396 let content_hash = generate_content_hash(&data);
398 let extracted_path = cache_dir.join(&content_hash);
399 tokio::fs::create_dir_all(&extracted_path).await?;
400
401 match extract_mcpb_zip(&data, &extracted_path) {
403 Ok(()) => {
404 let manifest_path = extracted_path.join("manifest.json");
406 let manifest: McpbManifest =
407 serde_json::from_str(&tokio::fs::read_to_string(&manifest_path).await.unwrap_or_default()).unwrap_or(McpbManifest {
408 name: source.rsplit('/').next().unwrap_or("unknown").to_string(),
409 version: None,
410 description: None,
411 user_config: None,
412 });
413
414 let mcp_config_path = extracted_path.join("mcp-config.json");
416 let mcp_config: HashMap<String, McpServerConfig> = if mcp_config_path.exists() {
417 serde_json::from_str(&tokio::fs::read_to_string(&mcp_config_path).await.unwrap_or_default()).unwrap_or_default()
418 } else {
419 HashMap::new()
420 };
421
422 let mut manifest = manifest;
424
425 if let Some(schema) = &manifest.user_config {
426 let user_config_path = extracted_path.join("user-config.json");
428 let existing_config: UserConfigValues = if user_config_path.exists() {
429 serde_json::from_str(&tokio::fs::read_to_string(&user_config_path).await.unwrap_or_default()).unwrap_or_default()
430 } else {
431 HashMap::new()
432 };
433
434 let (valid, errors) = validate_user_config(&existing_config, schema);
435 if !valid {
436 let config_schema = manifest.user_config.take().unwrap();
437 return Ok(Err(McpbNeedsConfigResult {
438 manifest,
439 extracted_path: extracted_path.to_string_lossy().to_string(),
440 content_hash,
441 config_schema,
442 existing_config,
443 validation_errors: errors,
444 }));
445 }
446 }
447
448 let extracted_str = extracted_path.to_string_lossy().to_string();
450 let _ = save_mcpb_cache(source, plugin_path, &extracted_str, &content_hash).await;
451
452 Ok(Ok(McpbLoadResult {
453 manifest,
454 mcp_config,
455 extracted_path: extracted_str,
456 content_hash,
457 }))
458 }
459 Err(e) => {
460 log::warn!("Failed to extract MCPB ZIP for {}: {}", source, e);
461 Ok(Ok(McpbLoadResult {
463 manifest: McpbManifest {
464 name: source.rsplit('/').next().unwrap_or("unknown").to_string(),
465 version: None,
466 description: None,
467 user_config: None,
468 },
469 mcp_config: HashMap::new(),
470 extracted_path: extracted_path.to_string_lossy().to_string(),
471 content_hash,
472 }))
473 }
474 }
475}