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
114 changes: 114 additions & 0 deletions src-tauri/src/infrastructure/executable_resolve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Resolve CLI executable paths for subprocess spawn.
//!
//! Tauri GUI apps on macOS (and some Linux desktop sessions) start with a minimal `PATH` that
//! omits Homebrew and other common install locations. [`resolve_command_for_spawn`] expands bare
//! names like `podman` / `docker` to an absolute path when possible so MCP stdio servers can start.

use std::path::{Path, PathBuf};

fn push_candidate(out: &mut Vec<PathBuf>, p: PathBuf) {
if p.as_os_str().is_empty() {
return;
}
if !out.iter().any(|x| x == &p) {
out.push(p);
}
}

/// Ordered list of paths to try for a CLI basename (e.g. `podman`, `docker`).
pub fn runtime_binary_candidates(name: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
push_candidate(&mut out, PathBuf::from(name));

if let Ok(path_var) = std::env::var("PATH") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path_var.split(sep) {
if dir.is_empty() {
continue;
}
push_candidate(&mut out, Path::new(dir).join(name));
}
}

#[cfg(target_os = "macos")]
{
push_candidate(&mut out, PathBuf::from(format!("/opt/homebrew/bin/{name}")));
push_candidate(&mut out, PathBuf::from(format!("/usr/local/bin/{name}")));
push_candidate(&mut out, PathBuf::from(format!("/opt/podman/bin/{name}")));
}

#[cfg(target_os = "linux")]
{
push_candidate(&mut out, PathBuf::from(format!("/usr/bin/{name}")));
push_candidate(&mut out, PathBuf::from(format!("/bin/{name}")));
}

if let Ok(home) = std::env::var("HOME") {
push_candidate(&mut out, Path::new(&home).join(".local/bin").join(name));
}

out
}

#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::fs;
use std::os::unix::fs::PermissionsExt;
match fs::metadata(path) {
Ok(m) => m.is_file() && m.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}

#[cfg(windows)]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}

/// If `command` is a bare executable name, return the first candidate that exists and looks
/// runnable; otherwise return `command` unchanged (absolute paths, relative paths with dirs, etc.).
pub fn resolve_command_for_spawn(command: &str) -> PathBuf {
let c = command.trim();
if c.is_empty() {
return PathBuf::from(c);
}

let path = Path::new(c);
if path.is_absolute() || c.contains(std::path::MAIN_SEPARATOR) {
return path.to_path_buf();
}

for candidate in runtime_binary_candidates(c) {
if candidate.as_os_str().is_empty() {
continue;
}
// Skip bare `docker` / `podman`: `Path::is_file()` would mean CWD, not PATH (GUI apps
// often lack PATH entries, so we only want absolute / PATH-derived candidates here).
if !candidate.is_absolute() && candidate.parent().is_none() {
continue;
}
if candidate.is_file() && is_executable_file(&candidate) {
if candidate != Path::new(c) {
log::debug!("resolved MCP stdio command `{c}` → {}", candidate.display());
}
return candidate;
}
}

PathBuf::from(c)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn resolve_keeps_absolute_paths() {
let p = if cfg!(windows) {
r"C:\Program Files\Docker\Docker\resources\bin\docker.exe"
} else {
"/opt/homebrew/bin/podman"
};
assert_eq!(resolve_command_for_spawn(p), PathBuf::from(p));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/infrastructure/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod bot_lifecycle;
pub mod executable_resolve;
pub mod http_server;
1 change: 1 addition & 0 deletions src-tauri/src/modules/mcp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ fn parse_tools(server_name: &str, result: &Value) -> Vec<ToolDef> {
.map(|s| s.to_string()),
input_schema: t
.get("inputSchema")
.or_else(|| t.get("input_schema"))
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
direct_return: false,
Expand Down
35 changes: 17 additions & 18 deletions src-tauri/src/modules/mcp/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,17 @@ impl Default for ToolRegistry {
}

impl ToolRegistry {
/// Number of MCP tools connected (one per `servers` entry in `mcp.json`: `dice`, `te_*`, …).
pub fn mcp_tool_count(&self) -> usize {
self.providers.len()
}

pub fn new(providers: Vec<Provider>) -> Self {
let cached_ollama_tools = build_ollama_tools(&providers);
let cached_tool_names = providers
.iter()
.flat_map(|p| p.tools().iter())
.filter(|t| should_expose_to_model(t))
.filter(|t| !is_deprecated_mcp_tool(t))
.map(|t| t.name.clone())
.collect();
Self {
Expand All @@ -162,7 +167,7 @@ impl ToolRegistry {
self.providers
.iter()
.flat_map(|p| p.tools().iter())
.filter(|t| should_expose_to_model(t))
.filter(|t| !is_deprecated_mcp_tool(t))
.cloned()
.collect()
}
Expand All @@ -171,10 +176,12 @@ impl ToolRegistry {
self.cached_ollama_tools.clone()
}

/// Names of commands offered to the model (flattened across all MCP tools).
pub fn tool_names(&self) -> &[String] {
&self.cached_tool_names
}

/// `true` when there is no command to expose (e.g. nothing connected yet).
pub fn is_empty(&self) -> bool {
self.cached_tool_names.is_empty()
}
Expand Down Expand Up @@ -235,28 +242,20 @@ impl ToolRegistry {
}
}

fn should_expose_to_model(tool: &ToolDef) -> bool {
let desc = tool.description.as_deref().unwrap_or("");
if desc.to_ascii_uppercase().contains("DEPRECATED") {
return false;
}
!REDUNDANT_TOOLS.contains(&tool.name.as_str())
/// Hide tools the server marks as deprecated (e.g. filesystem `read_file` → use `read_text_file`).
fn is_deprecated_mcp_tool(tool: &ToolDef) -> bool {
tool.description
.as_deref()
.unwrap_or("")
.to_ascii_uppercase()
.contains("DEPRECATED")
}

/// Tools that add noise without value for a small local model.
const REDUNDANT_TOOLS: &[&str] = &[
"read_media_file",
"read_multiple_files",
"list_directory_with_sizes",
"directory_tree",
"list_allowed_directories",
];

fn build_ollama_tools(providers: &[Provider]) -> Value {
let arr: Vec<Value> = providers
.iter()
.flat_map(|p| p.tools().iter())
.filter(|t| should_expose_to_model(t))
.filter(|t| !is_deprecated_mcp_tool(t))
.map(|t| {
json!({
"type": "function",
Expand Down
89 changes: 58 additions & 31 deletions src-tauri/src/modules/mcp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,54 @@ use std::sync::Arc;

const FILESYSTEM_SERVER_KEY: &str = "filesystem";

/// Prefer project `mcp.json` under `src-tauri/` (or crate-root `mcp.json`) by walking up from
/// [`std::env::current_exe`], so resolution does not depend on process CWD. Falls back to
/// `mcp.json` next to `connection.json` in app data.
fn app_data_mcp_path(store_path: &Path) -> PathBuf {
store_path
.parent()
.map(|p| p.join("mcp.json"))
.unwrap_or_else(|| PathBuf::from("mcp.json"))
}

/// If set, absolute or relative path to `mcp.json` (overrides all other resolution).
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.
pub fn resolve_mcp_config_path(store_path: &Path) -> (PathBuf, &'static str) {
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");
if let Ok(raw) = std::env::var(MCP_CONFIG_ENV) {
let t = raw.trim();
if !t.is_empty() {
return (PathBuf::from(t), "env");
}
}

#[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);
}
dir = d.parent().map(Path::to_path_buf);
}
}

let app_path = store_path
.parent()
.map(|p| p.join("mcp.json"))
.unwrap_or_else(|| PathBuf::from("mcp.json"));
let app_path = app_data_mcp_path(store_path);
(app_path, "app_data")
}

Expand Down Expand Up @@ -135,10 +157,8 @@ pub async fn connect_one_server(
ServerEntry::Native { id } => match native::native_for(server_key, id) {
Ok(p) => {
let n = p.tools.len();
let msg = format!(
"{server_key} native ({n} tool{})",
if n == 1 { "" } else { "s" }
);
let cmd_word = if n == 1 { "command" } else { "commands" };
let msg = format!("{server_key} native ({n} {cmd_word})");
(Some(Provider::Native(Arc::new(p))), msg)
}
Err(e) => (None, format!("{server_key} native failed: {e}")),
Expand All @@ -159,12 +179,9 @@ pub async fn connect_one_server(
{
Ok(client) => {
let n = client.tools.len();
let cmd_word = if n == 1 { "command" } else { "commands" };
let dr = if *direct_return { " direct_return" } else { "" };
let msg = format!(
"{server_key} stdio ({n} tool{}{})",
if n == 1 { "" } else { "s" },
dr
);
let msg = format!("{server_key} stdio ({n} {cmd_word}{dr})");
(Some(Provider::Mcp(Arc::new(client))), msg)
}
Err(e) => (None, format!("{server_key} stdio failed: {e}")),
Expand Down Expand Up @@ -255,7 +272,7 @@ pub async fn rebuild_registry_into_state(
*state.mcp.write().await = ToolRegistry::new(providers.clone());
}

let n = state.mcp.read().await.tool_names().len();
let n = state.mcp.read().await.mcp_tool_count();
state
.emit_log(
"mcp",
Expand Down Expand Up @@ -306,4 +323,14 @@ mod tests {
assert_eq!(cfg.workspace_roots, vec!["/a", "/b"]);
assert!(!cfg.servers.contains_key("filesystem"));
}

/// Release binaries always use `mcp.json` next to `connection.json` (no exe walk).
#[cfg(not(debug_assertions))]
#[test]
fn resolve_mcp_config_release_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");
assert_eq!(path, PathBuf::from("/tmp/pengine-fake-app/mcp.json"));
}
}
14 changes: 10 additions & 4 deletions src-tauri/src/modules/mcp/transport.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::protocol::{JsonRpcRequest, JsonRpcResponse};
use crate::infrastructure::executable_resolve;
use serde_json::Value;
use std::collections::HashMap;
use std::process::Stdio;
Expand All @@ -25,17 +26,22 @@ impl StdioTransport {
args: &[String],
env: &HashMap<String, String>,
) -> Result<Self, String> {
let mut cmd = Command::new(command);
let resolved = executable_resolve::resolve_command_for_spawn(command);
let mut cmd = Command::new(&resolved);
cmd.args(args)
.envs(env)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);

let mut child = cmd
.spawn()
.map_err(|e| format!("spawn `{command}` failed: {e}"))?;
let mut child = cmd.spawn().map_err(|e| {
format!(
"spawn `{}` (resolved as `{}`) failed: {e}",
command,
resolved.display()
)
})?;

let stdin = child.stdin.take().ok_or("no stdin")?;
let stdout = child.stdout.take().ok_or("no stdout")?;
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/modules/mcp/types.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};

/// Root config: `src-tauri/mcp.json` in dev or `mcp.json` next to app data (`connection.json`).
/// 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`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfig {
/// Host folders shared with the File Manager container (`/app/<basename>`). Replaces legacy
Expand Down
Loading