Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/custom-mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The dashboard **Tools** column lists whatever is in `mcp.json`. Use **+ Add cust
| Situation | Path |
|-----------|------|
| Packaged app (recommended mental model) | Next to `connection.json` under the app data directory (same folder as Telegram token storage). |
| Local dev | Often `src-tauri/mcp.json` when you run from a source tree (see resolver in `mcp_service::resolve_mcp_config_path`). |
| Local dev | Same as packaged: next to `connection.json` (see `mcp_service::resolve_mcp_config_path`). Use `PENGINE_MCP_CONFIG` if you want a repo-local file. |
| Override | Set **`PENGINE_MCP_CONFIG`** to an absolute or relative path. |

The active path is returned by **`GET http://127.0.0.1:21516/v1/mcp/config`** (`config_path` field).
Expand Down
13 changes: 11 additions & 2 deletions src-tauri/src/infrastructure/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ async fn handle_mcp_filesystem_put(
.filter(|p| !p.is_empty())
.collect();

let catalog_result = te_service::load_catalog().await;

let sync_note = {
let _guard = state.mcp_config_mutex.lock().await;

Expand All @@ -377,8 +379,15 @@ async fn handle_mcp_filesystem_put(
mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths);

let mut note = None::<String>;
if let Err(e) = te_service::sync_workspace_mounted_tools_if_installed(&mut cfg, &paths) {
note = Some(e);
match &catalog_result {
Ok(cat) => {
if let Err(e) =
te_service::sync_workspace_mounted_tools_for_catalog(&mut cfg, &paths, cat)
{
note = Some(e);
}
}
Err(e) => note = Some(e.clone()),
}

mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| {
Expand Down
51 changes: 18 additions & 33 deletions src-tauri/src/modules/mcp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,8 @@ const MCP_CONFIG_ENV: &str = "PENGINE_MCP_CONFIG";
/// Resolve the active `mcp.json` path.
///
/// - Optional override: [`MCP_CONFIG_ENV`] → use that file.
/// - **Release builds** (packaged native app): always `$APP_DATA/mcp.json` next to
/// `connection.json`, so Tool Engine installs and workspace folders persist regardless of where
/// the `.app` bundle lives (e.g. `/Applications` vs still under a source tree).
/// - **Debug builds**: walk up from [`std::env::current_exe`] to find crate-root or
/// `…/src-tauri/mcp.json` for local development, then fall back to app data.
/// - Otherwise: always `$APP_DATA/mcp.json` next to `connection.json`, so Tool Engine installs
/// and workspace folders persist regardless of cwd or where the binary lives (debug or release).
pub fn resolve_mcp_config_path(store_path: &Path) -> (PathBuf, &'static str) {
if let Ok(raw) = std::env::var(MCP_CONFIG_ENV) {
let t = raw.trim();
Expand All @@ -44,27 +41,6 @@ pub fn resolve_mcp_config_path(store_path: &Path) -> (PathBuf, &'static str) {
}
}

#[cfg(debug_assertions)]
{
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(Path::to_path_buf);
for _ in 0..16 {
let Some(ref d) = dir else {
break;
};
let from_repo_root = d.join("src-tauri").join("mcp.json");
if from_repo_root.exists() {
return (from_repo_root, "project");
}
let in_crate_root = d.join("mcp.json");
if d.join("Cargo.toml").exists() && in_crate_root.exists() {
return (in_crate_root, "project");
}
dir = d.parent().map(Path::to_path_buf);
}
}
}

let app_path = app_data_mcp_path(store_path);
(app_path, "app_data")
}
Expand Down Expand Up @@ -234,6 +210,7 @@ pub async fn rebuild_registry_into_state(
state: &crate::shared::state::AppState,
) -> Result<(), String> {
let _rebuild = state.mcp_rebuild_mutex.lock().await;
let catalog_result = crate::modules::tool_engine::service::load_catalog().await;
let cfg = {
let _cfg_guard = state.mcp_config_mutex.lock().await;
let mut cfg = match load_or_init_config(&state.mcp_config_path) {
Expand All @@ -248,10 +225,19 @@ pub async fn rebuild_registry_into_state(

let paths = filesystem_allowed_paths(&cfg);
let mut ws_changed = false;
match crate::modules::tool_engine::service::sync_workspace_mounted_tools_if_installed(
&mut cfg, &paths,
) {
Ok(changed) => ws_changed |= changed,
match &catalog_result {
Ok(cat) => {
match crate::modules::tool_engine::service::sync_workspace_mounted_tools_for_catalog(
&mut cfg, &paths, cat,
) {
Ok(changed) => ws_changed |= changed,
Err(e) => {
state
.emit_log("toolengine", &format!("workspace mount sync skipped: {e}"))
.await;
}
}
}
Err(e) => {
state
.emit_log("toolengine", &format!("workspace mount sync skipped: {e}"))
Expand Down Expand Up @@ -371,10 +357,9 @@ mod tests {
assert!(!cfg.servers.contains_key("filesystem"));
}

/// Release binaries always use `mcp.json` next to `connection.json` (no exe walk).
#[cfg(not(debug_assertions))]
/// Default resolution: `mcp.json` next to `connection.json` (no project-tree walk).
#[test]
fn resolve_mcp_config_release_uses_app_data_adjacent_to_store() {
fn resolve_mcp_config_uses_app_data_adjacent_to_store() {
let store = PathBuf::from("/tmp/pengine-fake-app/connection.json");
let (path, src) = resolve_mcp_config_path(&store);
assert_eq!(src, "app_data");
Expand Down
5 changes: 2 additions & 3 deletions src-tauri/src/modules/mcp/types.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};

/// Root config: `$APP_DATA/mcp.json` next to `connection.json` for release/native builds; debug
/// builds may use crate `mcp.json` (see `service::resolve_mcp_config_path`). Override with
/// `PENGINE_MCP_CONFIG`.
/// Root config: `$APP_DATA/mcp.json` next to `connection.json` (see `service::resolve_mcp_config_path`).
/// Override with `PENGINE_MCP_CONFIG`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfig {
/// Host folders shared with the File Manager container (`/app/<basename>`). Replaces legacy
Expand Down
17 changes: 11 additions & 6 deletions src-tauri/src/modules/tool_engine/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,14 @@ pub fn installed_tool_ids(mcp_config_path: &Path) -> Vec<String> {

/// Rewrite every **installed** catalog tool that uses `mount_workspace` so argv matches `host_paths`
/// (empty list → in-image stub root only). Returns whether `mcp.json` should be saved.
pub fn sync_workspace_mounted_tools_if_installed(
///
/// Pass the catalog from [`load_catalog`] (or tests) so callers can fetch **before** holding
/// `mcp_config_mutex`, avoiding network I/O under that lock.
pub fn sync_workspace_mounted_tools_for_catalog(
cfg: &mut McpConfig,
host_paths: &[String],
catalog: &ToolCatalog,
) -> Result<bool, String> {
let catalog = load_embedded_catalog()?;
let mut changed = false;
for entry in &catalog.tools {
let key = server_key(&entry.id);
Expand Down Expand Up @@ -444,12 +447,14 @@ pub async fn uninstall_tool(
}

// Remove the container image — prefer the ref from the installed entry.
let image_ref = installed_image_ref.or_else(|| {
load_embedded_catalog()
let image_ref = match installed_image_ref {
Some(r) => Some(r),
None => load_catalog()
.await
.ok()
.and_then(|cat| cat.tools.iter().find(|t| t.id == tool_id).cloned())
.and_then(|entry| image_reference(&entry).ok())
});
.and_then(|entry| image_reference(&entry).ok()),
};
if let Some(ref img) = image_ref {
let _ = tokio::process::Command::new(&runtime.binary)
.args(["rmi", img])
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/modules/tool_engine/tools.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"schema_version": 1,
"generated_at": "2026-04-12T00:00:00Z",
"catalog_revision": 1,
"valid_until": "2026-05-12T00:00:00Z",
"generated_at": "2026-04-13T00:00:00Z",
"catalog_revision": 2,
"valid_until": "2026-05-13T00:00:00Z",
"minimum_pengine_version": "0.5.0",
"tools": [
{
Expand Down