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
4 changes: 4 additions & 0 deletions src-tauri/mcp.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"dice": {
"type": "native",
"id": "dice"
},
"tool_manager": {
"type": "native",
"id": "tool_manager"
}
}
}
203 changes: 197 additions & 6 deletions src-tauri/src/modules/mcp/native.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
use super::types::ToolDef;
use crate::modules::mcp::service as mcp_service;
use crate::modules::tool_engine::runtime as tool_engine_runtime;
use crate::modules::tool_engine::service as tool_engine_service;
use crate::shared::state::AppState;
use serde_json::{json, Value};
use std::collections::HashSet;

const MAX_SIDES: u64 = 1_000_000;

/// Server key / native id used in `mcp.json` for the built-in tool manager.
pub const TOOL_MANAGER_ID: &str = "tool_manager";

enum NativeKind {
Dice,
ToolManager(AppState),
}

pub struct NativeProvider {
pub server_name: String,
pub tools: Vec<ToolDef>,
handler: fn(&str, &Value) -> Result<String, String>,
kind: NativeKind,
}

impl NativeProvider {
pub fn call(&self, tool_name: &str, args: &Value) -> Result<String, String> {
pub async fn call(&self, tool_name: &str, args: &Value) -> Result<String, String> {
if !self.tools.iter().any(|t| t.name == tool_name) {
return Err(format!("unknown native tool: {tool_name}"));
}
(self.handler)(tool_name, args)
match &self.kind {
NativeKind::Dice => handle_dice(tool_name, args),
NativeKind::ToolManager(state) => handle_tool_manager(tool_name, args, state).await,
}
}
}

/// Built-in dice tools under the given server key (must match `mcp.json` server name).
// ── Dice ────────────────────────────────────────────────────────────

pub fn dice_named(server_key: &str) -> NativeProvider {
NativeProvider {
server_name: server_key.to_string(),
Expand All @@ -39,7 +56,7 @@ pub fn dice_named(server_key: &str) -> NativeProvider {
}),
direct_return: true,
}],
handler: handle_dice,
kind: NativeKind::Dice,
}
}

Expand All @@ -58,10 +75,184 @@ fn handle_dice(_tool_name: &str, args: &Value) -> Result<String, String> {
Ok(format!("Rolled a d{sides}: {result}"))
}

// ── Tool Manager ────────────────────────────────────────────────────

pub fn tool_manager_named(server_key: &str, state: AppState) -> NativeProvider {
NativeProvider {
server_name: server_key.to_string(),
tools: vec![ToolDef {
server_name: server_key.to_string(),
name: "manage_tools".to_string(),
description: Some(
"Manage container-based tools from the catalog. All catalog tools (e.g. File Manager) \
are user-managed and can be freely installed or uninstalled on request. \
Use action 'list' to see all available catalog tools and their install status. \
Use action 'install' with a tool_id to install a tool. \
Use action 'uninstall' with a tool_id to remove an installed tool. \
Always call this tool when the user asks to install, uninstall, or list tools."
.to_string(),
),
input_schema: json!({
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": ["list", "install", "uninstall"],
"description": "The operation: 'list' to show available tools, 'install' or 'uninstall' to change a tool"
},
"tool_id": {
"type": "string",
"description": "Required for install/uninstall. Use the exact id from the 'list' output (e.g. 'pengine/file-manager'). Call with action 'list' first if unsure."
}
}
}),
direct_return: false,
}],
kind: NativeKind::ToolManager(state),
}
}

async fn handle_tool_manager(
_tool_name: &str,
args: &Value,
state: &AppState,
) -> Result<String, String> {
let action = args
.get("action")
.and_then(|v| v.as_str())
.ok_or("missing 'action' parameter")?;

match action {
"list" => handle_list_tools(state).await,
"install" => {
let tool_id = args
.get("tool_id")
.and_then(|v| v.as_str())
.ok_or("missing 'tool_id' for install")?;
handle_install_tool(tool_id, state).await
}
"uninstall" => {
let tool_id = args
.get("tool_id")
.and_then(|v| v.as_str())
.ok_or("missing 'tool_id' for uninstall")?;
handle_uninstall_tool(tool_id, state).await
}
_ => Err(format!("unknown action: {action}")),
}
}

async fn handle_list_tools(state: &AppState) -> Result<String, String> {
let catalog = tool_engine_service::load_catalog()?;
let installed = {
let _cfg_guard = state.mcp_config_mutex.lock().await;
tool_engine_service::installed_tool_ids(&state.mcp_config_path)
};
let installed_set: HashSet<&str> = installed.iter().map(|s| s.as_str()).collect();

let mut lines = Vec::new();
for tool in &catalog.tools {
let status = if installed_set.contains(tool.id.as_str()) {
"installed"
} else {
"not installed"
};
lines.push(format!(
"- {} (id: {}, v{}): {} [{}]",
tool.name, tool.id, tool.version, tool.description, status
));
}

if lines.is_empty() {
Ok("No tools available in the catalog.".to_string())
} else {
Ok(format!("Available tools:\n{}", lines.join("\n")))
}
}

async fn handle_install_tool(tool_id: &str, state: &AppState) -> Result<String, String> {
run_tool_mutation(tool_id, state, "install", ToolAction::Install).await?;
Ok(format!(
"Tool '{tool_id}' installed successfully and is now available."
))
}

async fn handle_uninstall_tool(tool_id: &str, state: &AppState) -> Result<String, String> {
run_tool_mutation(tool_id, state, "uninstall", ToolAction::Uninstall).await?;
Ok(format!("Tool '{tool_id}' uninstalled successfully."))
}

enum ToolAction {
Install,
Uninstall,
}

/// Shared sequence for install / uninstall: detect runtime, lock, log, act, log, rebuild.
async fn run_tool_mutation(
tool_id: &str,
state: &AppState,
verb: &str,
action: ToolAction,
) -> Result<(), String> {
let runtime = tool_engine_runtime::detect_runtime().await.ok_or(
"No container runtime (Docker/Podman) found. Please install Docker or Podman first.",
)?;

{
let _te_guard = state.tool_engine_mutex.lock().await;
state
.emit_log("toolengine", &format!("{verb}ing {tool_id} via chat…"))
.await;
match action {
ToolAction::Install => {
tool_engine_service::install_tool(
tool_id,
&runtime,
&state.mcp_config_path,
&state.mcp_config_mutex,
)
.await?;
}
ToolAction::Uninstall => {
tool_engine_service::uninstall_tool(
tool_id,
&runtime,
&state.mcp_config_path,
&state.mcp_config_mutex,
)
.await?;
}
}
state
.emit_log("toolengine", &format!("{tool_id} {verb}ed via chat"))
.await;
}

if let Err(e) = mcp_service::rebuild_registry_into_state(state).await {
state
.emit_log("mcp", &format!("registry rebuild after {verb} failed: {e}"))
.await;
return Err(e);
}
Ok(())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ── Registry ────────────────────────────────────────────────────────

/// Resolve `id` from `mcp.json` (`type: native`) into a provider under `server_key`.
pub fn native_for(server_key: &str, id: &str) -> Result<NativeProvider, String> {
/// `app_state` is required for stateful natives like `tool_manager`.
pub fn native_for(
server_key: &str,
id: &str,
app_state: Option<&AppState>,
) -> Result<NativeProvider, String> {
match id {
"dice" => Ok(dice_named(server_key)),
TOOL_MANAGER_ID => {
let state = app_state.ok_or_else(|| format!("{TOOL_MANAGER_ID} requires AppState"))?;
Ok(tool_manager_named(server_key, state.clone()))
}
_ => Err(format!("unknown native id: {id}")),
}
}
2 changes: 1 addition & 1 deletion src-tauri/src/modules/mcp/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl Provider {

pub async fn call_tool(&self, name: &str, args: Value) -> Result<String, String> {
match self {
Provider::Native(n) => n.call(name, &args),
Provider::Native(n) => n.call(name, &args).await,
Provider::Mcp(c) => c.call_tool(name, args).await,
}
}
Expand Down
37 changes: 34 additions & 3 deletions src-tauri/src/modules/mcp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ use super::registry::{Provider, ToolRegistry};
use super::types::{McpConfig, ServerEntry};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::Emitter;

const FILESYSTEM_SERVER_KEY: &str = "filesystem";
const REGISTRY_CHANGED_EVENT: &str = "pengine-registry-changed";

fn app_data_mcp_path(store_path: &Path) -> PathBuf {
store_path
Expand Down Expand Up @@ -127,6 +129,10 @@ fn default_config_value() -> serde_json::Value {
"dice": {
"type": "native",
"id": "dice"
},
"tool_manager": {
"type": "native",
"id": "tool_manager"
}
}
})
Expand All @@ -149,12 +155,14 @@ pub fn load_or_init_config(path: &Path) -> Result<McpConfig, String> {
}

/// Connect one server from config (native or stdio). Shared by tests and incremental rebuilds.
/// `app_state` is needed for stateful native tools (e.g. `tool_manager`); pass `None` in tests.
pub async fn connect_one_server(
server_key: &str,
entry: &ServerEntry,
app_state: Option<&crate::shared::state::AppState>,
) -> (Option<Provider>, String) {
match entry {
ServerEntry::Native { id } => match native::native_for(server_key, id) {
ServerEntry::Native { id } => match native::native_for(server_key, id, app_state) {
Ok(p) => {
let n = p.tools.len();
let cmd_word = if n == 1 { "command" } else { "commands" };
Expand Down Expand Up @@ -197,7 +205,7 @@ pub async fn build_mcp_providers(cfg: &McpConfig) -> (Vec<Provider>, Vec<String>
let mut status = Vec::new();

for (server_key, entry) in &cfg.servers {
let (prov, line) = connect_one_server(server_key, entry).await;
let (prov, line) = connect_one_server(server_key, entry, None).await;
status.push(line);
if let Some(p) = prov {
providers.push(p);
Expand Down Expand Up @@ -255,6 +263,24 @@ pub async fn rebuild_registry_into_state(
}
}

// Ensure tool_manager is always present (auto-add for existing configs).
if !cfg.servers.contains_key(native::TOOL_MANAGER_ID) {
cfg.servers.insert(
native::TOOL_MANAGER_ID.to_string(),
ServerEntry::Native {
id: native::TOOL_MANAGER_ID.to_string(),
},
);
if let Err(e) = save_config(&state.mcp_config_path, &cfg) {
log::warn!(
"failed to save mcp.json after auto-inserting native server {:?}: {} (path={})",
native::TOOL_MANAGER_ID,
e,
state.mcp_config_path.display()
);
}
}

cfg
};

Expand All @@ -265,7 +291,7 @@ pub async fn rebuild_registry_into_state(
// connects only emit a log line — no need to rebuild the registry.
let mut providers = Vec::new();
for (server_key, entry) in &cfg.servers {
let (prov, line) = connect_one_server(server_key, entry).await;
let (prov, line) = connect_one_server(server_key, entry, Some(state)).await;
state.emit_log("mcp", &line).await;
let Some(p) = prov else { continue };
providers.push(p);
Expand All @@ -279,6 +305,11 @@ pub async fn rebuild_registry_into_state(
&format!("ready ({n} tool{})", if n == 1 { "" } else { "s" }),
)
.await;

if let Some(handle) = state.app_handle.lock().await.as_ref() {
let _ = handle.emit(REGISTRY_CHANGED_EVENT, ());
}

Ok(())
}

Expand Down
Loading