mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 15:03:58 +02:00
refactor: better claude integration
This commit is contained in:
@@ -195,6 +195,15 @@ async fn main() {
|
||||
)
|
||||
.arg(Arg::new("action").required(true).help("Action (start)")),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("mcp-bridge")
|
||||
.about("Bridge stdio MCP to a local HTTP MCP server")
|
||||
.arg(
|
||||
Arg::new("url")
|
||||
.required(true)
|
||||
.help("HTTP MCP server URL (e.g. http://127.0.0.1:51080/mcp/TOKEN)"),
|
||||
),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
|
||||
@@ -461,6 +470,78 @@ async fn main() {
|
||||
log::error!("Invalid action for vpn-worker. Use 'start'");
|
||||
process::exit(1);
|
||||
}
|
||||
} else if let Some(bridge_matches) = matches.subcommand_matches("mcp-bridge") {
|
||||
let url = bridge_matches
|
||||
.get_one::<String>("url")
|
||||
.expect("url is required")
|
||||
.clone();
|
||||
|
||||
// stdio↔HTTP MCP bridge: translates stdio JSON-RPC to Streamable HTTP transport
|
||||
let client = reqwest::Client::new();
|
||||
let stdin = tokio::io::stdin();
|
||||
let reader = tokio::io::BufReader::new(stdin);
|
||||
let mut session_id: Option<String> = None;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
let mut lines = reader.lines();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a notification (no "id" field) to handle 202 responses
|
||||
let is_notification = serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok()
|
||||
.map(|v| v.get("id").is_none() || v["id"].is_null())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json");
|
||||
|
||||
if let Some(sid) = &session_id {
|
||||
req = req.header("mcp-session-id", sid);
|
||||
}
|
||||
|
||||
match req.body(line).send().await {
|
||||
Ok(resp) => {
|
||||
// Capture session ID from initialize response
|
||||
if let Some(sid) = resp.headers().get("mcp-session-id") {
|
||||
if let Ok(s) = sid.to_str() {
|
||||
session_id = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications return 202 with no body — don't write anything
|
||||
if is_notification {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(body) = resp.text().await {
|
||||
if !body.is_empty() {
|
||||
let _ = stdout.write_all(body.as_bytes()).await;
|
||||
let _ = stdout.write_all(b"\n").await;
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if !is_notification {
|
||||
let err = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"error": {"code": -32000, "message": format!("HTTP error: {e}")},
|
||||
});
|
||||
let _ = stdout.write_all(err.to_string().as_bytes()).await;
|
||||
let _ = stdout.write_all(b"\n").await;
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("No command specified");
|
||||
process::exit(1);
|
||||
|
||||
+288
-51
@@ -47,6 +47,7 @@ mod commercial_license;
|
||||
mod cookie_manager;
|
||||
pub mod daemon;
|
||||
pub mod daemon_client;
|
||||
#[allow(dead_code)]
|
||||
mod daemon_spawn;
|
||||
pub mod daemon_ws;
|
||||
pub mod events;
|
||||
@@ -474,7 +475,6 @@ fn get_mcp_server_status() -> bool {
|
||||
struct McpConfig {
|
||||
port: u16,
|
||||
token: String,
|
||||
config_json: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -495,23 +495,283 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let config_json = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"donut-browser": {
|
||||
"url": format!("http://127.0.0.1:{}/mcp", port),
|
||||
"headers": {
|
||||
"Authorization": format!("Bearer {}", token)
|
||||
}
|
||||
Ok(Some(McpConfig { port, token }))
|
||||
}
|
||||
|
||||
fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir().map(|h| {
|
||||
h.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("APPDATA").ok().map(|appdata| {
|
||||
std::path::PathBuf::from(appdata)
|
||||
.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs::config_dir().map(|c| {
|
||||
c.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
|
||||
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
Ok(dir.join("manifest.json").exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
let server_dir = ext_dir.join("server");
|
||||
std::fs::create_dir_all(&server_dir)
|
||||
.map_err(|e| format!("Failed to create extension directory: {e}"))?;
|
||||
|
||||
let mcp_url = format!("http://127.0.0.1:{port}/mcp/{token}");
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"manifest_version": "0.3",
|
||||
"name": "donut-browser",
|
||||
"display_name": "Donut Browser",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"description": "Control Donut Browser profiles, proxies, and automation via MCP",
|
||||
"author": { "name": "Donut Browser" },
|
||||
"tools_generated": true,
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
});
|
||||
std::fs::write(
|
||||
ext_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write manifest: {e}"))?;
|
||||
|
||||
let bridge_js = format!(
|
||||
r#"#!/usr/bin/env node
|
||||
const http = require("http");
|
||||
const readline = require("readline");
|
||||
const MCP_URL = "{mcp_url}";
|
||||
let sid = null;
|
||||
function post(line) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const u = new URL(MCP_URL);
|
||||
const o = {{
|
||||
hostname: u.hostname, port: u.port, path: u.pathname, method: "POST",
|
||||
headers: {{ "Content-Type": "application/json", Accept: "application/json" }},
|
||||
}};
|
||||
if (sid) o.headers["mcp-session-id"] = sid;
|
||||
const r = http.request(o, (res) => {{
|
||||
const s = res.headers["mcp-session-id"];
|
||||
if (s) sid = s;
|
||||
let b = "";
|
||||
res.on("data", (c) => (b += c));
|
||||
res.on("end", () => resolve(b));
|
||||
}});
|
||||
r.on("error", reject);
|
||||
r.write(line);
|
||||
r.end();
|
||||
}});
|
||||
}}
|
||||
const rl = readline.createInterface({{ input: process.stdin, crlfDelay: Infinity }});
|
||||
rl.on("line", (line) => {{
|
||||
if (!line.trim()) return;
|
||||
let notif = false;
|
||||
try {{ notif = JSON.parse(line).id == null; }} catch {{}}
|
||||
post(line).then((b) => {{
|
||||
if (!notif && b.trim()) process.stdout.write(b.trim() + "\n");
|
||||
}}).catch((e) => {{
|
||||
if (!notif) process.stdout.write(JSON.stringify({{
|
||||
jsonrpc: "2.0", id: null, error: {{ code: -32000, message: "HTTP error: " + e.message }}
|
||||
}}) + "\n");
|
||||
}});
|
||||
}});
|
||||
rl.on("close", () => setTimeout(() => process.exit(0), 500));
|
||||
"#
|
||||
);
|
||||
std::fs::write(server_dir.join("index.js"), bridge_js)
|
||||
.map_err(|e| format!("Failed to write bridge script: {e}"))?;
|
||||
|
||||
// Update the extensions-installations.json registry so Claude Desktop picks it up
|
||||
update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", Some(manifest))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
|
||||
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
if ext_dir.exists() {
|
||||
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
|
||||
}
|
||||
update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_claude_extensions_registry(
|
||||
ext_id: &str,
|
||||
manifest: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let registry_path = claude_desktop_extension_dir()
|
||||
.ok_or("Unsupported platform")?
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("extensions-installations.json"))
|
||||
.ok_or("Failed to resolve registry path")?;
|
||||
|
||||
let mut registry: serde_json::Value = if registry_path.exists() {
|
||||
let content = std::fs::read_to_string(®istry_path)
|
||||
.map_err(|e| format!("Failed to read registry: {e}"))?;
|
||||
serde_json::from_str(&content).unwrap_or(serde_json::json!({"extensions": {}}))
|
||||
} else {
|
||||
serde_json::json!({"extensions": {}})
|
||||
};
|
||||
|
||||
if registry.get("extensions").is_none() {
|
||||
registry["extensions"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
match manifest {
|
||||
Some(m) => {
|
||||
registry["extensions"][ext_id] = serde_json::json!({
|
||||
"id": ext_id,
|
||||
"version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"),
|
||||
"hash": "",
|
||||
"installedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"manifest": m,
|
||||
"signatureInfo": { "status": "unsigned" },
|
||||
"source": "local"
|
||||
});
|
||||
}
|
||||
None => {
|
||||
if let Some(exts) = registry
|
||||
.get_mut("extensions")
|
||||
.and_then(|e| e.as_object_mut())
|
||||
{
|
||||
exts.remove(ext_id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
|
||||
Ok(Some(McpConfig {
|
||||
port,
|
||||
token,
|
||||
config_json,
|
||||
}))
|
||||
let output =
|
||||
serde_json::to_string(®istry).map_err(|e| format!("Failed to serialize registry: {e}"))?;
|
||||
let tmp = registry_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &output).map_err(|e| format!("Failed to write registry: {e}"))?;
|
||||
std::fs::rename(&tmp, ®istry_path).map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_claude_cli() -> Option<std::path::PathBuf> {
|
||||
let mut candidates: Vec<std::path::PathBuf> = vec![
|
||||
std::path::PathBuf::from("/usr/local/bin/claude"),
|
||||
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.insert(0, home.join(".local/bin/claude"));
|
||||
candidates.push(home.join(".claude/local/claude"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
candidates.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
|
||||
);
|
||||
}
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Some(p.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "list"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.contains("donut-browser"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
|
||||
|
||||
let _ = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output();
|
||||
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_code() -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -965,38 +1225,13 @@ pub fn run() {
|
||||
mgr.ensure_icons_extracted();
|
||||
}
|
||||
|
||||
// Start the daemon for tray icon
|
||||
if let Err(e) = daemon_spawn::ensure_daemon_running() {
|
||||
log::warn!("Failed to start daemon: {e}");
|
||||
}
|
||||
|
||||
// Register this GUI's PID in daemon state so the daemon can kill us directly
|
||||
daemon_spawn::register_gui_pid();
|
||||
|
||||
// Monitor daemon health - quit GUI if daemon dies
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give the daemon time to fully start
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let is_running = tokio::task::spawn_blocking(daemon_spawn::is_daemon_running)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_running {
|
||||
log::warn!("Daemon is no longer running, quitting GUI immediately");
|
||||
// Use process::exit for immediate termination. Tauri's exit()
|
||||
// triggers a slow graceful shutdown that can take over a minute
|
||||
// waiting for async tasks (sync, version updater, etc.) to finish.
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Daemon (tray icon) is currently disabled — clean up any existing autostart
|
||||
if daemon::autostart::is_autostart_enabled() {
|
||||
log::info!("Removing daemon autostart (daemon is disabled)");
|
||||
if let Err(e) = daemon::autostart::disable_autostart() {
|
||||
log::warn!("Failed to remove daemon autostart: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create the main window programmatically
|
||||
#[allow(unused_variables)]
|
||||
@@ -1403,12 +1638,8 @@ pub fn run() {
|
||||
if is_running {
|
||||
scheduler.mark_profile_running(&profile_id).await;
|
||||
} else {
|
||||
// Sync was queued at launch; mark_profile_stopped triggers it
|
||||
scheduler.mark_profile_stopped(&profile_id).await;
|
||||
// Queue sync after profile stops (if sync is enabled)
|
||||
if profile.is_sync_enabled() {
|
||||
log::info!("Profile '{}' stopped, queuing sync", profile.name);
|
||||
scheduler.queue_profile_sync(profile_id.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1679,6 +1910,12 @@ pub fn run() {
|
||||
stop_mcp_server,
|
||||
get_mcp_server_status,
|
||||
get_mcp_config,
|
||||
is_mcp_in_claude_desktop,
|
||||
add_mcp_to_claude_desktop,
|
||||
remove_mcp_from_claude_desktop,
|
||||
is_mcp_in_claude_code,
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
// VPN commands
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
|
||||
+39
-25
@@ -41,7 +41,7 @@ pub struct McpRequest {
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
const PROTOCOL_VERSION: &str = "2025-03-26";
|
||||
const PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const SERVER_NAME: &str = "donut-browser";
|
||||
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -202,7 +202,6 @@ impl McpServer {
|
||||
return Ok(preferred);
|
||||
}
|
||||
|
||||
// Try random ports in 51000-51999 range
|
||||
for _ in 0..10 {
|
||||
let port = 51000 + (rand::random::<u16>() % 1000);
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
@@ -220,6 +219,12 @@ impl McpServer {
|
||||
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/mcp/{token}",
|
||||
post(Self::handle_mcp_post)
|
||||
.get(Self::handle_mcp_get)
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route(
|
||||
"/mcp",
|
||||
post(Self::handle_mcp_post)
|
||||
@@ -234,26 +239,26 @@ impl McpServer {
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = match TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
log::error!("[mcp] Failed to bind to port {}: {}", port, e);
|
||||
return;
|
||||
|
||||
let server = async {
|
||||
match TcpListener::bind(addr).await {
|
||||
Ok(listener) => {
|
||||
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
log::error!("[mcp] Server error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[mcp] Failed to bind on port {}: {}", port, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[mcp] HTTP server listening on http://127.0.0.1:{}/mcp",
|
||||
port
|
||||
);
|
||||
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
log::info!("[mcp] HTTP server shutting down");
|
||||
});
|
||||
|
||||
if let Err(e) = server.await {
|
||||
log::error!("[mcp] HTTP server error: {}", e);
|
||||
tokio::select! {
|
||||
_ = server => {},
|
||||
_ = shutdown_rx => {
|
||||
log::info!("[mcp] Server shutting down");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,19 +267,28 @@ impl McpServer {
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Health endpoint is public
|
||||
if req.uri().path() == "/health" {
|
||||
let path = req.uri().path();
|
||||
|
||||
if path == "/health" {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
let auth_header = req
|
||||
// Check token from URL path: /mcp/{token}
|
||||
let path_token = path
|
||||
.strip_prefix("/mcp/")
|
||||
.filter(|t| !t.is_empty() && !t.contains('/'));
|
||||
|
||||
// Check token from Authorization header
|
||||
let header_token = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok());
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
let token = auth_header.and_then(|h| h.strip_prefix("Bearer "));
|
||||
let valid =
|
||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
||||
|
||||
if token != Some(&state.token) {
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
@@ -1056,7 +1056,6 @@ export default function Home() {
|
||||
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full mt-2.5">
|
||||
|
||||
@@ -177,7 +177,6 @@ type Props = {
|
||||
onExtensionManagementDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
@@ -191,7 +190,6 @@ const HomeHeader = ({
|
||||
onExtensionManagementDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
crossOsUnlocked = false,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
|
||||
@@ -31,7 +31,6 @@ interface AppSettings {
|
||||
interface McpConfig {
|
||||
port: number;
|
||||
token: string;
|
||||
config_json: string;
|
||||
}
|
||||
|
||||
interface IntegrationsDialogProps {
|
||||
@@ -59,6 +58,8 @@ export function IntegrationsDialog({
|
||||
const [showMcpToken, setShowMcpToken] = useState(false);
|
||||
const [isApiStarting, setIsApiStarting] = useState(false);
|
||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||
const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false);
|
||||
const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false);
|
||||
|
||||
const { termsAccepted } = useWayfernTerms();
|
||||
|
||||
@@ -98,12 +99,32 @@ export function IntegrationsDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadClaudeDesktopStatus = useCallback(async () => {
|
||||
try {
|
||||
const exists = await invoke<boolean>("is_mcp_in_claude_desktop");
|
||||
setMcpInClaudeDesktop(exists);
|
||||
} catch {
|
||||
// Not critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadClaudeCodeStatus = useCallback(async () => {
|
||||
try {
|
||||
const exists = await invoke<boolean>("is_mcp_in_claude_code");
|
||||
setMcpInClaudeCode(exists);
|
||||
} catch {
|
||||
// Claude CLI may not be installed
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings();
|
||||
loadApiServerStatus();
|
||||
loadMcpConfig();
|
||||
loadMcpServerStatus();
|
||||
loadClaudeDesktopStatus();
|
||||
loadClaudeCodeStatus();
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
@@ -111,6 +132,8 @@ export function IntegrationsDialog({
|
||||
loadApiServerStatus,
|
||||
loadMcpConfig,
|
||||
loadMcpServerStatus,
|
||||
loadClaudeDesktopStatus,
|
||||
loadClaudeCodeStatus,
|
||||
]);
|
||||
|
||||
const handleApiToggle = async (enabled: boolean) => {
|
||||
@@ -175,45 +198,9 @@ export function IntegrationsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const obfuscateToken = (token: string) =>
|
||||
const _obfuscateToken = (token: string) =>
|
||||
"•".repeat(Math.min(token.length, 32));
|
||||
|
||||
const getFormattedMcpConfig = () => {
|
||||
if (!mcpConfig) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"donut-browser": {
|
||||
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${mcpConfig.token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
const getObfuscatedMcpConfig = () => {
|
||||
if (!mcpConfig) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"donut-browser": {
|
||||
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${obfuscateToken(mcpConfig.token)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
@@ -331,39 +318,129 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<div className="space-y-3 p-4 rounded-md border bg-muted/40">
|
||||
<div className="relative">
|
||||
<pre className="p-3 text-xs font-mono rounded-md bg-background border overflow-x-auto whitespace-pre">
|
||||
{showMcpToken
|
||||
? getFormattedMcpConfig()
|
||||
: getObfuscatedMcpConfig()}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2 flex items-center space-x-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowMcpToken(!showMcpToken)}
|
||||
>
|
||||
{showMcpToken ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showMcpToken ? "text" : "password"}
|
||||
value={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
|
||||
readOnly
|
||||
className="font-mono text-xs pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowMcpToken(!showMcpToken)}
|
||||
>
|
||||
{showMcpToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={getFormattedMcpConfig()}
|
||||
successMessage="Configuration copied"
|
||||
text={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpCopyHint")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpConfigPath")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("integrations.mcp.claudeDesktopTitle")}
|
||||
</p>
|
||||
{mcpInClaudeDesktop ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("remove_mcp_from_claude_desktop");
|
||||
setMcpInClaudeDesktop(false);
|
||||
showSuccessToast(
|
||||
t("integrations.mcp.removedFromClaudeDesktop"),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("integrations.mcp.removeFromClaudeDesktop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("add_mcp_to_claude_desktop");
|
||||
setMcpInClaudeDesktop(true);
|
||||
showSuccessToast(
|
||||
t("integrations.mcp.addedToClaudeDesktop"),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("integrations.mcp.addToClaudeDesktop")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("integrations.mcp.claudeCodeTitle")}
|
||||
</p>
|
||||
{mcpInClaudeCode ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("remove_mcp_from_claude_code");
|
||||
setMcpInClaudeCode(false);
|
||||
showSuccessToast(
|
||||
t("integrations.mcp.removedFromClaudeCode"),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("integrations.mcp.removeFromClaudeCode")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("add_mcp_to_claude_code");
|
||||
setMcpInClaudeCode(true);
|
||||
showSuccessToast(
|
||||
t("integrations.mcp.addedToClaudeCode"),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("integrations.mcp.addToClaudeCode")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP Enabled",
|
||||
"disabled": "MCP Disabled",
|
||||
"port": "Port",
|
||||
"token": "MCP Token",
|
||||
"token": "Authentication Token",
|
||||
"tokenCopied": "Token copied",
|
||||
"url": "MCP Server URL",
|
||||
"urlCopied": "URL copied",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "Configures claude_desktop_config.json automatically",
|
||||
"addToClaudeDesktop": "Add to Claude Desktop",
|
||||
"removeFromClaudeDesktop": "Remove from Claude Desktop",
|
||||
"addedToClaudeDesktop": "Added to Claude Desktop. Restart Claude Desktop and enable the extension in Settings.",
|
||||
"removedFromClaudeDesktop": "Removed from Claude Desktop config. Please restart Claude Desktop.",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Add to Claude Code",
|
||||
"removeFromClaudeCode": "Remove from Claude Code",
|
||||
"addedToClaudeCode": "Added to Claude Code",
|
||||
"removedFromClaudeCode": "Removed from Claude Code",
|
||||
"config": "MCP Configuration",
|
||||
"copyConfig": "Copy Configuration"
|
||||
},
|
||||
"mcpCopyHint": "Copy and paste this into your MCP client configuration.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Profile",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP Habilitado",
|
||||
"disabled": "MCP Deshabilitado",
|
||||
"port": "Puerto",
|
||||
"token": "Token MCP",
|
||||
"token": "Token de autenticación",
|
||||
"tokenCopied": "Token copiado",
|
||||
"url": "URL del servidor MCP",
|
||||
"urlCopied": "URL copiada",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "Configura claude_desktop_config.json automáticamente",
|
||||
"addToClaudeDesktop": "Agregar a Claude Desktop",
|
||||
"removeFromClaudeDesktop": "Eliminar de Claude Desktop",
|
||||
"addedToClaudeDesktop": "Agregado a Claude Desktop. Reinicia Claude Desktop y activa la extensión en Configuración.",
|
||||
"removedFromClaudeDesktop": "Eliminado de la configuración de Claude Desktop. Reinicia Claude Desktop.",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Agregar a Claude Code",
|
||||
"removeFromClaudeCode": "Eliminar de Claude Code",
|
||||
"addedToClaudeCode": "Agregado a Claude Code",
|
||||
"removedFromClaudeCode": "Eliminado de Claude Code",
|
||||
"config": "Configuración MCP",
|
||||
"copyConfig": "Copiar Configuración"
|
||||
},
|
||||
"mcpCopyHint": "Copia y pega esto en la configuración de tu cliente MCP.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) o %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Perfil",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP activé",
|
||||
"disabled": "MCP désactivé",
|
||||
"port": "Port",
|
||||
"token": "Jeton MCP",
|
||||
"token": "Jeton d'authentification",
|
||||
"tokenCopied": "Jeton copié",
|
||||
"url": "URL du serveur MCP",
|
||||
"urlCopied": "URL copiée",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "Configure claude_desktop_config.json automatiquement",
|
||||
"addToClaudeDesktop": "Ajouter à Claude Desktop",
|
||||
"removeFromClaudeDesktop": "Supprimer de Claude Desktop",
|
||||
"addedToClaudeDesktop": "Ajouté à Claude Desktop. Redémarrez Claude Desktop et activez l'extension dans les Paramètres.",
|
||||
"removedFromClaudeDesktop": "Supprimé de la configuration de Claude Desktop. Veuillez redémarrer Claude Desktop.",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Ajouter à Claude Code",
|
||||
"removeFromClaudeCode": "Supprimer de Claude Code",
|
||||
"addedToClaudeCode": "Ajouté à Claude Code",
|
||||
"removedFromClaudeCode": "Supprimé de Claude Code",
|
||||
"config": "Configuration MCP",
|
||||
"copyConfig": "Copier la configuration"
|
||||
},
|
||||
"mcpCopyHint": "Copiez et collez ceci dans la configuration de votre client MCP.",
|
||||
"mcpConfigPath": "Claude Desktop : ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer un profil",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP有効",
|
||||
"disabled": "MCP無効",
|
||||
"port": "ポート",
|
||||
"token": "MCPトークン",
|
||||
"token": "認証トークン",
|
||||
"tokenCopied": "トークンをコピーしました",
|
||||
"url": "MCPサーバーURL",
|
||||
"urlCopied": "URLをコピーしました",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "claude_desktop_config.json を自動的に設定します",
|
||||
"addToClaudeDesktop": "Claude Desktop に追加",
|
||||
"removeFromClaudeDesktop": "Claude Desktop から削除",
|
||||
"addedToClaudeDesktop": "Claude Desktop に追加しました。Claude Desktop を再起動し、設定で拡張機能を有効にしてください。",
|
||||
"removedFromClaudeDesktop": "Claude Desktop の設定から削除しました。Claude Desktop を再起動してください。",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Claude Code に追加",
|
||||
"removeFromClaudeCode": "Claude Code から削除",
|
||||
"addedToClaudeCode": "Claude Code に追加しました",
|
||||
"removedFromClaudeCode": "Claude Code から削除しました",
|
||||
"config": "MCP設定",
|
||||
"copyConfig": "設定をコピー"
|
||||
},
|
||||
"mcpCopyHint": "MCPクライアントの設定にコピーして貼り付けてください。",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) または %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "プロファイルをインポート",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP Ativado",
|
||||
"disabled": "MCP Desativado",
|
||||
"port": "Porta",
|
||||
"token": "Token MCP",
|
||||
"token": "Token de autenticação",
|
||||
"tokenCopied": "Token copiado",
|
||||
"url": "URL do servidor MCP",
|
||||
"urlCopied": "URL copiada",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "Configura claude_desktop_config.json automaticamente",
|
||||
"addToClaudeDesktop": "Adicionar ao Claude Desktop",
|
||||
"removeFromClaudeDesktop": "Remover do Claude Desktop",
|
||||
"addedToClaudeDesktop": "Adicionado ao Claude Desktop. Reinicie o Claude Desktop e ative a extensão em Configurações.",
|
||||
"removedFromClaudeDesktop": "Removido da configuração do Claude Desktop. Reinicie o Claude Desktop.",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Adicionar ao Claude Code",
|
||||
"removeFromClaudeCode": "Remover do Claude Code",
|
||||
"addedToClaudeCode": "Adicionado ao Claude Code",
|
||||
"removedFromClaudeCode": "Removido do Claude Code",
|
||||
"config": "Configuração MCP",
|
||||
"copyConfig": "Copiar Configuração"
|
||||
},
|
||||
"mcpCopyHint": "Copie e cole isso na configuração do seu cliente MCP.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Perfil",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP включён",
|
||||
"disabled": "MCP выключен",
|
||||
"port": "Порт",
|
||||
"token": "MCP токен",
|
||||
"token": "Токен аутентификации",
|
||||
"tokenCopied": "Токен скопирован",
|
||||
"url": "URL MCP сервера",
|
||||
"urlCopied": "URL скопирован",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "Автоматически настраивает claude_desktop_config.json",
|
||||
"addToClaudeDesktop": "Добавить в Claude Desktop",
|
||||
"removeFromClaudeDesktop": "Удалить из Claude Desktop",
|
||||
"addedToClaudeDesktop": "Добавлено в Claude Desktop. Перезапустите Claude Desktop и включите расширение в Настройках.",
|
||||
"removedFromClaudeDesktop": "Удалено из конфигурации Claude Desktop. Перезапустите Claude Desktop.",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "Добавить в Claude Code",
|
||||
"removeFromClaudeCode": "Удалить из Claude Code",
|
||||
"addedToClaudeCode": "Добавлено в Claude Code",
|
||||
"removedFromClaudeCode": "Удалено из Claude Code",
|
||||
"config": "Конфигурация MCP",
|
||||
"copyConfig": "Копировать конфигурацию"
|
||||
},
|
||||
"mcpCopyHint": "Скопируйте и вставьте это в конфигурацию вашего MCP-клиента.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) или %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Импорт профиля",
|
||||
|
||||
@@ -417,12 +417,24 @@
|
||||
"enabled": "MCP 已启用",
|
||||
"disabled": "MCP 已禁用",
|
||||
"port": "端口",
|
||||
"token": "MCP 令牌",
|
||||
"token": "认证令牌",
|
||||
"tokenCopied": "令牌已复制",
|
||||
"url": "MCP 服务器 URL",
|
||||
"urlCopied": "URL 已复制",
|
||||
"claudeDesktopTitle": "Claude Desktop",
|
||||
"claudeDesktopHint": "自动配置 claude_desktop_config.json",
|
||||
"addToClaudeDesktop": "添加到 Claude Desktop",
|
||||
"removeFromClaudeDesktop": "从 Claude Desktop 移除",
|
||||
"addedToClaudeDesktop": "已添加到 Claude Desktop。请重启 Claude Desktop 并在设置中启用扩展。",
|
||||
"removedFromClaudeDesktop": "已从 Claude Desktop 配置移除。请重启 Claude Desktop。",
|
||||
"claudeCodeTitle": "Claude Code",
|
||||
"addToClaudeCode": "添加到 Claude Code",
|
||||
"removeFromClaudeCode": "从 Claude Code 移除",
|
||||
"addedToClaudeCode": "已添加到 Claude Code",
|
||||
"removedFromClaudeCode": "已从 Claude Code 移除",
|
||||
"config": "MCP 配置",
|
||||
"copyConfig": "复制配置"
|
||||
},
|
||||
"mcpCopyHint": "将此内容复制并粘贴到您的 MCP 客户端配置中。",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) 或 %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "导入配置文件",
|
||||
|
||||
Reference in New Issue
Block a user