Skip to main content

ai_agent/utils/plugins/
mcpb_handler.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/mcpbHandler.ts
2#![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
15/// Result of loading an MCPB file (success case).
16pub 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
23/// Result when MCPB needs user configuration.
24pub 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/// MCPB manifest structure.
34#[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
43/// User configuration schema from DXT manifest.
44pub 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
60/// User configuration values for MCPB.
61pub type UserConfigValues = HashMap<String, serde_json::Value>;
62
63/// MCP server configuration.
64#[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/// Metadata stored for each cached MCPB.
76#[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
85/// Check if a source string is an MCPB file reference.
86pub fn is_mcpb_source(source: &str) -> bool {
87    source.ends_with(".mcpb") || source.ends_with(".dxt")
88}
89
90/// Check if a source is a URL.
91fn is_url(source: &str) -> bool {
92    source.starts_with("http://") || source.starts_with("https://")
93}
94
95/// Generate content hash for an MCPB file.
96fn 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
104/// Get cache directory for MCPB files.
105fn get_mcpb_cache_dir(plugin_path: &Path) -> PathBuf {
106    plugin_path.join(".mcpb-cache")
107}
108
109/// Get metadata file path for cached MCPB.
110fn get_metadata_path(cache_dir: &Path, source: &str) -> PathBuf {
111    use sha2::Digest;
112    // Use sha2 instead of md5 since md5 crate is not available
113    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
120/// Validate user configuration values against DXT user_config schema.
121pub 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        // Check required fields
131        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        // Type validation
151        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
192/// Check if an MCPB source has changed and needs re-extraction.
193pub 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    // Load metadata
198    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    // Check if extraction directory still exists
207    if !Path::new(&metadata.extracted_path).exists() {
208        log::debug!("MCPB extraction path missing: {}", metadata.extracted_path);
209        return true;
210    }
211
212    // For local files, check mtime
213    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
242/// Download MCPB file from URL.
243async 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
272/// Extract an MCPB ZIP archive to the target directory.
273fn 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
297/// Load a cached MCPB from the extracted path.
298async 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    // Load manifest from extracted files
310    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    // Load MCP config from extracted files if present
327    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
342/// Save MCPB cache metadata.
343async 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
366/// Load and extract an MCPB file.
367pub 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    // Check cache first
379    if !check_mcpb_changed(source, plugin_path).await {
380        // Load cached manifest from extracted path
381        if let Ok(cached) = load_cached_mcpb(source, plugin_path).await {
382            return Ok(Ok(cached));
383        }
384        // Fall through to re-extract if cache load fails
385        log::debug!("Failed to load cached MCPB for {}", source);
386    }
387
388    // Download or read the MCPB file
389    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    // Extract ZIP contents
397    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    // Extract ZIP archive
402    match extract_mcpb_zip(&data, &extracted_path) {
403        Ok(()) => {
404            // Load manifest from extracted files
405            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            // Load MCP config from extracted files if present
415            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            // Check if user config is required
423            let mut manifest = manifest;
424
425            if let Some(schema) = &manifest.user_config {
426                // Load existing user config if available
427                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            // Save cache metadata
449            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            // Fallback: return a basic result so the plugin can still be used
462            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}