From 8a8c209083973cd121570a2a1585924dc5aae17c Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 1 Aug 2026 10:55:02 +0800 Subject: [PATCH 1/4] feat: add devo mcp CLI, mcp admin RPCs, and interactive /mcps /skills pickers --- apps/web/app/_components/landing/data.ts | 4 +- .../docs/contributors/cli-and-tui-flow.mdx | 2 +- .../docs/contributors/cli-and-tui-flow.zh.mdx | 2 +- apps/web/content/docs/core-concepts/mcp.mdx | 20 +- .../web/content/docs/core-concepts/mcp.zh.mdx | 12 +- .../web/content/docs/core-concepts/skills.mdx | 3 +- .../content/docs/core-concepts/skills.zh.mdx | 2 +- .../get-started/coming-from-claude-code.mdx | 2 +- .../coming-from-claude-code.zh.mdx | 2 +- .../content/docs/reference/slash-commands.mdx | 4 +- .../docs/reference/slash-commands.zh.mdx | 4 +- apps/web/content/docs/using-devo/index.mdx | 2 +- apps/web/content/docs/using-devo/index.zh.mdx | 2 +- crates/cli/src/main.rs | 144 +++++ crates/cli/src/mcp_command.rs | 330 +++++++++++ crates/client/src/client_core.rs | 14 + crates/client/src/stdio.rs | 14 + crates/client/src/websocket.rs | 14 + crates/config/src/app.rs | 8 +- crates/config/src/app/mcp_store.rs | 157 +++++ crates/config/src/tests.rs | 146 +++++ crates/core/src/query/mod.rs | 43 +- crates/core/src/query/tests.rs | 78 +++ crates/core/src/tools/handlers/read.rs | 16 +- .../core/src/tools/handlers/shell_command.rs | 30 +- crates/core/src/tools/shell_exec/mod.rs | 5 +- crates/core/src/tools/shell_exec/pipe.rs | 1 - crates/core/src/tools/shell_exec/pty.rs | 2 - crates/core/src/tools/shell_exec/tests.rs | 11 + crates/protocol/src/acp_ts.rs | 21 + crates/protocol/src/canonical/methods.rs | 8 + crates/protocol/src/canonical/rpc_admin.rs | 57 ++ crates/protocol/src/protocol.rs | 21 + crates/protocol/src/slash_command.rs | 10 +- crates/server/src/bootstrap.rs | 6 +- crates/server/src/execution.rs | 12 + crates/server/src/lib.rs | 1 + crates/server/src/persistence.rs | 1 + crates/server/src/runtime.rs | 1 + crates/server/src/runtime/agents/lifecycle.rs | 1 + crates/server/src/runtime/connection.rs | 47 ++ crates/server/src/runtime/mcp.rs | 161 ++++++ .../src/runtime/turn_exec/tool_results.rs | 97 +++- crates/server/src/runtime/turn_reservation.rs | 1 + crates/server/src/session_context.rs | 22 +- crates/server/tests/acp_available_commands.rs | 1 + .../acp_permission_tool_status_contract.rs | 1 + crates/server/tests/acp_session_delete.rs | 2 + crates/server/tests/acp_session_lifecycle.rs | 1 + crates/server/tests/cli_log_failures.rs | 1 + crates/server/tests/command_exec.rs | 1 + crates/server/tests/end_to_end.rs | 2 + crates/server/tests/goal_title_generation.rs | 1 + crates/server/tests/persistence_resume.rs | 1 + .../tests/provider_failure_reporting.rs | 1 + crates/server/tests/provider_routing.rs | 1 + .../server/tests/session_fork_persistence.rs | 1 + .../tests/session_rollback_persistence.rs | 1 + .../server/tests/session_sandbox_profile.rs | 1 + crates/server/tests/skills_integration.rs | 1 + .../server/tests/support/goal_continuation.rs | 1 + .../tests/support/subagent_lifecycle.rs | 1 + crates/server/tests/turn_start_persistence.rs | 1 + crates/server/tests/websocket_client_e2e.rs | 1 + crates/tools/src/invocation.rs | 64 ++- crates/tui/src/app_command.rs | 25 + crates/tui/src/app_event.rs | 20 + crates/tui/src/bottom_pane/chat_composer.rs | 22 + crates/tui/src/bottom_pane/command_popup.rs | 4 +- .../src/bottom_pane/list_selection_view.rs | 28 +- crates/tui/src/bottom_pane/mod.rs | 201 ++++++- crates/tui/src/chatwidget.rs | 17 +- crates/tui/src/chatwidget/input.rs | 47 ++ crates/tui/src/chatwidget/mcp_picker.rs | 106 ++++ crates/tui/src/chatwidget/skills_picker.rs | 63 ++ crates/tui/src/chatwidget/slash_commands.rs | 4 +- crates/tui/src/chatwidget/worker_events.rs | 21 +- crates/tui/src/events.rs | 17 +- crates/tui/src/interactive.rs | 58 ++ crates/tui/src/lib.rs | 2 + crates/tui/src/mcp_command_tests.rs | 42 +- crates/tui/src/mcp_picker.rs | 537 ++++++++++++++++++ crates/tui/src/skills_picker.rs | 207 +++++++ crates/tui/src/slash_command.rs | 3 +- crates/tui/src/worker.rs | 104 +++- docs/configuration.ja.md | 4 +- docs/configuration.md | 31 +- docs/configuration.ru.md | 5 +- docs/configuration.zh-Hans.md | 24 +- docs/configuration.zh-Hant.md | 4 +- 90 files changed, 3085 insertions(+), 137 deletions(-) create mode 100644 crates/cli/src/mcp_command.rs create mode 100644 crates/config/src/app/mcp_store.rs create mode 100644 crates/server/src/runtime/mcp.rs create mode 100644 crates/tui/src/chatwidget/mcp_picker.rs create mode 100644 crates/tui/src/chatwidget/skills_picker.rs create mode 100644 crates/tui/src/mcp_picker.rs create mode 100644 crates/tui/src/skills_picker.rs diff --git a/apps/web/app/_components/landing/data.ts b/apps/web/app/_components/landing/data.ts index 72ad807b..48030c41 100644 --- a/apps/web/app/_components/landing/data.ts +++ b/apps/web/app/_components/landing/data.ts @@ -116,7 +116,7 @@ export const landingCopy = { }, { status: "yes", - evidence: "/mcp and mcp.json.", + evidence: "/mcps and mcp.json.", }, { status: "yes", @@ -498,7 +498,7 @@ export const landingCopy = { }, { status: "yes", - evidence: "/mcp 与 mcp.json。", + evidence: "/mcps 与 mcp.json。", }, { status: "yes", diff --git a/apps/web/content/docs/contributors/cli-and-tui-flow.mdx b/apps/web/content/docs/contributors/cli-and-tui-flow.mdx index 77d93ac1..988e8b2d 100644 --- a/apps/web/content/docs/contributors/cli-and-tui-flow.mdx +++ b/apps/web/content/docs/contributors/cli-and-tui-flow.mdx @@ -95,7 +95,7 @@ Configuration-changing commands such as `/model`, `/theme`, `/compact`, `/diff`, `/new`, and `/resume` are blocked while a generation is active. Commands that remain available during a task include `/goal`, `/btw`, -`/permissions`, `/status`, `/mcp`, `/skills`, `/clear`, and `/exit`. +`/permissions`, `/status`, `/mcps`, `/skills`, `/clear`, and `/exit`. ## Composer Triggers diff --git a/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx b/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx index c163a84d..a82ecbaf 100644 --- a/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx +++ b/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx @@ -87,7 +87,7 @@ Slash command 定义位于 `crates/tui/src/slash_command.rs`。Dispatch 位于 ` 会改变配置的命令,例如 `/model`、`/theme`、`/compact`、`/diff`、`/new` 和 `/resume`,会在 generation 活跃时被阻止。 -任务期间仍可使用的命令包括 `/goal`、`/btw`、`/permissions`、`/status`、`/mcp`、`/skills`、`/clear` 和 `/exit`。 +任务期间仍可使用的命令包括 `/goal`、`/btw`、`/permissions`、`/status`、`/mcps`、`/skills`、`/clear` 和 `/exit`。 ## Composer Triggers diff --git a/apps/web/content/docs/core-concepts/mcp.mdx b/apps/web/content/docs/core-concepts/mcp.mdx index 41264ec6..54e90f60 100644 --- a/apps/web/content/docs/core-concepts/mcp.mdx +++ b/apps/web/content/docs/core-concepts/mcp.mdx @@ -51,16 +51,20 @@ first use, or only by explicit request. ## Listing Servers -Use `/mcp` in the TUI to list configured MCP servers. - -The transcript output shows each server's id, display name, enabled state, -startup policy, transport kind, and target command or URL. If no servers are -configured, Devo shows: - -```text -No MCP servers configured. +Use `/mcps` in the TUI for an interactive server list (detail → tools; Enable/Disable +writes config and may need a session restart for live runtime), or manage user-level +entries with the CLI: + +```bash +devo mcp add time -- docker run -i --rm mcp/time +devo mcp add --transport http hello-mcp http://localhost:8080/mcp +devo mcp add --transport sse legacy-mcp https://example.com/mcp/sse +devo mcp list +devo mcp remove time ``` +Clients can also call the `mcp/list` / `mcp/tools` RPCs for runtime status and tool +catalogs. If no servers are configured, the picker shows an empty state. ## Mentions The `@` reference popup includes MCP servers. Selecting one inserts a stable diff --git a/apps/web/content/docs/core-concepts/mcp.zh.mdx b/apps/web/content/docs/core-concepts/mcp.zh.mdx index 3d2da515..d92ebed0 100644 --- a/apps/web/content/docs/core-concepts/mcp.zh.mdx +++ b/apps/web/content/docs/core-concepts/mcp.zh.mdx @@ -44,14 +44,16 @@ Startup policy 控制 enabled server 是 eager start、首次使用 lazy start ## 列出 Servers -在 TUI 中使用 `/mcp` 列出 configured MCP servers。 +在 TUI 中使用 `/mcps` 交互浏览 MCP servers(详情 → 工具;Enable/Disable 仅写配置,运行时可能需重启会话),也可用 CLI 管理用户级配置: -Transcript output 会显示每个 server 的 id、display name、enabled state、startup policy、transport kind 和 target command 或 URL。没有配置 servers 时,Devo 显示: - -```text -No MCP servers configured. +```bash +devo mcp add time -- docker run -i --rm mcp/time +devo mcp add --transport http hello-mcp http://localhost:8080/mcp +devo mcp list ``` +客户端也可调用 `mcp/list` / `mcp/tools` RPC 获取运行时状态与工具目录。没有配置 servers 时,picker 显示空状态。 + ## Mentions `@` reference popup 包含 MCP servers。选择一个会插入稳定 MCP reference token,例如: diff --git a/apps/web/content/docs/core-concepts/skills.mdx b/apps/web/content/docs/core-concepts/skills.mdx index 4cc8e57a..9f67ca68 100644 --- a/apps/web/content/docs/core-concepts/skills.mdx +++ b/apps/web/content/docs/core-concepts/skills.mdx @@ -32,7 +32,8 @@ named `skills` by default, includes bundled system skills when enabled, and can watch roots for changes. The skill list can include user, workspace, plugin, system, and admin skills. -Use `/skills` to render the current skill list in the transcript. +Use `/skills` in the TUI for an interactive skill list (detail → insert `@name` or +enable/disable). ## Available Skills Context diff --git a/apps/web/content/docs/core-concepts/skills.zh.mdx b/apps/web/content/docs/core-concepts/skills.zh.mdx index 6722fc10..cd09b54d 100644 --- a/apps/web/content/docs/core-concepts/skills.zh.mdx +++ b/apps/web/content/docs/core-concepts/skills.zh.mdx @@ -26,7 +26,7 @@ Skill 内的相对路径从 skill base directory 解析。 Skill discovery 默认启用。Devo 默认扫描名为 `skills` 的 user 和 workspace skill roots,在启用时包含 bundled system skills,并可 watch roots 的变化。 -Skill list 可以包含 user、workspace、plugin、system 和 admin skills。使用 `/skills` 在 transcript 中渲染当前 skill list。 +Skill list 可以包含 user、workspace、plugin、system 和 admin skills。使用 `/skills` 在 TUI 中交互浏览(详情 → 插入 `@name` 或启用/禁用)。 ## Available Skills Context diff --git a/apps/web/content/docs/get-started/coming-from-claude-code.mdx b/apps/web/content/docs/get-started/coming-from-claude-code.mdx index 63f5e5a0..b0028ff6 100644 --- a/apps/web/content/docs/get-started/coming-from-claude-code.mdx +++ b/apps/web/content/docs/get-started/coming-from-claude-code.mdx @@ -55,7 +55,7 @@ Explain this repository's structure. Do not edit files yet. | Search for files while prompting | `@` | | Run shell commands from the composer | `!` | | Resume previous work | `devo resume` or `/resume` | -| List MCP servers | `/mcp` | +| List MCP servers | `/mcps` | | List skills | `/skills` | See [Using Devo](../using-devo) for the TUI workflow pages. diff --git a/apps/web/content/docs/get-started/coming-from-claude-code.zh.mdx b/apps/web/content/docs/get-started/coming-from-claude-code.zh.mdx index 3fb87c13..410e3fcb 100644 --- a/apps/web/content/docs/get-started/coming-from-claude-code.zh.mdx +++ b/apps/web/content/docs/get-started/coming-from-claude-code.zh.mdx @@ -49,7 +49,7 @@ Explain this repository's structure. Do not edit files yet. | prompt 时搜索文件 | `@` | | 从 composer 运行 shell 命令 | `!` | | 恢复之前的工作 | `devo resume` 或 `/resume` | -| 列出 MCP servers | `/mcp` | +| 列出 MCP servers | `/mcps` | | 列出 skills | `/skills` | TUI 使用页面见 [Using Devo](../using-devo)。 diff --git a/apps/web/content/docs/reference/slash-commands.mdx b/apps/web/content/docs/reference/slash-commands.mdx index 3c60ad49..66d72529 100644 --- a/apps/web/content/docs/reference/slash-commands.mdx +++ b/apps/web/content/docs/reference/slash-commands.mdx @@ -13,8 +13,8 @@ current task is idle. | --- | --- | --- | | `/theme` | Switch the UI theme. | Not available during generation. | | `/model` | Choose the active model. | Not available during generation. Accepts inline model text. | -| `/skills` | Show available skills. | Runs `skills list`. | -| `/mcp` | Show configured MCP servers. | Runs `mcp list`. | +| `/skills` | Browse available skills. | Opens interactive skills picker. | +| `/mcps` | Browse configured MCP servers. | Opens interactive MCP picker (`/mcp` still accepted). | | `/compact` | Compact current session context. | Not available during generation. | | `/resume` | Resume a saved chat. | Not available during generation. | | `/new` | Start a new chat. | Not available during generation. | diff --git a/apps/web/content/docs/reference/slash-commands.zh.mdx b/apps/web/content/docs/reference/slash-commands.zh.mdx index 73e773b9..8ab402e9 100644 --- a/apps/web/content/docs/reference/slash-commands.zh.mdx +++ b/apps/web/content/docs/reference/slash-commands.zh.mdx @@ -11,8 +11,8 @@ Slash commands 在 TUI composer 中输入。有些命令在模型生成时仍可 | --- | --- | --- | | `/theme` | 切换 UI theme。 | Generation 期间不可用。 | | `/model` | 选择当前模型。 | Generation 期间不可用。接受 inline model text。 | -| `/skills` | 显示可用 skills。 | 运行 `skills list`。 | -| `/mcp` | 显示已配置 MCP servers。 | 运行 `mcp list`。 | +| `/skills` | 浏览可用 skills。 | 打开交互式 skills picker。 | +| `/mcps` | 浏览已配置 MCP servers。 | 打开交互式 MCP picker(仍接受 `/mcp`)。 | | `/compact` | Compact 当前 session context。 | Generation 期间不可用。 | | `/resume` | 恢复保存的 chat。 | Generation 期间不可用。 | | `/new` | 开始新的 chat。 | Generation 期间不可用。 | diff --git a/apps/web/content/docs/using-devo/index.mdx b/apps/web/content/docs/using-devo/index.mdx index 4452650a..5a036258 100644 --- a/apps/web/content/docs/using-devo/index.mdx +++ b/apps/web/content/docs/using-devo/index.mdx @@ -54,7 +54,7 @@ selection, file references, shell mode, and plan-mode toggling. | Start a fresh session | `/new` | | Browse saved sessions | `/resume` | | Change permissions | `/permissions` | -| List MCP servers | `/mcp` | +| List MCP servers | `/mcps` | | List skills | `/skills` | | Exit | `/exit` | diff --git a/apps/web/content/docs/using-devo/index.zh.mdx b/apps/web/content/docs/using-devo/index.zh.mdx index 869627ac..3f0aacf8 100644 --- a/apps/web/content/docs/using-devo/index.zh.mdx +++ b/apps/web/content/docs/using-devo/index.zh.mdx @@ -44,7 +44,7 @@ Composer 同时承担聊天输入、slash commands、model selection、file refe | 开始新会话 | `/new` | | 浏览保存的会话 | `/resume` | | 修改权限 | `/permissions` | -| 列出 MCP servers | `/mcp` | +| 列出 MCP servers | `/mcps` | | 列出 skills | `/skills` | | 退出 | `/exit` | diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 40603c47..834b5df3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -29,11 +29,14 @@ use tracing_subscriber::filter::LevelFilter; mod agent_command; mod doctor_command; +mod mcp_command; mod prompt_command; mod upgrade_command; use agent_command::run_agent; use doctor_command::run_doctor; +use mcp_command::McpCommand; +use mcp_command::run_mcp; use prompt_command::PromptOutputFormat; use prompt_command::run_prompt; use upgrade_command::run_upgrade; @@ -211,6 +214,10 @@ async fn run_cli() -> Result<()> { let _logging = install_logging(&cli)?; run_doctor().await } + Some(Command::Mcp { command }) => { + let _logging = install_logging(&cli)?; + run_mcp(command) + } Some(Command::Upgrade) => run_upgrade(), Some(Command::Resume { session_id }) => { maybe_print_startup_update(&cli).await; @@ -297,6 +304,7 @@ fn server_process_args_from_cli(cli: &Cli) -> Option { | Some(Command::Resume { .. }) | Some(Command::Prompt { .. }) | Some(Command::Doctor) + | Some(Command::Mcp { .. }) | Some(Command::Upgrade) | None => None, } @@ -321,6 +329,11 @@ enum Command { }, /// Diagnose configuration, provider connectivity, and system health. Doctor, + /// Manage MCP server entries in the user config. + Mcp { + #[command(subcommand)] + command: McpCommand, + }, /// Upgrade Devo to the latest released version. Upgrade, /// Start the runtime server process. @@ -420,10 +433,12 @@ mod tests { use super::Cli; use super::Command; + use super::McpCommand; use super::PromptOutputFormat; use super::cli_logging_overrides; use super::exit_messages; use super::format_token_usage_line; + use super::mcp_command::McpTransportKind; use super::onboarding_exit_messages; #[test] @@ -611,6 +626,135 @@ mod tests { } } + #[test] + fn cli_parses_mcp_add_stdio_trailing_command() { + let cli = Cli::try_parse_from([ + "devo", "mcp", "add", "time", "--", "docker", "run", "-i", "mcp/time", + ]) + .expect("parse mcp add stdio"); + + match cli.command { + Some(Command::Mcp { + command: + McpCommand::Add { + name, + transport, + rest, + .. + }, + }) => { + assert_eq!(name, "time"); + assert_eq!(transport, McpTransportKind::Stdio); + assert_eq!( + rest, + vec![ + "docker".to_string(), + "run".to_string(), + "-i".to_string(), + "mcp/time".to_string(), + ] + ); + } + other => panic!("expected mcp add, got {other:?}"), + } + } + + #[test] + fn cli_parses_mcp_add_http_and_sse() { + let http = Cli::try_parse_from([ + "devo", + "mcp", + "add", + "--transport", + "http", + "hello", + "http://localhost:8080/mcp", + ]) + .expect("parse mcp add http"); + match http.command { + Some(Command::Mcp { + command: + McpCommand::Add { + name, + transport, + rest, + .. + }, + }) => { + assert_eq!(name, "hello"); + assert_eq!(transport, McpTransportKind::Http); + assert_eq!(rest, vec!["http://localhost:8080/mcp".to_string()]); + } + other => panic!("expected mcp add http, got {other:?}"), + } + + let sse = Cli::try_parse_from([ + "devo", + "mcp", + "add", + "--transport", + "sse", + "legacy", + "https://example.com/mcp/sse", + ]) + .expect("parse mcp add sse"); + match sse.command { + Some(Command::Mcp { + command: + McpCommand::Add { + name, + transport, + rest, + .. + }, + }) => { + assert_eq!(name, "legacy"); + assert_eq!(transport, McpTransportKind::Sse); + assert_eq!(rest, vec!["https://example.com/mcp/sse".to_string()]); + } + other => panic!("expected mcp add sse, got {other:?}"), + } + } + + #[test] + fn cli_parses_mcp_list_remove_enable_disable() { + for (args, expected) in [ + (vec!["devo", "mcp", "list"], "list"), + (vec!["devo", "mcp", "remove", "time"], "remove"), + (vec!["devo", "mcp", "enable", "time"], "enable"), + (vec!["devo", "mcp", "disable", "time"], "disable"), + ] { + let cli = Cli::try_parse_from(args).expect("parse mcp management"); + match (expected, cli.command) { + ( + "list", + Some(Command::Mcp { + command: McpCommand::List, + }), + ) => {} + ( + "remove", + Some(Command::Mcp { + command: McpCommand::Remove { name }, + }), + ) => assert_eq!(name, "time"), + ( + "enable", + Some(Command::Mcp { + command: McpCommand::Enable { name }, + }), + ) => assert_eq!(name, "time"), + ( + "disable", + Some(Command::Mcp { + command: McpCommand::Disable { name }, + }), + ) => assert_eq!(name, "time"), + (label, other) => panic!("expected {label}, got {other:?}"), + } + } + } + #[test] fn cli_parses_server_status_and_shutdown_flags() { let status = Cli::try_parse_from(["devo", "server", "--status"]).expect("parse status"); diff --git a/crates/cli/src/mcp_command.rs b/crates/cli/src/mcp_command.rs new file mode 100644 index 00000000..12121747 --- /dev/null +++ b/crates/cli/src/mcp_command.rs @@ -0,0 +1,330 @@ +//! `devo mcp` subcommands for managing user-level MCP server configuration. +//! +//! These commands mutate `~/.devo/config.toml` (`[[mcp.servers]]`) using the +//! same schema the runtime loads. They do not refresh an already-running +//! interactive session. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use clap::Subcommand; +use clap::ValueEnum; +use devo_core::AppConfigStore; +use devo_core::McpAuthConfig; +use devo_core::McpTransportConfig; +use devo_core::mcp_server_record_for_cli; +use devo_util_paths::find_devo_home; + +/// Nested `devo mcp` management commands. +#[derive(Debug, Subcommand)] +pub enum McpCommand { + /// Add or replace an MCP server in the user config. + Add { + /// Stable server id (also used as the default display name). + name: String, + /// Transport kind. `http` maps to `streamable_http` in config.toml. + #[arg(long, value_enum, default_value_t = McpTransportKind::Stdio)] + transport: McpTransportKind, + /// Environment variables for stdio servers (`KEY=VALUE`). + #[arg(long = "env", value_name = "KEY=VALUE")] + env: Vec, + /// Static HTTP headers for http/sse servers (`KEY=VALUE`). + #[arg(long = "header", value_name = "KEY=VALUE")] + headers: Vec, + /// Bearer token for http/sse authentication. + #[arg(long = "bearer-token")] + bearer_token: Option, + /// Working directory for stdio servers. + #[arg(long)] + cwd: Option, + /// Remaining args: stdio command (after `--`) or remote URL. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + /// List MCP servers from the effective user/workspace config. + List, + /// Remove an MCP server from the user config. + Remove { + /// Server id to remove. + name: String, + }, + /// Enable an MCP server in the user config. + Enable { + /// Server id to enable. + name: String, + }, + /// Disable an MCP server in the user config. + Disable { + /// Server id to disable. + name: String, + }, +} + +/// CLI transport flag values for `devo mcp add --transport`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum McpTransportKind { + Stdio, + Http, + Sse, +} + +impl McpTransportKind { + fn as_display_label(self) -> &'static str { + match self { + Self::Stdio => "stdio", + Self::Http => "http", + Self::Sse => "sse", + } + } +} + +/// Runs a `devo mcp` subcommand against the user-level config. +pub fn run_mcp(command: &McpCommand) -> Result<()> { + let home = find_devo_home().context("resolve DEVO_HOME")?; + let mut store = + AppConfigStore::load(home, /*workspace_root*/ None).context("load user app config")?; + match command { + McpCommand::Add { + name, + transport, + env, + headers, + bearer_token, + cwd, + rest, + } => { + let transport_config = + build_transport(*transport, rest, env, headers, bearer_token.as_deref(), cwd)?; + let record = mcp_server_record_for_cli(name.clone(), transport_config); + store + .upsert_mcp_server(record) + .with_context(|| format!("add mcp server `{name}`"))?; + let path = store.user_config_file().display(); + match transport { + McpTransportKind::Stdio => { + println!( + "Added {} MCP server {name} to {path}", + transport.as_display_label() + ); + } + McpTransportKind::Http | McpTransportKind::Sse => { + let url = rest.first().map(String::as_str).unwrap_or_default(); + println!( + "Added {} MCP server {name} with URL: {url} to {path}", + transport.as_display_label() + ); + } + } + Ok(()) + } + McpCommand::List => { + let servers = store.mcp_servers(); + if servers.is_empty() { + println!("No MCP servers configured."); + return Ok(()); + } + for server in servers { + let (kind, target) = match &server.transport { + McpTransportConfig::Stdio { command, .. } => ("stdio", command.join(" ")), + McpTransportConfig::StreamableHttp { url, .. } => { + ("streamable_http", url.clone()) + } + McpTransportConfig::Sse { url, .. } => ("sse", url.clone()), + }; + let enabled = if server.enabled { "yes" } else { "no" }; + println!( + "{}\tenabled={enabled}\ttransport={kind}\ttarget={target}", + server.id + ); + } + Ok(()) + } + McpCommand::Remove { name } => { + store + .remove_mcp_server(name) + .with_context(|| format!("remove mcp server `{name}`"))?; + println!( + "Removed MCP server {name} from {}", + store.user_config_file().display() + ); + Ok(()) + } + McpCommand::Enable { name } => { + store + .set_mcp_server_enabled(name, /*enabled*/ true) + .with_context(|| format!("enable mcp server `{name}`"))?; + println!("Enabled MCP server {name}"); + Ok(()) + } + McpCommand::Disable { name } => { + store + .set_mcp_server_enabled(name, /*enabled*/ false) + .with_context(|| format!("disable mcp server `{name}`"))?; + println!("Disabled MCP server {name}"); + Ok(()) + } + } +} + +fn build_transport( + kind: McpTransportKind, + rest: &[String], + env: &[String], + headers: &[String], + bearer_token: Option<&str>, + cwd: &Option, +) -> Result { + match kind { + McpTransportKind::Stdio => { + if rest.is_empty() { + anyhow::bail!( + "stdio transport requires a command after `--`, e.g. `devo mcp add name -- npx -y server`" + ); + } + Ok(McpTransportConfig::Stdio { + command: rest.to_vec(), + cwd: cwd.clone(), + env: parse_key_values(env, "env")?, + env_vars: Vec::new(), + }) + } + McpTransportKind::Http => { + let url = require_remote_url(rest, "http")?; + Ok(McpTransportConfig::StreamableHttp { + url, + auth: bearer_auth(bearer_token), + http_headers: parse_key_values(headers, "header")?, + env_http_headers: BTreeMap::new(), + }) + } + McpTransportKind::Sse => { + let url = require_remote_url(rest, "sse")?; + Ok(McpTransportConfig::Sse { + url, + auth: bearer_auth(bearer_token), + http_headers: parse_key_values(headers, "header")?, + env_http_headers: BTreeMap::new(), + }) + } + } +} + +fn require_remote_url(rest: &[String], transport: &str) -> Result { + match rest { + [url] if !url.trim().is_empty() => Ok(url.clone()), + _ => anyhow::bail!( + "{transport} transport requires exactly one URL argument, e.g. `devo mcp add --transport {transport} name https://example.com/mcp`" + ), + } +} + +fn bearer_auth(token: Option<&str>) -> Option { + token + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(|token| McpAuthConfig::BearerToken { + token: token.to_string(), + }) +} + +fn parse_key_values(entries: &[String], flag: &str) -> Result> { + let mut map = BTreeMap::new(); + for entry in entries { + let Some((key, value)) = entry.split_once('=') else { + anyhow::bail!("invalid --{flag} `{entry}`; expected KEY=VALUE"); + }; + let key = key.trim(); + if key.is_empty() { + anyhow::bail!("invalid --{flag} `{entry}`; key must not be empty"); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn build_stdio_transport_joins_command_args() { + let transport = build_transport( + McpTransportKind::Stdio, + &[ + "docker".to_string(), + "run".to_string(), + "-i".to_string(), + "mcp/time".to_string(), + ], + &["LOCAL_TIMEZONE=UTC".to_string()], + &[], + None, + &None, + ) + .expect("stdio"); + assert_eq!( + transport, + McpTransportConfig::Stdio { + command: vec![ + "docker".to_string(), + "run".to_string(), + "-i".to_string(), + "mcp/time".to_string(), + ], + cwd: None, + env: BTreeMap::from([("LOCAL_TIMEZONE".to_string(), "UTC".to_string())]), + env_vars: Vec::new(), + } + ); + } + + #[test] + fn build_http_transport_maps_to_streamable_http() { + let transport = build_transport( + McpTransportKind::Http, + &["http://localhost:8080/mcp".to_string()], + &[], + &["X-Custom=1".to_string()], + Some("secret"), + &None, + ) + .expect("http"); + assert_eq!( + transport, + McpTransportConfig::StreamableHttp { + url: "http://localhost:8080/mcp".to_string(), + auth: Some(McpAuthConfig::BearerToken { + token: "secret".to_string(), + }), + http_headers: BTreeMap::from([("X-Custom".to_string(), "1".to_string())]), + env_http_headers: BTreeMap::new(), + } + ); + } + + #[test] + fn build_sse_transport_keeps_sse_kind() { + let transport = build_transport( + McpTransportKind::Sse, + &["https://example.com/mcp/sse".to_string()], + &[], + &[], + None, + &None, + ) + .expect("sse"); + assert_eq!( + transport, + McpTransportConfig::Sse { + url: "https://example.com/mcp/sse".to_string(), + auth: None, + http_headers: BTreeMap::new(), + env_http_headers: BTreeMap::new(), + } + ); + } +} diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 57b20a44..5ea12a0a 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -554,6 +554,20 @@ impl ServerClientCore { self.request_devo("skills/set_enabled", params).await } + pub(crate) async fn mcp_list( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpListParams, + ) -> Result { + self.request_devo("mcp/list", params).await + } + + pub(crate) async fn mcp_tools( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpToolsParams, + ) -> Result { + self.request_devo("mcp/tools", params).await + } + pub(crate) async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index ead50bda..4a255fc1 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -261,6 +261,20 @@ impl StdioServerClient { self.core.skills_set_enabled(params).await } + pub async fn mcp_list( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpListParams, + ) -> Result { + self.core.mcp_list(params).await + } + + pub async fn mcp_tools( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpToolsParams, + ) -> Result { + self.core.mcp_tools(params).await + } + pub async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index 8bcd609f..8cca09e1 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -245,6 +245,20 @@ impl WebSocketServerClient { self.core.request_devo("skills/set_enabled", params).await } + pub async fn mcp_list( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpListParams, + ) -> Result { + self.core.request_devo("mcp/list", params).await + } + + pub async fn mcp_tools( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpToolsParams, + ) -> Result { + self.core.request_devo("mcp/tools", params).await + } + pub async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 0768bf31..4938958a 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -43,6 +43,10 @@ use crate::upsert_user_auth_api_key; use crate::write_atomic; use crate::write_provider_config; +mod mcp_store; + +pub use mcp_store::mcp_server_record_for_cli; + /// Stores the fully normalized runtime configuration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AppConfig { @@ -564,7 +568,9 @@ fn provider_section_from_value( }) } -fn ensure_toml_table(value: &mut toml::Value) -> &mut toml::map::Map { +pub(crate) fn ensure_toml_table( + value: &mut toml::Value, +) -> &mut toml::map::Map { if !value.is_table() { *value = toml::Value::Table(Default::default()); } diff --git a/crates/config/src/app/mcp_store.rs b/crates/config/src/app/mcp_store.rs new file mode 100644 index 00000000..1166077c --- /dev/null +++ b/crates/config/src/app/mcp_store.rs @@ -0,0 +1,157 @@ +//! Document-preserving MCP server mutations on the user `config.toml`. + +use std::fs; + +use crate::McpServerId; +use crate::McpServerRecord; +use crate::read_provider_config_document; +use crate::write_atomic; + +use super::AppConfigLoader; +use super::AppConfigStore; +use super::ensure_toml_table; + +impl AppConfigStore { + /// Upserts one MCP server record into the user-level `config.toml`. + /// + /// Replaces an existing entry with the same `id`, otherwise appends. Creates + /// the `[mcp]` table and `servers` array when missing. + pub fn upsert_mcp_server(&mut self, record: McpServerRecord) -> anyhow::Result<()> { + if record.id.0.trim().is_empty() { + anyhow::bail!("mcp server id must not be empty"); + } + + let target_config_file = self.user_config_file.as_path(); + if let Some(parent) = target_config_file.parent() { + fs::create_dir_all(parent)?; + } + + let mut document = read_provider_config_document(target_config_file)?; + let servers = mcp_servers_array_mut(&mut document)?; + let server_value = toml::Value::try_from(&record) + .map_err(|error| anyhow::anyhow!("failed to serialize mcp server: {error}"))?; + let id = record.id.0.as_str(); + if let Some(existing) = servers + .iter_mut() + .find(|entry| server_entry_id(entry) == Some(id)) + { + *existing = server_value; + } else { + servers.push(server_value); + } + + let data = toml::to_string_pretty(&document)?; + write_atomic(target_config_file, data.as_bytes())?; + self.reload_effective_config()?; + Ok(()) + } + + /// Removes one MCP server by id from the user-level `config.toml`. + pub fn remove_mcp_server(&mut self, id: &str) -> anyhow::Result<()> { + let id = id.trim(); + if id.is_empty() { + anyhow::bail!("mcp server id must not be empty"); + } + + let target_config_file = self.user_config_file.as_path(); + let mut document = read_provider_config_document(target_config_file)?; + let servers = mcp_servers_array_mut(&mut document)?; + let before = servers.len(); + servers.retain(|entry| server_entry_id(entry) != Some(id)); + if servers.len() == before { + anyhow::bail!("mcp server `{id}` not found"); + } + + let data = toml::to_string_pretty(&document)?; + write_atomic(target_config_file, data.as_bytes())?; + self.reload_effective_config()?; + Ok(()) + } + + /// Sets the `enabled` flag for one MCP server in the user-level `config.toml`. + pub fn set_mcp_server_enabled(&mut self, id: &str, enabled: bool) -> anyhow::Result<()> { + let id = id.trim(); + if id.is_empty() { + anyhow::bail!("mcp server id must not be empty"); + } + + let target_config_file = self.user_config_file.as_path(); + let mut document = read_provider_config_document(target_config_file)?; + let servers = mcp_servers_array_mut(&mut document)?; + let Some(entry) = servers + .iter_mut() + .find(|entry| server_entry_id(entry) == Some(id)) + else { + anyhow::bail!("mcp server `{id}` not found"); + }; + let table = ensure_toml_table(entry); + table.insert("enabled".to_string(), toml::Value::Boolean(enabled)); + + let data = toml::to_string_pretty(&document)?; + write_atomic(target_config_file, data.as_bytes())?; + self.reload_effective_config()?; + Ok(()) + } + + /// Returns MCP servers from the effective (merged) config. + pub fn mcp_servers(&self) -> &[McpServerRecord] { + &self.config.mcp.servers + } + + /// Returns the user-level config.toml path used for MCP mutations. + pub fn user_config_file(&self) -> &std::path::Path { + &self.user_config_file + } + + fn reload_effective_config(&mut self) -> anyhow::Result<()> { + self.config = self + .loader + .load(self.workspace_root.as_deref()) + .map_err(|error| anyhow::anyhow!(error))?; + Ok(()) + } +} + +fn mcp_servers_array_mut(document: &mut toml::Value) -> anyhow::Result<&mut Vec> { + let document = ensure_toml_table(document); + let mcp = document + .entry("mcp".to_string()) + .or_insert_with(|| toml::Value::Table(Default::default())); + let mcp = ensure_toml_table(mcp); + let servers = mcp + .entry("servers".to_string()) + .or_insert_with(|| toml::Value::Array(Vec::new())); + if !servers.is_array() { + *servers = toml::Value::Array(Vec::new()); + } + servers + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("mcp.servers must be an array")) +} + +fn server_entry_id(entry: &toml::Value) -> Option<&str> { + entry + .as_table() + .and_then(|table| table.get("id")) + .and_then(toml::Value::as_str) +} + +/// Builds a default-ready MCP server record for CLI upserts. +pub fn mcp_server_record_for_cli( + id: impl Into, + transport: crate::McpTransportConfig, +) -> McpServerRecord { + let id = id.into(); + McpServerRecord { + display_name: id.clone(), + id: McpServerId(id), + transport, + startup_policy: crate::McpStartupPolicy::Lazy, + enabled: true, + trust_policy: crate::McpTrustPolicy::User, + allowed_capabilities: Vec::new(), + roots_policy: crate::McpRootsPolicy::None, + output_limits: crate::McpOutputLimits::default(), + auth_ref: None, + } +} diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index ac426944..735ecbd4 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -19,6 +19,13 @@ use super::HookShell; use super::HooksConfig; use super::LogRotation; use super::LoggingConfig; +use super::McpOutputLimits; +use super::McpRootsPolicy; +use super::McpServerId; +use super::McpServerRecord; +use super::McpStartupPolicy; +use super::McpTransportConfig; +use super::McpTrustPolicy; use super::ModelBindingConfig; use super::ModelOverrideConfig; use super::OAuthCredentialsStoreMode; @@ -1303,3 +1310,142 @@ fn loader_rejects_invalid_update_check_interval() { let _ = std::fs::remove_dir_all(root); } + +#[test] +fn mcp_upsert_remove_enable_round_trip_preserves_unrelated_sections() { + let root = unique_temp_dir("mcp-upsert-roundtrip"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::write( + home.join("config.toml"), + r#" +[updates] +enabled = true +check_on_startup = false +check_interval_hours = 12 + +[logging] +level = "warn" +"#, + ) + .expect("write user config"); + + let mut store = + AppConfigStore::load(home.clone(), /*workspace_root*/ None).expect("load store"); + let stdio_record = McpServerRecord { + id: McpServerId("time".to_string()), + display_name: "time".to_string(), + transport: McpTransportConfig::Stdio { + command: vec![ + "docker".to_string(), + "run".to_string(), + "-i".to_string(), + "--rm".to_string(), + "mcp/time".to_string(), + ], + cwd: None, + env: BTreeMap::new(), + env_vars: Vec::new(), + }, + startup_policy: McpStartupPolicy::Lazy, + enabled: true, + trust_policy: McpTrustPolicy::User, + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::None, + output_limits: McpOutputLimits::default(), + auth_ref: None, + }; + store + .upsert_mcp_server(stdio_record.clone()) + .expect("upsert stdio"); + + let http_record = McpServerRecord { + id: McpServerId("hello".to_string()), + display_name: "hello".to_string(), + transport: McpTransportConfig::StreamableHttp { + url: "http://localhost:8080/mcp".to_string(), + auth: None, + http_headers: BTreeMap::new(), + env_http_headers: BTreeMap::new(), + }, + startup_policy: McpStartupPolicy::Lazy, + enabled: true, + trust_policy: McpTrustPolicy::User, + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::None, + output_limits: McpOutputLimits::default(), + auth_ref: None, + }; + store + .upsert_mcp_server(http_record.clone()) + .expect("upsert http"); + + let sse_record = McpServerRecord { + id: McpServerId("legacy".to_string()), + display_name: "legacy".to_string(), + transport: McpTransportConfig::Sse { + url: "https://example.com/mcp/sse".to_string(), + auth: None, + http_headers: BTreeMap::new(), + env_http_headers: BTreeMap::new(), + }, + startup_policy: McpStartupPolicy::Lazy, + enabled: true, + trust_policy: McpTrustPolicy::User, + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::None, + output_limits: McpOutputLimits::default(), + auth_ref: None, + }; + store + .upsert_mcp_server(sse_record.clone()) + .expect("upsert sse"); + + let user_config = std::fs::read_to_string(home.join("config.toml")).expect("read user config"); + assert!(user_config.contains("check_on_startup")); + assert!(user_config.contains("level")); + assert_eq!( + store.mcp_servers(), + &[stdio_record.clone(), http_record, sse_record] + ); + + store + .set_mcp_server_enabled("time", /*enabled*/ false) + .expect("disable"); + assert!( + !store + .mcp_servers() + .iter() + .find(|server| server.id.0 == "time") + .expect("time server") + .enabled + ); + + store.remove_mcp_server("hello").expect("remove hello"); + assert!( + store + .mcp_servers() + .iter() + .all(|server| server.id.0 != "hello") + ); + + let reloaded = AppConfigStore::load(home, /*workspace_root*/ None).expect("reload"); + assert_eq!( + reloaded + .mcp_servers() + .iter() + .map(|server| server.id.0.as_str()) + .collect::>(), + vec!["time", "legacy"] + ); + assert!( + !reloaded + .mcp_servers() + .iter() + .find(|server| server.id.0 == "time") + .expect("time server") + .enabled + ); + + let _ = std::fs::remove_dir_all(root); +} diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs index 48f42939..6b39101e 100644 --- a/crates/core/src/query/mod.rs +++ b/crates/core/src/query/mod.rs @@ -200,6 +200,29 @@ async fn summarize_and_compact( const TOOL_RESULT_TRUNCATION_MARKER: &str = "\n...[truncated]"; +/// Tools that store the model-facing payload in Mixed `text` and put UI/protocol +/// metadata in `json` (shell exit/cwd; read preview/truncated). Omit JSON from +/// the prompt so the stream is not duplicated. +fn tool_result_omits_mixed_json_for_model(tool_name: Option<&str>) -> bool { + matches!(tool_name, Some("shell_command" | "bash" | "read")) +} + +fn serialize_tool_content_for_model(content: ToolContent, tool_name: Option<&str>) -> String { + if tool_result_omits_mixed_json_for_model(tool_name) { + content.text_for_model() + } else { + content.into_string() + } +} + +fn tool_content_model_bytes(content: &ToolContent, tool_name: Option<&str>) -> usize { + if tool_result_omits_mixed_json_for_model(tool_name) { + content.text_for_model_byte_len() + } else { + content.into_string_byte_len() + } +} + fn truncate_tool_result_for_model( content: String, tool_name: Option<&str>, @@ -306,17 +329,6 @@ fn is_injected_context_message(message: &RequestMessage) -> bool { }) } -fn tool_content_model_bytes(content: &ToolContent) -> usize { - match content { - ToolContent::Text(text) => text.len(), - ToolContent::Json(json) => json.to_string().len(), - ToolContent::Mixed { text, json } => { - text.as_ref().map_or(0, String::len) - + json.as_ref().map_or(0, |json| json.to_string().len()) - } - } -} - /// Agent loop orchestration: build request, stream, continue or run tools. /// /// Observation (`event`), provider retry (`provider_retry`), prompt estimates @@ -822,7 +834,12 @@ pub async fn query( let tool_error_count = results.iter().filter(|result| result.is_error).count(); let tool_output_bytes = results .iter() - .map(|result| tool_content_model_bytes(&result.content)) + .map(|result| { + let tool_name = tool_result_metadata + .get(result.tool_use_id.as_str()) + .map(|(tool_name, _, _)| tool_name.as_str()); + tool_content_model_bytes(&result.content, tool_name) + }) .sum::(); debug!( tool_calls = tool_calls.len(), @@ -840,7 +857,7 @@ pub async fn query( let tool_name = tool_result_metadata .get(r.tool_use_id.as_str()) .map(|(tool_name, _, _)| tool_name.as_str()); - let content_str = r.content.into_string(); + let content_str = serialize_tool_content_for_model(r.content, tool_name); let content = truncate_tool_result_for_model(content_str, tool_name, truncation_policy); ContentBlock::ToolResult { diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index e4094859..cce8f13a 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -270,6 +270,84 @@ fn model_tool_result_truncation_preserves_utf8_boundaries() { assert!(truncated.len() <= 18); } +#[test] +fn model_visible_shell_mixed_content_uses_text_only_once() { + use super::serialize_tool_content_for_model; + use crate::tools::ToolContent; + + let stream = "hello\nworld".to_string(); + let content = ToolContent::Mixed { + text: Some(stream.clone()), + json: Some(serde_json::json!({ + "command": "echo hello", + "exit": 0, + "cwd": "/tmp", + "description": "say hello", + })), + }; + + let model = serialize_tool_content_for_model(content.clone(), Some("shell_command")); + let truncated = truncate_tool_result_for_model( + model.clone(), + Some("shell_command"), + TruncationPolicyConfig::bytes(10_000).into(), + ); + + assert_eq!(model, stream); + assert_eq!(truncated, stream); + assert_eq!(model.matches("hello").count(), 1); + assert!(!truncated.contains("\"exit\"")); + assert!(!truncated.contains("\"command\"")); + assert_eq!( + serialize_tool_content_for_model(content, Some("bash")), + stream + ); +} + +#[test] +fn model_visible_webfetch_mixed_content_keeps_image_json() { + use super::serialize_tool_content_for_model; + use crate::tools::ToolContent; + + let content = ToolContent::Mixed { + text: Some("Image fetched successfully".into()), + json: Some(serde_json::json!({ + "title": "https://example.com/a.png (image/png)", + "mime": "image/png", + "image_base64": "abc123", + })), + }; + + let model = serialize_tool_content_for_model(content, Some("webfetch")); + assert!(model.contains("Image fetched successfully")); + assert!(model.contains("image_base64")); + assert!(model.contains("abc123")); +} + +#[test] +fn model_visible_read_mixed_content_omits_preview_json() { + use super::serialize_tool_content_for_model; + use crate::tools::ToolContent; + + let file_body = "line one\nline two\nline three".to_string(); + let text = + format!("/tmp/a.rs\nfile\n\n{file_body}\n"); + let content = ToolContent::Mixed { + text: Some(text.clone()), + json: Some(serde_json::json!({ + "preview": "line one\nline two\nline three", + "truncated": false, + "loaded": [], + })), + }; + + let model = serialize_tool_content_for_model(content, Some("read")); + assert_eq!(model, text); + assert_eq!(model.matches("line one").count(), 1); + assert!(!model.contains("\"preview\"")); + assert!(!model.contains("\"truncated\"")); +} + #[test] fn model_tool_result_truncation_preserves_agent_coordination_results() { let content = "abcdefghijklmnopqrstuvwxyz".to_string(); diff --git a/crates/core/src/tools/handlers/read.rs b/crates/core/src/tools/handlers/read.rs index 370ca9ff..894a6c68 100644 --- a/crates/core/src/tools/handlers/read.rs +++ b/crates/core/src/tools/handlers/read.rs @@ -9,6 +9,7 @@ use super::file_change_metadata::file_mtime; use crate::contracts::{ ToolCallError, ToolContext, ToolProgressSender, ToolResult, ToolResultContent, }; +use crate::invocation::ToolContent; use crate::json_schema::JsonSchema; use crate::read::{is_binary_file, missing_file_message, read_directory, read_file}; use crate::tool_handler::ToolHandler; @@ -119,8 +120,8 @@ impl ToolHandler for ReadHandler { let output = read_directory(&path, limit.unwrap_or(usize::MAX), offset.unwrap_or(1)); let output = output.map_err(|e| ToolCallError::ExecutionFailed(format!("{e}")))?; let display = output.display_content; - let text = output.content.into_string(); - let mut result = ToolResult::success(ToolResultContent::Text(text), "Directory read"); + let mut result = + ToolResult::success(tool_result_content(output.content), "Directory read"); result.display_content = display; return Ok(result); } @@ -184,13 +185,20 @@ impl ToolHandler for ReadHandler { let output = read_file(&path, limit.unwrap_or(usize::MAX), offset.unwrap_or(1)); let output = output.map_err(|e| ToolCallError::ExecutionFailed(format!("{e}")))?; let display = output.display_content; - let text = output.content.into_string(); - let mut result = ToolResult::success(ToolResultContent::Text(text), "File read"); + let mut result = ToolResult::success(tool_result_content(output.content), "File read"); result.display_content = display; Ok(result) } } +fn tool_result_content(content: ToolContent) -> ToolResultContent { + match content { + ToolContent::Text(text) => ToolResultContent::Text(text), + ToolContent::Json(json) => ToolResultContent::Json(json), + ToolContent::Mixed { text, json } => ToolResultContent::Mixed { text, json }, + } +} + fn is_full_file_read(offset: Option, limit: Option) -> bool { matches!(offset, None | Some(1)) && limit.is_none() } diff --git a/crates/core/src/tools/handlers/shell_command.rs b/crates/core/src/tools/handlers/shell_command.rs index fa31b9b7..09e7352a 100644 --- a/crates/core/src/tools/handlers/shell_command.rs +++ b/crates/core/src/tools/handlers/shell_command.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use crate::contracts::{ ToolCallError, ToolContext, ToolProgressSender, ToolResult, ToolResultContent, }; +use crate::invocation::ToolContent; use crate::registry_plan::shell_command_tool_spec; use crate::shell_exec::{ DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_YIELD_TIME_MS, ShellExecRequest, @@ -13,9 +14,9 @@ use crate::tool_spec::ToolSpec; /// Tool adapter for `shell_command` (and the legacy `bash` alias). /// -/// Parses model input and runs the command locally via [`execute_shell_command`]. -/// The ToolSpec comes from [`shell_command_tool_spec`] so the registry plan and -/// handler share one schema. +/// Parses model input and runs the command locally via the shell executor. +/// The ToolSpec is built from the shared registry-plan schema so the plan and +/// handler stay aligned. pub struct ShellCommandHandler { spec: ToolSpec, } @@ -34,6 +35,14 @@ impl ShellCommandHandler { } } +fn tool_result_content(content: ToolContent) -> ToolResultContent { + match content { + ToolContent::Text(text) => ToolResultContent::Text(text), + ToolContent::Json(json) => ToolResultContent::Json(json), + ToolContent::Mixed { text, json } => ToolResultContent::Mixed { text, json }, + } +} + #[async_trait] impl ToolHandler for ShellCommandHandler { fn spec(&self) -> &ToolSpec { @@ -95,15 +104,22 @@ impl ToolHandler for ShellCommandHandler { .map_err(|e| ToolCallError::ExecutionFailed(e.to_string()))?; let display = output.display_content; - let text = output.content.into_string(); + let content = tool_result_content(output.content); let mut result = if output.is_error { + let message = match &content { + ToolResultContent::Text(text) => text.clone(), + ToolResultContent::Mixed { text, json } => text + .clone() + .unwrap_or_else(|| json.as_ref().map(ToString::to_string).unwrap_or_default()), + ToolResultContent::Json(json) => json.to_string(), + }; ToolResult::error( - ToolResultContent::Text(text.clone()), + content, "Command failed", - ToolCallError::ExecutionFailed(text), + ToolCallError::ExecutionFailed(message), ) } else { - ToolResult::success(ToolResultContent::Text(text), "Command executed") + ToolResult::success(content, "Command executed") }; result.display_content = display; Ok(result) diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs index 1bafaf94..4b7aff1f 100644 --- a/crates/core/src/tools/shell_exec/mod.rs +++ b/crates/core/src/tools/shell_exec/mod.rs @@ -111,8 +111,9 @@ pub(crate) async fn execute_shell_command( } // TODO: Preview truncation belongs on the client, not the server. Move -// `preview` (and callers that stuff truncated text into tool metadata) to -// the client side so the server returns full output / structured metadata. +// `preview` (used today for short command metadata) and any remaining +// display-oriented shortening to the client so the server returns full +// stream text plus structured metadata without duplicating output. pub(crate) fn preview(text: &str) -> String { if text.len() <= MAX_METADATA_LENGTH { return text.to_string(); diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs index dc23193d..4e772435 100644 --- a/crates/core/src/tools/shell_exec/pipe.rs +++ b/crates/core/src/tools/shell_exec/pipe.rs @@ -125,7 +125,6 @@ pub(crate) async fn run_with_pipes( Ok(FunctionToolOutput::success_with_metadata( result_text.clone(), json!({ - "output": preview(&result_text), "command": command_preview, "exit": status.code(), "description": description, diff --git a/crates/core/src/tools/shell_exec/pty.rs b/crates/core/src/tools/shell_exec/pty.rs index 261a644c..7f372c7f 100644 --- a/crates/core/src/tools/shell_exec/pty.rs +++ b/crates/core/src/tools/shell_exec/pty.rs @@ -12,7 +12,6 @@ use crate::events::ToolProgressSender; use crate::invocation::FunctionToolOutput; use super::launch::SandboxLaunchPlan; -use super::preview; use super::resolve::ResolvedShellRun; use super::truncate_output; @@ -223,7 +222,6 @@ pub(crate) async fn run_with_pty( Ok(FunctionToolOutput::success_with_metadata( content, json!({ - "output": preview(&text), "command": command_to_run, "exit": exit_code, "description": description, diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index 3b87b815..8d7d59d6 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -238,6 +238,17 @@ async fn execute_shell_command_success_metadata_is_mixed() { } => { assert!(text.contains("metadata_test")); assert_eq!(metadata["description"], "metadata test"); + assert!(metadata.get("output").is_none()); + assert_eq!( + ToolContent::Mixed { + text: Some(text.clone()), + json: Some(metadata.clone()), + } + .text_for_model() + .matches("metadata_test") + .count(), + 1 + ); } content => panic!("expected mixed success output, got {content:?}"), } diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index c74431e3..b40b9187 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -9,6 +9,12 @@ use serde::Serialize; use ts_rs::Config; use ts_rs::TS; +use crate::canonical::rpc_admin::McpListParams; +use crate::canonical::rpc_admin::McpListResult; +use crate::canonical::rpc_admin::McpServerInfo; +use crate::canonical::rpc_admin::McpToolEntry; +use crate::canonical::rpc_admin::McpToolsParams; +use crate::canonical::rpc_admin::McpToolsResult; use crate::parse_command::ParsedCommand; use crate::*; @@ -277,6 +283,13 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -638,6 +651,12 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -762,6 +781,8 @@ fn register_devo_protocol_schemas( methods, ClientMethod::SkillsSetEnabled, ); + devo_method::(methods, ClientMethod::McpList); + devo_method::(methods, ClientMethod::McpTools); devo_method::(methods, ClientMethod::ModelCatalog); devo_method::(methods, ClientMethod::ModelConfig); devo_method::(methods, ClientMethod::ModelConfigSet); diff --git a/crates/protocol/src/canonical/methods.rs b/crates/protocol/src/canonical/methods.rs index 62a1fa35..dbda5aa4 100644 --- a/crates/protocol/src/canonical/methods.rs +++ b/crates/protocol/src/canonical/methods.rs @@ -379,6 +379,14 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ required_capability: None, idempotency: Idempotency::None, }, + MethodSpec { + name: "mcp/tools", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, MethodSpec { name: "context/usage/read", params_schema: schema_of::, diff --git a/crates/protocol/src/canonical/rpc_admin.rs b/crates/protocol/src/canonical/rpc_admin.rs index 59ba0020..bbe456ba 100644 --- a/crates/protocol/src/canonical/rpc_admin.rs +++ b/crates/protocol/src/canonical/rpc_admin.rs @@ -176,6 +176,25 @@ pub struct McpListResult { pub servers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpToolsParams { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpToolEntry { + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpToolsResult { + pub tools: Vec, +} + // ── context/usage/read ── #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -264,3 +283,41 @@ pub struct CredentialDeleteParams { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CredentialDeleteResult {} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn mcp_tools_params_and_result_round_trip_camel_case() { + let params = McpToolsParams { + name: "time".to_string(), + }; + let params_json = serde_json::to_value(¶ms).expect("serialize params"); + assert_eq!(params_json, serde_json::json!({ "name": "time" })); + assert_eq!( + serde_json::from_value::(params_json).expect("parse params"), + params + ); + + let result = McpToolsResult { + tools: vec![McpToolEntry { + name: "get_time".to_string(), + description: "Current time".to_string(), + }], + }; + let result_json = serde_json::to_value(&result).expect("serialize result"); + assert_eq!( + result_json, + serde_json::json!({ + "tools": [{ "name": "get_time", "description": "Current time" }] + }) + ); + assert_eq!( + serde_json::from_value::(result_json).expect("parse result"), + result + ); + } +} diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 25c61bb5..60cd9db8 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -105,6 +105,8 @@ pub enum ClientMethod { ProviderVendorList, ProviderValidate, ProviderVendorUpsert, + McpList, + McpTools, // New Native API methods (canonical types; not part of the legacy // `_devo/*` alias surface). SessionTurnsList, @@ -174,6 +176,8 @@ impl ClientMethod { Self::ProviderVendorList => "provider/list", Self::ProviderValidate => "provider/validate", Self::ProviderVendorUpsert => "provider/upsert", + Self::McpList => "mcp/list", + Self::McpTools => "mcp/tools", Self::SessionTurnsList => "session/turns/list", Self::SessionItemsList => "session/items/list", Self::SessionRollbackPreview => "session/rollback/preview", @@ -241,6 +245,8 @@ impl ClientMethod { "provider/list" => Self::ProviderVendorList, "provider/validate" => Self::ProviderValidate, "provider/upsert" => Self::ProviderVendorUpsert, + "mcp/list" => Self::McpList, + "mcp/tools" => Self::McpTools, "session/turns/list" => Self::SessionTurnsList, "session/items/list" => Self::SessionItemsList, "session/rollback/preview" => Self::SessionRollbackPreview, @@ -684,6 +690,21 @@ mod tests { ); } + #[test] + fn client_method_recognizes_mcp_list() { + assert_eq!(ClientMethod::parse("mcp/list"), Some(ClientMethod::McpList)); + assert_eq!(ClientMethod::McpList.as_str(), "mcp/list"); + } + + #[test] + fn client_method_recognizes_mcp_tools() { + assert_eq!( + ClientMethod::parse("mcp/tools"), + Some(ClientMethod::McpTools) + ); + assert_eq!(ClientMethod::McpTools.as_str(), "mcp/tools"); + } + #[test] fn client_method_recognizes_workspace_changes_read() { assert_eq!( diff --git a/crates/protocol/src/slash_command.rs b/crates/protocol/src/slash_command.rs index 83039625..e3c721f6 100644 --- a/crates/protocol/src/slash_command.rs +++ b/crates/protocol/src/slash_command.rs @@ -30,8 +30,8 @@ impl SlashCommand { match self { SlashCommand::Theme => "switch the UI theme", SlashCommand::Model => "choose the active model", - SlashCommand::Skills => "show available skills", - SlashCommand::Mcp => "show configured MCP servers", + SlashCommand::Skills => "browse available skills", + SlashCommand::Mcp => "browse MCP servers", SlashCommand::Compact => "compact the current session context", SlashCommand::Resume => "resume a saved chat", SlashCommand::New => "start a new chat", @@ -59,7 +59,7 @@ impl SlashCommand { SlashCommand::Theme => "theme", SlashCommand::Model => "model", SlashCommand::Skills => "skills", - SlashCommand::Mcp => "mcp", + SlashCommand::Mcp => "mcps", SlashCommand::Compact => "compact", SlashCommand::Resume => "resume", SlashCommand::New => "new", @@ -154,7 +154,7 @@ impl FromStr for SlashCommand { "theme" => Ok(Self::Theme), "model" => Ok(Self::Model), "skills" => Ok(Self::Skills), - "mcp" => Ok(Self::Mcp), + "mcps" | "mcp" => Ok(Self::Mcp), "compact" => Ok(Self::Compact), "resume" => Ok(Self::Resume), "new" => Ok(Self::New), @@ -178,7 +178,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { ("theme", SlashCommand::Theme), ("model", SlashCommand::Model), ("skills", SlashCommand::Skills), - ("mcp", SlashCommand::Mcp), + ("mcps", SlashCommand::Mcp), ("compact", SlashCommand::Compact), ("resume", SlashCommand::Resume), ("new", SlashCommand::New), diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index f161bf54..20007852 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -194,12 +194,13 @@ pub async fn run_server_process( "loaded server config" ); - let mcp_manager = Arc::new(RmcpMcpManager::new( + let mcp_manager: Arc = Arc::new(RmcpMcpManager::new( config.mcp.clone(), config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&config); - let registry = handlers::build_registry_from_plan_with_mcp(&tool_plan, mcp_manager).await; + let registry = + handlers::build_registry_from_plan_with_mcp(&tool_plan, Arc::clone(&mcp_manager)).await; let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( &config.provider.model_overrides, )?); @@ -233,6 +234,7 @@ pub async fn run_server_process( provider.provider, provider_router, Arc::clone(®istry), + mcp_manager, provider.default_model, model_catalog, Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 352e2c13..a85dec4d 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -11,6 +11,7 @@ use tokio::sync::Mutex; use tokio::sync::oneshot; use devo_core::AgentsMdConfig; +use devo_core::McpManager; use devo_core::ModelCatalog; use devo_core::SessionConfig; use devo_core::SessionRecord; @@ -129,6 +130,14 @@ pub struct ServerRuntimeDependencies { pub(crate) process_context: Arc, } +/// Builds an empty MCP manager for tests and bootstrap paths without servers. +pub fn empty_mcp_manager() -> Arc { + Arc::new(devo_mcp::manager::RmcpMcpManager::new( + devo_core::McpConfig::default(), + Default::default(), + )) +} + impl ServerRuntimeDependencies { /// Creates a new bundle of runtime dependencies for the transport server. /// TODO: Should fix the clippy::too_many_arguments, decrease the arguments count. @@ -137,6 +146,7 @@ impl ServerRuntimeDependencies { provider: Arc, provider_router: Arc, registry: Arc, + mcp_manager: Arc, default_model: String, model_catalog: Arc, provider_vendor_catalog: Arc, @@ -150,6 +160,7 @@ impl ServerRuntimeDependencies { Arc::clone(&provider), Arc::clone(&provider_router), Arc::clone(®istry), + mcp_manager, default_model.clone(), Arc::clone(&model_catalog), Arc::clone(&skill_catalog), @@ -353,6 +364,7 @@ mod tests { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + empty_mcp_manager(), "catalog-slug".to_string(), Arc::new(PresetModelCatalog::new(vec![ Model { diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index c3f7e823..aa640db9 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -33,6 +33,7 @@ pub use client::*; pub use connection::*; pub use event::*; pub use execution::ServerRuntimeDependencies; +pub use execution::empty_mcp_manager; pub use projection::*; pub use protocol::*; pub use provider_config::*; diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 1330ac38..42f770d0 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -3601,6 +3601,7 @@ mod tests { Arc::clone(&provider), Arc::new(devo_provider::SingleProviderRouter::new(provider)), Arc::new(devo_core::tools::ToolRegistry::new()), + crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(devo_core::PresetModelCatalog::default()), Arc::new(devo_core::ProviderVendorCatalog::default()), diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 340bcacc..6fff01a2 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -147,6 +147,7 @@ mod hooks; mod interaction_items; mod items; mod lifecycle; +mod mcp; mod model_api; mod outbound; mod permission_decision; diff --git a/crates/server/src/runtime/agents/lifecycle.rs b/crates/server/src/runtime/agents/lifecycle.rs index f04e3a7e..c51531bb 100644 --- a/crates/server/src/runtime/agents/lifecycle.rs +++ b/crates/server/src/runtime/agents/lifecycle.rs @@ -187,6 +187,7 @@ mod tests { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 49f67a56..251b8bfd 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -364,6 +364,8 @@ impl ServerRuntime { Some(ClientMethod::SkillsSetEnabled) => { Some(self.handle_skills_set_enabled(id?, params).await) } + Some(ClientMethod::McpList) => Some(self.handle_mcp_list(id?, params).await), + Some(ClientMethod::McpTools) => Some(self.handle_mcp_tools(id?, params).await), // get the model catalog, aka the configured models list Some(ClientMethod::ModelCatalog) => Some(self.handle_model_catalog(id?, params).await), Some(ClientMethod::ModelConfig) => Some(self.handle_model_config(id?, params).await), @@ -1473,6 +1475,7 @@ mod tests { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), @@ -1490,6 +1493,50 @@ mod tests { ) } + #[tokio::test] + async fn mcp_tools_returns_invalid_params_for_unknown_server() { + let temp = TempDir::new().expect("temp dir"); + let runtime = build_runtime(temp.path()); + let connection_id = initialized_connection(&runtime).await; + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 3, + "method": "mcp/tools", + "params": { "name": "missing-server" } + }), + ) + .await + .expect("mcp/tools response"); + let error: ErrorResponse = serde_json::from_value(response).expect("deserialize error"); + assert_eq!(error.error.code, ProtocolErrorCode::InvalidParams); + } + + #[tokio::test] + async fn mcp_list_returns_empty_servers_for_default_manager() { + let temp = TempDir::new().expect("temp dir"); + let runtime = build_runtime(temp.path()); + let connection_id = initialized_connection(&runtime).await; + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "mcp/list", + "params": {} + }), + ) + .await + .expect("mcp/list response"); + let result: SuccessResponse = + serde_json::from_value(response.clone()).expect("deserialize mcp/list"); + assert_eq!( + result.result, + devo_protocol::canonical::rpc_admin::McpListResult { servers: vec![] } + ); + } + fn assert_agent_message_chunk_update( update: &serde_json::Value, turn_id: TurnId, diff --git a/crates/server/src/runtime/mcp.rs b/crates/server/src/runtime/mcp.rs new file mode 100644 index 00000000..7df07ece --- /dev/null +++ b/crates/server/src/runtime/mcp.rs @@ -0,0 +1,161 @@ +//! MCP admin RPC handlers (`mcp/list`, `mcp/tools`). + +use devo_core::McpServerId; +use devo_core::McpStartupState; +use devo_protocol::SuccessResponse; +use devo_protocol::canonical::rpc_admin::McpListParams; +use devo_protocol::canonical::rpc_admin::McpListResult; +use devo_protocol::canonical::rpc_admin::McpServerInfo; +use devo_protocol::canonical::rpc_admin::McpToolEntry; +use devo_protocol::canonical::rpc_admin::McpToolsParams; +use devo_protocol::canonical::rpc_admin::McpToolsResult; + +use super::ServerRuntime; +use crate::ProtocolErrorCode; + +impl ServerRuntime { + pub(super) async fn handle_mcp_list( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + if let Err(error) = serde_json::from_value::(params) { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid mcp/list params: {error}"), + ); + } + + match self.deps.process_context.mcp_manager.statuses().await { + Ok(statuses) => { + let mut servers = statuses + .into_iter() + .map(|status| McpServerInfo { + name: status.server_id.0, + status: startup_state_label(&status.startup_state).to_string(), + tool_count: status.tools.len() as u32, + }) + .collect::>(); + servers.sort_by(|left, right| left.name.cmp(&right.name)); + serde_json::to_value(SuccessResponse { + id: request_id, + result: McpListResult { servers }, + }) + .expect("serialize mcp/list response") + } + Err(error) => self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to list mcp servers: {error}"), + ), + } + } + + pub(super) async fn handle_mcp_tools( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid mcp/tools params: {error}"), + ); + } + }; + + let name = params.name.trim(); + if name.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "mcp/tools requires a non-empty name".to_string(), + ); + } + let server_id = McpServerId(name.to_string()); + let manager = &self.deps.process_context.mcp_manager; + + let needs_refresh = match manager.statuses().await { + Ok(statuses) => { + let status = statuses.iter().find(|status| status.server_id == server_id); + match status { + None => true, + Some(status) => { + matches!( + status.startup_state, + McpStartupState::NotStarted + | McpStartupState::Stopped + | McpStartupState::Failed + ) || status.tools.is_empty() + && !matches!(status.startup_state, McpStartupState::Disabled) + } + } + } + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read mcp statuses: {error}"), + ); + } + }; + + if needs_refresh { + if let Err(error) = manager.refresh(&server_id).await { + // Still try to return whatever tools we have after a failed refresh. + tracing::warn!(server = %server_id, error = %error, "mcp/tools refresh failed"); + } + } + + match manager.statuses().await { + Ok(statuses) => { + let Some(status) = statuses + .into_iter() + .find(|status| status.server_id == server_id) + else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("mcp server `{name}` not found"), + ); + }; + let mut tools = status + .tools + .into_iter() + .map(|tool| McpToolEntry { + name: tool.name, + description: tool.description, + }) + .collect::>(); + tools.sort_by(|left, right| left.name.cmp(&right.name)); + serde_json::to_value(SuccessResponse { + id: request_id, + result: McpToolsResult { tools }, + }) + .expect("serialize mcp/tools response") + } + Err(error) => self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to list mcp tools: {error}"), + ), + } + } +} + +fn startup_state_label(state: &McpStartupState) -> &'static str { + match state { + McpStartupState::Disabled => "disabled", + McpStartupState::NotStarted => "not_started", + McpStartupState::Starting => "starting", + McpStartupState::Ready => "ready", + McpStartupState::Failed => "failed", + McpStartupState::AuthRequired => "auth_required", + McpStartupState::Degraded => "degraded", + McpStartupState::Stopped => "stopped", + } +} diff --git a/crates/server/src/runtime/turn_exec/tool_results.rs b/crates/server/src/runtime/turn_exec/tool_results.rs index ce70e939..cf550b0b 100644 --- a/crates/server/src/runtime/turn_exec/tool_results.rs +++ b/crates/server/src/runtime/turn_exec/tool_results.rs @@ -18,9 +18,29 @@ pub(super) fn tool_content_to_json(content: ToolContent) -> serde_json::Value { match content { ToolContent::Text(text) => serde_json::Value::String(text), ToolContent::Json(json) => json, - ToolContent::Mixed { text, json } => { - json.unwrap_or_else(|| serde_json::Value::String(text.unwrap_or_default())) + // Object metadata (shell_exec): keep the original object shape and only + // fill `output` from Mixed text when the producer omitted the duplicate. + ToolContent::Mixed { + text: Some(text), + json: Some(serde_json::Value::Object(mut map)), + } => { + map.entry("output".to_string()) + .or_insert_with(|| serde_json::Value::String(text)); + serde_json::Value::Object(map) } + // Preserve arrays/scalars/etc. exactly (e.g. hosted web_search hits). + ToolContent::Mixed { + text: _, + json: Some(json), + } => json, + ToolContent::Mixed { + text: Some(text), + json: None, + } => serde_json::Value::String(text), + ToolContent::Mixed { + text: None, + json: None, + } => serde_json::Value::Null, } } @@ -438,3 +458,76 @@ pub(super) async fn emit_tool_result_item( ) .await; } + +#[cfg(test)] +mod tests { + use super::tool_content_to_json; + use devo_core::tools::ToolContent; + use pretty_assertions::assert_eq; + + #[test] + fn mixed_object_fills_missing_output_from_text() { + let content = ToolContent::Mixed { + text: Some("hello\nworld".into()), + json: Some(serde_json::json!({ + "command": "echo hello", + "exit": 0, + })), + }; + assert_eq!( + tool_content_to_json(content), + serde_json::json!({ + "command": "echo hello", + "exit": 0, + "output": "hello\nworld", + }) + ); + } + + #[test] + fn mixed_object_preserves_existing_output() { + let content = ToolContent::Mixed { + text: Some("stream".into()), + json: Some(serde_json::json!({ + "exit": 0, + "output": "already set", + })), + }; + assert_eq!( + tool_content_to_json(content), + serde_json::json!({ + "exit": 0, + "output": "already set", + }) + ); + } + + #[test] + fn mixed_array_json_preserves_original_shape() { + let hits = serde_json::json!([ + {"title": "a", "url": "https://a.example"}, + {"title": "b", "url": "https://b.example"}, + ]); + let content = ToolContent::Mixed { + text: Some("search summary".into()), + json: Some(hits.clone()), + }; + assert_eq!(tool_content_to_json(content), hits); + } + + #[test] + fn mixed_webfetch_image_object_keeps_image_fields() { + let content = ToolContent::Mixed { + text: Some("Image fetched successfully".into()), + json: Some(serde_json::json!({ + "title": "https://example.com/a.png (image/png)", + "mime": "image/png", + "image_base64": "abc123", + })), + }; + let json = tool_content_to_json(content); + assert_eq!(json["image_base64"], "abc123"); + assert_eq!(json["mime"], "image/png"); + assert_eq!(json["output"], "Image fetched successfully"); + } +} diff --git a/crates/server/src/runtime/turn_reservation.rs b/crates/server/src/runtime/turn_reservation.rs index 920e246f..e7c8a834 100644 --- a/crates/server/src/runtime/turn_reservation.rs +++ b/crates/server/src/runtime/turn_reservation.rs @@ -138,6 +138,7 @@ mod tests { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index aa75ce9f..6de87d90 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -11,6 +11,7 @@ use devo_core::AgentsMdConfig; use devo_core::AppConfig; use devo_core::AppConfigStore; use devo_core::FileSystemSkillCatalog; +use devo_core::McpManager; use devo_core::Model; use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; @@ -58,6 +59,7 @@ pub(crate) struct SessionRuntimeContext { pub(crate) provider: Arc, pub(crate) provider_router: Arc, pub(crate) registry: Arc, + pub(crate) mcp_manager: Arc, pub(crate) default_model: String, pub(crate) model_catalog: Arc, pub(crate) skill_catalog: Arc>>, @@ -111,6 +113,7 @@ impl SessionRuntimeContext { provider: Arc, provider_router: Arc, registry: Arc, + mcp_manager: Arc, default_model: String, model_catalog: Arc, skill_catalog: Arc>>, @@ -121,6 +124,7 @@ impl SessionRuntimeContext { provider, provider_router, registry, + mcp_manager, default_model, model_catalog, skill_catalog, @@ -154,15 +158,24 @@ impl SessionRuntimeContext { .provider .is_operationally_equivalent_to(&inherited_config.provider) || config.provider_http != inherited_config.provider_http; - let registry = if !has_provider_configuration && config.mcp.servers.is_empty() { - Arc::clone(&inherited_context.registry) + let (registry, mcp_manager) = if !has_provider_configuration + && config.mcp.servers.is_empty() + { + ( + Arc::clone(&inherited_context.registry), + Arc::clone(&inherited_context.mcp_manager), + ) } else { - let mcp_manager = Arc::new(RmcpMcpManager::new( + let mcp_manager: Arc = Arc::new(RmcpMcpManager::new( config.mcp.clone(), config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&config); - Arc::new(handlers::build_registry_from_plan_with_mcp(&tool_plan, mcp_manager).await) + let registry = Arc::new( + handlers::build_registry_from_plan_with_mcp(&tool_plan, Arc::clone(&mcp_manager)) + .await, + ); + (registry, mcp_manager) }; let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( &config.provider.model_overrides, @@ -207,6 +220,7 @@ impl SessionRuntimeContext { provider, provider_router, registry, + mcp_manager, provider_default_model, model_catalog, skill_catalog, diff --git a/crates/server/tests/acp_available_commands.rs b/crates/server/tests/acp_available_commands.rs index bd1b86df..669a0749 100644 --- a/crates/server/tests/acp_available_commands.rs +++ b/crates/server/tests/acp_available_commands.rs @@ -211,6 +211,7 @@ fn build_runtime(data_root: &Path) -> Result> { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/acp_permission_tool_status_contract.rs b/crates/server/tests/acp_permission_tool_status_contract.rs index 91eb8e52..e2490851 100644 --- a/crates/server/tests/acp_permission_tool_status_contract.rs +++ b/crates/server/tests/acp_permission_tool_status_contract.rs @@ -542,6 +542,7 @@ fn build_runtime( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), registry, + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/acp_session_delete.rs b/crates/server/tests/acp_session_delete.rs index 5e939cbe..0fb8f4a7 100644 --- a/crates/server/tests/acp_session_delete.rs +++ b/crates/server/tests/acp_session_delete.rs @@ -122,6 +122,7 @@ async fn acp_session_delete_removes_session_from_history_and_is_idempotent() -> Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), @@ -407,6 +408,7 @@ fn build_runtime_with_provider( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/acp_session_lifecycle.rs b/crates/server/tests/acp_session_lifecycle.rs index 7c25d443..4a49a1b6 100644 --- a/crates/server/tests/acp_session_lifecycle.rs +++ b/crates/server/tests/acp_session_lifecycle.rs @@ -922,6 +922,7 @@ fn build_runtime(data_root: &Path) -> Result> { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/cli_log_failures.rs b/crates/server/tests/cli_log_failures.rs index 097d0063..31182cad 100644 --- a/crates/server/tests/cli_log_failures.rs +++ b/crates/server/tests/cli_log_failures.rs @@ -254,6 +254,7 @@ fn build_runtime( provider, provider_router, Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/command_exec.rs b/crates/server/tests/command_exec.rs index 2da2e5e8..a2f1c308 100644 --- a/crates/server/tests/command_exec.rs +++ b/crates/server/tests/command_exec.rs @@ -207,6 +207,7 @@ fn build_runtime(data_root: &std::path::Path) -> Result> { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/end_to_end.rs b/crates/server/tests/end_to_end.rs index e2464e48..d2170355 100644 --- a/crates/server/tests/end_to_end.rs +++ b/crates/server/tests/end_to_end.rs @@ -450,6 +450,7 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle() Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), @@ -668,6 +669,7 @@ async fn websocket_turn_streams_final_tool_metadata_for_read_and_glob() -> Resul Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(devo_core::tools::create_default_tool_registry()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/goal_title_generation.rs b/crates/server/tests/goal_title_generation.rs index 13c6da28..62d2519c 100644 --- a/crates/server/tests/goal_title_generation.rs +++ b/crates/server/tests/goal_title_generation.rs @@ -214,6 +214,7 @@ fn build_runtime( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index d315ddd1..91a26fc6 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -1727,6 +1727,7 @@ fn build_runtime_with_provider( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![ Model { diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 25b7e14b..4dfb15c5 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -387,6 +387,7 @@ fn build_runtime( provider, provider_router, Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "default-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "default-model".to_string(), diff --git a/crates/server/tests/provider_routing.rs b/crates/server/tests/provider_routing.rs index 26089faf..aa22dcd4 100644 --- a/crates/server/tests/provider_routing.rs +++ b/crates/server/tests/provider_routing.rs @@ -568,6 +568,7 @@ fn build_runtime_with_models( provider, provider_router, Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), default_model.to_string(), Arc::new(PresetModelCatalog::new(models)), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/session_fork_persistence.rs b/crates/server/tests/session_fork_persistence.rs index 86a8fad1..2af15e19 100644 --- a/crates/server/tests/session_fork_persistence.rs +++ b/crates/server/tests/session_fork_persistence.rs @@ -190,6 +190,7 @@ fn build_runtime(data_root: &Path) -> Result> { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/session_rollback_persistence.rs b/crates/server/tests/session_rollback_persistence.rs index 361a3cb0..d3df8347 100644 --- a/crates/server/tests/session_rollback_persistence.rs +++ b/crates/server/tests/session_rollback_persistence.rs @@ -222,6 +222,7 @@ fn build_runtime( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/session_sandbox_profile.rs b/crates/server/tests/session_sandbox_profile.rs index 2be43a87..c4f7701e 100644 --- a/crates/server/tests/session_sandbox_profile.rs +++ b/crates/server/tests/session_sandbox_profile.rs @@ -73,6 +73,7 @@ fn build_runtime(data_root: &Path) -> Result> { Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index d0497c5f..47f5b58f 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -163,6 +163,7 @@ fn build_runtime_with_registry( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), registry, + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/support/goal_continuation.rs b/crates/server/tests/support/goal_continuation.rs index 41c2b15c..e29c6996 100644 --- a/crates/server/tests/support/goal_continuation.rs +++ b/crates/server/tests/support/goal_continuation.rs @@ -285,6 +285,7 @@ pub fn build_runtime_with_registry( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), registry, + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/support/subagent_lifecycle.rs b/crates/server/tests/support/subagent_lifecycle.rs index d1b9fa02..7771c93e 100644 --- a/crates/server/tests/support/subagent_lifecycle.rs +++ b/crates/server/tests/support/subagent_lifecycle.rs @@ -270,6 +270,7 @@ pub fn build_runtime( Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(create_default_tool_registry()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/server/tests/turn_start_persistence.rs b/crates/server/tests/turn_start_persistence.rs index 4c13c6c9..194336f5 100644 --- a/crates/server/tests/turn_start_persistence.rs +++ b/crates/server/tests/turn_start_persistence.rs @@ -491,6 +491,7 @@ fn build_runtime( provider, router, Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::new(vec![Model { slug: "test-model".to_string(), diff --git a/crates/server/tests/websocket_client_e2e.rs b/crates/server/tests/websocket_client_e2e.rs index a598b2b3..390f4acb 100644 --- a/crates/server/tests/websocket_client_e2e.rs +++ b/crates/server/tests/websocket_client_e2e.rs @@ -64,6 +64,7 @@ async fn websocket_server_client_drives_listener_session_and_notifications() -> Arc::clone(&provider), Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), Arc::new(ProviderVendorCatalog::default()), diff --git a/crates/tools/src/invocation.rs b/crates/tools/src/invocation.rs index 4dcfa3ee..4903f541 100644 --- a/crates/tools/src/invocation.rs +++ b/crates/tools/src/invocation.rs @@ -64,6 +64,47 @@ impl ToolContent { } } + /// Model-facing serialization for tools whose `text` is the stream and whose + /// `json` is non-model metadata (e.g. shell exit/cwd). Prefer + /// [`Self::into_string`] when JSON carries model-visible payload (images, + /// search hits, etc.). + pub fn text_for_model(self) -> String { + match self { + ToolContent::Text(text) => text, + ToolContent::Json(json) => json.to_string(), + ToolContent::Mixed { text, json } => match text { + Some(text) => text, + None => json.map(|value| value.to_string()).unwrap_or_default(), + }, + } + } + + /// Byte length of [`Self::text_for_model`] without consuming `self`. + pub fn text_for_model_byte_len(&self) -> usize { + match self { + ToolContent::Text(text) => text.len(), + ToolContent::Json(json) => json.to_string().len(), + ToolContent::Mixed { text, json } => match text { + Some(text) => text.len(), + None => json.as_ref().map_or(0, |value| value.to_string().len()), + }, + } + } + + /// Byte length of [`Self::into_string`] without consuming `self`. + pub fn into_string_byte_len(&self) -> usize { + match self { + ToolContent::Text(text) => text.len(), + ToolContent::Json(json) => json.to_string().len(), + ToolContent::Mixed { text, json } => { + let text_len = text.as_ref().map_or(0, String::len); + let json_len = json.as_ref().map_or(0, |value| value.to_string().len()); + let separator = usize::from(text.is_some() && json.is_some()); + text_len + separator + json_len + } + } + } + pub fn into_string(self) -> String { match self { ToolContent::Text(t) => t, @@ -185,9 +226,30 @@ mod tests { json: Some(serde_json::json!({"key": 1})), }; assert_eq!(c.text_part(), Some("text")); - let s = c.into_string(); + assert_eq!(c.text_for_model_byte_len(), 4); + let s = c.clone().into_string(); assert!(s.contains("text")); assert!(s.contains("key")); + assert_eq!(c.clone().text_for_model(), "text"); + assert_eq!(c.into_string_byte_len(), s.len()); + } + + #[test] + fn tool_content_mixed_text_for_model_omits_json() { + let output = "hello\nworld".to_string(); + let content = ToolContent::Mixed { + text: Some(output.clone()), + json: Some(serde_json::json!({ + "command": "echo hello", + "exit": 0, + "cwd": "/tmp", + })), + }; + let model = content.text_for_model(); + assert_eq!(model, output); + assert_eq!(model.matches("hello").count(), 1); + assert!(!model.contains("\"exit\"")); + assert!(!model.contains("\"command\"")); } #[test] diff --git a/crates/tui/src/app_command.rs b/crates/tui/src/app_command.rs index 0d2d4a54..a44b6e65 100644 --- a/crates/tui/src/app_command.rs +++ b/crates/tui/src/app_command.rs @@ -113,6 +113,23 @@ pub(crate) enum AppCommand { ForkAtUserTurn { user_turn_index: u32, }, + /// Request MCP server runtime statuses (`mcp/list`). + ListMcpServers, + /// Request tools for one MCP server (`mcp/tools`). + ListMcpTools { + name: String, + }, + /// Persist enable/disable for one MCP server in user config. + SetMcpServerEnabled { + name: String, + enabled: bool, + }, + /// Persistently enable or disable one skill by `SKILL.md` path. + SetSkillEnabled { + path: PathBuf, + enabled: bool, + name: String, + }, } #[allow(dead_code)] @@ -371,6 +388,10 @@ impl AppCommand { Self::DeleteSession => "delete_session", Self::RollbackToUserTurn { .. } => "rollback_to_user_turn", Self::ForkAtUserTurn { .. } => "fork_at_user_turn", + Self::ListMcpServers => "list_mcp_servers", + Self::ListMcpTools { .. } => "list_mcp_tools", + Self::SetMcpServerEnabled { .. } => "set_mcp_server_enabled", + Self::SetSkillEnabled { .. } => "set_skill_enabled", } } @@ -465,6 +486,10 @@ impl AppCommand { Self::ForkAtUserTurn { user_turn_index } => AppCommandView::ThreadRollback { num_turns: *user_turn_index, }, + Self::ListMcpServers + | Self::ListMcpTools { .. } + | Self::SetMcpServerEnabled { .. } + | Self::SetSkillEnabled { .. } => AppCommandView::ReloadUserConfig, } } diff --git a/crates/tui/src/app_event.rs b/crates/tui/src/app_event.rs index 5781da44..5cdcdf5b 100644 --- a/crates/tui/src/app_event.rs +++ b/crates/tui/src/app_event.rs @@ -147,6 +147,16 @@ pub(crate) enum AppEvent { /// Replace the current status message. StatusMessageChanged { message: String }, + /// Insert text into the composer at the cursor (used by `/mcps` / `/skills` pickers). + /// + /// When `binding` is set, `text` is inserted as a highlighted atomic chip and + /// expanded to `binding` in the model-facing payload on submit (same path as + /// `@` file mentions). + InsertComposerText { + text: String, + binding: Option, + }, + #[allow(dead_code)] /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec }, @@ -175,6 +185,16 @@ pub(crate) enum AppEvent { ThemeSelected { name: String }, /// Apply show-reasoning preference (collapsed vs full). CollapseReasoningSelected { collapsed: bool }, + /// Open the `/mcps` server list from the cached snapshot. + McpOpenServerList, + /// Open the `/mcps` detail panel for one server. + McpOpenServerDetail { name: String }, + /// Select one MCP server from the picker (opens detail). + McpServerSelected { name: String }, + /// Open the `/skills` list from the cached snapshot. + SkillOpenList, + /// Select one skill from the picker (opens detail). + SkillSelected { name: String }, /// Clear the managed inline UI and re-emit committed transcript lines. /// /// Used after theme changes so already-flushed header/logo cells can be diff --git a/crates/tui/src/bottom_pane/chat_composer.rs b/crates/tui/src/bottom_pane/chat_composer.rs index 57c56476..d5882e20 100644 --- a/crates/tui/src/bottom_pane/chat_composer.rs +++ b/crates/tui/src/bottom_pane/chat_composer.rs @@ -1317,6 +1317,28 @@ impl ChatComposer { self.sync_popups(); } + /// Insert `display` as a highlighted atomic chip bound to a model-facing value. + /// + /// `display` must be a mention-shaped token (e.g. `@get_current_time`) so the + /// existing submit/history mention machinery can restore and expand it. + pub(crate) fn insert_bound_element(&mut self, display: &str, binding_path: &str) { + let Some(mention) = Self::mention_name_from_insert_text(display) else { + self.insert_str(display); + self.insert_str(" "); + return; + }; + let id = self.textarea.insert_element(display); + self.mention_bindings.insert( + id, + ComposerMentionBinding { + mention, + path: binding_path.to_string(), + }, + ); + self.textarea.insert_str(" "); + self.sync_popups(); + } + /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if !self.input_enabled { diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index 4f0ba4df..c50e48f5 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -278,7 +278,7 @@ mod tests { CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); - assert_eq!(cmds, vec!["model", "mcp"]); + assert_eq!(cmds, vec!["model", "mcps"]); } #[test] @@ -328,7 +328,7 @@ mod tests { "theme", "model", "skills", - "mcp", + "mcps", "compact", "resume", "new", diff --git a/crates/tui/src/bottom_pane/list_selection_view.rs b/crates/tui/src/bottom_pane/list_selection_view.rs index b834bfbf..348fe37a 100644 --- a/crates/tui/src/bottom_pane/list_selection_view.rs +++ b/crates/tui/src/bottom_pane/list_selection_view.rs @@ -142,6 +142,8 @@ pub(crate) struct SelectionViewParams { pub view_id: Option<&'static str>, pub title: Option, pub subtitle: Option, + /// Rich subtitle lines. When non-empty, used instead of [`Self::subtitle`]. + pub subtitle_lines: Vec>, pub footer_note: Option>, pub footer_hint: Option>, pub items: Vec, @@ -184,6 +186,7 @@ impl Default for SelectionViewParams { view_id: None, title: None, subtitle: None, + subtitle_lines: Vec::new(), footer_note: None, footer_hint: None, items: Vec::new(), @@ -254,14 +257,23 @@ impl ListSelectionView { accent_color: Color, ) -> Self { let mut header = params.header; - if params.title.is_some() || params.subtitle.is_some() { - let title = params.title.map(|title| Line::from(title.bold())); - let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = Box::new(ColumnRenderable::with([ - header, - Box::new(title), - Box::new(subtitle), - ])); + let has_subtitle = params.subtitle.is_some() || !params.subtitle_lines.is_empty(); + if params.title.is_some() || has_subtitle { + let mut column = ColumnRenderable::new(); + column.push(header); + if let Some(title) = params.title { + column.push(Line::from(title.bold())); + } + if !params.subtitle_lines.is_empty() { + for line in params.subtitle_lines { + column.push(line); + } + } else if let Some(subtitle) = params.subtitle { + for line in subtitle.lines() { + column.push(Line::from(line.to_string().dim())); + } + } + header = Box::new(column); } let mut s = Self { view_id: params.view_id, diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 8341a1ff..2db547de 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -445,11 +445,44 @@ impl BottomPane { self.request_redraw(); } - #[allow(dead_code)] pub(crate) fn composer_text(&self) -> String { self.composer.current_text() } + /// Insert text at the composer cursor, adding a leading space when needed. + pub(crate) fn insert_composer_text(&mut self, text: &str) { + let text = text.trim(); + if text.is_empty() { + return; + } + let current = self.composer.current_text(); + let needs_leading_space = + !current.is_empty() && !current.ends_with(|ch: char| ch.is_whitespace()); + if needs_leading_space { + self.composer.insert_str(&format!(" {text} ")); + } else { + self.composer.insert_str(&format!("{text} ")); + } + self.request_redraw(); + } + + /// Insert a highlighted chip whose model-facing value differs from the display text. + pub(crate) fn insert_composer_bound_text(&mut self, display: &str, binding: &str) { + let display = display.trim(); + let binding = binding.trim(); + if display.is_empty() || binding.is_empty() { + return; + } + let current = self.composer.current_text(); + let needs_leading_space = + !current.is_empty() && !current.ends_with(|ch: char| ch.is_whitespace()); + if needs_leading_space { + self.composer.insert_str(" "); + } + self.composer.insert_bound_element(display, binding); + self.request_redraw(); + } + #[cfg(test)] pub(crate) fn placeholder_text(&self) -> &str { &self.placeholder_text @@ -704,6 +737,25 @@ impl BottomPane { self.view_stack.last().map(std::convert::AsRef::as_ref) } + /// Children for an open bottom-pane view: optional status, composer, then + /// the view below — matching `@` / `/` composer popups. + fn active_view_layout_children<'a>( + &'a self, + view: &'a dyn BottomPaneView, + include_composer: bool, + ) -> Vec<&'a dyn Renderable> { + let mut children: Vec<&dyn Renderable> = Vec::with_capacity(4); + if let Some(status) = &self.status { + children.push(&STATUS_SEPARATOR); + children.push(status); + } + if include_composer { + children.push(&self.composer); + } + children.push(view); + children + } + fn push_view(&mut self, view: Box) { self.view_stack.push(view); self.request_redraw(); @@ -964,15 +1016,9 @@ impl Renderable for BottomPane { if area.is_empty() { return; } - if let Some(view) = self.active_view() - && let Some(status) = &self.status - { - let children: [&dyn Renderable; 3] = [&STATUS_SEPARATOR, status, view]; - self.render_children(area, buf, &children); - return; - } if let Some(view) = self.active_view() { - view.render(area, buf); + let children = self.active_view_layout_children(view, /*include_composer*/ true); + self.render_children(area, buf, &children); return; } let mut children: Vec<&dyn Renderable> = Vec::with_capacity(5); @@ -997,14 +1043,9 @@ impl Renderable for BottomPane { } fn desired_height(&self, width: u16) -> u16 { - if let Some(view) = self.active_view() - && let Some(status) = &self.status - { - let children: [&dyn Renderable; 3] = [&STATUS_SEPARATOR, status, view]; - return self.desired_children_height(width, &children); - } if let Some(view) = self.active_view() { - return view.desired_height(width); + let children = self.active_view_layout_children(view, /*include_composer*/ true); + return self.desired_children_height(width, &children); } let mut children: Vec<&dyn Renderable> = Vec::with_capacity(5); if let Some(status) = &self.status { @@ -1026,14 +1067,20 @@ impl Renderable for BottomPane { } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if let Some(view) = self.active_view() - && let Some(status) = &self.status - { - let children: [&dyn Renderable; 3] = [&STATUS_SEPARATOR, status, view]; - return self.child_cursor_pos(area, &children); - } if let Some(view) = self.active_view() { - return view.cursor_pos(area); + // Include composer for vertical offset (view sits below it), but skip + // its caret so an unfocused draft does not steal focus from the panel. + let mut y = area.y; + if let Some(status) = &self.status { + for child in [&STATUS_SEPARATOR as &dyn Renderable, status] { + let height = child.desired_height(area.width); + y = y.saturating_add(height); + } + } + y = y.saturating_add(self.composer.desired_height(area.width)); + let view_area = Rect::new(area.x, y, area.width, area.bottom().saturating_sub(y)) + .intersection(area); + return view.cursor_pos(view_area); } let mut children: Vec<&dyn Renderable> = Vec::with_capacity(5); if let Some(status) = &self.status { @@ -1182,3 +1229,111 @@ impl Renderable for ModelPickerView { u16::try_from(self.render_lines(width).len()).unwrap_or(u16::MAX) } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::bottom_pane::list_selection_view::SelectionItem; + use crate::bottom_pane::list_selection_view::SelectionViewParams; + use crate::tui::frame_requester::FrameRequester; + + fn test_bottom_pane() -> BottomPane { + let (tx, _rx) = mpsc::unbounded_channel::(); + BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(tx), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: true, + placeholder_text: "Ask Devo".to_string(), + disable_paste_burst: true, + skills: None, + animations_enabled: false, + }) + } + + fn render_bottom_pane(pane: &BottomPane, width: u16) -> String { + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect::>() + .join("\n") + } + + #[test] + fn active_view_stacks_below_composer_draft() { + let mut pane = test_bottom_pane(); + let draft = "keep this draft visible"; + pane.set_text_content(draft.to_string(), Vec::new(), Vec::new()); + let composer_only_height = pane.desired_height(/*width*/ 80); + let app_event_tx = pane.app_event_tx.clone(); + let accent = pane.accent_color; + + pane.open_popup_view(Box::new(ListSelectionView::new( + SelectionViewParams { + title: Some("Test Picker".to_string()), + items: vec![SelectionItem { + name: "Option A".to_string(), + dismiss_on_select: true, + ..SelectionItem::default() + }], + ..SelectionViewParams::default() + }, + app_event_tx, + accent, + ))); + + let stacked_height = pane.desired_height(/*width*/ 80); + assert!( + stacked_height > composer_only_height, + "stacked height {stacked_height} should exceed composer-only {composer_only_height}" + ); + + let rendered = render_bottom_pane(&pane, /*width*/ 80); + let draft_row = rendered + .lines() + .position(|line| line.contains(draft)) + .expect("missing composer draft"); + let picker_row = rendered + .lines() + .position(|line| line.contains("Test Picker")) + .expect("missing picker title"); + assert!( + picker_row > draft_row, + "picker should render below composer draft; draft_row={draft_row} picker_row={picker_row}\n{rendered}" + ); + + // Cursor must not land on the unfocused composer while a view is open. + let area = Rect::new(0, 0, 80, stacked_height); + let cursor = pane.cursor_pos(area); + let composer_height = pane.composer.desired_height(80); + if let Some((_, cursor_y)) = cursor { + assert!( + cursor_y >= composer_height, + "cursor y={cursor_y} should stay in the panel below composer height {composer_height}" + ); + } else { + // Selection views may not expose a caret; None is fine. + assert_eq!(cursor, None); + } + } +} diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 55feca35..e6289ca6 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -79,6 +79,10 @@ mod reasoning_effort; mod reasoning_view; +mod mcp_picker; + +mod skills_picker; + mod worker_events; use self::permission_presets::permission_preset_items; @@ -91,7 +95,6 @@ use self::subagent_monitor::SubagentMonitorState; use self::text_stream::ActiveTextItem; pub(crate) const MCP_SERVERS_TRANSCRIPT_TITLE: &str = "⬡ MCP Servers"; -pub(crate) const SKILLS_TRANSCRIPT_TITLE: &str = "▦ Skills"; #[cfg(test)] pub(crate) use self::reasoning_effort::ReasoningEffortListEntry; @@ -316,6 +319,14 @@ pub(crate) struct ChatWidget { startup_header_mascot_frame_index: usize, startup_header_next_animation_at: Instant, next_seq: u64, + /// Merged config + runtime snapshot for the interactive `/mcps` flow. + mcp_servers_snapshot: Option>, + /// After enable/disable, reopen this server's detail once list refreshes. + mcp_reopen_detail: Option, + /// Snapshot for the interactive `/skills` flow. + skills_snapshot: Option>, + /// After enable/disable, reopen this skill's detail once list refreshes. + skills_reopen_detail: Option, } impl ChatWidget { @@ -547,6 +558,10 @@ impl ChatWidget { startup_header_mascot_frame_index: 0, startup_header_next_animation_at: Instant::now() + STARTUP_HEADER_ANIMATION_INTERVAL, next_seq: 0, + mcp_servers_snapshot: None, + mcp_reopen_detail: None, + skills_snapshot: None, + skills_reopen_detail: None, }; // Model onboarding can inject additional startup UI before the first frame is drawn. diff --git a/crates/tui/src/chatwidget/input.rs b/crates/tui/src/chatwidget/input.rs index 00dfb2b7..46f01d24 100644 --- a/crates/tui/src/chatwidget/input.rs +++ b/crates/tui/src/chatwidget/input.rs @@ -244,10 +244,38 @@ impl ChatWidget { AppEvent::CollapseReasoningSelected { collapsed } => { self.apply_collapse_reasoning(collapsed); } + AppEvent::McpOpenServerList => { + self.open_mcp_server_list(); + } + AppEvent::McpOpenServerDetail { name } => { + self.open_mcp_server_detail(&name); + } + AppEvent::McpServerSelected { name } => { + self.open_mcp_server_detail(&name); + } + AppEvent::SkillOpenList => { + self.open_skills_list(); + } + AppEvent::SkillSelected { name } => { + self.open_skill_detail(&name); + } AppEvent::ReasoningEffortSelected { value } => { self.set_reasoning_effort_selection(value) } AppEvent::StatusMessageChanged { message } => self.set_status_message(message), + AppEvent::InsertComposerText { text, binding } => { + match binding { + Some(path) => { + self.bottom_pane.insert_composer_bound_text(&text, &path); + self.set_status_message(format!("Inserted `{text}` into prompt")); + } + None => { + self.bottom_pane.insert_composer_text(&text); + self.set_status_message(format!("Inserted `{text}` into prompt")); + } + } + self.frame_requester.schedule_frame(); + } AppEvent::DebugSubagentStep { step } => self.apply_subagent_debug_step(step), AppEvent::HistoryEntryRequested { .. } => { self.set_status_message("Persistent composer history is not available"); @@ -714,4 +742,23 @@ mod tests { ] ); } + + #[test] + fn mcp_tool_chips_expand_to_flat_tool_names_in_text_item() { + let user_message = UserMessage { + text: "call @get_current_time please".to_string(), + mention_bindings: vec![MentionBinding { + mention: "get_current_time".to_string(), + path: "mcp__time__get_current_time".to_string(), + }], + ..UserMessage::default() + }; + + assert_eq!( + input_items_for_user_message(&user_message), + vec![InputItem::Text { + text: "call mcp__time__get_current_time please".to_string(), + }] + ); + } } diff --git a/crates/tui/src/chatwidget/mcp_picker.rs b/crates/tui/src/chatwidget/mcp_picker.rs new file mode 100644 index 00000000..1b7e7ac2 --- /dev/null +++ b/crates/tui/src/chatwidget/mcp_picker.rs @@ -0,0 +1,106 @@ +//! Interactive `/mcps` picker flow on `ChatWidget`. + +use std::path::PathBuf; + +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::mcp_picker::McpPickerServer; +use crate::mcp_picker::mcp_server_detail_params; +use crate::mcp_picker::mcp_server_list_params; +use crate::mcp_picker::mcp_tools_list_params; +use crate::mcp_picker::merge_mcp_picker_servers; +use devo_core::AppConfigLoader; +use devo_core::FileSystemAppConfigLoader; +use devo_core::McpConfig; +use devo_protocol::canonical::rpc_admin::McpServerInfo; +use devo_protocol::canonical::rpc_admin::McpToolEntry; +use devo_util_paths::find_devo_home; + +use super::ChatWidget; + +impl ChatWidget { + pub(crate) fn set_mcp_reopen_detail(&mut self, name: Option) { + self.mcp_reopen_detail = name; + } + + pub(super) fn on_mcp_servers_listed(&mut self, runtime: Vec) { + let (config, config_path) = load_mcp_config_for_picker(Some(&self.session.cwd)); + let servers = merge_mcp_picker_servers(&config, &runtime, &config_path); + self.mcp_servers_snapshot = Some(servers); + + if let Some(name) = self.mcp_reopen_detail.take() { + self.open_mcp_server_detail(&name); + return; + } + + self.open_mcp_server_list(); + } + + pub(super) fn on_mcp_tools_listed(&mut self, name: String, tools: Vec) { + self.open_mcp_tools_list(&name, &tools); + } + + pub(super) fn open_mcp_server_list(&mut self) { + let Some(servers) = self.mcp_servers_snapshot.clone() else { + self.set_status_message("No MCP server snapshot"); + return; + }; + if servers.is_empty() { + self.set_status_message("No MCP servers configured"); + } else { + self.set_status_message("Select an MCP server"); + } + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + mcp_server_list_params(&servers), + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.frame_requester.schedule_frame(); + } + + pub(super) fn open_mcp_server_detail(&mut self, name: &str) { + let Some(server) = self + .mcp_servers_snapshot + .as_ref() + .and_then(|servers| servers.iter().find(|server| server.id == name)) + .cloned() + else { + self.set_status_message(format!("MCP server `{name}` not found")); + self.open_mcp_server_list(); + return; + }; + self.open_mcp_server_detail_view(server); + } + + fn open_mcp_server_detail_view(&mut self, server: McpPickerServer) { + self.set_status_message(format!("MCP · {}", server.display_name)); + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + mcp_server_detail_params(&server), + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.frame_requester.schedule_frame(); + } + + fn open_mcp_tools_list(&mut self, name: &str, tools: &[McpToolEntry]) { + self.set_status_message(format!("Tools · {name}")); + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + mcp_tools_list_params(name, tools), + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.frame_requester.schedule_frame(); + } +} + +fn load_mcp_config_for_picker(cwd: Option<&std::path::Path>) -> (McpConfig, PathBuf) { + let config_home = find_devo_home().unwrap_or_else(|_| PathBuf::from(".")); + let config_path = config_home.join("config.toml"); + let config = FileSystemAppConfigLoader::new(config_home) + .load(cwd) + .map(|app| app.mcp) + .unwrap_or_default(); + (config, config_path) +} diff --git a/crates/tui/src/chatwidget/skills_picker.rs b/crates/tui/src/chatwidget/skills_picker.rs new file mode 100644 index 00000000..57ec8593 --- /dev/null +++ b/crates/tui/src/chatwidget/skills_picker.rs @@ -0,0 +1,63 @@ +//! Interactive `/skills` picker flow on `ChatWidget`. + +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::skills_picker::SkillPickerEntry; +use crate::skills_picker::skill_detail_params; +use crate::skills_picker::skills_list_params; + +use super::ChatWidget; + +impl ChatWidget { + pub(crate) fn set_skills_reopen_detail(&mut self, name: Option) { + self.skills_reopen_detail = name; + } + + pub(super) fn on_skills_listed_for_picker(&mut self, picker_skills: Vec) { + self.skills_snapshot = Some(picker_skills); + if let Some(name) = self.skills_reopen_detail.take() { + self.open_skill_detail(&name); + return; + } + self.open_skills_list(); + } + + pub(super) fn open_skills_list(&mut self) { + let Some(skills) = self.skills_snapshot.clone() else { + self.set_status_message("No skills snapshot"); + return; + }; + if skills.is_empty() { + self.set_status_message("No skills found"); + } else { + self.set_status_message("Select a skill"); + } + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + skills_list_params(&skills), + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.frame_requester.schedule_frame(); + } + + pub(super) fn open_skill_detail(&mut self, name: &str) { + let Some(skill) = self + .skills_snapshot + .as_ref() + .and_then(|skills| skills.iter().find(|skill| skill.name == name)) + .cloned() + else { + self.set_status_message(format!("Skill `{name}` not found")); + self.open_skills_list(); + return; + }; + self.set_status_message(format!("Skill · {}", skill.name)); + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + skill_detail_params(&skill), + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.frame_requester.schedule_frame(); + } +} diff --git a/crates/tui/src/chatwidget/slash_commands.rs b/crates/tui/src/chatwidget/slash_commands.rs index f81318b5..9b6bb492 100644 --- a/crates/tui/src/chatwidget/slash_commands.rs +++ b/crates/tui/src/chatwidget/slash_commands.rs @@ -133,9 +133,7 @@ impl ChatWidget { } SlashCommand::Mcp => { self.app_event_tx - .send(AppEvent::Command(AppCommand::RunUserShellCommand { - command: "mcp list".to_string(), - })); + .send(AppEvent::Command(AppCommand::ListMcpServers)); self.set_status_message("Loading MCP servers"); } SlashCommand::Skills => { diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 4dd1333d..fd657028 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -34,7 +34,6 @@ use super::ActiveToolCall; use super::ChatWidget; use super::DotStatus; use super::PendingApprovalRequest; -use super::SKILLS_TRANSCRIPT_TITLE; use super::text_stream::ActiveTextItemId; fn format_retry_status_message(attempt: usize, backoff_ms: u64) -> String { @@ -1176,16 +1175,26 @@ impl ChatWidget { self.on_subagent_monitor_event(event); } WorkerEvent::SkillsListed { - body, skills, - show_in_transcript, + picker_skills, + open_picker, } => { self.bottom_pane.set_skill_mentions(Some(skills)); - if show_in_transcript { - self.add_padded_markdown_history(SKILLS_TRANSCRIPT_TITLE, &body); - self.set_status_message("Skills loaded"); + if open_picker { + self.on_skills_listed_for_picker(picker_skills); + } else { + self.skills_snapshot = Some(picker_skills); + if let Some(name) = self.skills_reopen_detail.take() { + self.open_skill_detail(&name); + } } } + WorkerEvent::McpServersListed { servers } => { + self.on_mcp_servers_listed(servers); + } + WorkerEvent::McpToolsListed { name, tools } => { + self.on_mcp_tools_listed(name, tools); + } WorkerEvent::AcpAvailableCommandsUpdated { commands } => { self.acp_available_commands = commands; let count = self.acp_available_commands.len(); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 9a560611..5e367d63 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -491,12 +491,21 @@ pub(crate) enum WorkerEvent { SubagentMonitor { event: SubagentMonitorEvent }, /// Current known skills were listed from the server. SkillsListed { - /// Pre-rendered skill summary shown in the bottom panel. - body: String, /// Structured skill metadata used by the composer `@skill` popup. skills: Vec, - /// Whether this list should be rendered into the transcript. - show_in_transcript: bool, + /// Full skill list used by the interactive `/skills` picker. + picker_skills: Vec, + /// Whether `/skills` should open the interactive picker. + open_picker: bool, + }, + /// MCP server runtime statuses from `mcp/list`. + McpServersListed { + servers: Vec, + }, + /// Tools for one MCP server from `mcp/tools`. + McpToolsListed { + name: String, + tools: Vec, }, /// ACP-native available commands changed for the active session. AcpAvailableCommandsUpdated { diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 235e8751..d563371d 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -5,6 +5,7 @@ use anyhow::Result; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use devo_core::AppConfigLoader; +use devo_core::AppConfigStore; use devo_core::FileSystemAppConfigLoader; use devo_protocol::Model; use devo_protocol::ModelCatalog; @@ -952,6 +953,8 @@ fn handle_worker_event( | WorkerEvent::SubagentDiscovered { .. } | WorkerEvent::SubagentMonitor { .. } | WorkerEvent::SkillsListed { .. } + | WorkerEvent::McpServersListed { .. } + | WorkerEvent::McpToolsListed { .. } | WorkerEvent::AcpAvailableCommandsUpdated { .. } | WorkerEvent::AcpCurrentModeUpdated { .. } | WorkerEvent::AcpConfigOptionsUpdated { .. } @@ -1256,6 +1259,61 @@ fn handle_app_command( tui.replace_inline_session_ui()?; worker.fork_at_user_turn(*user_turn_index)?; } + AppCommand::ListMcpServers => { + worker.list_mcp_servers()?; + chat_widget.set_status_message("Loading MCP servers"); + } + AppCommand::ListMcpTools { name } => { + worker.list_mcp_tools(name.clone())?; + chat_widget.set_status_message(format!("Loading tools · {name}")); + } + AppCommand::SetMcpServerEnabled { name, enabled } => { + match find_devo_home() + .map_err(anyhow::Error::from) + .and_then(|home| { + let mut store = AppConfigStore::load(home, Some(context.cwd)) + .map_err(anyhow::Error::from)?; + store.set_mcp_server_enabled(name, *enabled)?; + Ok(()) + }) { + Ok(()) => { + let action = if *enabled { "enabled" } else { "disabled" }; + chat_widget.set_mcp_reopen_detail(Some(name.clone())); + chat_widget.set_status_message(format!( + "MCP `{name}` {action} in config (restart session for live runtime)" + )); + worker.list_mcp_servers()?; + } + Err(error) => { + chat_widget.add_to_history(crate::history_cell::new_error_event_with_hint( + format!("Failed to update MCP server `{name}`: {error}"), + Some("mcp enable/disable failed".to_string()), + )); + chat_widget.set_status_message(format!("Failed to update MCP `{name}`")); + } + } + } + AppCommand::SetSkillEnabled { + path, + enabled, + name, + } => { + chat_widget.set_skills_reopen_detail(Some(name.clone())); + match worker.set_skill_enabled(path.clone(), *enabled) { + Ok(()) => { + let action = if *enabled { "enabled" } else { "disabled" }; + chat_widget.set_status_message(format!("Skill `{name}` {action}")); + } + Err(error) => { + chat_widget.set_skills_reopen_detail(None); + chat_widget.add_to_history(crate::history_cell::new_error_event_with_hint( + format!("Failed to update skill `{name}`: {error}"), + Some("skills set_enabled failed".to_string()), + )); + chat_widget.set_status_message(format!("Failed to update skill `{name}`")); + } + } + } } Ok(()) } diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a51dc481..811d9271 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -39,6 +39,7 @@ pub mod markdown_render; mod markdown_stream; #[cfg(test)] mod mcp_command_tests; +mod mcp_picker; mod mcp_servers; #[cfg(test)] mod model_display_tests; @@ -52,6 +53,7 @@ mod read_display; mod render; mod shell_output; mod shimmer; +mod skills_picker; mod slash_command; mod startup_header; mod startup_logo_cell; diff --git a/crates/tui/src/mcp_command_tests.rs b/crates/tui/src/mcp_command_tests.rs index 568675f5..fa625591 100644 --- a/crates/tui/src/mcp_command_tests.rs +++ b/crates/tui/src/mcp_command_tests.rs @@ -1,4 +1,4 @@ -//! Focused regression tests for the `/mcp` slash command wiring. +//! Focused regression tests for the `/mcps` slash command wiring. use std::path::PathBuf; @@ -38,7 +38,7 @@ fn widget_with_model(model: Model) -> (ChatWidget, mpsc::UnboundedReceiver, + pub(crate) tool_count: Option, +} + +/// Merge configured servers with runtime `mcp/list` statuses. +pub(crate) fn merge_mcp_picker_servers( + config: &McpConfig, + runtime: &[McpServerInfo], + config_path: &Path, +) -> Vec { + let config_path = config_path.display().to_string(); + let mut servers: Vec = config + .servers + .iter() + .map(|record| { + let runtime = runtime.iter().find(|server| server.name == record.id.0); + picker_server_from_record(record, runtime, &config_path) + }) + .collect(); + + for runtime_server in runtime { + if servers + .iter() + .any(|server| server.id == runtime_server.name) + { + continue; + } + servers.push(McpPickerServer { + id: runtime_server.name.clone(), + display_name: runtime_server.name.clone(), + enabled: true, + transport_kind: "unknown".to_string(), + target: "(runtime only)".to_string(), + auth_summary: "unknown".to_string(), + capabilities: "unknown".to_string(), + config_path: config_path.clone(), + status: Some(runtime_server.status.clone()), + tool_count: Some(runtime_server.tool_count), + }); + } + + servers.sort_by(|left, right| left.id.cmp(&right.id)); + servers +} + +fn picker_server_from_record( + record: &McpServerRecord, + runtime: Option<&McpServerInfo>, + config_path: &str, +) -> McpPickerServer { + let (transport_kind, target, auth_summary) = transport_summary(&record.transport); + McpPickerServer { + id: record.id.0.clone(), + display_name: record.display_name.clone(), + enabled: record.enabled, + transport_kind, + target, + auth_summary, + capabilities: capabilities_summary(&record.allowed_capabilities), + config_path: config_path.to_string(), + status: runtime.map(|server| server.status.clone()), + tool_count: runtime.map(|server| server.tool_count), + } +} + +fn transport_summary(transport: &McpTransportConfig) -> (String, String, String) { + match transport { + McpTransportConfig::Stdio { command, .. } => { + let target = if command.is_empty() { + "(empty command)".to_string() + } else { + command.join(" ") + }; + ("stdio".to_string(), target, "none".to_string()) + } + McpTransportConfig::StreamableHttp { url, auth, .. } => ( + "streamable_http".to_string(), + url.clone(), + auth_summary(auth.as_ref()), + ), + McpTransportConfig::Sse { url, auth, .. } => { + ("sse".to_string(), url.clone(), auth_summary(auth.as_ref())) + } + } +} + +fn auth_summary(auth: Option<&McpAuthConfig>) -> String { + match auth { + Some(McpAuthConfig::BearerToken { .. }) => "bearer token".to_string(), + None => "none".to_string(), + } +} + +fn capabilities_summary(capabilities: &[McpCapability]) -> String { + if capabilities.is_empty() { + return "none".to_string(); + } + capabilities + .iter() + .map(|capability| match capability { + McpCapability::Tools => "tools", + McpCapability::Resources => "resources", + McpCapability::Prompts => "prompts", + }) + .collect::>() + .join(", ") +} + +fn list_status_label(server: &McpPickerServer) -> &str { + if !server.enabled { + "disabled" + } else { + server.status.as_deref().unwrap_or("unknown") + } +} + +/// Compact right-column meta for the server list (no command/URL). +fn list_row_meta(server: &McpPickerServer) -> String { + let status = list_status_label(server); + match server.tool_count { + Some(count) => format!("{status} · {} · {count} tools", server.transport_kind), + None => format!("{status} · {}", server.transport_kind), + } +} + +/// Build the searchable server list selection params. +pub(crate) fn mcp_server_list_params(servers: &[McpPickerServer]) -> SelectionViewParams { + let items = if servers.is_empty() { + vec![SelectionItem { + name: "No MCP servers configured".to_string(), + description: Some("Add one with `devo mcp add`, then restart the session.".to_string()), + is_disabled: true, + dismiss_on_select: false, + ..SelectionItem::default() + }] + } else { + servers + .iter() + .map(|server| { + let name = server.id.clone(); + SelectionItem { + name: server.display_name.clone(), + description: Some(list_row_meta(server)), + search_value: Some(format!( + "{} {} {} {} {}", + server.display_name, + server.id, + server.transport_kind, + server.target, + list_status_label(server) + )), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::McpServerSelected { name: name.clone() }); + })], + ..SelectionItem::default() + } + }) + .collect() + }; + + SelectionViewParams { + title: Some("MCP servers".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search".to_string()), + ..SelectionViewParams::default() + } +} + +fn detail_status_label(server: &McpPickerServer) -> &str { + if !server.enabled { + "disabled" + } else { + server.status.as_deref().unwrap_or("unknown") + } +} + +fn detail_subtitle_lines(server: &McpPickerServer) -> Vec> { + use ratatui::style::Style; + use ratatui::style::Stylize; + use ratatui::text::Line; + use ratatui::text::Span; + + let status = detail_status_label(server); + let summary = match server.tool_count { + Some(count) => format!("{status} · {} · {count} tools", server.transport_kind), + None => format!("{status} · {}", server.transport_kind), + }; + + let mut lines = vec![Line::from(summary.dim())]; + + lines.push(Line::from(vec![ + Span::styled("name ".to_string(), Style::default().dim()), + Span::raw(server.id.clone()), + ])); + + let target = server.target.trim(); + if !target.is_empty() && target != "(runtime only)" { + lines.push(Line::from(vec![ + Span::styled("cmd ".to_string(), Style::default().dim()), + Span::styled(target.to_string(), Style::default().cyan()), + ])); + } + + if server.auth_summary != "none" { + lines.push(Line::from(vec![ + Span::styled("auth ".to_string(), Style::default().dim()), + Span::raw(server.auth_summary.clone()), + ])); + } + + lines +} + +/// Detail actions for one MCP server (same shell as `/skills` detail). +pub(crate) fn mcp_server_detail_params(server: &McpPickerServer) -> SelectionViewParams { + let tools_id = server.id.clone(); + let toggle_id = server.id.clone(); + let enabled = server.enabled; + + let mut items = vec![SelectionItem { + name: "View tools".to_string(), + description: Some("Browse and insert tool names".to_string()), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::Command(AppCommand::ListMcpTools { + name: tools_id.clone(), + })); + })], + ..SelectionItem::default() + }]; + + items.push(SelectionItem { + name: if enabled { + "Disable".to_string() + } else { + "Enable".to_string() + }, + description: Some("Writes config; restart for live runtime".to_string()), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::Command(AppCommand::SetMcpServerEnabled { + name: toggle_id.clone(), + enabled: !enabled, + })); + })], + ..SelectionItem::default() + }); + + items.push(SelectionItem { + name: "Back".to_string(), + description: Some("Return to server list".to_string()), + dismiss_on_select: true, + actions: vec![Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::McpOpenServerList); + })], + ..SelectionItem::default() + }); + + SelectionViewParams { + title: Some(server.display_name.clone()), + subtitle_lines: detail_subtitle_lines(server), + footer_hint: Some(standard_popup_hint_line()), + items, + on_cancel: Some(Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::McpOpenServerList); + })), + ..SelectionViewParams::default() + } +} + +/// Model-facing flat tool name (`mcp__server__tool`). +pub(crate) fn mcp_flat_tool_name(server_id: &str, tool_name: &str) -> String { + format!( + "mcp__{}__{}", + sanitize_model_name(server_id), + sanitize_model_name(tool_name) + ) +} + +/// Build the searchable tools list for one MCP server. +pub(crate) fn mcp_tools_list_params( + server_name: &str, + tools: &[devo_protocol::canonical::rpc_admin::McpToolEntry], +) -> SelectionViewParams { + let server_name_for_cancel = server_name.to_string(); + let items = if tools.is_empty() { + vec![SelectionItem { + name: "No tools advertised".to_string(), + description: Some("This server did not expose a tools catalog.".to_string()), + is_disabled: true, + dismiss_on_select: false, + ..SelectionItem::default() + }] + } else { + tools + .iter() + .map(|tool| { + let flat_name = mcp_flat_tool_name(server_name, &tool.name); + let tool_name = tool.name.clone(); + let description = { + let collapsed = tool + .description + .split_whitespace() + .collect::>() + .join(" "); + (!collapsed.is_empty()).then(|| truncate_text(&collapsed, 48)) + }; + SelectionItem { + name: tool.name.clone(), + description, + search_value: Some(format!("{} {}", tool.name, tool.description)), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::InsertComposerText { + text: format!("@{tool_name}"), + binding: Some(flat_name.clone()), + }); + })], + ..SelectionItem::default() + } + }) + .collect() + }; + + SelectionViewParams { + title: Some(format!("Tools · {server_name}")), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search".to_string()), + on_cancel: Some(Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::McpOpenServerDetail { + name: server_name_for_cancel.clone(), + }); + })), + ..SelectionViewParams::default() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tokio::sync::mpsc; + + use super::*; + use crate::app_command::AppCommand; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use devo_core::McpServerId; + use devo_core::McpStartupPolicy; + + #[test] + fn merge_mcp_picker_servers_overlays_runtime_status() { + let config = McpConfig { + servers: vec![McpServerRecord { + id: McpServerId("time".to_string()), + display_name: "Time".to_string(), + transport: McpTransportConfig::Stdio { + command: vec!["docker".to_string(), "run".to_string()], + cwd: None, + env: Default::default(), + env_vars: Vec::new(), + }, + startup_policy: McpStartupPolicy::Eager, + enabled: true, + trust_policy: Default::default(), + allowed_capabilities: vec![McpCapability::Tools], + roots_policy: Default::default(), + output_limits: Default::default(), + auth_ref: None, + }], + ..McpConfig::default() + }; + let runtime = vec![McpServerInfo { + name: "time".to_string(), + status: "ready".to_string(), + tool_count: 3, + }]; + let merged = merge_mcp_picker_servers(&config, &runtime, Path::new("/tmp/config.toml")); + assert_eq!( + merged, + vec![McpPickerServer { + id: "time".to_string(), + display_name: "Time".to_string(), + enabled: true, + transport_kind: "stdio".to_string(), + target: "docker run".to_string(), + auth_summary: "none".to_string(), + capabilities: "tools".to_string(), + config_path: PathBuf::from("/tmp/config.toml").display().to_string(), + status: Some("ready".to_string()), + tool_count: Some(3), + }] + ); + } + + #[test] + fn mcp_server_list_params_action_emits_server_selected() { + let servers = vec![McpPickerServer { + id: "time".to_string(), + display_name: "Time".to_string(), + enabled: true, + transport_kind: "stdio".to_string(), + target: "docker run".to_string(), + auth_summary: "none".to_string(), + capabilities: "tools".to_string(), + config_path: "/tmp/config.toml".to_string(), + status: Some("ready".to_string()), + tool_count: Some(3), + }]; + let params = mcp_server_list_params(&servers); + assert_eq!(params.items.len(), 1); + assert_eq!(params.title.as_deref(), Some("MCP servers")); + assert!(params.items[0].name_prefix_spans.is_empty()); + let description = params.items[0] + .description + .as_deref() + .expect("compact meta"); + assert!(!description.contains('\n')); + assert!(!description.contains("docker")); + assert_eq!(description, "ready · stdio · 3 tools"); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let sender = AppEventSender::new(tx); + params.items[0].actions[0](&sender); + assert_eq!( + rx.try_recv().expect("selection event"), + AppEvent::McpServerSelected { + name: "time".to_string(), + } + ); + } + + #[test] + fn mcp_server_detail_params_exposes_tools_and_toggle() { + let server = McpPickerServer { + id: "time".to_string(), + display_name: "Time".to_string(), + enabled: true, + transport_kind: "stdio".to_string(), + target: "docker run".to_string(), + auth_summary: "none".to_string(), + capabilities: "tools".to_string(), + config_path: "/tmp/config.toml".to_string(), + status: Some("ready".to_string()), + tool_count: Some(3), + }; + let params = mcp_server_detail_params(&server); + assert_eq!(params.title.as_deref(), Some("Time")); + assert_eq!(params.subtitle_lines.len(), 3); + assert_eq!( + params.subtitle_lines[0].to_string(), + "ready · stdio · 3 tools" + ); + assert_eq!(params.subtitle_lines[1].to_string(), "name time"); + assert_eq!(params.subtitle_lines[2].to_string(), "cmd docker run"); + assert!( + params.subtitle_lines[2] + .spans + .iter() + .any(|span| { span.content.as_ref() == "docker run" && span.style.fg.is_some() }) + ); + assert_eq!(params.items[0].name, "View tools"); + assert_eq!(params.items[1].name, "Disable"); + + let (tx, mut rx) = mpsc::unbounded_channel(); + params.items[0].actions[0](&AppEventSender::new(tx)); + assert_eq!( + rx.try_recv().expect("tools"), + AppEvent::Command(AppCommand::ListMcpTools { + name: "time".to_string(), + }) + ); + } + + #[test] + fn mcp_tools_list_params_enter_inserts_flat_tool_name() { + assert_eq!( + mcp_flat_tool_name("Docs Server", "echo-tool"), + "mcp__docs_server__echo_tool" + ); + let params = mcp_tools_list_params( + "time", + &[devo_protocol::canonical::rpc_admin::McpToolEntry { + name: "get_current_time".to_string(), + description: "Return the current time".to_string(), + }], + ); + assert!(params.items[0].dismiss_on_select); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let sender = AppEventSender::new(tx); + params.items[0].actions[0](&sender); + assert_eq!( + rx.try_recv().expect("insert event"), + AppEvent::InsertComposerText { + text: "@get_current_time".to_string(), + binding: Some("mcp__time__get_current_time".to_string()), + } + ); + } +} diff --git a/crates/tui/src/skills_picker.rs b/crates/tui/src/skills_picker.rs new file mode 100644 index 00000000..974a5781 --- /dev/null +++ b/crates/tui/src/skills_picker.rs @@ -0,0 +1,207 @@ +//! Interactive `/skills` picker helpers. + +use std::path::PathBuf; + +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::list_selection_view::SelectionItem; +use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +/// One skill row for the interactive `/skills` flow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillPickerEntry { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) enabled: bool, + pub(crate) source: String, + pub(crate) path: PathBuf, +} + +pub(crate) fn skill_picker_entry_from_record(skill: &devo_server::SkillRecord) -> SkillPickerEntry { + SkillPickerEntry { + id: skill.id.clone(), + name: skill.name.clone(), + description: skill + .short_description + .clone() + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| skill.description.clone()), + enabled: skill.enabled, + source: skill_source_label(&skill.source), + path: skill.path.clone(), + } +} + +fn skill_source_label(source: &devo_server::SkillSource) -> String { + match source { + devo_server::SkillSource::User => "user".to_string(), + devo_server::SkillSource::Workspace { cwd } => format!("workspace ({})", cwd.display()), + devo_server::SkillSource::Plugin { plugin_id } => format!("plugin ({plugin_id})"), + devo_server::SkillSource::System => "system".to_string(), + devo_server::SkillSource::Admin => "admin".to_string(), + } +} + +fn compact_source_label(source: &str) -> &str { + if source.starts_with("workspace") { + "workspace" + } else if source.starts_with("plugin") { + "plugin" + } else { + source + } +} + +/// Searchable list of configured skills. +pub(crate) fn skills_list_params(skills: &[SkillPickerEntry]) -> SelectionViewParams { + let items = if skills.is_empty() { + vec![SelectionItem { + name: "No skills found".to_string(), + description: Some("Add skills under ~/.devo/skills or the workspace.".to_string()), + is_disabled: true, + dismiss_on_select: false, + ..SelectionItem::default() + }] + } else { + skills + .iter() + .map(|skill| { + let name = skill.name.clone(); + let meta = if skill.enabled { + compact_source_label(&skill.source).to_string() + } else { + format!("{} · disabled", compact_source_label(&skill.source)) + }; + SelectionItem { + name: skill.name.clone(), + description: Some(meta), + search_value: Some(format!( + "{} {} {}", + skill.name, skill.description, skill.source + )), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SkillSelected { name: name.clone() }); + })], + ..SelectionItem::default() + } + }) + .collect() + }; + + SelectionViewParams { + title: Some("Skills".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search".to_string()), + ..SelectionViewParams::default() + } +} + +/// Detail actions for one skill. +pub(crate) fn skill_detail_params(skill: &SkillPickerEntry) -> SelectionViewParams { + let insert_name = skill.name.clone(); + let path = skill.path.clone(); + let enabled = skill.enabled; + let toggle_name = skill.name.clone(); + let back_name = skill.name.clone(); + + let mut items = vec![SelectionItem { + name: "Insert into prompt".to_string(), + description: Some(format!("Append @{insert_name}")), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::InsertComposerText { + text: format!("@{insert_name}"), + binding: None, + }); + })], + ..SelectionItem::default() + }]; + + items.push(SelectionItem { + name: if enabled { + "Disable".to_string() + } else { + "Enable".to_string() + }, + description: Some("Update enabled state for this session".to_string()), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::Command(AppCommand::SetSkillEnabled { + path: path.clone(), + enabled: !enabled, + name: toggle_name.clone(), + })); + })], + ..SelectionItem::default() + }); + + items.push(SelectionItem { + name: "Back".to_string(), + description: Some("Return to skills list".to_string()), + dismiss_on_select: true, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SkillOpenList); + })], + ..SelectionItem::default() + }); + + let status = if skill.enabled { "enabled" } else { "disabled" }; + let subtitle = format!( + "{}\n\nSource {}\nStatus {status}\nPath {}", + skill.description.trim(), + skill.source, + skill.path.display() + ); + + SelectionViewParams { + title: Some(skill.name.clone()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), + items, + on_cancel: Some(Box::new(move |tx: &AppEventSender| { + let _ = back_name; + tx.send(AppEvent::SkillOpenList); + })), + ..SelectionViewParams::default() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + + #[test] + fn skills_list_params_select_emits_skill_selected() { + let skills = vec![SkillPickerEntry { + id: "docs".to_string(), + name: "docs".to_string(), + description: "Docs skill".to_string(), + enabled: true, + source: "user".to_string(), + path: PathBuf::from("/tmp/docs/SKILL.md"), + }]; + let params = skills_list_params(&skills); + assert_eq!(params.title.as_deref(), Some("Skills")); + assert!(params.items[0].name_prefix_spans.is_empty()); + assert_eq!(params.items[0].description.as_deref(), Some("user")); + let (tx, mut rx) = mpsc::unbounded_channel(); + params.items[0].actions[0](&AppEventSender::new(tx)); + assert_eq!( + rx.try_recv().expect("event"), + AppEvent::SkillSelected { + name: "docs".to_string(), + } + ); + } +} diff --git a/crates/tui/src/slash_command.rs b/crates/tui/src/slash_command.rs index dd0d12c9..6cd4e519 100644 --- a/crates/tui/src/slash_command.rs +++ b/crates/tui/src/slash_command.rs @@ -9,11 +9,12 @@ mod tests { #[test] fn mcp_slash_command_parses_and_is_listed() { + assert_eq!("mcps".parse::(), Ok(SlashCommand::Mcp)); assert_eq!("mcp".parse::(), Ok(SlashCommand::Mcp)); assert!( built_in_slash_commands() .iter() - .any(|(name, command)| *name == "mcp" && *command == SlashCommand::Mcp) + .any(|(name, command)| *name == "mcps" && *command == SlashCommand::Mcp) ); } diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 177e52e1..01f9d155 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -277,6 +277,12 @@ enum OperationCommand { ListSessions, /// Request a skills list from the server. ListSkills, + /// Request MCP server runtime statuses from the server. + ListMcpServers, + /// Request tools for one MCP server from the server. + ListMcpTools { + name: String, + }, /// Request or update a server-backed composer reference search. ReferenceSearchRequested { query: String, @@ -571,6 +577,20 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } + /// Requests MCP server runtime statuses from the background worker. + pub(crate) fn list_mcp_servers(&self) -> Result<()> { + self.command_tx + .send(OperationCommand::ListMcpServers) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + /// Requests tools for one MCP server from the background worker. + pub(crate) fn list_mcp_tools(&self, name: String) -> Result<()> { + self.command_tx + .send(OperationCommand::ListMcpTools { name }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + pub(crate) fn reference_search_requested(&self, query: String) -> Result<()> { self.command_tx .send(OperationCommand::ReferenceSearchRequested { query }) @@ -583,7 +603,6 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - #[allow(dead_code)] pub(crate) fn set_skill_enabled(&self, path: PathBuf, enabled: bool) -> Result<()> { self.command_tx .send(OperationCommand::SetSkillEnabled { path, enabled }) @@ -1262,6 +1281,39 @@ async fn run_worker_inner( }); } } + Some(OperationCommand::ListMcpServers) => { + if let Err(error) = emit_mcp_servers_list(&mut client, event_tx).await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + Some(OperationCommand::ListMcpTools { name }) => { + if let Err(error) = + emit_mcp_tools_list(&mut client, name, event_tx).await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } Some(OperationCommand::ReferenceSearchRequested { query }) => { match emit_reference_search_update( &mut client, @@ -3086,7 +3138,7 @@ async fn emit_skills_list( client: &mut StdioServerClient, cwd: &Path, event_tx: &mpsc::UnboundedSender, - show_in_transcript: bool, + open_picker: bool, ) -> Result<()> { let result = tokio::time::timeout( Duration::from_secs(5), @@ -3097,7 +3149,7 @@ async fn emit_skills_list( ) .await .context("skills list request timed out")??; - emit_skills_list_result(result.skills, event_tx, show_in_transcript); + emit_skills_list_result(result.skills, event_tx, open_picker); Ok(()) } @@ -3130,21 +3182,59 @@ async fn emit_reference_search_update( fn emit_skills_list_result( skills: Vec, event_tx: &mpsc::UnboundedSender, - show_in_transcript: bool, + open_picker: bool, ) { - let body = render_skill_list_body(&skills); + let picker_skills = skills + .iter() + .map(crate::skills_picker::skill_picker_entry_from_record) + .collect(); let skills = skills .iter() .filter(|skill| skill.enabled) .map(skill_metadata_from_record) .collect(); let _ = event_tx.send(WorkerEvent::SkillsListed { - body, skills, - show_in_transcript, + picker_skills, + open_picker, }); } +async fn emit_mcp_servers_list( + client: &mut StdioServerClient, + event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let result = tokio::time::timeout( + Duration::from_secs(5), + client.mcp_list(devo_protocol::canonical::rpc_admin::McpListParams {}), + ) + .await + .context("mcp list request timed out")??; + let _ = event_tx.send(WorkerEvent::McpServersListed { + servers: result.servers, + }); + Ok(()) +} + +async fn emit_mcp_tools_list( + client: &mut StdioServerClient, + name: String, + event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let result = tokio::time::timeout( + Duration::from_secs(10), + client + .mcp_tools(devo_protocol::canonical::rpc_admin::McpToolsParams { name: name.clone() }), + ) + .await + .context("mcp tools request timed out")??; + let _ = event_tx.send(WorkerEvent::McpToolsListed { + name, + tools: result.tools, + }); + Ok(()) +} + fn render_skill_list_body(skills: &[devo_server::SkillRecord]) -> String { if skills.is_empty() { return "_No skills found._".to_string(); diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index 55b3fd88..8d2ad7ee 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -286,4 +286,6 @@ url = "https://example.com/mcp/sse" `servers` は配列です。したがって、workspace の `[[mcp.servers]]` リストは ユーザーレベルのリストを `id` 単位でマージせず置き換えます。 -TUI の `/mcp list` で設定を確認できます。 +TUI の `/mcps` で対話的に確認できます(一覧 → 詳細 → ツール。Enable/Disable は設定のみ更新し、実行時反映にはセッション再起動が必要な場合があります)。クライアントは `mcp/list` / `mcp/tools` RPC も利用できます。ユーザー設定 (`~/.devo/config.toml`) は +`devo mcp add|list|remove|enable|disable` でも管理できます(`--transport stdio|http|sse`)。 +クライアントは `mcp/list` RPC でランタイム状態を取得できます。 diff --git a/docs/configuration.md b/docs/configuration.md index 1a2880c2..61993877 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -297,4 +297,33 @@ Merge behavior: `[mcp]` is merged field-wise like other tables, but `servers` is an array. A project-level `[[mcp.servers]]` list therefore replaces the user-level list instead of merging by `id`. -Verify the configuration in the TUI with `/mcp list`. +### CLI management + +Manage user-level MCP servers (`~/.devo/config.toml`) with `devo mcp`: + +```bash +# Stdio (command + args after --) +devo mcp add time -- docker run -i --rm mcp/time +devo mcp add filesystem --env HOME=/tmp -- npx -y @modelcontextprotocol/server-filesystem . + +# Streamable HTTP (`--transport http` writes kind = "streamable_http") +devo mcp add --transport http hello-mcp http://localhost:8080/mcp +devo mcp add --transport http github --bearer-token "$TOKEN" https://api.githubcopilot.com/mcp/ + +# Legacy SSE +devo mcp add --transport sse legacy-mcp https://example.com/mcp/sse + +devo mcp list +devo mcp enable time +devo mcp disable time +devo mcp remove time +``` + +CLI changes apply on the next Devo start (or config reload). They do not hot-reload +an already-running interactive session. + +Verify configuration in the TUI with `/mcps` (interactive server list → detail → +tools; Enable/Disable writes config only and may need a session restart for live +runtime). Clients can also call the `mcp/list` / `mcp/tools` RPCs for runtime +status and tool catalogs from the shared MCP manager. Use `devo mcp add|list|remove|enable|disable` +for CLI management. diff --git a/docs/configuration.ru.md b/docs/configuration.ru.md index b821cb67..ac3fc716 100644 --- a/docs/configuration.ru.md +++ b/docs/configuration.ru.md @@ -296,4 +296,7 @@ url = "https://example.com/mcp/sse" `servers` - это массив. Поэтому список `[[mcp.servers]]` уровня проекта заменяет пользовательский список целиком, а не сливает по `id`. -Проверить конфигурацию можно в TUI командой `/mcp list`. +Проверить конфигурацию можно в TUI командой `/mcps` (интерактивный список → детали → инструменты; Enable/Disable только пишет конфиг, для runtime может понадобиться перезапуск сессии). Пользовательский +`~/.devo/config.toml` также можно менять через +`devo mcp add|list|remove|enable|disable` (`--transport stdio|http|sse`). Клиенты +могут вызывать RPC `mcp/list` для runtime-статуса. diff --git a/docs/configuration.zh-Hans.md b/docs/configuration.zh-Hans.md index 29ed89a6..684af6d1 100644 --- a/docs/configuration.zh-Hans.md +++ b/docs/configuration.zh-Hans.md @@ -274,4 +274,26 @@ url = "https://example.com/mcp/sse" 合并行为:`[mcp]` 与其他表一样按字段合并,但 `servers` 是数组。项目级的 `[[mcp.servers]]` 列表会整体替换用户级列表,而不是按 `id` 合并。 -可在 TUI 中用 `/mcp list` 验证配置。 +### CLI 管理 + +用 `devo mcp` 管理用户级 MCP 服务器(`~/.devo/config.toml`): + +```bash +# Stdio(`--` 后为 command + args) +devo mcp add time -- docker run -i --rm mcp/time + +# Streamable HTTP(`--transport http` 写入 kind = "streamable_http") +devo mcp add --transport http hello-mcp http://localhost:8080/mcp + +# 旧版 SSE +devo mcp add --transport sse legacy-mcp https://example.com/mcp/sse + +devo mcp list +devo mcp enable time +devo mcp disable time +devo mcp remove time +``` + +CLI 修改在下次启动 Devo(或配置重载)后生效,不会热更新已在运行的交互会话。 + +可在 TUI 中用 `/mcps`(交互式列表 → 详情 → 工具;Enable/Disable 仅写配置,运行时可能需重启会话)验证配置。客户端也可调用 `mcp/list` / `mcp/tools` RPC。也可用 `devo mcp add|list|remove|enable|disable` 管理用户级配置。 diff --git a/docs/configuration.zh-Hant.md b/docs/configuration.zh-Hant.md index 29e19f5f..e5fbe466 100644 --- a/docs/configuration.zh-Hant.md +++ b/docs/configuration.zh-Hant.md @@ -274,4 +274,6 @@ url = "https://example.com/mcp/sse" 合併行為:`[mcp]` 與其他表一樣依欄位合併,但 `servers` 是陣列。專案級的 `[[mcp.servers]]` 列表會整體取代使用者級列表,而不是依 `id` 合併。 -可在 TUI 中用 `/mcp list` 驗證配置。 +可在 TUI 中用 `/mcps`(互動式清單 → 詳情 → 工具;Enable/Disable 僅寫設定,執行階段可能需重啟工作階段)驗證配置。也可用 `devo mcp add|list|remove|enable|disable` +管理使用者級 `~/.devo/config.toml`(支援 `--transport stdio|http|sse`)。客戶端可呼叫 +`mcp/list` RPC 取得執行時狀態。 From 6e84e6b4870f325bea9fc01d6cb8d1ea839d0089 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 1 Aug 2026 11:00:45 +0800 Subject: [PATCH 2/4] ci: build release CLI and desktop from one cargo compile per target --- .github/workflows/release.yml | 156 +++++++++++++++------------------- apps/desktop/package.json | 1 + 2 files changed, 71 insertions(+), 86 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0c70f8f..6fddcc97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ env: RIPGREP_VERSION: 15.1.0 jobs: - # ── Matrix build ───────────────────────────────────────────────────────── + # ── Per-target: compile devo once, then package CLI and/or desktop ─────── build: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} @@ -19,23 +19,57 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-musl ext: "" - archive: tar.gz + package_cli: true + package_desktop: true + desktop_platform: linux + desktop_arch: x64 + rg_target: x86_64-unknown-linux-musl + electron_args: --linux --x64 - os: ubuntu-latest target: aarch64-unknown-linux-musl ext: "" - archive: tar.gz + package_cli: true + package_desktop: true + desktop_platform: linux + desktop_arch: arm64 + rg_target: aarch64-unknown-linux-gnu + electron_args: --linux --arm64 - os: macos-latest target: aarch64-apple-darwin ext: "" - archive: tar.gz + package_cli: true + package_desktop: true + desktop_platform: mac + desktop_arch: arm64 + rg_target: aarch64-apple-darwin + electron_args: --mac --arm64 - os: macos-latest target: x86_64-apple-darwin ext: "" - archive: tar.gz + package_cli: true + package_desktop: true + desktop_platform: mac + desktop_arch: x64 + rg_target: x86_64-apple-darwin + electron_args: --mac --x64 - os: windows-latest target: x86_64-pc-windows-msvc ext: ".exe" - archive: zip + package_cli: true + package_desktop: true + desktop_platform: windows + desktop_arch: x64 + rg_target: x86_64-pc-windows-msvc + electron_args: --win --x64 + - os: windows-latest + target: aarch64-pc-windows-msvc + ext: ".exe" + package_cli: false + package_desktop: true + desktop_platform: windows + desktop_arch: arm64 + rg_target: aarch64-pc-windows-msvc + electron_args: --win --arm64 steps: - uses: actions/checkout@v4 @@ -57,12 +91,8 @@ jobs: with: tool: cargo-zigbuild@0.22.3 - - name: Build release binary (x86_64 Linux) - if: matrix.target == 'x86_64-unknown-linux-musl' - run: cargo zigbuild --release --target ${{ matrix.target }} -p devo-cli - - - name: Build release binary (Linux ARM64) - if: matrix.target == 'aarch64-unknown-linux-musl' + - name: Build release binary (Linux) + if: runner.os == 'Linux' run: cargo zigbuild --release --target ${{ matrix.target }} -p devo-cli - name: Build release binary (macOS) @@ -77,7 +107,9 @@ jobs: if: runner.os == 'macOS' run: strip target/${{ matrix.target }}/release/devo${{ matrix.ext }} - - name: Prepare staging directory + # ── CLI archive (skipped for desktop-only targets) ──────────────────── + - name: Prepare CLI staging directory + if: matrix.package_cli shell: bash run: | staging=devo-${{ github.ref_name }}-${{ matrix.target }} @@ -87,95 +119,34 @@ jobs: echo "STAGING=$staging" >> "$GITHUB_ENV" - name: Create tar.gz archive (Linux/macOS) - if: runner.os != 'Windows' + if: matrix.package_cli && runner.os != 'Windows' run: tar czf "${{ env.STAGING }}.tar.gz" "${{ env.STAGING }}" - name: Create zip archive (Windows) - if: runner.os == 'Windows' + if: matrix.package_cli && runner.os == 'Windows' shell: pwsh run: | $staging = "$env:STAGING" Compress-Archive -Path "$staging/*" -DestinationPath "$staging.zip" - uses: actions/upload-artifact@v4 + if: matrix.package_cli with: name: devo-${{ github.ref_name }}-${{ matrix.target }} path: devo-${{ github.ref_name }}-${{ matrix.target }}.* - # ── Desktop app builds ─────────────────────────────────────────────────── - desktop: - name: Build desktop ${{ matrix.platform }} ${{ matrix.arch }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: macos-latest - platform: mac - arch: arm64 - target: aarch64-apple-darwin - rg_target: aarch64-apple-darwin - script: package:mac:arm64 - - os: macos-latest - platform: mac - arch: x64 - target: x86_64-apple-darwin - rg_target: x86_64-apple-darwin - script: package:mac:x64 - - os: windows-latest - platform: windows - arch: x64 - target: x86_64-pc-windows-msvc - rg_target: x86_64-pc-windows-msvc - script: package:win:x64 - - os: windows-latest - platform: windows - arch: arm64 - target: aarch64-pc-windows-msvc - rg_target: aarch64-pc-windows-msvc - script: package:win:arm64 - - os: ubuntu-latest - platform: linux - arch: x64 - target: x86_64-unknown-linux-musl - rg_target: x86_64-unknown-linux-musl - script: package:linux:x64 - - os: ubuntu-latest - platform: linux - arch: arm64 - target: aarch64-unknown-linux-musl - rg_target: aarch64-unknown-linux-gnu - script: package:linux:arm64 - - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - uses: Swatinem/rust-cache@v2 - with: - key: desktop-${{ matrix.target }} - - - uses: mlugg/setup-zig@v2 - if: runner.os == 'Linux' - - - name: Install cargo-zigbuild - uses: taiki-e/install-action@v2 - if: runner.os == 'Linux' - with: - tool: cargo-zigbuild@0.22.3 - + # ── Desktop package (reuses the binary compiled above) ──────────────── - uses: oven-sh/setup-bun@v2 + if: matrix.package_desktop - name: Install Linux packaging dependencies - if: runner.os == 'Linux' + if: matrix.package_desktop && runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y rpm - name: Download ripgrep sidecar (Linux/macOS) - if: runner.os != 'Windows' + if: matrix.package_desktop && runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -193,7 +164,7 @@ jobs: echo "DEVO_DESKTOP_RUNTIME_RG_BIN=$rg_bin" >> "$GITHUB_ENV" - name: Download ripgrep sidecar (Windows) - if: runner.os == 'Windows' + if: matrix.package_desktop && runner.os == 'Windows' shell: pwsh run: | $rgVersion = $env:RIPGREP_VERSION @@ -214,18 +185,31 @@ jobs: "DEVO_DESKTOP_RUNTIME_RG_BIN=$($rg.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - name: Install desktop dependencies + if: matrix.package_desktop working-directory: apps/desktop run: bun install --frozen-lockfile - - name: Build desktop package + - name: Build desktop frontend + if: matrix.package_desktop + working-directory: apps/desktop + run: bun run build + + - name: Stage desktop runtime from release binary + if: matrix.package_desktop + working-directory: apps/desktop + run: bun scripts/prepare-runtime.ts --target ${{ matrix.target }} + + - name: Package desktop with electron-builder + if: matrix.package_desktop working-directory: apps/desktop - run: bun run ${{ matrix.script }} + run: bunx electron-builder ${{ matrix.electron_args }} --config electron-builder.yml --publish never env: CSC_IDENTITY_AUTO_DISCOVERY: false - uses: actions/upload-artifact@v4 + if: matrix.package_desktop with: - name: devo-desktop-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.arch }} + name: devo-desktop-${{ github.ref_name }}-${{ matrix.desktop_platform }}-${{ matrix.desktop_arch }} path: | apps/desktop/release/*.AppImage apps/desktop/release/*.blockmap @@ -239,7 +223,7 @@ jobs: # ── Create GitHub Release ────────────────────────────────────────────── release: name: Create Release - needs: [build, desktop] + needs: [build] runs-on: ubuntu-24.04 permissions: contents: write diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e52d0ac6..75c1d3fd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -26,6 +26,7 @@ "dev:wayland": "bun run sync:version && bun run gen:protocol-types && bun run dev:cli && bun run brand:electron-dev && electron-vite dev -- --ozone-platform=wayland --enable-features=WaylandWindowDecorations --disable-features=WaylandFractionalScaleV1", "dev:web": "bun run sync:version && bun run gen:protocol-types && vite --config src/renderer/vite.web.config.ts", "runtime:host": "cargo build --manifest-path ../../Cargo.toml --release -p devo-cli --bin devo && bun scripts/prepare-runtime.ts", + "runtime:prebuilt": "bun scripts/prepare-runtime.ts", "runtime:mac:arm64": "cargo build --manifest-path ../../Cargo.toml --release --target aarch64-apple-darwin -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target aarch64-apple-darwin", "runtime:mac:x64": "cargo build --manifest-path ../../Cargo.toml --release --target x86_64-apple-darwin -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target x86_64-apple-darwin", "runtime:linux:arm64": "cargo zigbuild --manifest-path ../../Cargo.toml --release --target aarch64-unknown-linux-musl -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target aarch64-unknown-linux-musl", From 6e65ab4ad7e3e40dd7901ae0fef61861331c1478 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 1 Aug 2026 12:01:38 +0800 Subject: [PATCH 3/4] refactor: move code search into a bundled MCP server --- .github/workflows/release.yml | 17 +- Cargo.lock | 6 +- Cargo.toml | 4 +- apps/desktop/scripts/prepare-runtime.test.ts | 9 + apps/desktop/scripts/prepare-runtime.ts | 23 + .../docs/core-concepts/code-search.mdx | 20 +- .../docs/core-concepts/code-search.zh.mdx | 51 +-- crates/cli/src/prompt_command.rs | 5 +- .../Cargo.toml | 22 +- .../src/cache.rs | 0 .../src/chunking.rs | 0 .../src/dense.rs | 0 .../src/files.rs | 0 .../src/grammars.rs | 0 .../src/index.rs | 0 .../src/lib.rs | 11 +- crates/code-search-mcp/src/main.rs | 17 + .../src/matrix.rs | 0 crates/code-search-mcp/src/mcp.rs | 406 ++++++++++++++++++ .../src/ranking.rs | 0 .../src/refresh.rs | 0 .../src/semantic.rs | 0 .../src/service.rs | 0 .../src/singleflight.rs | 0 .../src/tokens.rs | 0 .../src/types.rs | 8 +- .../src/watch.rs | 0 .../tests/find_related_paths.rs | 2 +- crates/config/README.md | 13 +- crates/config/src/app.rs | 1 + crates/config/src/experimental.rs | 33 +- crates/config/src/mcp.rs | 218 +++++++++- crates/config/src/tests.rs | 115 +++-- crates/core/Cargo.toml | 11 +- crates/core/src/tools/deferred_loading.rs | 8 +- crates/core/src/tools/glob.txt | 2 +- crates/core/src/tools/grep.txt | 2 +- crates/core/src/tools/handlers/code_search.rs | 392 ----------------- crates/core/src/tools/handlers/mod.rs | 46 +- crates/core/src/tools/registry.rs | 26 -- crates/core/src/tools/registry_plan.rs | 151 +------ crates/server/Cargo.toml | 1 - crates/server/src/bootstrap.rs | 4 +- crates/server/src/runtime.rs | 3 - .../server/src/runtime/code_index_warmup.rs | 180 -------- .../runtime/handlers/acp/session_support.rs | 5 +- crates/server/src/runtime/lifecycle.rs | 1 - crates/server/src/runtime/mcp.rs | 8 +- crates/server/src/runtime/session_cache.rs | 22 - .../src/runtime/turn_exec/tool_display.rs | 2 +- crates/server/src/session_context.rs | 11 +- crates/server/src/tool_actions.rs | 8 +- crates/tools/src/handler_kind.rs | 1 - crates/tools/src/tool_summary.rs | 2 +- crates/tui/src/tool_io_cell.rs | 2 +- crates/tui/src/worker.rs | 16 +- docs/configuration.md | 17 + install.ps1 | 13 + install.sh | 24 +- .../app/L2-DES-APP-005-config-toml-schema.md | 28 +- .../L2-DES-MCP-002-bundled-code-search-mcp.md | 34 ++ .../L2-DES-TOOL-003-deferred-tool-loading.md | 13 +- 62 files changed, 972 insertions(+), 1042 deletions(-) rename crates/{code-search => code-search-mcp}/Cargo.toml (80%) rename crates/{code-search => code-search-mcp}/src/cache.rs (100%) rename crates/{code-search => code-search-mcp}/src/chunking.rs (100%) rename crates/{code-search => code-search-mcp}/src/dense.rs (100%) rename crates/{code-search => code-search-mcp}/src/files.rs (100%) rename crates/{code-search => code-search-mcp}/src/grammars.rs (100%) rename crates/{code-search => code-search-mcp}/src/index.rs (100%) rename crates/{code-search => code-search-mcp}/src/lib.rs (63%) create mode 100644 crates/code-search-mcp/src/main.rs rename crates/{code-search => code-search-mcp}/src/matrix.rs (100%) create mode 100644 crates/code-search-mcp/src/mcp.rs rename crates/{code-search => code-search-mcp}/src/ranking.rs (100%) rename crates/{code-search => code-search-mcp}/src/refresh.rs (100%) rename crates/{code-search => code-search-mcp}/src/semantic.rs (100%) rename crates/{code-search => code-search-mcp}/src/service.rs (100%) rename crates/{code-search => code-search-mcp}/src/singleflight.rs (100%) rename crates/{code-search => code-search-mcp}/src/tokens.rs (100%) rename crates/{code-search => code-search-mcp}/src/types.rs (98%) rename crates/{code-search => code-search-mcp}/src/watch.rs (100%) rename crates/{code-search => code-search-mcp}/tests/find_related_paths.rs (97%) delete mode 100644 crates/core/src/tools/handlers/code_search.rs delete mode 100644 crates/server/src/runtime/code_index_warmup.rs create mode 100644 specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fddcc97..7ef9fc10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,19 +93,27 @@ jobs: - name: Build release binary (Linux) if: runner.os == 'Linux' - run: cargo zigbuild --release --target ${{ matrix.target }} -p devo-cli + run: | + cargo zigbuild --release --target ${{ matrix.target }} -p devo-cli + cargo zigbuild --release --target ${{ matrix.target }} -p devo-code-search-mcp - name: Build release binary (macOS) if: runner.os == 'macOS' - run: cargo build --release --target ${{ matrix.target }} -p devo-cli + run: | + cargo build --release --target ${{ matrix.target }} -p devo-cli + cargo build --release --target ${{ matrix.target }} -p devo-code-search-mcp - name: Build release binary (Windows) if: runner.os == 'Windows' - run: cargo build --release --target ${{ matrix.target }} -p devo-cli + run: | + cargo build --release --target ${{ matrix.target }} -p devo-cli + cargo build --release --target ${{ matrix.target }} -p devo-code-search-mcp - name: Strip symbols (macOS) if: runner.os == 'macOS' - run: strip target/${{ matrix.target }}/release/devo${{ matrix.ext }} + run: | + strip target/${{ matrix.target }}/release/devo${{ matrix.ext }} + strip target/${{ matrix.target }}/release/devo-code-search-mcp${{ matrix.ext }} # ── CLI archive (skipped for desktop-only targets) ──────────────────── - name: Prepare CLI staging directory @@ -115,6 +123,7 @@ jobs: staging=devo-${{ github.ref_name }}-${{ matrix.target }} mkdir -p "$staging" cp target/${{ matrix.target }}/release/devo${{ matrix.ext }} "$staging/" + cp target/${{ matrix.target }}/release/devo-code-search-mcp${{ matrix.ext }} "$staging/" cp README.md LICENSE "$staging/" 2>/dev/null || true echo "STAGING=$staging" >> "$GITHUB_ENV" diff --git a/Cargo.lock b/Cargo.lock index 76d9abbd..68922611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,7 +1775,7 @@ dependencies = [ ] [[package]] -name = "devo-code-search" +name = "devo-code-search-mcp" version = "0.1.32" dependencies = [ "anyhow", @@ -1792,11 +1792,13 @@ dependencies = [ "pretty_assertions", "rayon", "reqwest 0.13.4", + "rmcp", "serde", "serde_json", "sha2 0.10.9", "tempfile", "thiserror 2.0.18", + "tokio", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -1850,7 +1852,6 @@ dependencies = [ "base64 0.22.1", "bm25", "chrono", - "devo-code-search", "devo-config", "devo-execpolicy", "devo-network-proxy", @@ -2111,7 +2112,6 @@ dependencies = [ "chrono", "clap", "devo-client", - "devo-code-search", "devo-core", "devo-execpolicy", "devo-file-search", diff --git a/Cargo.toml b/Cargo.toml index c09dc157..8ff60d08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ "crates/core", - "crates/code-search", + "crates/code-search-mcp", "crates/execpolicy", "crates/tools", "crates/provider", @@ -59,7 +59,7 @@ crossterm = { version = "0.28", features = ["event-stream"] } derive_more = "2" devo-arg0 = { path = "crates/arg0" } devo-client = { path = "crates/client" } -devo-code-search = { path = "crates/code-search" } +devo-code-search-mcp = { path = "crates/code-search-mcp" } devo-config = { path = "crates/config" } devo-core = { path = "crates/core" } devo-execpolicy = { path = "crates/execpolicy" } diff --git a/apps/desktop/scripts/prepare-runtime.test.ts b/apps/desktop/scripts/prepare-runtime.test.ts index 513aea5f..510a04a5 100644 --- a/apps/desktop/scripts/prepare-runtime.test.ts +++ b/apps/desktop/scripts/prepare-runtime.test.ts @@ -45,6 +45,7 @@ describe("prepare-runtime helpers", () => { mkdirSync(targetDir, { recursive: true }) mkdirSync(desktopDir, { recursive: true }) writeFileSync(join(targetDir, "devo"), "") + writeFileSync(join(targetDir, "devo-code-search-mcp"), "") expect(() => stageRuntime({ @@ -62,11 +63,14 @@ describe("prepare-runtime helpers", () => { const root = mkdtempSync(join(tmpdir(), "devo-runtime-test-")) const desktopDir = join(root, "desktop") const sourceDir = join(root, "source") + const releaseDir = join(root, "target", "release") const devoBin = join(sourceDir, "devo") const rgBin = join(sourceDir, "rg") mkdirSync(sourceDir, { recursive: true }) + mkdirSync(releaseDir, { recursive: true }) writeFileSync(devoBin, "devo") writeFileSync(rgBin, "rg") + writeFileSync(join(releaseDir, "devo-code-search-mcp"), "mcp") stageRuntime({ desktopDir, @@ -78,9 +82,14 @@ describe("prepare-runtime helpers", () => { expect({ devo: readFileSync(join(desktopDir, "resources", "runtime", "bin", "devo"), "utf8"), + mcp: readFileSync( + join(desktopDir, "resources", "runtime", "bin", "devo-code-search-mcp"), + "utf8", + ), rg: readFileSync(join(desktopDir, "resources", "runtime", "bin", "rg"), "utf8"), }).toEqual({ devo: "devo", + mcp: "mcp", rg: "rg", }) }) diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 890a733a..8a76cda3 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -45,8 +45,22 @@ export function defaultDevoSourcePath({ return join(repoRoot, ...targetParts, runtimeBinaryName("devo", platformForTargetTriple(targetTriple, platform))) } +export function defaultCodeSearchMcpSourcePath({ + repoRoot, + targetTriple, + platform = process.platform, +}: DefaultSourcePathOptions): string { + const targetParts = targetTriple ? ["target", targetTriple, "release"] : ["target", "release"] + return join( + repoRoot, + ...targetParts, + runtimeBinaryName("devo-code-search-mcp", platformForTargetTriple(targetTriple, platform)), + ) +} + export function stageRuntime(options: StageRuntimeOptions): void { const devoSource = options.devoBin ?? defaultDevoSourcePath(options) + const codeSearchMcpSource = defaultCodeSearchMcpSourcePath(options) const targetPlatform = platformForTargetTriple(options.targetTriple, options.platform ?? process.platform) const rgOverride = options.rgBin ?? optionalPath(process.env.DEVO_DESKTOP_RUNTIME_RG_BIN) @@ -74,6 +88,9 @@ export function stageRuntime(options: StageRuntimeOptions): void { if (!existsSync(devoSource)) { throw new Error(`Devo runtime binary not found at ${devoSource}`) } + if (!existsSync(codeSearchMcpSource)) { + throw new Error(`code_search MCP binary not found at ${codeSearchMcpSource}`) + } if (!rgSource || !existsSync(rgSource)) { throw new Error("ripgrep sidecar not found. Install rg or pass --rg-bin .") } @@ -83,11 +100,17 @@ export function stageRuntime(options: StageRuntimeOptions): void { mkdirSync(runtimeBinDir, { recursive: true }) const devoDest = join(runtimeBinDir, runtimeBinaryName("devo", targetPlatform)) + const codeSearchMcpDest = join( + runtimeBinDir, + runtimeBinaryName("devo-code-search-mcp", targetPlatform), + ) const rgDest = join(runtimeBinDir, runtimeBinaryName("rg", targetPlatform)) copyExecutable(devoSource, devoDest, targetPlatform) + copyExecutable(codeSearchMcpSource, codeSearchMcpDest, targetPlatform) copyExecutable(rgSource, rgDest, targetPlatform) console.log(`Prepared Desktop runtime: ${devoDest}`) + console.log(`Prepared code_search MCP sidecar: ${codeSearchMcpDest}`) console.log(`Prepared ripgrep sidecar: ${rgDest}`) } diff --git a/apps/web/content/docs/core-concepts/code-search.mdx b/apps/web/content/docs/core-concepts/code-search.mdx index 0115131e..8431f33e 100644 --- a/apps/web/content/docs/core-concepts/code-search.mdx +++ b/apps/web/content/docs/core-concepts/code-search.mdx @@ -1,10 +1,14 @@ --- title: Code Search -description: Understand Devo's local semantic code search. +description: Understand Devo's local semantic code search MCP server. --- -`code_search` is Devo's built-in read-only retrieval tool for finding relevant -code by meaning, identifiers, and related locations. +`code_search` is Devo's optional semantic retrieval tool for finding relevant +code by meaning, identifiers, and related locations. It ships as the bundled +stdio MCP server `devo-code-search-mcp` and is **disabled by default**. + +Enable it with `devo mcp enable code_search` or the TUI `/mcps` picker. When +enabled, the model sees the tool as `mcp__code_search__code_search`. It is different from shell search. Shell search is exact text search. Code search builds a local index of the workspace and ranks chunks with hybrid @@ -50,16 +54,16 @@ Production code search uses a local model2vec embedding provider with the `minishlab/potion-code-16M` model. The model is loaded lazily on first embedding call. -If the model files are missing, Devo tries to fetch them on first use. In an -offline environment, pre-populate the local model cache before relying on +If the model files are missing, the MCP server tries to fetch them on first use. +In an offline environment, pre-populate the local model cache before relying on `code_search`; otherwise the tool reports a recoverable model-unavailable configuration error. ## Cache Behavior -Devo keeps one in-memory index per workspace root, content filter, and embedding -model identity. It also stores disk cache metadata and embedding rows so repeat -queries do not always rebuild the whole index. +The MCP process keeps one in-memory index per workspace root, content filter, +and embedding model identity. It also stores disk cache metadata and embedding +rows so repeat queries do not always rebuild the whole index. The service prefers correctness over speed: diff --git a/apps/web/content/docs/core-concepts/code-search.zh.mdx b/apps/web/content/docs/core-concepts/code-search.zh.mdx index 58073974..3ec63d06 100644 --- a/apps/web/content/docs/core-concepts/code-search.zh.mdx +++ b/apps/web/content/docs/core-concepts/code-search.zh.mdx @@ -1,9 +1,11 @@ --- title: Code Search -description: 理解 Devo 的本地语义代码搜索。 +description: 理解 Devo 的本地语义代码搜索 MCP。 --- -`code_search` 是 Devo 内置的只读 retrieval tool,用于按语义、identifier 和相关位置查找代码。 +`code_search` 是 Devo 可选的语义 retrieval tool,用于按语义、identifier 和相关位置查找代码。它以捆绑的 stdio MCP 二进制 `devo-code-search-mcp` 形式提供,**默认禁用**。 + +用 `devo mcp enable code_search` 或 TUI `/mcps` 启用。启用后模型看到的工具名是 `mcp__code_search__code_search`。 它不同于 shell search。Shell search 是精确文本搜索。Code search 会构建 workspace 本地索引,并用 hybrid semantic 和 sparse matching 对 chunks 排序。 @@ -28,50 +30,15 @@ Code-search engine 处理: | Operation | 用途 | | --- | --- | -| `search` | 查找匹配自然语言或 identifier query 的 chunks。 | -| `find_related` | 查找与 source file 和 1-indexed line number 相关的 chunks。 | - -Search requests 可以按这些条件限定: - -- path; -- content kind:`code`、`docs`、`config` 或 `all`; -- language filters; -- `top_k` result count。 +| `search` | 按自然语言或 identifier query 查找 chunks。 | +| `find_related` | 查找与源文件 + 1-indexed 行相关的 chunks。 | -默认 result count 是 5,最大是 20。 +Search 可按 path、content kind(`code` / `docs` / `config` / `all`)、language filters、`top_k` 限定范围。默认返回 5 条,最多 20 条。 ## 本地模型 -Production code search 使用本地 model2vec embedding provider 和 `minishlab/potion-code-16M` 模型。模型会在第一次 embedding call 时 lazy load。 - -如果模型文件缺失,Devo 会在首次使用时尝试获取它们。离线环境中,应先预置 local model cache,再依赖 `code_search`;否则 tool 会报告可恢复的 model-unavailable configuration error。 - -## Cache 行为 - -Devo 会为每个 workspace root、content filter 和 embedding model identity 保留一个 in-memory index。它也保存 disk cache metadata 和 embedding rows,避免重复 query 总是重建整个 index。 - -Service 优先 correctness: - -1. watcher 认为 workspace 未变时,复用 clean warm index; -2. 否则 walk manifest; -3. 复用匹配的 memory 或 disk cache; -4. 只重新读取和 re-embed changed files。 +生产环境使用本地 model2vec embedding:`minishlab/potion-code-16M`,首次 embedding 时懒加载。离线环境需预先安装模型缓存(`install.sh --install-code-search-model`)。 ## 何时使用 -使用 code search,当: - -- 你知道概念但不知道确切 symbol; -- identifier search 返回太多结果; -- 想找特定行附近的 related code; -- 仓库有多个相似实现; -- 自然语言意图比精确 grep 文本更容易表达。 - -使用 shell search,当: - -- 你知道确切字符串; -- 需要 regex; -- 需要完整 match 列表; -- 需要 command-line flags 或 file-glob control。 - -Devo 可能在同一任务中同时使用两者:shell search 用于精确确认,code search 用于更广泛导航。 +语义探索、架构问题、related-code、自然语言意图时用 code search;精确字符串 / 正则用 grep / shell search。 diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index a0f8f13c..b2281414 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -88,7 +88,10 @@ pub(crate) async fn run_prompt( let registry = { let mcp_manager = std::sync::Arc::new(RmcpMcpManager::new( - app_config.mcp.clone(), + app_config + .mcp + .clone() + .with_code_search_workspace_cwd(cwd.clone()), app_config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&app_config); diff --git a/crates/code-search/Cargo.toml b/crates/code-search-mcp/Cargo.toml similarity index 80% rename from crates/code-search/Cargo.toml rename to crates/code-search-mcp/Cargo.toml index 1984d2d4..e2d9af63 100644 --- a/crates/code-search/Cargo.toml +++ b/crates/code-search-mcp/Cargo.toml @@ -1,12 +1,16 @@ [package] -description = "Semble-style hybrid code retrieval for Devo workspace tools" +description = "Semantic code retrieval and bundled code_search MCP server" edition.workspace = true license.workspace = true -name = "devo-code-search" +name = "devo-code-search-mcp" version.workspace = true +[[bin]] +name = "devo-code-search-mcp" +path = "src/main.rs" + [lib] -name = "devo_code_search" +name = "devo_code_search_mcp" path = "src/lib.rs" [dependencies] @@ -26,8 +30,18 @@ reqwest-hf = { package = "reqwest", version = "0.13", default-features = false, "rustls", "socks", ] } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +rmcp = { workspace = true, default-features = false, features = [ + "macros", + "server", + "transport-async-rw", +] } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "rt-multi-thread", +] } sha2 = { workspace = true } thiserror = { workspace = true } tree-sitter = { workspace = true } diff --git a/crates/code-search/src/cache.rs b/crates/code-search-mcp/src/cache.rs similarity index 100% rename from crates/code-search/src/cache.rs rename to crates/code-search-mcp/src/cache.rs diff --git a/crates/code-search/src/chunking.rs b/crates/code-search-mcp/src/chunking.rs similarity index 100% rename from crates/code-search/src/chunking.rs rename to crates/code-search-mcp/src/chunking.rs diff --git a/crates/code-search/src/dense.rs b/crates/code-search-mcp/src/dense.rs similarity index 100% rename from crates/code-search/src/dense.rs rename to crates/code-search-mcp/src/dense.rs diff --git a/crates/code-search/src/files.rs b/crates/code-search-mcp/src/files.rs similarity index 100% rename from crates/code-search/src/files.rs rename to crates/code-search-mcp/src/files.rs diff --git a/crates/code-search/src/grammars.rs b/crates/code-search-mcp/src/grammars.rs similarity index 100% rename from crates/code-search/src/grammars.rs rename to crates/code-search-mcp/src/grammars.rs diff --git a/crates/code-search/src/index.rs b/crates/code-search-mcp/src/index.rs similarity index 100% rename from crates/code-search/src/index.rs rename to crates/code-search-mcp/src/index.rs diff --git a/crates/code-search/src/lib.rs b/crates/code-search-mcp/src/lib.rs similarity index 63% rename from crates/code-search/src/lib.rs rename to crates/code-search-mcp/src/lib.rs index be3b0a4b..94e682ee 100644 --- a/crates/code-search/src/lib.rs +++ b/crates/code-search-mcp/src/lib.rs @@ -1,10 +1,7 @@ -//! Semantic code retrieval for Devo. +//! Semantic code retrieval and the bundled `code_search` MCP server. //! -//! This crate implements the built-in read-only `code_search` tool's retrieval -//! engine: workspace discovery, code chunking, dense embeddings, sparse BM25, -//! hybrid ranking, related-code lookup, and warm/disk cache refresh. The public -//! surface is intentionally small so Devo's tool runtime owns schema validation -//! and execution policy while this crate owns retrieval mechanics. +//! This crate owns the retrieval engine and the stdio MCP adapter Devo launches +//! as `devo-code-search-mcp`. mod cache; mod chunking; @@ -13,6 +10,7 @@ mod files; mod grammars; mod index; mod matrix; +mod mcp; mod ranking; mod refresh; mod semantic; @@ -25,6 +23,7 @@ mod watch; pub use dense::EmbeddingProvider; pub use dense::HashEmbeddingProvider; pub use dense::Model2VecEmbeddingProvider; +pub use mcp::CodeSearchMcpServer; pub use service::CodeSearchService; pub use types::Chunk; pub use types::CodeSearchError; diff --git a/crates/code-search-mcp/src/main.rs b/crates/code-search-mcp/src/main.rs new file mode 100644 index 00000000..8047768a --- /dev/null +++ b/crates/code-search-mcp/src/main.rs @@ -0,0 +1,17 @@ +//! Stdio entrypoint for the bundled `code_search` MCP server. + +use devo_code_search_mcp::CodeSearchMcpServer; +use rmcp::ServiceExt; + +fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { + (tokio::io::stdin(), tokio::io::stdout()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = CodeSearchMcpServer::production()?; + server.prewarm(); + let running = server.serve(stdio()).await?; + running.waiting().await?; + Ok(()) +} diff --git a/crates/code-search/src/matrix.rs b/crates/code-search-mcp/src/matrix.rs similarity index 100% rename from crates/code-search/src/matrix.rs rename to crates/code-search-mcp/src/matrix.rs diff --git a/crates/code-search-mcp/src/mcp.rs b/crates/code-search-mcp/src/mcp.rs new file mode 100644 index 00000000..5b353053 --- /dev/null +++ b/crates/code-search-mcp/src/mcp.rs @@ -0,0 +1,406 @@ +//! Stdio MCP adapter around [`CodeSearchService`](crate::CodeSearchService). +//! +//! Devo launches this as an optional bundled MCP server. The process cwd is the +//! workspace root used for path confinement and indexing. + +use std::borrow::Cow; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use devo_network_proxy::NetworkProxyConfig; +use rmcp::ErrorData as McpError; +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, JsonObject, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, +}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + CodeSearchError, CodeSearchOperation, CodeSearchService, ContentFilter, DEFAULT_TOP_K, + RelatedRequest, SearchFilters, SearchOutput, SearchRequest, +}; + +const TOOL_NAME: &str = "code_search"; +const TOOL_DESCRIPTION: &str = "Preferred codebase investigation and code retrieval tool for the current workspace. Use code_search before find or grep when you need to understand how code is implemented, locate relevant modules or symbols, answer architecture questions, find related code, or search by natural-language intent."; + +/// Long-lived MCP server that owns one [`CodeSearchService`]. +#[derive(Clone)] +pub struct CodeSearchMcpServer { + service: Arc, + workspace_root: PathBuf, + tools: Arc>, +} + +impl CodeSearchMcpServer { + /// Builds a production server that indexes the process current directory. + pub fn production() -> Result { + let workspace_root = std::env::current_dir()?; + let proxy = NetworkProxyConfig { + proxy_url: None, + no_proxy: None, + }; + let service = Arc::new(CodeSearchService::production_with_network_proxy(proxy)); + Ok(Self::with_service(service, workspace_root)) + } + + /// Builds a server around an injected service and workspace root (tests). + pub fn with_service(service: Arc, workspace_root: PathBuf) -> Self { + Self { + service, + workspace_root, + tools: Arc::new(vec![code_search_tool()]), + } + } + + /// Prefers warming the default code index for the workspace root. + pub fn prewarm(&self) { + let _ = self + .service + .prewarm(&self.workspace_root, ContentFilter::Code); + } + + /// Runs a tool call against the workspace-scoped service. + pub fn execute(&self, input: serde_json::Value) -> Result { + let input: CodeSearchInput = + serde_json::from_value(input).map_err(|error| error.to_string())?; + let request = build_request(&self.workspace_root, input)?; + match request { + CodeSearchRequest::Search(request) => self.service.search(request), + CodeSearchRequest::FindRelated(request) => self.service.find_related(request), + } + .map_err(map_code_search_error) + } +} + +fn code_search_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "Search operation: search for query text or find chunks related to file_path:line", + "enum": ["search", "find_related"] + }, + "query": { + "type": "string", + "description": "Natural-language or code query. Required for search." + }, + "file_path": { + "type": "string", + "description": "Workspace-relative or absolute source file path. Required for find_related." + }, + "line": { + "type": "integer", + "description": "1-indexed source line inside file_path. Required for find_related." + }, + "path": { + "type": "string", + "description": "Workspace-relative or absolute search root inside the workspace. Defaults to workspace root." + }, + "content": { + "type": "string", + "description": "Content filter. Defaults to code.", + "enum": ["code", "docs", "config", "all"] + }, + "top_k": { + "type": "integer", + "description": "Maximum results to return. Defaults to 5, maximum 20." + }, + "filter_paths": { + "type": "array", + "description": "Optional path prefixes to include", + "items": { + "type": "string", + "description": "Workspace-relative path prefix to include" + } + }, + "filter_languages": { + "type": "array", + "description": "Optional language filters such as rust or python", + "items": { + "type": "string", + "description": "Language name to include" + } + } + }, + "required": ["operation"], + "additionalProperties": false + })) + .expect("code_search tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed(TOOL_NAME), + Cow::Borrowed(TOOL_DESCRIPTION), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool +} + +impl ServerHandler for CodeSearchMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: self.tools.as_ref().clone(), + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + if request.name != TOOL_NAME { + return Err(McpError::invalid_params( + format!("unknown tool: {}", request.name), + None, + )); + } + let args = match request.arguments { + Some(arguments) => { + serde_json::Value::Object(arguments.into_iter().collect::()) + } + None => { + return Err(McpError::invalid_params( + "missing arguments for code_search tool", + None, + )); + } + }; + match self.execute(args) { + Ok(output) => { + let structured = serde_json::to_value(&output) + .map_err(|error| McpError::internal_error(error.to_string(), None))?; + Ok(CallToolResult { + content: vec![Content::text(result_summary(&output))], + structured_content: Some(structured), + is_error: Some(false), + meta: None, + }) + } + Err(message) => Ok(CallToolResult { + content: vec![Content::text(message)], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodeSearchInput { + operation: CodeSearchOperation, + query: Option, + file_path: Option, + line: Option, + path: Option, + content: Option, + top_k: Option, + filter_paths: Option>, + filter_languages: Option>, +} + +enum CodeSearchRequest { + Search(SearchRequest), + FindRelated(RelatedRequest), +} + +fn build_request( + workspace_root: &Path, + input: CodeSearchInput, +) -> Result { + let root = resolve_search_root(workspace_root, input.path.as_deref())?; + let content = input.content.unwrap_or_default(); + let top_k = input.top_k.unwrap_or(DEFAULT_TOP_K); + let filters = SearchFilters::normalized( + input.filter_paths.unwrap_or_default(), + input.filter_languages.unwrap_or_default(), + ); + + match input.operation { + CodeSearchOperation::Search => { + if input.file_path.is_some() || input.line.is_some() { + return Err("`file_path` and `line` are only valid for find_related".to_string()); + } + let query = input + .query + .ok_or_else(|| "`query` is required for search".to_string())?; + Ok(CodeSearchRequest::Search(SearchRequest { + root, + query, + content, + top_k, + filters, + })) + } + CodeSearchOperation::FindRelated => { + if input.query.is_some() { + return Err("`query` is only valid for search".to_string()); + } + let file_path = input + .file_path + .ok_or_else(|| "`file_path` is required for find_related".to_string())?; + let line = input + .line + .ok_or_else(|| "`line` is required for find_related".to_string())?; + Ok(CodeSearchRequest::FindRelated(RelatedRequest { + root, + file_path, + line, + content, + top_k, + filters, + })) + } + } +} + +fn resolve_search_root( + workspace_root: &Path, + requested_path: Option<&Path>, +) -> Result { + let workspace = workspace_root + .canonicalize() + .map_err(|error| error.to_string())?; + let candidate = match requested_path { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => workspace.join(path), + None => workspace.clone(), + }; + let canonical = candidate + .canonicalize() + .map_err(|error| error.to_string())?; + if !canonical.starts_with(&workspace) { + return Err(format!( + "`path` must be inside the workspace root: {}", + candidate.display() + )); + } + if !canonical.is_dir() { + return Err(format!( + "`path` must resolve to a directory: {}", + candidate.display() + )); + } + Ok(canonical) +} + +fn map_code_search_error(error: CodeSearchError) -> String { + match error { + CodeSearchError::InvalidInput(message) + | CodeSearchError::ModelUnavailable(message) + | CodeSearchError::Index(message) + | CodeSearchError::Io(message) => message, + } +} + +fn result_summary(output: &SearchOutput) -> String { + let count = output.results.len(); + match output.operation { + CodeSearchOperation::Search => { + if count == 0 { + "No code search results".to_string() + } else { + format!("{count} code search results") + } + } + CodeSearchOperation::FindRelated => { + if count == 0 { + "No related code chunks".to_string() + } else { + format!("{count} related code chunks") + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::Arc; + + use pretty_assertions::assert_eq; + + use super::*; + use crate::{CodeSearchService, HashEmbeddingProvider}; + + fn test_server(workspace: PathBuf, cache: PathBuf) -> CodeSearchMcpServer { + let service = + CodeSearchService::new(Arc::new(HashEmbeddingProvider::new("test", 16)), cache); + CodeSearchMcpServer::with_service(Arc::new(service), workspace) + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: code_search MCP rejects missing search query. + #[test] + fn execute_rejects_missing_search_query() { + let temp = tempfile::tempdir().expect("tempdir"); + let server = test_server(temp.path().to_path_buf(), temp.path().join("cache")); + let error = server + .execute(json!({ "operation": "search" })) + .expect_err("missing query should fail"); + assert!(error.contains("`query`")); + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: code_search MCP rejects paths outside the workspace. + #[test] + fn execute_rejects_path_outside_workspace() { + let workspace = tempfile::tempdir().expect("workspace"); + let outside = tempfile::tempdir().expect("outside"); + let server = test_server( + workspace.path().to_path_buf(), + workspace.path().join("cache"), + ); + let error = server + .execute(json!({ + "operation": "search", + "query": "parse", + "path": outside.path() + })) + .expect_err("outside path should fail"); + assert!(error.contains("inside the workspace")); + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: code_search MCP returns structured search results. + #[test] + fn execute_returns_search_results() { + let workspace = tempfile::tempdir().expect("workspace"); + let cache = tempfile::tempdir().expect("cache"); + fs::write( + workspace.path().join("parser.rs"), + "pub fn parse_input() {}\n", + ) + .expect("write"); + let server = test_server(workspace.path().to_path_buf(), cache.path().to_path_buf()); + + let output = server + .execute(json!({ + "operation": "search", + "query": "parse input", + "top_k": 1 + })) + .expect("search succeeds"); + + assert_eq!(output.operation, CodeSearchOperation::Search); + assert_eq!(output.results.len(), 1); + } +} diff --git a/crates/code-search/src/ranking.rs b/crates/code-search-mcp/src/ranking.rs similarity index 100% rename from crates/code-search/src/ranking.rs rename to crates/code-search-mcp/src/ranking.rs diff --git a/crates/code-search/src/refresh.rs b/crates/code-search-mcp/src/refresh.rs similarity index 100% rename from crates/code-search/src/refresh.rs rename to crates/code-search-mcp/src/refresh.rs diff --git a/crates/code-search/src/semantic.rs b/crates/code-search-mcp/src/semantic.rs similarity index 100% rename from crates/code-search/src/semantic.rs rename to crates/code-search-mcp/src/semantic.rs diff --git a/crates/code-search/src/service.rs b/crates/code-search-mcp/src/service.rs similarity index 100% rename from crates/code-search/src/service.rs rename to crates/code-search-mcp/src/service.rs diff --git a/crates/code-search/src/singleflight.rs b/crates/code-search-mcp/src/singleflight.rs similarity index 100% rename from crates/code-search/src/singleflight.rs rename to crates/code-search-mcp/src/singleflight.rs diff --git a/crates/code-search/src/tokens.rs b/crates/code-search-mcp/src/tokens.rs similarity index 100% rename from crates/code-search/src/tokens.rs rename to crates/code-search-mcp/src/tokens.rs diff --git a/crates/code-search/src/types.rs b/crates/code-search-mcp/src/types.rs similarity index 98% rename from crates/code-search/src/types.rs rename to crates/code-search-mcp/src/types.rs index 81f4d634..e5f03684 100644 --- a/crates/code-search/src/types.rs +++ b/crates/code-search-mcp/src/types.rs @@ -712,7 +712,7 @@ mod tests { fn bench_chunk_location_unix_path() { let chunk = Chunk { content: "content".to_string(), - file_path: Path::new("crates/code-search/src/index.rs").to_path_buf(), + file_path: Path::new("crates/code-search-mcp/src/index.rs").to_path_buf(), start_line: 123, end_line: 145, language: "rust".to_string(), @@ -728,7 +728,7 @@ mod tests { let elapsed = started.elapsed(); assert_eq!( total_len, - "crates/code-search/src/index.rs:123-145".len() * iterations + "crates/code-search-mcp/src/index.rs:123-145".len() * iterations ); println!( "chunk_location_unix_path iterations={iterations} elapsed_ms={} per_call_ns={:.2}", @@ -742,14 +742,14 @@ mod tests { fn bench_chunk_cmp_location_same_path_lines() { let left = Chunk { content: "left".to_string(), - file_path: Path::new("crates/code-search/src/index.rs").to_path_buf(), + file_path: Path::new("crates/code-search-mcp/src/index.rs").to_path_buf(), start_line: 1_234_567, end_line: 1_234_589, language: "rust".to_string(), }; let right = Chunk { content: "right".to_string(), - file_path: Path::new("crates/code-search/src/index.rs").to_path_buf(), + file_path: Path::new("crates/code-search-mcp/src/index.rs").to_path_buf(), start_line: 1_234_568, end_line: 1_234_590, language: "rust".to_string(), diff --git a/crates/code-search/src/watch.rs b/crates/code-search-mcp/src/watch.rs similarity index 100% rename from crates/code-search/src/watch.rs rename to crates/code-search-mcp/src/watch.rs diff --git a/crates/code-search/tests/find_related_paths.rs b/crates/code-search-mcp/tests/find_related_paths.rs similarity index 97% rename from crates/code-search/tests/find_related_paths.rs rename to crates/code-search-mcp/tests/find_related_paths.rs index 822b858f..ac40f9cd 100644 --- a/crates/code-search/tests/find_related_paths.rs +++ b/crates/code-search-mcp/tests/find_related_paths.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -use devo_code_search::{ +use devo_code_search_mcp::{ CodeSearchError, CodeSearchService, ContentFilter, HashEmbeddingProvider, RelatedRequest, SearchFilters, }; diff --git a/crates/config/README.md b/crates/config/README.md index f7185462..518268c8 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -89,7 +89,7 @@ provider fields without clearing every omitted provider field from lower layers. - `skills.bundled.enabled = true` - `skills.include_instructions = true` - `skills.config = []` -- `experimental.code_search = true` +- bundled `[[mcp.servers]]` entry `id = "code_search"` with `enabled = false` - `tools.web_search.mode = "provider"` - `updates.enabled = true` - `updates.check_on_startup = true` @@ -149,8 +149,15 @@ enabled = false name = "code-review" enabled = true -[experimental] -code-search = true +[[mcp.servers]] +id = "code_search" +display_name = "Code Search" +enabled = false +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "stdio" +command = ["devo-code-search-mcp"] [tools.web_search] mode = "local" # disabled, provider, or local diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 4938958a..01f909f9 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -662,6 +662,7 @@ impl AppConfigLoader for FileSystemAppConfigLoader { message: source.to_string(), })?; config.provider = provider_config; + config.mcp.ensure_bundled_servers(); validate_app_config(&config)?; Ok(config) } diff --git a/crates/config/src/experimental.rs b/crates/config/src/experimental.rs index 9c9ee380..3846f8ee 100644 --- a/crates/config/src/experimental.rs +++ b/crates/config/src/experimental.rs @@ -2,30 +2,9 @@ use serde::Deserialize; use serde::Serialize; /// Experimental feature gates. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExperimentalConfig { - /// Enables the built-in `code_search` tool. - #[serde( - default = "default_code_search", - rename = "code-search", - alias = "code_search", - skip_serializing_if = "is_true" - )] - pub code_search: bool, -} - -impl Default for ExperimentalConfig { - fn default() -> Self { - Self { - code_search: default_code_search(), - } - } -} - -fn default_code_search() -> bool { - true -} - -fn is_true(value: &bool) -> bool { - *value -} +/// +/// The former `code-search` gate has been removed. Semantic code search is now +/// provided by the bundled `code_search` MCP server (`devo-code-search-mcp`), +/// which is disabled by default until the user enables it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ExperimentalConfig {} diff --git a/crates/config/src/mcp.rs b/crates/config/src/mcp.rs index 31957fb6..d7f21bb3 100644 --- a/crates/config/src/mcp.rs +++ b/crates/config/src/mcp.rs @@ -4,6 +4,9 @@ use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; +/// Stable id for the bundled code_search MCP server. +pub const BUNDLED_CODE_SEARCH_MCP_SERVER_ID: &str = "code_search"; + /// Environment variable forwarding rule for configured MCP servers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] @@ -64,14 +67,92 @@ pub struct McpConfig { impl Default for McpConfig { fn default() -> Self { - Self { + let mut config = Self { servers: Vec::new(), auto_start: true, refresh_on_config_reload: true, + }; + ensure_bundled_mcp_servers(&mut config); + config + } +} + +impl McpConfig { + /// Inserts missing bundled MCP servers without overwriting user records. + pub fn ensure_bundled_servers(&mut self) { + ensure_bundled_mcp_servers(self); + } + + /// Sets `cwd` on the bundled `code_search` stdio server when it is unset. + /// + /// Used so session workspace roots are passed through the existing MCP + /// transport `cwd` field without special-casing the MCP manager. + pub fn with_code_search_workspace_cwd(mut self, cwd: PathBuf) -> Self { + self.apply_code_search_workspace_cwd(cwd); + self + } + + /// Sets `cwd` on the bundled `code_search` stdio server when it is unset. + pub fn apply_code_search_workspace_cwd(&mut self, cwd: PathBuf) { + for record in &mut self.servers { + if record.id.0 != BUNDLED_CODE_SEARCH_MCP_SERVER_ID { + continue; + } + if let McpTransportConfig::Stdio { + cwd: server_cwd, .. + } = &mut record.transport + && server_cwd.is_none() + { + *server_cwd = Some(cwd); + } + break; } } } +/// Returns the bundled, disabled-by-default code_search MCP server record. +pub fn bundled_code_search_mcp_server() -> McpServerRecord { + McpServerRecord { + id: McpServerId(BUNDLED_CODE_SEARCH_MCP_SERVER_ID.to_string()), + display_name: "Code Search".to_string(), + transport: McpTransportConfig::Stdio { + command: vec!["devo-code-search-mcp".to_string()], + cwd: None, + env: BTreeMap::new(), + env_vars: vec![ + McpServerEnvVar::from("DEVO_HOME"), + McpServerEnvVar::from("HTTP_PROXY"), + McpServerEnvVar::from("HTTPS_PROXY"), + McpServerEnvVar::from("ALL_PROXY"), + McpServerEnvVar::from("NO_PROXY"), + McpServerEnvVar::from("http_proxy"), + McpServerEnvVar::from("https_proxy"), + McpServerEnvVar::from("all_proxy"), + McpServerEnvVar::from("no_proxy"), + ], + }, + startup_policy: McpStartupPolicy::Lazy, + enabled: false, + trust_policy: McpTrustPolicy::default(), + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::default(), + output_limits: McpOutputLimits::default(), + auth_ref: None, + } +} + +fn ensure_bundled_mcp_servers(config: &mut McpConfig) { + let bundled = bundled_code_search_mcp_server(); + if config + .servers + .iter() + .any(|record| record.id.0 == bundled.id.0) + { + return; + } + config.servers.push(bundled); +} + fn default_mcp_auto_start() -> bool { true } @@ -127,7 +208,7 @@ impl std::fmt::Display for McpServerId { } } -/// Describes how the runtime connects to an MCP server. +/// Describes how the runtime connects to the server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum McpTransportConfig { @@ -240,3 +321,136 @@ impl Default for McpOutputLimits { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use pretty_assertions::assert_eq; + + use super::*; + + fn bundled_stdio_env_vars() -> Vec { + match bundled_code_search_mcp_server().transport { + McpTransportConfig::Stdio { env_vars, .. } => env_vars, + _ => Vec::new(), + } + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: default MCP config includes the disabled bundled code_search server. + #[test] + fn default_mcp_config_includes_disabled_code_search_server() { + let config = McpConfig::default(); + let server = config + .servers + .iter() + .find(|record| record.id.0 == BUNDLED_CODE_SEARCH_MCP_SERVER_ID) + .expect("bundled code_search server"); + assert!(!server.enabled); + assert_eq!(server.startup_policy, McpStartupPolicy::Lazy); + assert_eq!( + server.transport, + McpTransportConfig::Stdio { + command: vec!["devo-code-search-mcp".to_string()], + cwd: None, + env: BTreeMap::new(), + env_vars: bundled_stdio_env_vars(), + } + ); + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: ensure_bundled_servers inserts missing records but preserves user ones. + #[test] + fn ensure_bundled_servers_preserves_existing_code_search_record() { + let mut config = McpConfig { + servers: vec![McpServerRecord { + id: McpServerId(BUNDLED_CODE_SEARCH_MCP_SERVER_ID.to_string()), + display_name: "Custom".to_string(), + transport: McpTransportConfig::Stdio { + command: vec!["custom".to_string()], + cwd: None, + env: BTreeMap::new(), + env_vars: Vec::new(), + }, + startup_policy: McpStartupPolicy::Eager, + enabled: true, + trust_policy: McpTrustPolicy::default(), + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::default(), + output_limits: McpOutputLimits::default(), + auth_ref: None, + }], + auto_start: true, + refresh_on_config_reload: true, + }; + config.ensure_bundled_servers(); + assert_eq!(config.servers.len(), 1); + assert!(config.servers[0].enabled); + assert_eq!(config.servers[0].display_name, "Custom"); + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: ensure_bundled_servers inserts the bundled server when absent. + #[test] + fn ensure_bundled_servers_inserts_when_missing() { + let mut config = McpConfig { + servers: Vec::new(), + auto_start: true, + refresh_on_config_reload: true, + }; + config.ensure_bundled_servers(); + assert_eq!(config.servers.len(), 1); + assert_eq!(config.servers[0].id.0, BUNDLED_CODE_SEARCH_MCP_SERVER_ID); + assert!(!config.servers[0].enabled); + } + + /// Trace: L2-DES-MCP-002 + /// Verifies: apply_code_search_workspace_cwd fills cwd only when unset. + #[test] + fn apply_code_search_workspace_cwd_sets_missing_cwd_only() { + let mut config = McpConfig::default(); + config.apply_code_search_workspace_cwd(PathBuf::from("/workspace")); + let server = config + .servers + .iter() + .find(|record| record.id.0 == BUNDLED_CODE_SEARCH_MCP_SERVER_ID) + .expect("bundled server"); + match &server.transport { + McpTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/workspace"))); + } + _ => panic!("expected stdio transport"), + } + + let mut custom = McpConfig { + servers: vec![McpServerRecord { + id: McpServerId(BUNDLED_CODE_SEARCH_MCP_SERVER_ID.to_string()), + display_name: "Custom".to_string(), + transport: McpTransportConfig::Stdio { + command: vec!["custom".to_string()], + cwd: Some(PathBuf::from("/explicit")), + env: BTreeMap::new(), + env_vars: Vec::new(), + }, + startup_policy: McpStartupPolicy::Eager, + enabled: true, + trust_policy: McpTrustPolicy::default(), + allowed_capabilities: Vec::new(), + roots_policy: McpRootsPolicy::default(), + output_limits: McpOutputLimits::default(), + auth_ref: None, + }], + auto_start: true, + refresh_on_config_reload: true, + }; + custom.apply_code_search_workspace_cwd(PathBuf::from("/workspace")); + match &custom.servers[0].transport { + McpTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/explicit"))); + } + _ => panic!("expected stdio transport"), + } + } +} diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 735ecbd4..a0904479 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -141,7 +141,7 @@ check_interval_hours = 48 include_instructions: Some(true), config: Vec::new(), }, - experimental: ExperimentalConfig { code_search: true }, + experimental: ExperimentalConfig::default(), mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::default()), mcp: super::McpConfig::default(), tools: ToolsConfig::default(), @@ -441,11 +441,14 @@ pattern = "deploy" } #[test] -fn default_app_config_enables_code_search() { - assert_eq!( - AppConfig::default().experimental, - ExperimentalConfig { code_search: true } - ); +fn default_app_config_includes_disabled_code_search_mcp_server() { + let mcp = AppConfig::default().mcp; + let server = mcp + .servers + .iter() + .find(|record| record.id.0 == super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID) + .expect("bundled code_search server"); + assert!(!server.enabled); } #[test] @@ -548,77 +551,47 @@ fn loader_rejects_empty_server_auth_name_when_enabled() { } #[test] -fn loader_accepts_experimental_code_search_kebab_key() { - let root = unique_temp_dir("config-experimental-kebab"); +fn loader_ignores_legacy_experimental_code_search_keys() { + let root = unique_temp_dir("config-experimental-legacy"); let home = root.join("home").join(".devo"); std::fs::create_dir_all(&home).expect("home config dir"); std::fs::write( home.join("config.toml"), - "[experimental]\ncode-search = true\n", + "[experimental]\ncode-search = true\ncode_search = false\n", ) .expect("write user config"); let loader = FileSystemAppConfigLoader::new(home); let config = loader.load(None).expect("load config"); - assert_eq!( - config.experimental, - ExperimentalConfig { code_search: true } - ); + assert_eq!(config.experimental, ExperimentalConfig::default()); + let server = config + .mcp + .servers + .iter() + .find(|record| record.id.0 == super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID) + .expect("bundled code_search server"); + assert!(!server.enabled); let _ = std::fs::remove_dir_all(root); } #[test] -fn loader_accepts_experimental_code_search_snake_alias() { - let root = unique_temp_dir("config-experimental-snake"); +fn loader_ensures_bundled_code_search_mcp_when_servers_list_is_empty() { + let root = unique_temp_dir("config-bundled-mcp-ensure"); let home = root.join("home").join(".devo"); std::fs::create_dir_all(&home).expect("home config dir"); - std::fs::write( - home.join("config.toml"), - "[experimental]\ncode_search = true\n", - ) - .expect("write user config"); + std::fs::write(home.join("config.toml"), "[mcp]\nservers = []\n").expect("write user config"); let loader = FileSystemAppConfigLoader::new(home); let config = loader.load(None).expect("load config"); + assert_eq!(config.mcp.servers.len(), 1); assert_eq!( - config.experimental, - ExperimentalConfig { code_search: true } - ); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn loader_merges_experimental_config_in_normal_precedence_order() { - let root = unique_temp_dir("config-experimental-merge"); - let home = root.join("home").join(".devo"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&home).expect("home config dir"); - std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); - std::fs::write( - home.join("config.toml"), - "[experimental]\ncode-search = false\n", - ) - .expect("write user config"); - std::fs::write( - workspace.join(".devo").join("config.toml"), - "[experimental]\ncode-search = true\n", - ) - .expect("write project config"); - let cli_overrides: toml::Value = "[experimental]\ncode-search = false\n" - .parse() - .expect("parse cli overrides"); - - let loader = FileSystemAppConfigLoader::new(home).with_cli_overrides(cli_overrides); - let config = loader.load(Some(&workspace)).expect("load config"); - - assert_eq!( - config.experimental, - ExperimentalConfig { code_search: false } + config.mcp.servers[0].id.0, + super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID ); + assert!(!config.mcp.servers[0].enabled); let _ = std::fs::remove_dir_all(root); } @@ -1404,9 +1377,22 @@ level = "warn" let user_config = std::fs::read_to_string(home.join("config.toml")).expect("read user config"); assert!(user_config.contains("check_on_startup")); assert!(user_config.contains("level")); + let server_ids: Vec<&str> = store + .mcp_servers() + .iter() + .map(|server| server.id.0.as_str()) + .collect(); + assert!(server_ids.contains(&"time")); + assert!(server_ids.contains(&"hello")); + assert!(server_ids.contains(&"legacy")); + assert!(server_ids.contains(&super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID)); assert_eq!( - store.mcp_servers(), - &[stdio_record.clone(), http_record, sse_record] + store + .mcp_servers() + .iter() + .find(|server| server.id.0 == "time") + .expect("time server"), + &stdio_record ); store @@ -1430,14 +1416,15 @@ level = "warn" ); let reloaded = AppConfigStore::load(home, /*workspace_root*/ None).expect("reload"); - assert_eq!( - reloaded - .mcp_servers() - .iter() - .map(|server| server.id.0.as_str()) - .collect::>(), - vec!["time", "legacy"] - ); + let reloaded_ids: Vec<&str> = reloaded + .mcp_servers() + .iter() + .map(|server| server.id.0.as_str()) + .collect(); + assert!(reloaded_ids.contains(&"time")); + assert!(reloaded_ids.contains(&"legacy")); + assert!(reloaded_ids.contains(&super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID)); + assert!(!reloaded_ids.contains(&"hello")); assert!( !reloaded .mcp_servers() diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 89f265c1..88002272 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -5,17 +5,8 @@ license.workspace = true name = "devo-core" version.workspace = true -[features] -default = ["code-search"] -# Semantic code search tool (`code_search`). Optional because it pulls in heavy -# dependencies (tokenizers, model2vec, hf-hub/hf-xet, hnsw_rs and the full -# tree-sitter grammar set). Disable with `--no-default-features` for a slim -# build that omits the entire code-search stack. -code-search = ["dep:devo-code-search"] - [dependencies] devo-config = { workspace = true } -devo-code-search = { workspace = true, optional = true } devo-execpolicy = { workspace = true } devo-network-proxy = { workspace = true } devo-protocol = { workspace = true } @@ -64,5 +55,5 @@ devo-safety = { workspace = true } futures = { workspace = true } pretty_assertions = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = ["full"] } +tokio = { workspace = true, features = ["full", "test-util"] } uuid = { workspace = true } diff --git a/crates/core/src/tools/deferred_loading.rs b/crates/core/src/tools/deferred_loading.rs index ea165be6..178ba222 100644 --- a/crates/core/src/tools/deferred_loading.rs +++ b/crates/core/src/tools/deferred_loading.rs @@ -413,9 +413,6 @@ fn alias_map(registered: &HashSet<&str>) -> HashMap { insert_alias(&mut aliases, registered, "file_search", "find"); insert_alias(&mut aliases, registered, "rg", "grep"); insert_alias(&mut aliases, registered, "ripgrep", "grep"); - insert_alias(&mut aliases, registered, "semantic-search", "code_search"); - insert_alias(&mut aliases, registered, "semantic_search", "code_search"); - insert_alias(&mut aliases, registered, "semble", "code_search"); insert_alias(&mut aliases, registered, "fetch-url", "fetch_url"); insert_alias(&mut aliases, registered, "urlfetch", "fetch_url"); insert_alias(&mut aliases, registered, "websearch", "web_search"); @@ -457,7 +454,6 @@ fn default_preloaded_tools() -> BTreeSet { "read", "find", "grep", - "code_search", "ls", "write", "apply_patch", @@ -837,7 +833,7 @@ mod tests { } #[test] - fn tool_search_does_not_resolve_code_search_alias_when_unregistered() { + fn tool_search_does_not_resolve_removed_code_search_alias() { let mut loaded = LoadedDeferredTools::default(); let tools = vec![ tool("read", "Read a file."), @@ -851,7 +847,7 @@ mod tests { &mut loaded, &DeferredLoadingConfig::default(), ) - .expect_err("unregistered code_search alias should not resolve"); + .expect_err("removed code_search alias should not resolve"); assert_eq!( err, diff --git a/crates/core/src/tools/glob.txt b/crates/core/src/tools/glob.txt index ef380494..77b7f2c6 100644 --- a/crates/core/src/tools/glob.txt +++ b/crates/core/src/tools/glob.txt @@ -3,4 +3,4 @@ - Returns matching file paths sorted by modification time when supported by the runtime - Use this tool when you need to find files by name patterns - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead when available -- When code_search is available, prefer it for codebase investigation, related-code lookup, module discovery, or natural-language searches +- Prefer name/pattern matching with this tool. If a semantic code_search MCP tool is available and loaded, prefer that for related-code lookup and natural-language searches diff --git a/crates/core/src/tools/grep.txt b/crates/core/src/tools/grep.txt index 962e6cd7..269f2817 100644 --- a/crates/core/src/tools/grep.txt +++ b/crates/core/src/tools/grep.txt @@ -9,4 +9,4 @@ Usage: - Use the Agent tool for open-ended searches requiring multiple rounds when an Agent tool is available - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code) - Multiline matching: By default patterns match within single lines only. If a multiline option exists and you need cross-line patterns like `struct \{[\s\S]*?field`, use it. -- When code_search is available, prefer it for codebase investigation, architecture questions, related-code lookup, symbol/module discovery, and natural-language intent searches +- Prefer exact/regex search with this tool. If a semantic code_search MCP tool is available and loaded, prefer that for architecture questions, related-code lookup, and natural-language intent searches diff --git a/crates/core/src/tools/handlers/code_search.rs b/crates/core/src/tools/handlers/code_search.rs deleted file mode 100644 index f4eabfd3..00000000 --- a/crates/core/src/tools/handlers/code_search.rs +++ /dev/null @@ -1,392 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use async_trait::async_trait; -use devo_code_search::{ - CodeSearchError, CodeSearchOperation, CodeSearchService, ContentFilter, DEFAULT_TOP_K, - RelatedRequest, SearchFilters, SearchOutput, SearchRequest, -}; -use serde::Deserialize; - -use crate::contracts::{ - ToolCallError, ToolContext, ToolProgress, ToolProgressSender, ToolResult, ToolResultContent, -}; -use crate::registry_plan::code_search_tool_spec; -use crate::tool_handler::ToolHandler; -use crate::tool_spec::ToolSpec; - -pub struct CodeSearchHandler { - spec: ToolSpec, - service: Arc, -} - -impl CodeSearchHandler { - pub fn new() -> Self { - Self::with_service(Arc::new(CodeSearchService::production())) - } - - pub fn new_with_network_proxy(network_proxy: devo_network_proxy::NetworkProxyConfig) -> Self { - Self::with_service(Arc::new(CodeSearchService::production_with_network_proxy( - network_proxy, - ))) - } - - pub fn with_service(service: Arc) -> Self { - let spec = code_search_tool_spec(); - Self { spec, service } - } -} - -impl Default for CodeSearchHandler { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CodeSearchInput { - operation: CodeSearchOperation, - query: Option, - file_path: Option, - line: Option, - path: Option, - content: Option, - top_k: Option, - filter_paths: Option>, - filter_languages: Option>, -} - -#[async_trait] -impl ToolHandler for CodeSearchHandler { - fn spec(&self) -> &ToolSpec { - &self.spec - } - - async fn handle( - &self, - ctx: ToolContext, - input: serde_json::Value, - progress: Option, - ) -> Result { - if ctx.cancel_token.is_cancelled() { - return Err(ToolCallError::Cancelled); - } - let input: CodeSearchInput = serde_json::from_value(input) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - let request = build_request(&ctx.workspace_root, input)?; - - if let Some(ref sender) = progress { - let root = match &request { - CodeSearchRequest::Search(r) => &r.root, - CodeSearchRequest::FindRelated(r) => &r.root, - }; - let content = match &request { - CodeSearchRequest::Search(r) => r.content, - CodeSearchRequest::FindRelated(r) => r.content, - }; - if self.service.needs_index_build(root, content) { - let _ = sender.send(ToolProgress::StatusUpdate { - message: "First code search: building index (may take a moment, usually quick)" - .to_string(), - percent: None, - }); - } - } - - let service = Arc::clone(&self.service); - let output = tokio::task::spawn_blocking(move || match request { - CodeSearchRequest::Search(request) => service.search(request), - CodeSearchRequest::FindRelated(request) => service.find_related(request), - }) - .await - .map_err(|error| ToolCallError::ExecutionFailed(error.to_string()))? - .map_err(map_code_search_error)?; - if ctx.cancel_token.is_cancelled() { - return Err(ToolCallError::Cancelled); - } - let summary = result_summary(&output); - let display = display_output(&output, ctx.budgets.output_limit_bytes); - let json = serde_json::to_value(&output) - .map_err(|error| ToolCallError::InternalError(error.to_string()))?; - let mut result = ToolResult::success(ToolResultContent::Json(json), summary); - result.display_content = Some(display); - Ok(result) - } -} - -enum CodeSearchRequest { - Search(SearchRequest), - FindRelated(RelatedRequest), -} - -fn build_request( - workspace_root: &Path, - input: CodeSearchInput, -) -> Result { - let root = resolve_search_root(workspace_root, input.path.as_deref())?; - let content = input.content.unwrap_or_default(); - let top_k = input.top_k.unwrap_or(DEFAULT_TOP_K); - let filters = SearchFilters::normalized( - input.filter_paths.unwrap_or_default(), - input.filter_languages.unwrap_or_default(), - ); - - match input.operation { - CodeSearchOperation::Search => { - if input.file_path.is_some() || input.line.is_some() { - return Err(ToolCallError::InvalidInput( - "`file_path` and `line` are only valid for find_related".to_string(), - )); - } - let query = input.query.ok_or_else(|| { - ToolCallError::InvalidInput("`query` is required for search".to_string()) - })?; - Ok(CodeSearchRequest::Search(SearchRequest { - root, - query, - content, - top_k, - filters, - })) - } - CodeSearchOperation::FindRelated => { - if input.query.is_some() { - return Err(ToolCallError::InvalidInput( - "`query` is only valid for search".to_string(), - )); - } - let file_path = input.file_path.ok_or_else(|| { - ToolCallError::InvalidInput("`file_path` is required for find_related".to_string()) - })?; - let line = input.line.ok_or_else(|| { - ToolCallError::InvalidInput("`line` is required for find_related".to_string()) - })?; - Ok(CodeSearchRequest::FindRelated(RelatedRequest { - root, - file_path, - line, - content, - top_k, - filters, - })) - } - } -} - -fn resolve_search_root( - workspace_root: &Path, - requested_path: Option<&Path>, -) -> Result { - let workspace = workspace_root - .canonicalize() - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - let candidate = match requested_path { - Some(path) if path.is_absolute() => path.to_path_buf(), - Some(path) => workspace.join(path), - None => workspace.clone(), - }; - let canonical = candidate - .canonicalize() - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - if !canonical.starts_with(&workspace) { - return Err(ToolCallError::InvalidInput(format!( - "`path` must be inside the workspace root: {}", - candidate.display() - ))); - } - if !canonical.is_dir() { - return Err(ToolCallError::InvalidInput(format!( - "`path` must resolve to a directory: {}", - candidate.display() - ))); - } - Ok(canonical) -} - -fn map_code_search_error(error: CodeSearchError) -> ToolCallError { - match error { - CodeSearchError::InvalidInput(message) => ToolCallError::InvalidInput(message), - CodeSearchError::ModelUnavailable(message) => ToolCallError::NeedsConfiguration(message), - CodeSearchError::Index(message) | CodeSearchError::Io(message) => { - ToolCallError::ExecutionFailed(message) - } - } -} - -fn result_summary(output: &SearchOutput) -> String { - let count = output.results.len(); - match output.operation { - CodeSearchOperation::Search => { - if count == 0 { - "No code search results".to_string() - } else { - format!("{count} code search results") - } - } - CodeSearchOperation::FindRelated => { - if count == 0 { - "No related code chunks".to_string() - } else { - format!("{count} related code chunks") - } - } - } -} - -fn display_output(output: &SearchOutput, output_limit_bytes: usize) -> String { - if output.results.is_empty() { - return result_summary(output); - } - let mut display = String::new(); - for result in &output.results { - let first_line = result - .chunk - .content - .lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or("") - .trim(); - display.push_str(&format!( - "{} score={:.4}\n{}\n\n", - result.chunk.location(), - result.score, - first_line - )); - if display.len() >= output_limit_bytes { - display.truncate(output_limit_bytes); - display.push_str("\n(truncated)"); - break; - } - } - display.trim_end().to_string() -} - -#[cfg(test)] -mod tests { - use std::fs; - - use devo_code_search::HashEmbeddingProvider; - use pretty_assertions::assert_eq; - use tokio_util::sync::CancellationToken; - - use crate::contracts::ToolBudgets; - use crate::invocation::ToolCallId; - - use super::*; - - fn context(root: PathBuf) -> ToolContext { - ToolContext { - tool_call_id: ToolCallId("call-1".to_string()), - session_id: "session-1".to_string(), - turn_id: Some("turn-1".to_string()), - workspace_root: root, - budgets: ToolBudgets { - output_limit_bytes: 32_768, - wall_time_limit_ms: None, - }, - cancel_token: CancellationToken::new(), - agent_scope: crate::contracts::ToolAgentScope::Parent, - collaboration_mode: devo_protocol::CollaborationMode::Build, - agent_coordinator: None, - client_filesystem: None, - file_read_ledger: None, - network_proxy: None, - network_no_proxy: None, - sandbox_profile: None, - } - } - - fn test_handler(cache_dir: PathBuf) -> CodeSearchHandler { - let service = - CodeSearchService::new(Arc::new(HashEmbeddingProvider::new("test", 16)), cache_dir); - CodeSearchHandler::with_service(Arc::new(service)) - } - - #[test] - fn handler_constructor_sets_code_search_spec() { - let handler = CodeSearchHandler::new(); - - assert_eq!(handler.spec().name, "code_search"); - } - - /// Trace: L2-DES-TOOL-001 - /// Verifies: code_search validates operation-specific required fields. - #[tokio::test] - async fn handler_rejects_missing_search_query() { - let temp = tempfile::tempdir().expect("tempdir"); - let handler = test_handler(temp.path().join("cache")); - let error = handler - .handle( - context(temp.path().to_path_buf()), - serde_json::json!({ "operation": "search" }), - None, - ) - .await - .expect_err("missing query should fail"); - - assert!(matches!(error, ToolCallError::InvalidInput(_))); - } - - /// Trace: L2-DES-TOOL-001 - /// Verifies: code_search search roots cannot escape the workspace. - #[tokio::test] - async fn handler_rejects_path_outside_workspace() { - let workspace = tempfile::tempdir().expect("workspace"); - let outside = tempfile::tempdir().expect("outside"); - let handler = test_handler(workspace.path().join("cache")); - let error = handler - .handle( - context(workspace.path().to_path_buf()), - serde_json::json!({ - "operation": "search", - "query": "parse", - "path": outside.path() - }), - None, - ) - .await - .expect_err("outside path should fail"); - - assert!(matches!(error, ToolCallError::InvalidInput(_))); - } - - /// Trace: L2-DES-TOOL-001 - /// Verifies: code_search returns structured JSON and display content for successful searches. - #[tokio::test] - async fn handler_returns_json_results_and_display_content() { - let workspace = tempfile::tempdir().expect("workspace"); - let cache = tempfile::tempdir().expect("cache"); - fs::write( - workspace.path().join("parser.rs"), - "pub fn parse_input() {}\n", - ) - .expect("write"); - let handler = test_handler(cache.path().to_path_buf()); - - let result = handler - .handle( - context(workspace.path().to_path_buf()), - serde_json::json!({ - "operation": "search", - "query": "parse input", - "top_k": 1 - }), - None, - ) - .await - .expect("search succeeds"); - - let ToolResultContent::Json(json) = result.content else { - panic!("expected JSON result"); - }; - assert_eq!(json["operation"], "search"); - assert_eq!(json["results"].as_array().expect("results").len(), 1); - assert!( - result - .display_content - .expect("display") - .contains("parser.rs") - ); - } -} diff --git a/crates/core/src/tools/handlers/mod.rs b/crates/core/src/tools/handlers/mod.rs index 7f5f033a..c3721dd0 100644 --- a/crates/core/src/tools/handlers/mod.rs +++ b/crates/core/src/tools/handlers/mod.rs @@ -1,7 +1,5 @@ mod agent; mod apply_patch; -#[cfg(feature = "code-search")] -mod code_search; mod edit; mod exec_command; mod file_change_metadata; @@ -24,8 +22,6 @@ mod websearch; pub(crate) use agent::register_agent_tools; pub use apply_patch::ApplyPatchHandler; -#[cfg(feature = "code-search")] -pub use code_search::CodeSearchHandler; pub use edit::EditHandler; pub use exec_command::{ExecCommandHandler, WriteStdinHandler}; pub use file_write::WriteHandler; @@ -119,7 +115,7 @@ fn build_registry_from_builder( handlers: Vec<(ToolHandlerKind, String)>, mut builder: ToolRegistryBuilder, mcp_handlers: Vec<(String, Arc)>, - config: &ToolPlanConfig, + _config: &ToolPlanConfig, ) -> crate::registry::ToolRegistry { let process_store = Arc::new(ProcessStore::new()); let background_tasks = Arc::new(crate::tools::background_tasks::BackgroundTaskStore::new( @@ -136,25 +132,6 @@ fn build_registry_from_builder( for (kind, name) in handlers { let handler: Arc = match kind { - #[cfg(feature = "code-search")] - ToolHandlerKind::CodeSearch => { - let service = Arc::new( - devo_code_search::CodeSearchService::production_with_network_proxy( - devo_network_proxy::NetworkProxyConfig { - proxy_url: config.network_proxy.clone(), - no_proxy: config.network_no_proxy.clone(), - }, - ), - ); - builder.set_code_search_service(Arc::clone(&service)); - Arc::new(CodeSearchHandler::with_service(service)) - } - // When the `code-search` feature is disabled the planner never emits - // this handler kind (see registry_plan), so the arm is unreachable. - #[cfg(not(feature = "code-search"))] - ToolHandlerKind::CodeSearch => { - unreachable!("code_search handler requested but `code-search` feature is disabled") - } ToolHandlerKind::ShellCommand => Arc::new(ShellCommandHandler::new()), ToolHandlerKind::Read => Arc::new(ReadHandler::new()), ToolHandlerKind::Write => Arc::new(WriteHandler::new()), @@ -227,27 +204,6 @@ mod tests { assert!(registry.get("bash").is_some()); } - #[cfg(feature = "code-search")] - #[test] - fn registry_exposes_the_code_search_handlers_shared_service() { - let registry = build_registry_from_plan(&ToolPlanConfig::default()); - - assert!(registry.get("code_search").is_some()); - assert!(registry.code_search_service().is_some()); - } - - #[cfg(feature = "code-search")] - #[test] - fn registry_has_no_code_search_service_when_the_tool_is_disabled() { - let registry = build_registry_from_plan(&ToolPlanConfig { - code_search: false, - ..ToolPlanConfig::default() - }); - - assert!(registry.get("code_search").is_none()); - assert!(registry.code_search_service().is_none()); - } - #[test] fn default_registry_exposes_update_goal_tool() { // Trace: L2-DES-GOAL-001 diff --git a/crates/core/src/tools/registry.rs b/crates/core/src/tools/registry.rs index d7423c7f..c9847869 100644 --- a/crates/core/src/tools/registry.rs +++ b/crates/core/src/tools/registry.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; -#[cfg(feature = "code-search")] -use devo_code_search::CodeSearchService; use devo_protocol::ToolDefinition; use crate::contracts::ToolContext; @@ -29,8 +27,6 @@ pub struct ToolRegistry { pub(crate) spec_search_text: HashMap, pub(crate) unified_exec_store: Option>, pub(crate) loaded_deferred_tools: Arc>, - #[cfg(feature = "code-search")] - code_search_service: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -50,8 +46,6 @@ impl ToolRegistry { spec_search_text: HashMap::new(), unified_exec_store: None, loaded_deferred_tools: Arc::new(Mutex::new(LoadedDeferredTools::default())), - #[cfg(feature = "code-search")] - code_search_service: None, } } @@ -115,10 +109,6 @@ impl ToolRegistry { let mut registry = ToolRegistry::new(); registry.unified_exec_store = self.unified_exec_store.clone(); registry.loaded_deferred_tools = Arc::clone(&self.loaded_deferred_tools); - #[cfg(feature = "code-search")] - { - registry.code_search_service = self.code_search_service.clone(); - } for spec in &self.specs { if !names.iter().any(|name| *name == spec.name) { @@ -186,11 +176,6 @@ impl ToolRegistry { Arc::clone(&self.loaded_deferred_tools) } - #[cfg(feature = "code-search")] - pub fn code_search_service(&self) -> Option> { - self.code_search_service.clone() - } - pub fn effective_deferred_loading_config( &self, base: &DeferredLoadingConfig, @@ -339,8 +324,6 @@ pub struct ToolRegistryBuilder { spec_search_text: HashMap, unified_exec_store: Option>, loaded_deferred_tools: Arc>, - #[cfg(feature = "code-search")] - code_search_service: Option>, } impl ToolRegistryBuilder { @@ -353,8 +336,6 @@ impl ToolRegistryBuilder { spec_search_text: HashMap::new(), unified_exec_store: None, loaded_deferred_tools: Arc::new(Mutex::new(LoadedDeferredTools::default())), - #[cfg(feature = "code-search")] - code_search_service: None, } } @@ -387,11 +368,6 @@ impl ToolRegistryBuilder { self.loaded_deferred_tools = loaded_tools; } - #[cfg(feature = "code-search")] - pub fn set_code_search_service(&mut self, service: Arc) { - self.code_search_service = Some(service); - } - pub fn tool_definitions(&self) -> Vec { self.specs .iter() @@ -433,8 +409,6 @@ impl ToolRegistryBuilder { spec_search_text: self.spec_search_text, unified_exec_store: self.unified_exec_store, loaded_deferred_tools: self.loaded_deferred_tools, - #[cfg(feature = "code-search")] - code_search_service: self.code_search_service, } } } diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs index fe2aa7a9..5bdee892 100644 --- a/crates/core/src/tools/registry_plan.rs +++ b/crates/core/src/tools/registry_plan.rs @@ -48,7 +48,6 @@ impl Default for ToolRegistryPlan { pub struct ToolPlanConfig { pub use_shell_command: bool, pub use_unified_exec: bool, - pub code_search: bool, pub web_search: bool, pub web_fetch: bool, pub network_proxy: Option, @@ -60,7 +59,6 @@ impl ToolPlanConfig { Self { web_search: app_config_uses_local_web_search(config), web_fetch: app_config_uses_local_web_fetch(config), - code_search: config.experimental.code_search, network_proxy: config.provider_http.proxy_url.clone(), network_no_proxy: config.provider_http.no_proxy.clone(), ..Self::default() @@ -72,7 +70,6 @@ impl ToolPlanConfig { // - use_shell_command and use_unified_exec are independent (shell_command is the // canonical shell tool name; setting use_shell_command false keeps legacy "bash") // - unified exec adds new tools alongside shell_command - // - code_search is a read-only search tool and does not conflict with either // - all can be true simultaneously with no conflict } } @@ -82,7 +79,6 @@ impl Default for ToolPlanConfig { ToolPlanConfig { use_shell_command: true, use_unified_exec: true, - code_search: true, web_search: false, web_fetch: true, network_proxy: None, @@ -340,99 +336,6 @@ fn grep_schema() -> JsonSchema { ) } -#[cfg(feature = "code-search")] -fn code_search_schema() -> JsonSchema { - let enum_string = |description: &str, values: &[&str]| { - let mut schema = JsonSchema::string(Some(description)); - schema.enum_values = Some( - values - .iter() - .map(|value| serde_json::Value::String((*value).to_string())) - .collect(), - ); - schema - }; - JsonSchema::object( - BTreeMap::from([ - ( - "operation".to_string(), - enum_string( - "Search operation: search for query text or find chunks related to file_path:line", - &["search", "find_related"], - ), - ), - ( - "query".to_string(), - JsonSchema::string(Some("Natural-language or code query. Required for search.")), - ), - ( - "file_path".to_string(), - JsonSchema::string(Some( - "Workspace-relative or absolute source file path. Required for find_related.", - )), - ), - ( - "line".to_string(), - JsonSchema::integer(Some( - "1-indexed source line inside file_path. Required for find_related.", - )), - ), - ( - "path".to_string(), - JsonSchema::string(Some( - "Workspace-relative or absolute search root inside the workspace. Defaults to workspace root.", - )), - ), - ( - "content".to_string(), - enum_string( - "Content filter. Defaults to code.", - &["code", "docs", "config", "all"], - ), - ), - ( - "top_k".to_string(), - JsonSchema::integer(Some( - "Maximum results to return. Defaults to 5, maximum 20.", - )), - ), - ( - "filter_paths".to_string(), - JsonSchema::array( - JsonSchema::string(Some("Workspace-relative path prefix to include")), - Some("Optional path prefixes to include"), - ), - ), - ( - "filter_languages".to_string(), - JsonSchema::array( - JsonSchema::string(Some("Language name to include")), - Some("Optional language filters such as rust or python"), - ), - ), - ]), - Some(vec!["operation".to_string()]), - Some(/*additional_properties*/ false), - ) -} - -#[cfg(feature = "code-search")] -pub(crate) fn code_search_tool_spec() -> ToolSpec { - ToolSpec { - name: "code_search".to_string(), - description: "Preferred codebase investigation and code retrieval tool for the current workspace. Use code_search before find or grep when you need to understand how code is implemented, locate relevant modules or symbols, answer architecture questions, find related code, or search by natural-language intent.".to_string(), - input_schema: code_search_schema(), - output_mode: ToolOutputMode::StructuredJson, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![ToolCapabilityTag::SearchWorkspace], - supports_parallel: true, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: Some(true), - supports_streaming: None, - } -} - fn apply_patch_schema() -> JsonSchema { JsonSchema::object( BTreeMap::from([( @@ -821,11 +724,6 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { ToolHandlerKind::Grep, ); - #[cfg(feature = "code-search")] - if config.code_search { - plan.push(code_search_tool_spec(), ToolHandlerKind::CodeSearch); - } - plan.push( ToolSpec { name: "apply_patch".to_string(), @@ -1025,18 +923,6 @@ mod tests { let config = ToolPlanConfig::default(); assert!(config.use_unified_exec); assert!(config.use_shell_command); - assert!(config.code_search); - } - - #[test] - fn config_from_app_config_copies_disabled_code_search() { - let app_config = AppConfig { - experimental: devo_config::ExperimentalConfig { code_search: false }, - ..AppConfig::default() - }; - let config = ToolPlanConfig::from_app_config(&app_config); - - assert!(!config.code_search); } #[test] @@ -1173,14 +1059,11 @@ mod tests { ); } - /// Trace: L2-DES-TOOL-001 - /// Verifies: semantic code retrieval is registered as a read-only parallel workspace search tool. + /// Trace: L2-DES-MCP-002 + /// Verifies: native code_search is no longer registered; retrieval is MCP-only. #[test] - fn plan_builder_omits_code_search_when_disabled() { - let plan = build_tool_registry_plan(&ToolPlanConfig { - code_search: false, - ..ToolPlanConfig::default() - }); + fn plan_builder_omits_native_code_search() { + let plan = build_tool_registry_plan(&ToolPlanConfig::default()); let spec_names: Vec<&str> = plan.specs.iter().map(|spec| spec.name.as_str()).collect(); let handler_names: Vec<&str> = plan .handlers @@ -1191,30 +1074,4 @@ mod tests { assert!(!spec_names.contains(&"code_search")); assert!(!handler_names.contains(&"code_search")); } - - /// Trace: L2-DES-TOOL-001 - /// Verifies: semantic code retrieval is registered as a read-only parallel workspace search tool. - #[test] - fn plan_builder_registers_code_search_by_default() { - let plan = build_tool_registry_plan(&ToolPlanConfig::default()); - let spec = plan - .specs - .iter() - .find(|spec| spec.name == "code_search") - .expect("code_search spec"); - - assert_eq!(spec.execution_mode, ToolExecutionMode::ReadOnly); - assert_eq!(spec.output_mode, ToolOutputMode::StructuredJson); - assert_eq!(spec.supports_parallel, true); - assert_eq!(spec.supports_cancellation, Some(true)); - assert!( - spec.capability_tags - .contains(&ToolCapabilityTag::SearchWorkspace) - ); - assert!( - plan.handlers - .iter() - .any(|(kind, name)| *kind == ToolHandlerKind::CodeSearch && name == "code_search") - ); - } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 2113c8e4..43100b7e 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,7 +15,6 @@ base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true } devo-client = { workspace = true } -devo-code-search = { workspace = true } devo-core = { workspace = true } devo-execpolicy = { workspace = true } devo-file-search = { workspace = true } diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index 20007852..3106678d 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -195,7 +195,9 @@ pub async fn run_server_process( ); let mcp_manager: Arc = Arc::new(RmcpMcpManager::new( - config.mcp.clone(), + config.mcp.clone().with_code_search_workspace_cwd( + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ), config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&config); diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 6fff01a2..1bdd2cce 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -135,7 +135,6 @@ mod acp_fs; mod active_turn; mod agents; mod approval; -mod code_index_warmup; mod command_exec; mod connection; mod control_requests; @@ -224,7 +223,6 @@ pub struct ServerRuntime { Mutex>, /// Live client-owned shell/process sessions. command_exec_manager: command_exec::CommandExecManager, - code_index_warmup: code_index_warmup::CodeIndexWarmup, /// Turn-scoped workspace baselines captured at actual execution start. active_workspace_baselines: Mutex>, /// Short-lived, connection-bound P4d rollback plans. @@ -380,7 +378,6 @@ impl ServerRuntime { subagent_usage: Mutex::new(subagent_usage::SubagentUsageState::default()), reference_searches: Mutex::new(HashMap::new()), command_exec_manager: command_exec::CommandExecManager::new(), - code_index_warmup: code_index_warmup::CodeIndexWarmup::new(), active_workspace_baselines: Mutex::new(HashMap::new()), restore_plans: Mutex::new(HashMap::new()), title_generation_in_flight: Mutex::new(HashSet::new()), diff --git a/crates/server/src/runtime/code_index_warmup.rs b/crates/server/src/runtime/code_index_warmup.rs deleted file mode 100644 index d68857a7..00000000 --- a/crates/server/src/runtime/code_index_warmup.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Dedicated background worker for warming workspace code indexes. - -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, mpsc}; - -use devo_code_search::{CodeSearchService, ContentFilter}; - -const WARMUP_QUEUE_CAPACITY: usize = 16; - -struct WarmupJob { - key: String, - root: PathBuf, - service: Arc, -} - -pub(crate) struct CodeIndexWarmup { - sender: Mutex>>, - queued: Arc>>, -} - -impl CodeIndexWarmup { - pub(crate) fn new() -> Self { - let (sender, receiver) = mpsc::sync_channel::(WARMUP_QUEUE_CAPACITY); - let queued = Arc::new(Mutex::new(HashSet::new())); - let worker_queued = Arc::clone(&queued); - let sender = match std::thread::Builder::new() - .name("devo-code-index".to_string()) - .spawn(move || run_worker(receiver, worker_queued)) - { - Ok(_) => Some(sender), - Err(error) => { - tracing::warn!(%error, "failed to start code-index warmup worker"); - None - } - }; - Self { - sender: Mutex::new(sender), - queued, - } - } - - pub(crate) fn enqueue(&self, root: PathBuf, service: Arc) { - let root_key = root.to_string_lossy().into_owned(); - let key = format!("{:p}|{root_key}", Arc::as_ptr(&service)); - let Ok(mut queued) = self.queued.lock() else { - tracing::warn!("code-index warmup queue lock poisoned"); - return; - }; - if !queued.insert(key.clone()) { - return; - } - let job = WarmupJob { - key: key.clone(), - root, - service, - }; - let send_result = self - .sender - .lock() - .ok() - .and_then(|sender| sender.as_ref().map(|sender| sender.try_send(job))); - if !matches!(send_result, Some(Ok(()))) { - queued.remove(&key); - if matches!(send_result, Some(Err(mpsc::TrySendError::Full(_)))) { - tracing::debug!(root = %root_key, "code-index warmup queue is full"); - } - } - } - - pub(crate) fn shutdown(&self) { - if let Ok(mut sender) = self.sender.lock() { - sender.take(); - } - } - - #[cfg(test)] - fn queued_len(&self) -> usize { - self.queued - .lock() - .map(|queued| queued.len()) - .unwrap_or_default() - } -} - -fn run_worker(receiver: mpsc::Receiver, queued: Arc>>) { - while let Ok(job) = receiver.recv() { - tracing::info!(root = %job.root.display(), "warming code-search index"); - match job.service.prewarm(&job.root, ContentFilter::Code) { - Ok(stats) => tracing::info!( - root = %job.root.display(), - indexed_files = stats.indexed_files, - total_chunks = stats.total_chunks, - "code-search index warmup completed" - ), - Err(error) => tracing::warn!( - root = %job.root.display(), - %error, - "code-search index warmup failed; first search will retry" - ), - } - if let Ok(mut queued) = queued.lock() { - queued.remove(&job.key); - } - } -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, Instant}; - - use devo_code_search::{CodeSearchError, EmbeddingProvider, HashEmbeddingProvider}; - use pretty_assertions::assert_eq; - - use super::*; - - struct BlockingProvider { - inner: HashEmbeddingProvider, - started: mpsc::Sender<()>, - release: Mutex>, - calls: Arc, - } - - impl EmbeddingProvider for BlockingProvider { - fn model_id(&self) -> &str { - "blocking-test" - } - - fn embed(&self, texts: &[String]) -> Result>, CodeSearchError> { - self.calls.fetch_add(1, Ordering::SeqCst); - self.started.send(()).expect("signal embedding start"); - self.release - .lock() - .expect("release lock") - .recv() - .expect("release embedding"); - self.inner.embed(texts) - } - } - - #[test] - fn warmup_is_nonblocking_and_deduplicates_an_active_workspace() { - let root = tempfile::tempdir().expect("workspace"); - let cache = tempfile::tempdir().expect("cache"); - std::fs::write(root.path().join("lib.rs"), "pub fn alpha() {}\n").expect("write"); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let calls = Arc::new(AtomicUsize::new(0)); - let service = Arc::new(CodeSearchService::new( - Arc::new(BlockingProvider { - inner: HashEmbeddingProvider::new("blocking-test", 16), - started: started_tx, - release: Mutex::new(release_rx), - calls: Arc::clone(&calls), - }), - cache.path().to_path_buf(), - )); - let warmup = CodeIndexWarmup::new(); - - warmup.enqueue(root.path().to_path_buf(), Arc::clone(&service)); - started_rx - .recv_timeout(Duration::from_secs(5)) - .expect("background embedding started"); - warmup.enqueue(root.path().to_path_buf(), Arc::clone(&service)); - - assert_eq!(warmup.queued_len(), 1); - assert_eq!(calls.load(Ordering::SeqCst), 1); - warmup.shutdown(); - release_tx.send(()).expect("release embedding"); - - let deadline = Instant::now() + Duration::from_secs(5); - while service.needs_index_build(root.path(), ContentFilter::Code) - && Instant::now() < deadline - { - std::thread::yield_now(); - } - assert!(!service.needs_index_build(root.path(), ContentFilter::Code)); - } -} diff --git a/crates/server/src/runtime/handlers/acp/session_support.rs b/crates/server/src/runtime/handlers/acp/session_support.rs index 3d9340fa..d3889a32 100644 --- a/crates/server/src/runtime/handlers/acp/session_support.rs +++ b/crates/server/src/runtime/handlers/acp/session_support.rs @@ -62,7 +62,10 @@ impl ServerRuntime { config.mcp_oauth_credentials_store.unwrap_or_default(), ) }; - let mcp_manager = Arc::new(RmcpMcpManager::new(mcp_config, oauth_store_mode)); + let mcp_manager = Arc::new(RmcpMcpManager::new( + mcp_config.with_code_search_workspace_cwd(cwd.to_path_buf()), + oauth_store_mode, + )); let registry = devo_core::tools::handlers::build_registry_from_plan_with_mcp(&tool_plan, mcp_manager) .await; diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index a2546b1a..07c1b0a2 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -263,7 +263,6 @@ impl ServerRuntime { /// Completes deferred (in-progress) items for all active turns and /// persists interrupted turn records. Called on graceful shutdown. pub async fn shutdown(self: &Arc) { - self.code_index_warmup.shutdown(); self.command_exec_manager.terminate_all().await; let session_handles = self.list_session_handles().await; diff --git a/crates/server/src/runtime/mcp.rs b/crates/server/src/runtime/mcp.rs index 7df07ece..15e862a1 100644 --- a/crates/server/src/runtime/mcp.rs +++ b/crates/server/src/runtime/mcp.rs @@ -104,11 +104,9 @@ impl ServerRuntime { } }; - if needs_refresh { - if let Err(error) = manager.refresh(&server_id).await { - // Still try to return whatever tools we have after a failed refresh. - tracing::warn!(server = %server_id, error = %error, "mcp/tools refresh failed"); - } + if needs_refresh && let Err(error) = manager.refresh(&server_id).await { + // Still try to return whatever tools we have after a failed refresh. + tracing::warn!(server = %server_id, error = %error, "mcp/tools refresh failed"); } match manager.statuses().await { diff --git a/crates/server/src/runtime/session_cache.rs b/crates/server/src/runtime/session_cache.rs index 0c3f348a..0dcefd56 100644 --- a/crates/server/src/runtime/session_cache.rs +++ b/crates/server/src/runtime/session_cache.rs @@ -177,34 +177,12 @@ impl ServerRuntime { .await; self.touch_parent_session_lru(session_id).await; self.evict_parent_sessions_if_needed(Some(session_id)).await; - self.enqueue_code_index_warmup(session_id).await; Ok(handle) } pub(crate) async fn after_root_session_insert(self: &Arc, session_id: SessionId) { self.touch_parent_session_lru(session_id).await; self.evict_parent_sessions_if_needed(Some(session_id)).await; - self.enqueue_code_index_warmup(session_id).await; - } - - async fn enqueue_code_index_warmup(&self, session_id: SessionId) { - let Some(handle) = self.session(session_id).await else { - return; - }; - let Some(snapshot) = handle.hook_context_snapshot().await else { - return; - }; - if snapshot.summary.parent_session_id.is_some() { - return; - } - let root = snapshot.summary.cwd; - let Some(shell_context) = handle.shell_exec_context(root.clone()).await else { - return; - }; - let Some(service) = shell_context.tool_registry.code_search_service() else { - return; - }; - self.code_index_warmup.enqueue(root, service); } pub(crate) async fn touch_parent_session_lru(&self, session_id: SessionId) { diff --git a/crates/server/src/runtime/turn_exec/tool_display.rs b/crates/server/src/runtime/turn_exec/tool_display.rs index 81029b77..fc6ae02a 100644 --- a/crates/server/src/runtime/turn_exec/tool_display.rs +++ b/crates/server/src/runtime/turn_exec/tool_display.rs @@ -194,7 +194,7 @@ pub(super) fn command_display_from_input(tool_name: &str, input: &serde_json::Va format!("grep {pattern} in {path}") } } - "code_search" => code_search_display_from_input(input), + "code_search" | "mcp__code_search__code_search" => code_search_display_from_input(input), _ => String::new(), } } diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index 6de87d90..c9d00e12 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -159,15 +159,22 @@ impl SessionRuntimeContext { .is_operationally_equivalent_to(&inherited_config.provider) || config.provider_http != inherited_config.provider_http; let (registry, mcp_manager) = if !has_provider_configuration - && config.mcp.servers.is_empty() + && !config.mcp.servers.iter().any(|record| record.enabled) { ( Arc::clone(&inherited_context.registry), Arc::clone(&inherited_context.mcp_manager), ) } else { + let workspace_cwd = workspace_root + .map(Path::to_path_buf) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let mcp_config = config + .mcp + .clone() + .with_code_search_workspace_cwd(workspace_cwd); let mcp_manager: Arc = Arc::new(RmcpMcpManager::new( - config.mcp.clone(), + mcp_config, config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&config); diff --git a/crates/server/src/tool_actions.rs b/crates/server/src/tool_actions.rs index b7ee1064..ce0bab7c 100644 --- a/crates/server/src/tool_actions.rs +++ b/crates/server/src/tool_actions.rs @@ -26,9 +26,11 @@ pub(crate) fn exploration_actions_from_tool_input( .and_then(serde_json::Value::as_str) .map(ToOwned::to_owned), }], - "code_search" => code_search_action_from_input(command, input) - .into_iter() - .collect(), + "code_search" | "mcp__code_search__code_search" => { + code_search_action_from_input(command, input) + .into_iter() + .collect() + } _ => Vec::new(), } } diff --git a/crates/tools/src/handler_kind.rs b/crates/tools/src/handler_kind.rs index 00d9b43c..99ed7490 100644 --- a/crates/tools/src/handler_kind.rs +++ b/crates/tools/src/handler_kind.rs @@ -1,6 +1,5 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ToolHandlerKind { - CodeSearch, ShellCommand, Read, Write, diff --git a/crates/tools/src/tool_summary.rs b/crates/tools/src/tool_summary.rs index 0569d8be..55a61df3 100644 --- a/crates/tools/src/tool_summary.rs +++ b/crates/tools/src/tool_summary.rs @@ -111,7 +111,7 @@ pub fn tool_summary(name: &str, input: &serde_json::Value, cwd: &Path) -> String let rel = make_relative(cwd, path); format!("grep: '{pattern}' in {rel}") } - "code_search" => { + "code_search" | "mcp__code_search__code_search" => { let operation = string_arg(input, "operation", "search"); match operation { "find_related" => { diff --git a/crates/tui/src/tool_io_cell.rs b/crates/tui/src/tool_io_cell.rs index 5e8aaf22..4f0f266d 100644 --- a/crates/tui/src/tool_io_cell.rs +++ b/crates/tui/src/tool_io_cell.rs @@ -195,7 +195,7 @@ pub(crate) fn tool_input_lines(tool_name: &str, input: &Value) -> Vec fields(input, &[("pattern", &["pattern"]), ("path", &["path"])]), "find" | "glob" => fields(input, &[("pattern", &["pattern"]), ("path", &["path"])]), - "code_search" => fields( + "code_search" | "mcp__code_search__code_search" => fields( input, &[ ("operation", &["operation"]), diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 01f9d155..a13a1cfa 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -4008,7 +4008,7 @@ fn pretty_tool_call_summary(tool_name: &str, input: &serde_json::Value) -> Optio None => Some(format!("Search {query}")), } } - "code_search" => { + "code_search" | "mcp__code_search__code_search" => { let query = input .get("query") .and_then(serde_json::Value::as_str) @@ -4226,7 +4226,7 @@ fn tool_call_started_actions( ), ]; } - if payload.tool_name == "code_search" { + if payload.tool_name == "code_search" || payload.tool_name == "mcp__code_search__code_search" { return code_search_command_action_from_parameters("code_search", &payload.parameters) .into_iter() .collect(); @@ -4248,9 +4248,11 @@ fn tool_call_updated_actions( "find" | "glob" => find_command_action_from_parameters(summary, &payload.parameters) .into_iter() .collect(), - "code_search" => code_search_command_action_from_parameters(summary, &payload.parameters) - .into_iter() - .collect(), + "code_search" | "mcp__code_search__code_search" => { + code_search_command_action_from_parameters(summary, &payload.parameters) + .into_iter() + .collect() + } _ => Vec::new(), } } @@ -4416,7 +4418,9 @@ fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String { None => Some(pattern.to_string()), } } - "code_search" => Some(code_search_summary_from_input(input)), + "code_search" | "mcp__code_search__code_search" => { + Some(code_search_summary_from_input(input)) + } "webfetch" | "web_fetch" | "web-fetch" | "fetch_url" | "fetch-url" => web_fetch_url(input), "web_search" | "websearch" | "web-search" => web_search_query(input), "lsp" => { diff --git a/docs/configuration.md b/docs/configuration.md index 61993877..a53b24c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,6 +224,23 @@ is one entry in the `servers` array, and its `transport` table selects how Devo connects. Supported transports are `stdio`, `streamable_http`, and the deprecated `sse`. +Devo also presets a bundled, disabled-by-default semantic search server: + +```toml +[[mcp.servers]] +id = "code_search" +display_name = "Code Search" +enabled = false +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "stdio" +command = ["devo-code-search-mcp"] +``` + +Enable it with `devo mcp enable code_search` (or `/mcps` in the TUI). The +`devo-code-search-mcp` binary is installed next to `devo`. + Stdio example: ```toml diff --git a/install.ps1 b/install.ps1 index 1fdc5690..e5d883db 100644 --- a/install.ps1 +++ b/install.ps1 @@ -441,6 +441,11 @@ function Install-DevoOffline { New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null Copy-Item -Path $exe.FullName -Destination (Join-Path $InstallDir "devo.exe") -Force + + $mcpExe = Get-ChildItem -Recurse -Filter "devo-code-search-mcp.exe" -Path $devoTmpDir | Select-Object -First 1 + if ($mcpExe) { + Copy-Item -Path $mcpExe.FullName -Destination (Join-Path $InstallDir "devo-code-search-mcp.exe") -Force + } } function Install-RipgrepSidecarOffline { @@ -571,6 +576,14 @@ function Main { New-Item -ItemType Directory -Force -Path $installDir | Out-Null Copy-Item -Path $exe.FullName -Destination (Join-Path $installDir "devo.exe") -Force + + $mcpExe = Get-ChildItem -Recurse -Filter "devo-code-search-mcp.exe" -Path $tmpDir | Select-Object -First 1 + if ($mcpExe) { + Copy-Item -Path $mcpExe.FullName -Destination (Join-Path $installDir "devo-code-search-mcp.exe") -Force + Write-Host "Installed code_search MCP sidecar" + } else { + Write-Host "Optional devo-code-search-mcp.exe was not found in the archive." + } } Install-RipgrepSidecar -InstallDir $installDir -TempRoot $tmpDir Install-CodeSearchModel -TempRoot $tmpDir diff --git a/install.sh b/install.sh index 58317079..a9843be5 100644 --- a/install.sh +++ b/install.sh @@ -8,6 +8,7 @@ set -eu APP="devo" +CODE_SEARCH_MCP_APP="devo-code-search-mcp" REPO="7df-lab/devo" RG_APP="rg" RG_REPO="BurntSushi/ripgrep" @@ -470,15 +471,22 @@ check_version() { find_extracted_binary() { search_dir="$1" - found_binary="$(find "$search_dir" -name "$APP" -type f | sed -n '1p')" + binary_name="${2:-$APP}" + found_binary="$(find "$search_dir" -name "$binary_name" -type f | sed -n '1p')" if [ -z "$found_binary" ]; then - die "Failed to locate the ${APP} binary inside the downloaded archive" + die "Failed to locate the ${binary_name} binary inside the downloaded archive" fi printf '%s\n' "$found_binary" } +find_extracted_optional_binary() { + search_dir="$1" + binary_name="$2" + find "$search_dir" -name "$binary_name" -type f | sed -n '1p' +} + find_extracted_rg_binary() { search_dir="$1" found_binary="$(find "$search_dir" -name "$RG_APP" -type f | sed -n '1p')" @@ -521,9 +529,16 @@ download_and_install() { tar -xzf "$tmp_dir/$archive_name" -C "$tmp_dir" extracted_binary="$(find_extracted_binary "$tmp_dir")" + extracted_mcp_binary="$(find_extracted_optional_binary "$tmp_dir" "$CODE_SEARCH_MCP_APP")" mkdir -p "$install_dir" install -m 755 "$extracted_binary" "${install_dir}/${APP}" + if [ -n "$extracted_mcp_binary" ]; then + install -m 755 "$extracted_mcp_binary" "${install_dir}/${CODE_SEARCH_MCP_APP}" + print_message info "${MUTED}Installed ${NC}${CODE_SEARCH_MCP_APP}${MUTED} sidecar${NC}" + else + print_message warning "Optional ${CODE_SEARCH_MCP_APP} binary was not found in the archive." + fi rm -rf "$tmp_dir" trap - EXIT INT TERM @@ -678,9 +693,14 @@ install_offline_devo() { tar -xzf "$archive_path" -C "$tmp_dir" extracted_binary="$(find_extracted_binary "$tmp_dir")" + extracted_mcp_binary="$(find_extracted_optional_binary "$tmp_dir" "$CODE_SEARCH_MCP_APP")" mkdir -p "$install_dir" install -m 755 "$extracted_binary" "${install_dir}/${APP}" + if [ -n "$extracted_mcp_binary" ]; then + install -m 755 "$extracted_mcp_binary" "${install_dir}/${CODE_SEARCH_MCP_APP}" + print_message info "${MUTED}Installed ${NC}${CODE_SEARCH_MCP_APP}${MUTED} sidecar${NC}" + fi rm -rf "$tmp_dir" trap - EXIT INT TERM diff --git a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md index 824b9e21..765b3c65 100644 --- a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md +++ b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md @@ -526,16 +526,32 @@ Rules: ## Experimental -`[experimental]` controls runtime feature gates. +`[experimental]` is reserved for runtime feature gates. -Fields: +The former `code-search` boolean gate has been removed. Semantic code search is +provided by the bundled MCP server `code_search` (`devo-code-search-mcp`), which +is present in MCP config with `enabled = false` by default. Users enable it via +`devo mcp enable code_search` or the TUI `/mcps` UI. -- `code-search`: boolean; when true, the built-in `code_search` code retrieval tool is registered and exposed. +Legacy `[experimental] code-search` / `code_search` keys are ignored for +backward compatibility. -Rules: +## Bundled MCP servers + +Devo ensures the following server is present after config load when missing by +id (user records with the same id are never overwritten): + +```toml +[[mcp.servers]] +id = "code_search" +display_name = "Code Search" +enabled = false +startup_policy = "lazy" -- Missing `code-search` defaults to true. -- `code_search` may be accepted as a compatibility alias, but writers should emit `code-search`. +[mcp.servers.transport] +kind = "stdio" +command = ["devo-code-search-mcp"] +``` ## Tools diff --git a/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md b/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md new file mode 100644 index 00000000..7f3b975c --- /dev/null +++ b/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md @@ -0,0 +1,34 @@ +# L2-DES-MCP-002 Bundled code_search MCP server + +- Artifact ID: L2-DES-MCP-002 +- Revision: 1 +- Status: Implemented +- Active Baseline: yes + +## Summary + +Semantic code retrieval is provided by the bundled stdio MCP binary +`devo-code-search-mcp`, not by a native Devo tool handler. + +## Design + +- Server id: `code_search` +- Tool name (MCP): `code_search` +- Model-facing name when enabled: `mcp__code_search__code_search` +- Default: config entry present with `enabled = false`, `startup_policy = lazy` +- Transport: `stdio` with `command = ["devo-code-search-mcp"]` +- Workspace root: stdio process cwd (Devo launches with session workspace as + fallback cwd when the server record omits `cwd`) +- Binary is shipped next to `devo` in CLI archives, install scripts, and desktop + runtime `bin/` + +## Enablement + +Users enable with `devo mcp enable code_search` or TUI `/mcps`. Config load +ensure-by-id inserts the bundled record when missing and never overwrites a +user record with the same id. + +## Traceability + +Refines MCP integration architecture and replaces the former +`[experimental] code-search` built-in tool gate. diff --git a/specs/L2/tool/L2-DES-TOOL-003-deferred-tool-loading.md b/specs/L2/tool/L2-DES-TOOL-003-deferred-tool-loading.md index 2585684a..fa37208c 100644 --- a/specs/L2/tool/L2-DES-TOOL-003-deferred-tool-loading.md +++ b/specs/L2/tool/L2-DES-TOOL-003-deferred-tool-loading.md @@ -62,7 +62,7 @@ The mechanism must: ``` ┌─────────────────────────────────────────────────────────────┐ │ System Prompt │ -│ [Pre-loaded tool schemas: read, find, grep, code_search, ...] │ +│ [Pre-loaded tool schemas: read, find, grep, ...] │ │ │ │ │ │ Deferred tools: │ @@ -87,7 +87,7 @@ The mechanism must: ▼ ┌─────────────────────────────────────────────────────────────┐ │ Next Turn Prompt │ -│ [Pre-loaded tool schemas: read, find, grep, code_search, ...] │ +│ [Pre-loaded tool schemas: read, find, grep, ...] │ │ [Loaded deferred schemas: web_search, fetch_url] │ │ │ │ Deferred tools: │ @@ -111,7 +111,7 @@ These tools are included in every model turn when registered by the effective co | `read` | File read | Read file contents and metadata, including supported attachments. | | `find` | Search | Filename and path search backed by ripgrep. | | `grep` | Search | High-performance content search, normally backed by ripgrep. | -| `code_search` | Search | Preferred codebase investigation and code retrieval tool, enabled by default unless `[experimental] code-search = false`. | +| `code_search` / `mcp__code_search__code_search` | MCP | Optional semantic code retrieval via the bundled `code_search` MCP server (`devo-code-search-mcp`). Config preset is `enabled = false` by default. | | `ls` | File read | List directory contents with optional pattern filtering. | | `write` | File mutation | Create or overwrite files through structured content. | | `apply_patch` | File mutation | Apply structured patches to files. | @@ -390,13 +390,14 @@ MCP tools are conditionally deferred. Each MCP server can define a tool loading In user or project `config.toml`: ```toml -[experimental] -code-search = true +[[mcp.servers]] +id = "code_search" +enabled = false [tools.deferred_loading] enabled = true default_policy = "defer_optional" -preloaded = ["read", "find", "grep", "ls", "write", "apply_patch", "shell_command", "plan", "approval"] # code_search is registered by default unless disabled +preloaded = ["read", "find", "grep", "ls", "write", "apply_patch", "shell_command", "plan", "approval"] deferred = ["web_search", "fetch_url", "skill", "spawn_subagent", "multi_tool_use"] hidden = [] From e7723896708bfb6b94957701ac41e5bedbb81236 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 1 Aug 2026 13:22:13 +0800 Subject: [PATCH 4/4] feat: hot-reload MCP enable/disable via mcp/set_enabled --- crates/client/src/client_core.rs | 7 + crates/client/src/stdio.rs | 7 + crates/client/src/websocket.rs | 7 + crates/code-search-mcp/src/main.rs | 7 +- crates/code-search-mcp/src/mcp.rs | 12 + crates/config/src/app/mcp_store.rs | 10 + crates/config/src/mcp.rs | 11 - crates/config/src/tests.rs | 39 +++ crates/core/src/mcp.rs | 12 + crates/core/src/tools/handlers/mod.rs | 112 ++++++- crates/core/src/tools/registry.rs | 5 + crates/core/src/tools/shell_exec/launch.rs | 2 +- crates/core/src/tools/shell_exec/mod.rs | 2 +- crates/core/src/tools/shell_exec/tests.rs | 5 +- crates/mcp/src/manager.rs | 296 +++++++++++++++--- crates/protocol/src/acp_ts.rs | 7 + crates/protocol/src/canonical/methods.rs | 8 + crates/protocol/src/canonical/rpc_admin.rs | 13 + crates/protocol/src/protocol.rs | 12 + crates/rmcp-client/src/program_resolver.rs | 56 +++- crates/server/src/runtime/connection.rs | 143 ++++++++- crates/server/src/runtime/handlers/acp/mcp.rs | 3 - crates/server/src/runtime/mcp.rs | 196 ++++++++++-- .../src/runtime/session_actor/actor_loop.rs | 5 +- .../src/runtime/session_actor/commands.rs | 3 + .../src/runtime/session_actor/handle.rs | 13 + crates/server/src/runtime/turn_exec/mod.rs | 2 +- crates/server/src/session_context.rs | 34 +- crates/tui/src/chatwidget/worker_events.rs | 29 ++ crates/tui/src/events.rs | 8 + crates/tui/src/interactive.rs | 20 +- crates/tui/src/mcp_picker.rs | 33 +- crates/tui/src/mcp_servers.rs | 6 +- crates/tui/src/worker.rs | 57 +++- docs/configuration.ja.md | 11 +- docs/configuration.md | 16 +- docs/configuration.ru.md | 13 +- docs/configuration.zh-Hans.md | 11 +- docs/configuration.zh-Hant.md | 11 +- ...ES-MCP-001-mcp-integration-architecture.md | 11 +- .../L2-DES-MCP-002-bundled-code-search-mcp.md | 12 +- 41 files changed, 1098 insertions(+), 169 deletions(-) diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 5ea12a0a..5c4b2bb5 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -568,6 +568,13 @@ impl ServerClientCore { self.request_devo("mcp/tools", params).await } + pub(crate) async fn mcp_set_enabled( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpSetEnabledParams, + ) -> Result { + self.request_devo("mcp/set_enabled", params).await + } + pub(crate) async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 4a255fc1..85ea727d 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -275,6 +275,13 @@ impl StdioServerClient { self.core.mcp_tools(params).await } + pub async fn mcp_set_enabled( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpSetEnabledParams, + ) -> Result { + self.core.mcp_set_enabled(params).await + } + pub async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index 8cca09e1..db462eca 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -259,6 +259,13 @@ impl WebSocketServerClient { self.core.request_devo("mcp/tools", params).await } + pub async fn mcp_set_enabled( + &mut self, + params: devo_protocol::canonical::rpc_admin::McpSetEnabledParams, + ) -> Result { + self.core.request_devo("mcp/set_enabled", params).await + } + pub async fn model_catalog( &mut self, params: ModelCatalogParams, diff --git a/crates/code-search-mcp/src/main.rs b/crates/code-search-mcp/src/main.rs index 8047768a..431e8bf1 100644 --- a/crates/code-search-mcp/src/main.rs +++ b/crates/code-search-mcp/src/main.rs @@ -10,7 +10,12 @@ fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { #[tokio::main] async fn main() -> Result<(), Box> { let server = CodeSearchMcpServer::production()?; - server.prewarm(); + // Serve the MCP handshake first. Prefetching/indexing must not block + // `initialize` / `tools/list`, or clients time out and report 0 tools. + let prewarm_server = server.clone(); + tokio::spawn(async move { + let _ = tokio::task::spawn_blocking(move || prewarm_server.prewarm()).await; + }); let running = server.serve(stdio()).await?; running.waiting().await?; Ok(()) diff --git a/crates/code-search-mcp/src/mcp.rs b/crates/code-search-mcp/src/mcp.rs index 5b353053..756e6ffb 100644 --- a/crates/code-search-mcp/src/mcp.rs +++ b/crates/code-search-mcp/src/mcp.rs @@ -403,4 +403,16 @@ mod tests { assert_eq!(output.operation, CodeSearchOperation::Search); assert_eq!(output.results.len(), 1); } + + /// Trace: L2-DES-MCP-002 + /// Verifies: the MCP server advertises tools capability and the code_search tool. + #[test] + fn server_advertises_code_search_tool() { + let temp = tempfile::tempdir().expect("tempdir"); + let server = test_server(temp.path().to_path_buf(), temp.path().join("cache")); + let info = server.get_info(); + assert!(info.capabilities.tools.is_some()); + assert_eq!(server.tools.len(), 1); + assert_eq!(server.tools[0].name.as_ref(), TOOL_NAME); + } } diff --git a/crates/config/src/app/mcp_store.rs b/crates/config/src/app/mcp_store.rs index 1166077c..6505024d 100644 --- a/crates/config/src/app/mcp_store.rs +++ b/crates/config/src/app/mcp_store.rs @@ -69,6 +69,11 @@ impl AppConfigStore { } /// Sets the `enabled` flag for one MCP server in the user-level `config.toml`. + /// + /// Bundled servers (for example `code_search`) are injected into the + /// effective config on load and may not yet exist on disk. Enabling or + /// disabling them materializes the full bundled record into + /// `config.toml` instead of failing with "not found". pub fn set_mcp_server_enabled(&mut self, id: &str, enabled: bool) -> anyhow::Result<()> { let id = id.trim(); if id.is_empty() { @@ -82,6 +87,11 @@ impl AppConfigStore { .iter_mut() .find(|entry| server_entry_id(entry) == Some(id)) else { + if id == crate::BUNDLED_CODE_SEARCH_MCP_SERVER_ID { + let mut record = crate::bundled_code_search_mcp_server(); + record.enabled = enabled; + return self.upsert_mcp_server(record); + } anyhow::bail!("mcp server `{id}` not found"); }; let table = ensure_toml_table(entry); diff --git a/crates/config/src/mcp.rs b/crates/config/src/mcp.rs index d7f21bb3..acdef4f4 100644 --- a/crates/config/src/mcp.rs +++ b/crates/config/src/mcp.rs @@ -60,9 +60,6 @@ pub struct McpConfig { /// Whether enabled servers should be auto-started during bootstrap. #[serde(default = "default_mcp_auto_start")] pub auto_start: bool, - /// Whether config reload should refresh running server catalogs. - #[serde(default = "default_mcp_refresh_on_config_reload")] - pub refresh_on_config_reload: bool, } impl Default for McpConfig { @@ -70,7 +67,6 @@ impl Default for McpConfig { let mut config = Self { servers: Vec::new(), auto_start: true, - refresh_on_config_reload: true, }; ensure_bundled_mcp_servers(&mut config); config @@ -157,10 +153,6 @@ fn default_mcp_auto_start() -> bool { true } -fn default_mcp_refresh_on_config_reload() -> bool { - true -} - /// Stores the configured metadata for one MCP server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct McpServerRecord { @@ -383,7 +375,6 @@ mod tests { auth_ref: None, }], auto_start: true, - refresh_on_config_reload: true, }; config.ensure_bundled_servers(); assert_eq!(config.servers.len(), 1); @@ -398,7 +389,6 @@ mod tests { let mut config = McpConfig { servers: Vec::new(), auto_start: true, - refresh_on_config_reload: true, }; config.ensure_bundled_servers(); assert_eq!(config.servers.len(), 1); @@ -443,7 +433,6 @@ mod tests { auth_ref: None, }], auto_start: true, - refresh_on_config_reload: true, }; custom.apply_code_search_workspace_cwd(PathBuf::from("/workspace")); match &custom.servers[0].transport { diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index a0904479..14f92235 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -596,6 +596,45 @@ fn loader_ensures_bundled_code_search_mcp_when_servers_list_is_empty() { let _ = std::fs::remove_dir_all(root); } +/// Trace: L2-DES-MCP-002 +/// Verifies: enabling bundled code_search materializes it into user config.toml. +#[test] +fn set_mcp_server_enabled_materializes_bundled_code_search() { + let root = unique_temp_dir("config-bundled-mcp-enable"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::write(home.join("config.toml"), "[mcp]\nservers = []\n").expect("write user config"); + + let config_file = home.join("config.toml"); + let mut store = AppConfigStore::load(home, /*workspace_root*/ None).expect("load store"); + assert!( + !std::fs::read_to_string(&config_file) + .expect("read user config") + .contains("code_search") + ); + + store + .set_mcp_server_enabled( + super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID, + /*enabled*/ true, + ) + .expect("enable bundled code_search"); + + let server = store + .mcp_servers() + .iter() + .find(|record| record.id.0 == super::BUNDLED_CODE_SEARCH_MCP_SERVER_ID) + .expect("bundled code_search server"); + assert!(server.enabled); + + let user_config = std::fs::read_to_string(&config_file).expect("read user config"); + assert!(user_config.contains("code_search")); + assert!(user_config.contains("devo-code-search-mcp")); + assert!(user_config.contains("enabled = true") || user_config.contains("enabled=true")); + + let _ = std::fs::remove_dir_all(root); +} + #[test] fn loader_reads_hook_command_config() { let root = unique_temp_dir("config-hooks"); diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index 7068b20b..dd3642fc 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -236,6 +236,18 @@ pub trait McpManager: Send + Sync { async fn refresh(&self, server_id: &McpServerId) -> Result; + /// Enable or disable one MCP server without affecting sibling clients. + /// + /// When `enabled` is true, the manager starts (or refreshes) that server. + /// When false, it drops the client and marks the server disabled. Startup + /// failures should update status to [`McpStartupState::Failed`] rather than + /// poisoning other servers. + async fn set_enabled( + &self, + server_id: &McpServerId, + enabled: bool, + ) -> Result; + async fn invoke_tool( &self, server_id: &McpServerId, diff --git a/crates/core/src/tools/handlers/mod.rs b/crates/core/src/tools/handlers/mod.rs index c3721dd0..4d4abd8a 100644 --- a/crates/core/src/tools/handlers/mod.rs +++ b/crates/core/src/tools/handlers/mod.rs @@ -62,12 +62,23 @@ pub fn build_registry_from_plan(config: &ToolPlanConfig) -> crate::registry::Too for spec in specs { builder.push_spec(spec); } - build_registry_from_builder(handlers, builder, Vec::new(), config) + build_registry_from_builder(handlers, builder, Vec::new(), None) } pub async fn build_registry_from_plan_with_mcp( config: &ToolPlanConfig, mcp_manager: Arc, +) -> crate::registry::ToolRegistry { + rebuild_registry_from_plan_with_mcp(config, mcp_manager, None).await +} + +/// Rebuilds a tool registry from the current MCP manager, optionally reusing +/// the previous unified-exec [`ProcessStore`] and deferred-load state so live +/// sessions keep background processes across MCP enable/disable. +pub async fn rebuild_registry_from_plan_with_mcp( + config: &ToolPlanConfig, + mcp_manager: Arc, + previous: Option<&crate::registry::ToolRegistry>, ) -> crate::registry::ToolRegistry { let plan = build_tool_registry_plan(config); let specs = plan.specs; @@ -108,16 +119,18 @@ pub async fn build_registry_from_plan_with_mcp( )); } - build_registry_from_builder(handlers, builder, mcp_handlers, config) + build_registry_from_builder(handlers, builder, mcp_handlers, previous) } fn build_registry_from_builder( handlers: Vec<(ToolHandlerKind, String)>, mut builder: ToolRegistryBuilder, mcp_handlers: Vec<(String, Arc)>, - _config: &ToolPlanConfig, + previous: Option<&crate::registry::ToolRegistry>, ) -> crate::registry::ToolRegistry { - let process_store = Arc::new(ProcessStore::new()); + let process_store = previous + .and_then(|registry| registry.unified_exec_store.clone()) + .unwrap_or_else(|| Arc::new(ProcessStore::new())); let background_tasks = Arc::new(crate::tools::background_tasks::BackgroundTaskStore::new( Arc::clone(&process_store), )); @@ -125,11 +138,12 @@ fn build_registry_from_builder( builder.push_spec(goal_update_spec()); builder.push_spec(tool_search_spec()); - let loaded_deferred_tools = Arc::new(std::sync::Mutex::new(LoadedDeferredTools::default())); + let loaded_deferred_tools = previous + .map(|registry| Arc::clone(®istry.loaded_deferred_tools)) + .unwrap_or_else(|| Arc::new(std::sync::Mutex::new(LoadedDeferredTools::default()))); builder.set_unified_exec_store(Arc::clone(&process_store)); builder.set_loaded_deferred_tools(Arc::clone(&loaded_deferred_tools)); builder.register_handler("update_goal", Arc::new(GoalUpdateHandler::new())); - for (kind, name) in handlers { let handler: Arc = match kind { ToolHandlerKind::ShellCommand => Arc::new(ShellCommandHandler::new()), @@ -191,9 +205,74 @@ fn build_registry_from_builder( #[cfg(test)] mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use pretty_assertions::assert_eq; + use serde_json::Value; + use super::*; + use crate::mcp::McpError; + use crate::mcp::McpManager; + use crate::mcp::McpServerId; + use crate::mcp::McpServerStatus; + use crate::mcp::McpToolInfo; use crate::tool_spec::ToolExecutionMode; + struct EmptyMcpManager; + + #[async_trait] + impl McpManager for EmptyMcpManager { + async fn statuses(&self) -> Result, McpError> { + Ok(Vec::new()) + } + + async fn discover_tools(&self) -> Result, McpError> { + Ok(Vec::new()) + } + + async fn refresh(&self, server_id: &McpServerId) -> Result { + Err(McpError::McpServerUnavailable { + server_id: server_id.clone(), + }) + } + + async fn set_enabled( + &self, + server_id: &McpServerId, + _enabled: bool, + ) -> Result { + Err(McpError::McpServerUnavailable { + server_id: server_id.clone(), + }) + } + + async fn invoke_tool( + &self, + server_id: &McpServerId, + tool_name: &str, + _input: Value, + ) -> Result { + Err(McpError::McpToolInvocationFailed { + server_id: server_id.clone(), + tool_name: tool_name.to_string(), + message: "empty manager".to_string(), + }) + } + + async fn read_resource( + &self, + server_id: &McpServerId, + uri: &str, + ) -> Result { + Err(McpError::McpResourceReadFailed { + server_id: server_id.clone(), + uri: uri.to_string(), + message: "empty manager".to_string(), + }) + } + } + #[test] fn default_registry_exposes_shell_command_and_accepts_bash_alias() { let registry = build_registry_from_plan(&ToolPlanConfig::default()); @@ -223,4 +302,25 @@ mod tests { assert_eq!(spec.execution_mode, ToolExecutionMode::Mutating); assert!(!spec.supports_parallel); } + + #[tokio::test] + async fn rebuild_registry_reuses_process_store() { + let manager: Arc = Arc::new(EmptyMcpManager); + let first = rebuild_registry_from_plan_with_mcp( + &ToolPlanConfig::default(), + Arc::clone(&manager), + None, + ) + .await; + let first_store = first + .unified_exec_store() + .expect("first registry should own a process store"); + let second = + rebuild_registry_from_plan_with_mcp(&ToolPlanConfig::default(), manager, Some(&first)) + .await; + let second_store = second + .unified_exec_store() + .expect("rebuilt registry should own a process store"); + assert!(Arc::ptr_eq(&first_store, &second_store)); + } } diff --git a/crates/core/src/tools/registry.rs b/crates/core/src/tools/registry.rs index c9847869..4d557d83 100644 --- a/crates/core/src/tools/registry.rs +++ b/crates/core/src/tools/registry.rs @@ -53,6 +53,11 @@ impl ToolRegistry { self.handlers.get(name) } + /// Returns the shared unified-exec process store when this registry owns one. + pub fn unified_exec_store(&self) -> Option> { + self.unified_exec_store.clone() + } + pub fn spec(&self, name: &str) -> Option<&ToolSpec> { self.spec_index.get(name).map(|&idx| &self.specs[idx]) } diff --git a/crates/core/src/tools/shell_exec/launch.rs b/crates/core/src/tools/shell_exec/launch.rs index 61cc6bae..eef24428 100644 --- a/crates/core/src/tools/shell_exec/launch.rs +++ b/crates/core/src/tools/shell_exec/launch.rs @@ -144,7 +144,7 @@ impl SandboxLaunchPlan { }) } - #[cfg(test)] + #[cfg(all(test, any(target_os = "macos", windows)))] pub(crate) fn wrap(&self) -> &devo_sandbox::SandboxWrap { &self.wrap } diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs index 4b7aff1f..e6453e39 100644 --- a/crates/core/src/tools/shell_exec/mod.rs +++ b/crates/core/src/tools/shell_exec/mod.rs @@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken; use crate::events::ToolProgressSender; use crate::invocation::FunctionToolOutput; -#[cfg(test)] +#[cfg(all(test, any(target_os = "macos", windows)))] pub(crate) use launch::SandboxLaunchPlan; #[cfg(test)] pub(crate) use resolve::platform_shell_program; diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index 8d7d59d6..d0044adc 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -2,7 +2,6 @@ use super::*; use crate::ToolContent; use pretty_assertions::assert_eq; use std::hint::black_box; -use std::path::Path; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -279,7 +278,7 @@ async fn execute_shell_command_error_output_is_text_only() { assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); } -use super::{SandboxLaunchPlan, platform_shell_program, preview, resolve_shell, truncate_output}; +use super::{platform_shell_program, preview, resolve_shell, truncate_output}; #[cfg(unix)] #[tokio::test] @@ -345,7 +344,7 @@ fn macos_pipe_and_pty_launch_plans_wrap_via_sandbox_exec() { assert!(wrapped.helper_enforces, "{label}"); } devo_sandbox::SandboxWrap::None => assert!( - !Path::new("/usr/bin/sandbox-exec").is_file(), + !std::path::Path::new("/usr/bin/sandbox-exec").is_file(), "{label}: sandbox-exec exists but wrap was declined" ), } diff --git a/crates/mcp/src/manager.rs b/crates/mcp/src/manager.rs index cfa74c04..d9a0d7fd 100644 --- a/crates/mcp/src/manager.rs +++ b/crates/mcp/src/manager.rs @@ -40,7 +40,7 @@ const MCP_OPERATION_TIMEOUT: Duration = Duration::from_secs(10); /// Runtime MCP manager that owns active RMCP clients. pub struct RmcpMcpManager { - config: McpConfig, + config: RwLock, oauth_store_mode: OAuthCredentialsStoreMode, clients: RwLock>>, statuses: RwLock>, @@ -61,7 +61,7 @@ impl RmcpMcpManager { } Self { - config, + config: RwLock::new(config), oauth_store_mode, clients: RwLock::new(HashMap::with_capacity(server_count)), statuses: RwLock::new(statuses), @@ -86,23 +86,58 @@ impl RmcpMcpManager { Ok(client) } - fn record(&self, server_id: &McpServerId) -> Result<&McpServerRecord, McpError> { + async fn record(&self, server_id: &McpServerId) -> Result { self.config + .read() + .await .servers .iter() .find(|record| &record.id == server_id) + .cloned() .ok_or_else(|| McpError::McpServerUnavailable { server_id: server_id.clone(), }) } - fn discoverable_tool_records(&self) -> impl Iterator { - self.config.servers.iter().filter(|record| { - record.enabled - && tools_capability_allowed(record) - && self.config.auto_start - && !matches!(record.startup_policy, McpStartupPolicy::Manual) - }) + async fn discoverable_tool_records(&self) -> Vec { + let config = self.config.read().await; + config + .servers + .iter() + .filter(|record| { + record.enabled + && tools_capability_allowed(record) + && config.auto_start + && !matches!(record.startup_policy, McpStartupPolicy::Manual) + }) + .cloned() + .collect() + } + + async fn stop_server(&self, server_id: &McpServerId) -> McpServerStatus { + self.clients.write().await.remove(server_id); + let status = empty_status(server_id.clone(), McpStartupState::Disabled); + self.statuses + .write() + .await + .insert(server_id.clone(), status.clone()); + status + } + + async fn mark_failed(&self, server_id: &McpServerId, message: &str) -> McpServerStatus { + warn!( + server_id = %server_id, + error = %message, + "MCP server startup failed" + ); + self.clients.write().await.remove(server_id); + let mut status = empty_status(server_id.clone(), McpStartupState::Failed); + status.last_refreshed_at = Some(chrono::Utc::now()); + self.statuses + .write() + .await + .insert(server_id.clone(), status.clone()); + status } } @@ -125,8 +160,9 @@ impl McpManager for RmcpMcpManager { } async fn discover_tools(&self) -> Result, McpError> { - let mut tools = Vec::with_capacity(self.config.servers.len()); - for record in self.discoverable_tool_records() { + let records = self.discoverable_tool_records().await; + let mut tools = Vec::with_capacity(records.len()); + for record in &records { match self.refresh(&record.id).await { Ok(_) => { let Some(client) = self.clients.read().await.get(&record.id).cloned() else { @@ -169,7 +205,7 @@ impl McpManager for RmcpMcpManager { } async fn refresh(&self, server_id: &McpServerId) -> Result { - let record = self.record(server_id)?; + let record = self.record(server_id).await?; if !record.enabled { return Err(McpError::McpServerUnavailable { server_id: server_id.clone(), @@ -181,27 +217,38 @@ impl McpManager for RmcpMcpManager { empty_status(server_id.clone(), McpStartupState::Starting), ); - let client = self.ensure_client(record).await?; - let tools = if tools_capability_allowed(record) { - client - .list_tools(None, Some(MCP_OPERATION_TIMEOUT)) - .await - .map_err(|err| McpError::McpProtocolError { - server_id: server_id.clone(), - message: err.to_string(), - })? - .tools - .into_iter() - .map(|tool| McpToolDescriptor { - server_id: server_id.clone(), - name: tool.name.into_owned(), - description: tool - .description - .map(std::borrow::Cow::into_owned) - .unwrap_or_default(), - input_schema: Value::Object(Arc::unwrap_or_clone(tool.input_schema)), - }) - .collect() + let client = match self.ensure_client(&record).await { + Ok(client) => client, + Err(err) => { + let message = err.to_string(); + self.mark_failed(server_id, &message).await; + return Err(err); + } + }; + let tools = if tools_capability_allowed(&record) { + match client.list_tools(None, Some(MCP_OPERATION_TIMEOUT)).await { + Ok(list) => list + .tools + .into_iter() + .map(|tool| McpToolDescriptor { + server_id: server_id.clone(), + name: tool.name.into_owned(), + description: tool + .description + .map(std::borrow::Cow::into_owned) + .unwrap_or_default(), + input_schema: Value::Object(Arc::unwrap_or_clone(tool.input_schema)), + }) + .collect(), + Err(err) => { + let message = err.to_string(); + self.mark_failed(server_id, &message).await; + return Err(McpError::McpProtocolError { + server_id: server_id.clone(), + message, + }); + } + } } else { Vec::new() }; @@ -222,26 +269,72 @@ impl McpManager for RmcpMcpManager { Ok(status) } + async fn set_enabled( + &self, + server_id: &McpServerId, + enabled: bool, + ) -> Result { + { + let mut config = self.config.write().await; + let record = config + .servers + .iter_mut() + .find(|record| &record.id == server_id) + .ok_or_else(|| McpError::McpServerUnavailable { + server_id: server_id.clone(), + })?; + record.enabled = enabled; + } + + if !enabled { + return Ok(self.stop_server(server_id).await); + } + + match self.refresh(server_id).await { + Ok(status) => Ok(status), + Err(err) => { + // Persist succeeded; surface Failed status so callers can keep + // going while siblings remain unaffected. + Ok(self + .statuses + .read() + .await + .get(server_id) + .cloned() + .unwrap_or_else(|| { + tracing::debug!( + server_id = %server_id, + error = %err, + "MCP enable failed without a status entry" + ); + let mut failed = empty_status(server_id.clone(), McpStartupState::Failed); + failed.last_refreshed_at = Some(chrono::Utc::now()); + failed + })) + } + } + } + async fn invoke_tool( &self, server_id: &McpServerId, tool_name: &str, input: Value, ) -> Result { - let record = self.record(server_id)?; + let record = self.record(server_id).await?; if !record.enabled { return Err(McpError::McpServerUnavailable { server_id: server_id.clone(), }); } - if !tools_capability_allowed(record) { + if !tools_capability_allowed(&record) { return Err(McpError::McpToolInvocationFailed { server_id: server_id.clone(), tool_name: tool_name.to_string(), message: "MCP tools capability is not allowed for this server".to_string(), }); } - let client = self.ensure_client(record).await?; + let client = self.ensure_client(&record).await?; let result = client .call_tool( tool_name.to_string(), @@ -491,8 +584,8 @@ mod tests { ) } - #[test] - fn tool_discovery_respects_allowed_capabilities() { + #[tokio::test] + async fn tool_discovery_respects_allowed_capabilities() { let mut disabled = record( "disabled", vec![McpCapability::Tools], @@ -517,10 +610,15 @@ mod tests { let server_ids = manager .discoverable_tool_records() - .map(|record| record.id.0.as_str()) + .await + .into_iter() + .map(|record| record.id.0) .collect::>(); - assert_eq!(server_ids, vec!["implicit", "tools"]); + assert_eq!( + server_ids, + vec!["implicit".to_string(), "tools".to_string()] + ); } #[tokio::test] @@ -570,4 +668,120 @@ mod tests { ); assert!(manager.clients.read().await.is_empty()); } + + #[tokio::test] + async fn set_enabled_flips_config_and_marks_disabled_without_touching_siblings() { + let mut sibling = record( + "sibling", + vec![McpCapability::Tools], + McpStartupPolicy::Eager, + ); + sibling.enabled = true; + let mut target = record( + "target", + vec![McpCapability::Tools], + McpStartupPolicy::Eager, + ); + target.enabled = true; + let manager = manager_with(vec![sibling, target]); + manager.statuses.write().await.insert( + McpServerId("sibling".to_string()), + empty_status(McpServerId("sibling".to_string()), McpStartupState::Ready), + ); + + let status = manager + .set_enabled(&McpServerId("target".to_string()), /*enabled*/ false) + .await + .expect("disable should succeed"); + + assert_eq!(status.startup_state, McpStartupState::Disabled); + assert!( + !manager + .config + .read() + .await + .servers + .iter() + .find(|record| record.id.0 == "target") + .expect("target") + .enabled + ); + assert!( + manager + .config + .read() + .await + .servers + .iter() + .find(|record| record.id.0 == "sibling") + .expect("sibling") + .enabled + ); + assert_eq!( + manager + .statuses + .read() + .await + .get(&McpServerId("sibling".to_string())) + .expect("sibling status") + .startup_state, + McpStartupState::Ready + ); + assert!( + !manager + .clients + .read() + .await + .contains_key(&McpServerId("target".to_string())) + ); + } + + #[tokio::test] + async fn set_enabled_true_with_bad_binary_persists_failed_status() { + let mut bad = record("bad", vec![McpCapability::Tools], McpStartupPolicy::Eager); + bad.enabled = false; + bad.transport = McpTransportConfig::Stdio { + command: vec!["__devo_missing_mcp_binary__".to_string()], + cwd: None, + env: BTreeMap::new(), + env_vars: Vec::new(), + }; + let mut sibling = record( + "sibling", + vec![McpCapability::Tools], + McpStartupPolicy::Eager, + ); + sibling.enabled = false; + let manager = manager_with(vec![sibling, bad]); + + let status = manager + .set_enabled(&McpServerId("bad".to_string()), /*enabled*/ true) + .await + .expect("enable should return status even on startup failure"); + + assert_eq!(status.startup_state, McpStartupState::Failed); + assert!( + manager + .config + .read() + .await + .servers + .iter() + .find(|record| record.id.0 == "bad") + .expect("bad") + .enabled + ); + assert!( + !manager + .config + .read() + .await + .servers + .iter() + .find(|record| record.id.0 == "sibling") + .expect("sibling") + .enabled + ); + assert!(manager.clients.read().await.is_empty()); + } } diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index b40b9187..2fcd7091 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -12,6 +12,8 @@ use ts_rs::TS; use crate::canonical::rpc_admin::McpListParams; use crate::canonical::rpc_admin::McpListResult; use crate::canonical::rpc_admin::McpServerInfo; +use crate::canonical::rpc_admin::McpSetEnabledParams; +use crate::canonical::rpc_admin::McpSetEnabledResult; use crate::canonical::rpc_admin::McpToolEntry; use crate::canonical::rpc_admin::McpToolsParams; use crate::canonical::rpc_admin::McpToolsResult; @@ -289,6 +291,8 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -657,6 +661,8 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -783,6 +789,7 @@ fn register_devo_protocol_schemas( ); devo_method::(methods, ClientMethod::McpList); devo_method::(methods, ClientMethod::McpTools); + devo_method::(methods, ClientMethod::McpSetEnabled); devo_method::(methods, ClientMethod::ModelCatalog); devo_method::(methods, ClientMethod::ModelConfig); devo_method::(methods, ClientMethod::ModelConfigSet); diff --git a/crates/protocol/src/canonical/methods.rs b/crates/protocol/src/canonical/methods.rs index dbda5aa4..5245d7a2 100644 --- a/crates/protocol/src/canonical/methods.rs +++ b/crates/protocol/src/canonical/methods.rs @@ -387,6 +387,14 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ required_capability: None, idempotency: Idempotency::None, }, + MethodSpec { + name: "mcp/set_enabled", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, MethodSpec { name: "context/usage/read", params_schema: schema_of::, diff --git a/crates/protocol/src/canonical/rpc_admin.rs b/crates/protocol/src/canonical/rpc_admin.rs index bbe456ba..6d6ac10f 100644 --- a/crates/protocol/src/canonical/rpc_admin.rs +++ b/crates/protocol/src/canonical/rpc_admin.rs @@ -195,6 +195,19 @@ pub struct McpToolsResult { pub tools: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpSetEnabledParams { + pub name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpSetEnabledResult { + pub servers: Vec, +} + // ── context/usage/read ── #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 60cd9db8..02c73d38 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -107,6 +107,7 @@ pub enum ClientMethod { ProviderVendorUpsert, McpList, McpTools, + McpSetEnabled, // New Native API methods (canonical types; not part of the legacy // `_devo/*` alias surface). SessionTurnsList, @@ -178,6 +179,7 @@ impl ClientMethod { Self::ProviderVendorUpsert => "provider/upsert", Self::McpList => "mcp/list", Self::McpTools => "mcp/tools", + Self::McpSetEnabled => "mcp/set_enabled", Self::SessionTurnsList => "session/turns/list", Self::SessionItemsList => "session/items/list", Self::SessionRollbackPreview => "session/rollback/preview", @@ -247,6 +249,7 @@ impl ClientMethod { "provider/upsert" => Self::ProviderVendorUpsert, "mcp/list" => Self::McpList, "mcp/tools" => Self::McpTools, + "mcp/set_enabled" => Self::McpSetEnabled, "session/turns/list" => Self::SessionTurnsList, "session/items/list" => Self::SessionItemsList, "session/rollback/preview" => Self::SessionRollbackPreview, @@ -705,6 +708,15 @@ mod tests { assert_eq!(ClientMethod::McpTools.as_str(), "mcp/tools"); } + #[test] + fn client_method_recognizes_mcp_set_enabled() { + assert_eq!( + ClientMethod::parse("mcp/set_enabled"), + Some(ClientMethod::McpSetEnabled) + ); + assert_eq!(ClientMethod::McpSetEnabled.as_str(), "mcp/set_enabled"); + } + #[test] fn client_method_recognizes_workspace_changes_read() { assert_eq!( diff --git a/crates/rmcp-client/src/program_resolver.rs b/crates/rmcp-client/src/program_resolver.rs index 395b330d..c05e1018 100644 --- a/crates/rmcp-client/src/program_resolver.rs +++ b/crates/rmcp-client/src/program_resolver.rs @@ -7,7 +7,7 @@ //! scripts natively through shebangs. //! //! The `resolve` function abstracts these platform differences: -//! - On Unix: Returns the program unchanged (OS handles script execution) +//! - On Unix: Expands a leading `~/`, then returns the program (OS handles PATH/scripts) //! - On Windows: Uses the `which` crate to resolve full paths including extensions use std::collections::HashMap; @@ -16,16 +16,39 @@ use std::path::Path; /// Resolves a program to its executable path on Unix systems. /// -/// Unix systems handle PATH resolution and script execution natively through -/// the kernel's shebang (`#!`) mechanism, so this function simply returns -/// the program name unchanged. +/// Expands a leading `~/` (or bare `~`) to the user home directory. Beyond +/// that, Unix systems handle PATH resolution and script execution natively +/// through the kernel's shebang (`#!`) mechanism, so the resolved program +/// string is returned as-is. #[cfg(unix)] pub fn resolve( program: OsString, - _env: &HashMap, + env: &HashMap, _cwd: &Path, ) -> std::io::Result { - Ok(program) + Ok(expand_user_path(program, env)) +} + +/// Expands a leading `~` segment to the current user's home directory. +#[cfg(unix)] +fn expand_user_path(program: OsString, env: &HashMap) -> OsString { + let Some(program_str) = program.to_str() else { + return program; + }; + let home = env + .get(std::ffi::OsStr::new("HOME")) + .cloned() + .or_else(|| std::env::var_os("HOME")); + if program_str == "~" { + return home.unwrap_or(program); + } + let Some(rest) = program_str.strip_prefix("~/") else { + return program; + }; + match home { + Some(home) => Path::new(&home).join(rest).into_os_string(), + None => program, + } } /// Resolves a program to its executable path on Windows systems. @@ -101,6 +124,27 @@ mod tests { Ok(()) } + /// Unix: Verifies leading `~/` in MCP commands expands to `$HOME`. + #[cfg(unix)] + #[test] + fn unix_resolve_expands_tilde_home_prefix() { + let mut env = HashMap::new(); + env.insert(OsString::from("HOME"), OsString::from("/tmp/fake-home")); + let resolved = resolve( + OsString::from("~/bin/devo-code-search-mcp"), + &env, + Path::new("/"), + ) + .expect("resolve"); + assert_eq!( + resolved, + OsString::from("/tmp/fake-home/bin/devo-code-search-mcp") + ); + let unchanged = resolve(OsString::from("devo-code-search-mcp"), &env, Path::new("/")) + .expect("resolve bare"); + assert_eq!(unchanged, OsString::from("devo-code-search-mcp")); + } + /// Windows: Verifies scripts fail to execute without the proper extension. #[cfg(windows)] #[tokio::test] diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 251b8bfd..00d813e0 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -366,6 +366,9 @@ impl ServerRuntime { } Some(ClientMethod::McpList) => Some(self.handle_mcp_list(id?, params).await), Some(ClientMethod::McpTools) => Some(self.handle_mcp_tools(id?, params).await), + Some(ClientMethod::McpSetEnabled) => { + Some(self.handle_mcp_set_enabled(id?, params).await) + } // get the model catalog, aka the configured models list Some(ClientMethod::ModelCatalog) => Some(self.handle_model_catalog(id?, params).await), Some(ClientMethod::ModelConfig) => Some(self.handle_model_config(id?, params).await), @@ -1514,7 +1517,7 @@ mod tests { } #[tokio::test] - async fn mcp_list_returns_empty_servers_for_default_manager() { + async fn mcp_list_includes_bundled_disabled_code_search() { let temp = TempDir::new().expect("temp dir"); let runtime = build_runtime(temp.path()); let connection_id = initialized_connection(&runtime).await; @@ -1531,10 +1534,142 @@ mod tests { .expect("mcp/list response"); let result: SuccessResponse = serde_json::from_value(response.clone()).expect("deserialize mcp/list"); - assert_eq!( - result.result, - devo_protocol::canonical::rpc_admin::McpListResult { servers: vec![] } + let code_search = result + .result + .servers + .iter() + .find(|server| server.name == "code_search") + .expect("bundled code_search should be listed"); + assert_eq!(code_search.status, "disabled"); + assert_eq!(code_search.tool_count, 0); + } + + #[tokio::test] + async fn mcp_set_enabled_applies_bad_binary_as_failed_status() { + let temp = TempDir::new().expect("temp dir"); + { + let mut store = AppConfigStore::load(temp.path().to_path_buf(), None) + .expect("load app config store"); + store + .upsert_mcp_server(devo_core::McpServerRecord { + id: devo_core::McpServerId("bad_mcp".to_string()), + display_name: "Bad MCP".to_string(), + transport: devo_core::McpTransportConfig::Stdio { + command: vec!["__devo_missing_mcp_binary__".to_string()], + cwd: None, + env: Default::default(), + env_vars: Vec::new(), + }, + startup_policy: devo_core::McpStartupPolicy::Lazy, + enabled: false, + trust_policy: Default::default(), + allowed_capabilities: Vec::new(), + roots_policy: Default::default(), + output_limits: Default::default(), + auth_ref: None, + }) + .expect("upsert bad mcp server"); + } + + let mcp_manager = Arc::new(devo_mcp::manager::RmcpMcpManager::new( + { + let store = AppConfigStore::load(temp.path().to_path_buf(), None) + .expect("reload app config store"); + store.effective_config().mcp.clone() + }, + Default::default(), + )); + let db = Arc::new( + crate::db::Database::open(temp.path().join("connection.db")) + .expect("open test database"), + ); + let provider: Arc = Arc::new(NoopProvider); + let runtime = ServerRuntime::new( + temp.path().to_path_buf(), + ServerRuntimeDependencies::new( + Arc::clone(&provider), + Arc::new(SingleProviderRouter::new(provider)), + Arc::new(ToolRegistry::new()), + mcp_manager, + "test-model".to_string(), + Arc::new(PresetModelCatalog::default()), + Arc::new(ProviderVendorCatalog::default()), + Box::new(FileSystemSkillCatalog::new(SkillsConfig { + bundled: Some(BundledSkillsConfig { enabled: false }), + ..SkillsConfig::default() + })), + devo_core::AgentsMdConfig::default(), + db, + Arc::new(std::sync::Mutex::new( + AppConfigStore::load(temp.path().to_path_buf(), None) + .expect("load app config store"), + )), + ), ); + let connection_id = initialized_connection(&runtime).await; + + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 4, + "method": "mcp/set_enabled", + "params": { "name": "bad_mcp", "enabled": true } + }), + ) + .await + .expect("mcp/set_enabled response"); + let result: SuccessResponse = + serde_json::from_value(response).expect("deserialize mcp/set_enabled"); + let bad = result + .result + .servers + .iter() + .find(|server| server.name == "bad_mcp") + .expect("bad_mcp should be listed"); + assert_eq!(bad.status, "failed"); + assert_eq!(bad.tool_count, 0); + + let list_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 5, + "method": "mcp/list", + "params": {} + }), + ) + .await + .expect("mcp/list response"); + let list: SuccessResponse = + serde_json::from_value(list_response).expect("deserialize mcp/list"); + let listed = list + .result + .servers + .iter() + .find(|server| server.name == "bad_mcp") + .expect("bad_mcp should remain listed"); + assert_eq!(listed.status, "failed"); + } + + #[tokio::test] + async fn mcp_set_enabled_rejects_unknown_server() { + let temp = TempDir::new().expect("temp dir"); + let runtime = build_runtime(temp.path()); + let connection_id = initialized_connection(&runtime).await; + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 6, + "method": "mcp/set_enabled", + "params": { "name": "missing-server", "enabled": true } + }), + ) + .await + .expect("mcp/set_enabled response"); + let error: ErrorResponse = serde_json::from_value(response).expect("deserialize error"); + assert_eq!(error.error.code, ProtocolErrorCode::InternalError); } fn assert_agent_message_chunk_update( diff --git a/crates/server/src/runtime/handlers/acp/mcp.rs b/crates/server/src/runtime/handlers/acp/mcp.rs index e6e5b8ab..3acdd1d2 100644 --- a/crates/server/src/runtime/handlers/acp/mcp.rs +++ b/crates/server/src/runtime/handlers/acp/mcp.rs @@ -92,7 +92,6 @@ pub(super) fn acp_mcp_config( Ok(McpConfig { servers: records, auto_start: true, - refresh_on_config_reload: false, }) } @@ -229,7 +228,6 @@ mod tests { auth_ref: None, }], auto_start: true, - refresh_on_config_reload: false, } ); } @@ -311,7 +309,6 @@ mod tests { }, ], auto_start: true, - refresh_on_config_reload: false, } ); } diff --git a/crates/server/src/runtime/mcp.rs b/crates/server/src/runtime/mcp.rs index 15e862a1..fb025c58 100644 --- a/crates/server/src/runtime/mcp.rs +++ b/crates/server/src/runtime/mcp.rs @@ -1,11 +1,17 @@ -//! MCP admin RPC handlers (`mcp/list`, `mcp/tools`). +//! MCP admin RPC handlers (`mcp/list`, `mcp/tools`, `mcp/set_enabled`). + +use std::sync::Arc; use devo_core::McpServerId; use devo_core::McpStartupState; +use devo_core::tools::ToolPlanConfig; +use devo_core::tools::handlers; use devo_protocol::SuccessResponse; use devo_protocol::canonical::rpc_admin::McpListParams; use devo_protocol::canonical::rpc_admin::McpListResult; use devo_protocol::canonical::rpc_admin::McpServerInfo; +use devo_protocol::canonical::rpc_admin::McpSetEnabledParams; +use devo_protocol::canonical::rpc_admin::McpSetEnabledResult; use devo_protocol::canonical::rpc_admin::McpToolEntry; use devo_protocol::canonical::rpc_admin::McpToolsParams; use devo_protocol::canonical::rpc_admin::McpToolsResult; @@ -27,23 +33,12 @@ impl ServerRuntime { ); } - match self.deps.process_context.mcp_manager.statuses().await { - Ok(statuses) => { - let mut servers = statuses - .into_iter() - .map(|status| McpServerInfo { - name: status.server_id.0, - status: startup_state_label(&status.startup_state).to_string(), - tool_count: status.tools.len() as u32, - }) - .collect::>(); - servers.sort_by(|left, right| left.name.cmp(&right.name)); - serde_json::to_value(SuccessResponse { - id: request_id, - result: McpListResult { servers }, - }) - .expect("serialize mcp/list response") - } + match self.mcp_server_infos().await { + Ok(servers) => serde_json::to_value(SuccessResponse { + id: request_id, + result: McpListResult { servers }, + }) + .expect("serialize mcp/list response"), Err(error) => self.error_response( request_id, ProtocolErrorCode::InternalError, @@ -143,6 +138,171 @@ impl ServerRuntime { ), } } + + pub(super) async fn handle_mcp_set_enabled( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid mcp/set_enabled params: {error}"), + ); + } + }; + + let name = params.name.trim(); + if name.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "mcp/set_enabled requires a non-empty name".to_string(), + ); + } + + let config_file = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store + .user_config_dir() + .join("config.toml") + .display() + .to_string() + }; + if let Some(reason) = self + .config_change_hook_block_reason("mcp", Some(config_file)) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("mcp config change blocked by hook: {reason}"), + ); + } + + { + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + if let Err(error) = store.set_mcp_server_enabled(name, params.enabled) { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to update mcp config: {error}"), + ); + } + } + + let server_id = McpServerId(name.to_string()); + let process_context = &self.deps.process_context; + if let Err(error) = process_context + .mcp_manager + .set_enabled(&server_id, params.enabled) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to apply mcp enablement: {error}"), + ); + } + + let tool_plan = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + ToolPlanConfig::from_app_config(store.effective_config()) + }; + let previous = process_context.tool_registry(); + let new_registry = Arc::new( + handlers::rebuild_registry_from_plan_with_mcp( + &tool_plan, + Arc::clone(&process_context.mcp_manager), + Some(previous.as_ref()), + ) + .await, + ); + process_context.replace_tool_registry(Arc::clone(&new_registry)); + + for handle in self.list_session_handles().await { + let Some(session_context) = handle.runtime_context().await else { + continue; + }; + let session_registry = + if Arc::ptr_eq(&session_context.mcp_manager, &process_context.mcp_manager) { + Arc::clone(&new_registry) + } else { + if let Err(error) = session_context + .mcp_manager + .set_enabled(&server_id, params.enabled) + .await + { + tracing::warn!( + server = %server_id, + error = %error, + "failed to apply mcp enablement on session manager" + ); + continue; + } + let previous = session_context.tool_registry(); + let rebuilt = Arc::new( + handlers::rebuild_registry_from_plan_with_mcp( + &tool_plan, + Arc::clone(&session_context.mcp_manager), + Some(previous.as_ref()), + ) + .await, + ); + session_context.replace_tool_registry(Arc::clone(&rebuilt)); + rebuilt + }; + let _ = handle.set_tool_registry(Some(session_registry)).await; + } + + match self.mcp_server_infos().await { + Ok(servers) => serde_json::to_value(SuccessResponse { + id: request_id, + result: McpSetEnabledResult { servers }, + }) + .expect("serialize mcp/set_enabled response"), + Err(error) => self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("mcp enablement applied but list failed: {error}"), + ), + } + } + + async fn mcp_server_infos(&self) -> Result, String> { + let statuses = self + .deps + .process_context + .mcp_manager + .statuses() + .await + .map_err(|error| error.to_string())?; + let mut servers = statuses + .into_iter() + .map(|status| McpServerInfo { + name: status.server_id.0, + status: startup_state_label(&status.startup_state).to_string(), + tool_count: status.tools.len() as u32, + }) + .collect::>(); + servers.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(servers) + } } fn startup_state_label(state: &McpStartupState) -> &'static str { diff --git a/crates/server/src/runtime/session_actor/actor_loop.rs b/crates/server/src/runtime/session_actor/actor_loop.rs index 1c83b41d..153f681e 100644 --- a/crates/server/src/runtime/session_actor/actor_loop.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -120,7 +120,7 @@ pub(super) async fn run_session_actor( let tool_registry = state .tool_registry .clone() - .unwrap_or_else(|| Arc::clone(&state.runtime_context.registry)); + .unwrap_or_else(|| state.runtime_context.tool_registry()); let _ = reply.send(ShellExecContextSnapshot { permission_mode: state.core.config.permission_mode, permission_profile: state.core.config.permission_profile.clone(), @@ -473,6 +473,9 @@ pub(super) async fn run_session_actor( state.tool_registry = tool_registry; let _ = reply.send(()); } + SessionCommand::GetRuntimeContext { reply } => { + let _ = reply.send(Arc::clone(&state.runtime_context)); + } SessionCommand::GetResumeSnapshot { reply } => { let pending_texts = state .pending_turn_queue diff --git a/crates/server/src/runtime/session_actor/commands.rs b/crates/server/src/runtime/session_actor/commands.rs index 3c9888e4..66543163 100644 --- a/crates/server/src/runtime/session_actor/commands.rs +++ b/crates/server/src/runtime/session_actor/commands.rs @@ -182,6 +182,9 @@ pub(crate) enum SessionCommand { tool_registry: Option>, reply: oneshot::Sender<()>, }, + GetRuntimeContext { + reply: oneshot::Sender>, + }, GetResumeSnapshot { reply: oneshot::Sender, }, diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 1d0234f3..596db8b7 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -655,6 +655,19 @@ impl SessionHandle { reply_rx.await.is_ok() } + pub(crate) async fn runtime_context( + &self, + ) -> Option> { + let (reply_tx, reply_rx) = oneshot::channel(); + if !self + .send(SessionCommand::GetRuntimeContext { reply: reply_tx }) + .await + { + return None; + } + reply_rx.await.ok() + } + pub(crate) async fn resume_snapshot(&self) -> Option { let (reply_tx, reply_rx) = oneshot::channel(); if !self diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index 141eff35..9acf83b8 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -80,7 +80,7 @@ impl ServerRuntime { state .tool_registry .clone() - .unwrap_or_else(|| Arc::clone(&state.runtime_context.registry)) + .unwrap_or_else(|| state.runtime_context.tool_registry()) } } diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index c9d00e12..64752091 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -58,7 +58,9 @@ use crate::load_server_provider; pub(crate) struct SessionRuntimeContext { pub(crate) provider: Arc, pub(crate) provider_router: Arc, - pub(crate) registry: Arc, + /// Live tool registry. Wrapped so MCP enable/disable can swap the Arc for + /// the next turn without rebuilding the whole process context graph. + pub(crate) registry: Arc>>, pub(crate) mcp_manager: Arc, pub(crate) default_model: String, pub(crate) model_catalog: Arc, @@ -123,7 +125,7 @@ impl SessionRuntimeContext { Self { provider, provider_router, - registry, + registry: Arc::new(StdMutex::new(registry)), mcp_manager, default_model, model_catalog, @@ -133,6 +135,22 @@ impl SessionRuntimeContext { } } + pub(crate) fn tool_registry(&self) -> Arc { + Arc::clone( + &self + .registry + .lock() + .expect("tool registry mutex should not be poisoned"), + ) + } + + pub(crate) fn replace_tool_registry(&self, registry: Arc) { + *self + .registry + .lock() + .expect("tool registry mutex should not be poisoned") = registry; + } + pub(crate) async fn load_for_workspace( user_config_dir: PathBuf, workspace_root: Option<&Path>, @@ -178,10 +196,10 @@ impl SessionRuntimeContext { config.mcp_oauth_credentials_store.unwrap_or_default(), )); let tool_plan = ToolPlanConfig::from_app_config(&config); - let registry = Arc::new( + let registry = Arc::new(StdMutex::new(Arc::new( handlers::build_registry_from_plan_with_mcp(&tool_plan, Arc::clone(&mcp_manager)) .await, - ); + ))); (registry, mcp_manager) }; let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( @@ -223,20 +241,20 @@ impl SessionRuntimeContext { )) as Box, )); - Ok(Arc::new(Self::from_parts( + Ok(Arc::new(Self { provider, provider_router, registry, mcp_manager, - provider_default_model, + default_model: provider_default_model, model_catalog, skill_catalog, - AgentsMdConfig { + agents_md: AgentsMdConfig { project_root_markers: config.project_root_markers.clone(), ..AgentsMdConfig::default() }, config_store, - ))) + })) } pub(crate) fn provider_for_route(&self, route: ProviderRoute) -> Arc { diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index fd657028..c966bde7 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -1195,6 +1195,35 @@ impl ChatWidget { WorkerEvent::McpToolsListed { name, tools } => { self.on_mcp_tools_listed(name, tools); } + WorkerEvent::McpServerEnabled { + name, + enabled, + servers, + } => { + let action = if enabled { "enabled" } else { "disabled" }; + let status = servers + .iter() + .find(|server| server.name == name) + .map(|server| server.status.as_str()) + .unwrap_or("unknown"); + self.set_mcp_reopen_detail(Some(name.clone())); + if status == "failed" { + self.set_status_message(format!( + "MCP `{name}` {action} in config but runtime startup failed" + )); + } else { + self.set_status_message(format!("MCP `{name}` {action}")); + } + self.on_mcp_servers_listed(servers); + } + WorkerEvent::McpServerEnableFailed { name, message } => { + self.set_mcp_reopen_detail(None); + self.add_to_history(crate::history_cell::new_error_event_with_hint( + format!("Failed to update MCP server `{name}`: {message}"), + Some("mcp enable/disable failed".to_string()), + )); + self.set_status_message(format!("Failed to update MCP `{name}`")); + } WorkerEvent::AcpAvailableCommandsUpdated { commands } => { self.acp_available_commands = commands; let count = self.acp_available_commands.len(); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 5e367d63..f783a5be 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -507,6 +507,14 @@ pub(crate) enum WorkerEvent { name: String, tools: Vec, }, + /// MCP enable/disable applied via `mcp/set_enabled`. + McpServerEnabled { + name: String, + enabled: bool, + servers: Vec, + }, + /// MCP enable/disable failed. + McpServerEnableFailed { name: String, message: String }, /// ACP-native available commands changed for the active session. AcpAvailableCommandsUpdated { /// Commands advertised through `session/update`. diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index d563371d..4db98ec1 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -5,7 +5,6 @@ use anyhow::Result; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use devo_core::AppConfigLoader; -use devo_core::AppConfigStore; use devo_core::FileSystemAppConfigLoader; use devo_protocol::Model; use devo_protocol::ModelCatalog; @@ -955,6 +954,8 @@ fn handle_worker_event( | WorkerEvent::SkillsListed { .. } | WorkerEvent::McpServersListed { .. } | WorkerEvent::McpToolsListed { .. } + | WorkerEvent::McpServerEnabled { .. } + | WorkerEvent::McpServerEnableFailed { .. } | WorkerEvent::AcpAvailableCommandsUpdated { .. } | WorkerEvent::AcpCurrentModeUpdated { .. } | WorkerEvent::AcpConfigOptionsUpdated { .. } @@ -1268,23 +1269,14 @@ fn handle_app_command( chat_widget.set_status_message(format!("Loading tools · {name}")); } AppCommand::SetMcpServerEnabled { name, enabled } => { - match find_devo_home() - .map_err(anyhow::Error::from) - .and_then(|home| { - let mut store = AppConfigStore::load(home, Some(context.cwd)) - .map_err(anyhow::Error::from)?; - store.set_mcp_server_enabled(name, *enabled)?; - Ok(()) - }) { + chat_widget.set_mcp_reopen_detail(Some(name.clone())); + match worker.set_mcp_server_enabled(name.clone(), *enabled) { Ok(()) => { let action = if *enabled { "enabled" } else { "disabled" }; - chat_widget.set_mcp_reopen_detail(Some(name.clone())); - chat_widget.set_status_message(format!( - "MCP `{name}` {action} in config (restart session for live runtime)" - )); - worker.list_mcp_servers()?; + chat_widget.set_status_message(format!("Updating MCP `{name}` · {action}")); } Err(error) => { + chat_widget.set_mcp_reopen_detail(None); chat_widget.add_to_history(crate::history_cell::new_error_event_with_hint( format!("Failed to update MCP server `{name}`: {error}"), Some("mcp enable/disable failed".to_string()), diff --git a/crates/tui/src/mcp_picker.rs b/crates/tui/src/mcp_picker.rs index 585ab24f..1708d760 100644 --- a/crates/tui/src/mcp_picker.rs +++ b/crates/tui/src/mcp_picker.rs @@ -148,6 +148,9 @@ fn list_status_label(server: &McpPickerServer) -> &str { /// Compact right-column meta for the server list (no command/URL). fn list_row_meta(server: &McpPickerServer) -> String { let status = list_status_label(server); + if !server.enabled { + return status.to_string(); + } match server.tool_count { Some(count) => format!("{status} · {} · {count} tools", server.transport_kind), None => format!("{status} · {}", server.transport_kind), @@ -215,9 +218,13 @@ fn detail_subtitle_lines(server: &McpPickerServer) -> Vec format!("{status} · {} · {count} tools", server.transport_kind), - None => format!("{status} · {}", server.transport_kind), + let summary = if !server.enabled { + status.to_string() + } else { + match server.tool_count { + Some(count) => format!("{status} · {} · {count} tools", server.transport_kind), + None => format!("{status} · {}", server.transport_kind), + } }; let mut lines = vec![Line::from(summary.dim())]; @@ -466,6 +473,26 @@ mod tests { ); } + #[test] + fn mcp_server_list_params_disabled_omits_transport_and_tool_count() { + let servers = vec![McpPickerServer { + id: "code_search".to_string(), + display_name: "Code Search".to_string(), + enabled: false, + transport_kind: "stdio".to_string(), + target: "devo-code-search-mcp".to_string(), + auth_summary: "none".to_string(), + capabilities: "tools".to_string(), + config_path: "/tmp/config.toml".to_string(), + status: Some("disabled".to_string()), + tool_count: Some(0), + }]; + let params = mcp_server_list_params(&servers); + assert_eq!(params.items[0].description.as_deref(), Some("disabled")); + let detail = mcp_server_detail_params(&servers[0]); + assert_eq!(detail.subtitle_lines[0].to_string(), "disabled"); + } + #[test] fn mcp_server_detail_params_exposes_tools_and_toggle() { let server = McpPickerServer { diff --git a/crates/tui/src/mcp_servers.rs b/crates/tui/src/mcp_servers.rs index 494ac91c..0216acc5 100644 --- a/crates/tui/src/mcp_servers.rs +++ b/crates/tui/src/mcp_servers.rs @@ -58,7 +58,10 @@ mod tests { #[test] fn render_mcp_servers_markdown_handles_empty_config() { assert_eq!( - render_mcp_servers_markdown(&McpConfig::default()), + render_mcp_servers_markdown(&McpConfig { + servers: Vec::new(), + auto_start: true, + }), "_No MCP servers configured._" ); } @@ -120,7 +123,6 @@ mod tests { }, ], auto_start: true, - refresh_on_config_reload: true, }); assert!(body.contains("`docs` - Docs")); diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index a13a1cfa..e3214994 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -294,6 +294,11 @@ enum OperationCommand { path: PathBuf, enabled: bool, }, + /// Persistently enable or disable one MCP server and apply it live. + SetMcpServerEnabled { + name: String, + enabled: bool, + }, /// Request proactive compaction for the active session. CompactSession, /// Show the current goal for the active session. @@ -609,6 +614,12 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } + pub(crate) fn set_mcp_server_enabled(&self, name: String, enabled: bool) -> Result<()> { + self.command_tx + .send(OperationCommand::SetMcpServerEnabled { name, enabled }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + /// Requests proactive compaction for the current active session. pub(crate) fn compact_session(&self) -> Result<()> { self.command_tx @@ -1282,18 +1293,17 @@ async fn run_worker_inner( } } Some(OperationCommand::ListMcpServers) => { - if let Err(error) = emit_mcp_servers_list(&mut client, event_tx).await + if let Err(error) = + emit_mcp_servers_list(&mut client, event_tx).await { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, + // Still open the picker from config so a single + // broken/runtime-stuck MCP server cannot blank /mcps. + tracing::warn!( + error = %error, + "mcp/list failed; opening /mcps from config only" + ); + let _ = event_tx.send(WorkerEvent::McpServersListed { + servers: Vec::new(), }); } } @@ -1616,6 +1626,31 @@ async fn run_worker_inner( } } } + Some(OperationCommand::SetMcpServerEnabled { name, enabled }) => { + match client + .mcp_set_enabled( + devo_protocol::canonical::rpc_admin::McpSetEnabledParams { + name: name.clone(), + enabled, + }, + ) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::McpServerEnabled { + name, + enabled, + servers: result.servers, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::McpServerEnableFailed { + name, + message: error.to_string(), + }); + } + } + } Some(OperationCommand::StartNewSession) => { if let Some(active_session_id) = session_id { match pause_active_goal_before_session_leave( diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index 8d2ad7ee..bef06ad4 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -219,7 +219,6 @@ stdio の例: ```toml [mcp] auto_start = true -refresh_on_config_reload = true [[mcp.servers]] id = "filesystem" @@ -264,7 +263,7 @@ url = "https://example.com/mcp/sse" フィールドの説明: -- `auto_start` と `refresh_on_config_reload` は既定で `true` です。 +- `auto_start` は既定で `true` です。実行中セッションでの MCP 有効化/無効化は `mcp/set_enabled`(TUI `/mcps`)で即時反映されます。 - `startup_policy` は有効なサーバーの起動タイミングを制御します: `eager` は ブートストラップ時、`lazy` は初回利用時、`manual` は明示的な要求のみです。 - stdio では `env` がリテラル値を渡し、`env_vars` がローカル環境から継承する @@ -286,6 +285,8 @@ url = "https://example.com/mcp/sse" `servers` は配列です。したがって、workspace の `[[mcp.servers]]` リストは ユーザーレベルのリストを `id` 単位でマージせず置き換えます。 -TUI の `/mcps` で対話的に確認できます(一覧 → 詳細 → ツール。Enable/Disable は設定のみ更新し、実行時反映にはセッション再起動が必要な場合があります)。クライアントは `mcp/list` / `mcp/tools` RPC も利用できます。ユーザー設定 (`~/.devo/config.toml`) は -`devo mcp add|list|remove|enable|disable` でも管理できます(`--transport stdio|http|sse`)。 -クライアントは `mcp/list` RPC でランタイム状態を取得できます。 +TUI の `/mcps` で対話的に確認できます(一覧 → 詳細 → ツール。Enable/Disable は +設定を保存し、`mcp/set_enabled` で次ターンからライブ適用します)。クライアントは +`mcp/list` / `mcp/tools` / `mcp/set_enabled` RPC も利用できます。ユーザー設定 +(`~/.devo/config.toml`) は `devo mcp add|list|remove|enable|disable` でも管理できます +(`--transport stdio|http|sse`)。 diff --git a/docs/configuration.md b/docs/configuration.md index a53b24c3..9c599a92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -246,7 +246,6 @@ Stdio example: ```toml [mcp] auto_start = true -refresh_on_config_reload = true [[mcp.servers]] id = "filesystem" @@ -291,7 +290,7 @@ url = "https://example.com/mcp/sse" Field notes: -- `auto_start` and `refresh_on_config_reload` default to `true`. +- `auto_start` defaults to `true`. Enable/disable of MCP servers in a running session is applied live via `mcp/set_enabled` (TUI `/mcps`). - `startup_policy` controls when an enabled server starts: `eager` during bootstrap, `lazy` on first use, or `manual` only by explicit request. - For stdio, `env` provides literal values and `env_vars` lists names inherited @@ -336,11 +335,12 @@ devo mcp disable time devo mcp remove time ``` -CLI changes apply on the next Devo start (or config reload). They do not hot-reload -an already-running interactive session. +CLI `devo mcp enable|disable` writes user `config.toml` for offline use. An +already-running interactive session applies enable/disable live through the TUI +`/mcps` path (`mcp/set_enabled` RPC). Verify configuration in the TUI with `/mcps` (interactive server list → detail → -tools; Enable/Disable writes config only and may need a session restart for live -runtime). Clients can also call the `mcp/list` / `mcp/tools` RPCs for runtime -status and tool catalogs from the shared MCP manager. Use `devo mcp add|list|remove|enable|disable` -for CLI management. +tools; Enable/Disable persists config and applies the manager + tool registry +for the next turn). Clients can also call `mcp/list`, `mcp/tools`, and +`mcp/set_enabled`. Use `devo mcp add|list|remove|enable|disable` for CLI +management. diff --git a/docs/configuration.ru.md b/docs/configuration.ru.md index ac3fc716..229d36c9 100644 --- a/docs/configuration.ru.md +++ b/docs/configuration.ru.md @@ -227,7 +227,6 @@ Devo подключается к серверам [Model Context Protocol](https ```toml [mcp] auto_start = true -refresh_on_config_reload = true [[mcp.servers]] id = "filesystem" @@ -272,7 +271,7 @@ url = "https://example.com/mcp/sse" Примечания к полям: -- `auto_start` и `refresh_on_config_reload` по умолчанию равны `true`. +- `auto_start` по умолчанию равен `true`. Включение/отключение MCP в активной сессии применяется через `mcp/set_enabled` (TUI `/mcps`). - `startup_policy` управляет запуском включенного сервера: `eager` - при bootstrap, `lazy` - при первом использовании, `manual` - только по явному запросу. @@ -296,7 +295,9 @@ url = "https://example.com/mcp/sse" `servers` - это массив. Поэтому список `[[mcp.servers]]` уровня проекта заменяет пользовательский список целиком, а не сливает по `id`. -Проверить конфигурацию можно в TUI командой `/mcps` (интерактивный список → детали → инструменты; Enable/Disable только пишет конфиг, для runtime может понадобиться перезапуск сессии). Пользовательский -`~/.devo/config.toml` также можно менять через -`devo mcp add|list|remove|enable|disable` (`--transport stdio|http|sse`). Клиенты -могут вызывать RPC `mcp/list` для runtime-статуса. +Проверить конфигурацию можно в TUI командой `/mcps` (интерактивный список → +детали → инструменты; Enable/Disable сохраняет конфиг и применяет +`mcp/set_enabled` для следующего хода). Пользовательский `~/.devo/config.toml` +также можно менять через `devo mcp add|list|remove|enable|disable` +(`--transport stdio|http|sse`). Клиенты могут вызывать RPC `mcp/list`, +`mcp/tools` и `mcp/set_enabled`. diff --git a/docs/configuration.zh-Hans.md b/docs/configuration.zh-Hans.md index 684af6d1..8929c68e 100644 --- a/docs/configuration.zh-Hans.md +++ b/docs/configuration.zh-Hans.md @@ -210,7 +210,6 @@ stdio 示例: ```toml [mcp] auto_start = true -refresh_on_config_reload = true [[mcp.servers]] id = "filesystem" @@ -255,7 +254,7 @@ url = "https://example.com/mcp/sse" 字段说明: -- `auto_start` 与 `refresh_on_config_reload` 默认均为 `true`。 +- `auto_start` 默认为 `true`。运行中会话的 MCP 启用/禁用通过 `mcp/set_enabled`(TUI `/mcps`)即时生效。 - `startup_policy` 控制已启用服务器的启动时机:`eager` 在启动阶段启动,`lazy` 首次使用时启动,`manual` 仅按显式请求启动。 - stdio 下,`env` 提供字面量值,`env_vars` 列出从本地环境继承的变量名; @@ -294,6 +293,10 @@ devo mcp disable time devo mcp remove time ``` -CLI 修改在下次启动 Devo(或配置重载)后生效,不会热更新已在运行的交互会话。 +CLI `devo mcp enable|disable` 会写入用户 `config.toml`(离线配置)。已在运行的 +交互会话通过 TUI `/mcps`(`mcp/set_enabled` RPC)即时启用/禁用。 -可在 TUI 中用 `/mcps`(交互式列表 → 详情 → 工具;Enable/Disable 仅写配置,运行时可能需重启会话)验证配置。客户端也可调用 `mcp/list` / `mcp/tools` RPC。也可用 `devo mcp add|list|remove|enable|disable` 管理用户级配置。 +可在 TUI 中用 `/mcps`(交互式列表 → 详情 → 工具;Enable/Disable 会持久化配置并为 +下一回合应用管理器与工具注册表)验证配置。客户端也可调用 `mcp/list`、 +`mcp/tools`、`mcp/set_enabled`。也可用 `devo mcp add|list|remove|enable|disable` +管理用户级配置。 diff --git a/docs/configuration.zh-Hant.md b/docs/configuration.zh-Hant.md index e5fbe466..d713fcd8 100644 --- a/docs/configuration.zh-Hant.md +++ b/docs/configuration.zh-Hant.md @@ -210,7 +210,6 @@ stdio 範例: ```toml [mcp] auto_start = true -refresh_on_config_reload = true [[mcp.servers]] id = "filesystem" @@ -255,7 +254,7 @@ url = "https://example.com/mcp/sse" 欄位說明: -- `auto_start` 與 `refresh_on_config_reload` 預設均為 `true`。 +- `auto_start` 預設為 `true`。執行中工作階段的 MCP 啟用/停用會透過 `mcp/set_enabled`(TUI `/mcps`)即時套用。 - `startup_policy` 控制已啟用伺服器的啟動時機:`eager` 在啟動階段啟動,`lazy` 首次使用時啟動,`manual` 僅依明確請求啟動。 - stdio 下,`env` 提供字面值,`env_vars` 列出從本機環境繼承的變數名稱; @@ -274,6 +273,8 @@ url = "https://example.com/mcp/sse" 合併行為:`[mcp]` 與其他表一樣依欄位合併,但 `servers` 是陣列。專案級的 `[[mcp.servers]]` 列表會整體取代使用者級列表,而不是依 `id` 合併。 -可在 TUI 中用 `/mcps`(互動式清單 → 詳情 → 工具;Enable/Disable 僅寫設定,執行階段可能需重啟工作階段)驗證配置。也可用 `devo mcp add|list|remove|enable|disable` -管理使用者級 `~/.devo/config.toml`(支援 `--transport stdio|http|sse`)。客戶端可呼叫 -`mcp/list` RPC 取得執行時狀態。 +可在 TUI 中用 `/mcps`(互動式清單 → 詳情 → 工具;Enable/Disable 會寫入設定並透過 +`mcp/set_enabled` 即時套用到下一個回合)驗證配置。也可用 +`devo mcp add|list|remove|enable|disable` 管理使用者級 `~/.devo/config.toml` +(支援 `--transport stdio|http|sse`)。客戶端可呼叫 `mcp/list`、`mcp/tools`、 +`mcp/set_enabled` RPC。 diff --git a/specs/L2/mcp/L2-DES-MCP-001-mcp-integration-architecture.md b/specs/L2/mcp/L2-DES-MCP-001-mcp-integration-architecture.md index 0e1a29cd..d0251d79 100644 --- a/specs/L2/mcp/L2-DES-MCP-001-mcp-integration-architecture.md +++ b/specs/L2/mcp/L2-DES-MCP-001-mcp-integration-architecture.md @@ -271,15 +271,16 @@ Client projections should include: Representative protocol surfaces may include: -- `mcp.listServers` -- `mcp.refreshServer` -- `mcp.startServer` -- `mcp.stopServer` -- `mcp.listCapabilities` +- `mcp/list` +- `mcp/tools` +- `mcp/set_enabled` (persist config and apply per-server start/stop live; rebuilds the tool registry Arc so the next turn sees catalog changes) +- `mcp.refreshServer` / `mcp.startServer` / `mcp.stopServer` (optional aliases later; `mcp/set_enabled` is the user-facing compose) - `mcp.readResource` These protocol surfaces are client/server methods. Model-requested MCP tool execution still flows through the tool supervisor. +Enable/disable must be host-owned: clients call `mcp/set_enabled` rather than writing `config.toml` and expecting a silent runtime apply. In-flight turns keep their existing registry Arc; subsequent turns use the swapped registry. + ## Error Handling MCP errors should be normalized into stable categories: diff --git a/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md b/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md index 7f3b975c..39735e71 100644 --- a/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md +++ b/specs/L2/mcp/L2-DES-MCP-002-bundled-code-search-mcp.md @@ -21,12 +21,22 @@ Semantic code retrieval is provided by the bundled stdio MCP binary fallback cwd when the server record omits `cwd`) - Binary is shipped next to `devo` in CLI archives, install scripts, and desktop runtime `bin/` +- Startup must accept MCP `initialize` / `tools/list` before index prewarm; + prewarm runs in the background after the stdio server is serving ## Enablement Users enable with `devo mcp enable code_search` or TUI `/mcps`. Config load ensure-by-id inserts the bundled record when missing and never overwrites a -user record with the same id. +user record with the same id. Enable/disable materializes the bundled record +into user `config.toml` when it was only present in the effective in-memory +config. + +In a running Devo session, TUI `/mcps` Enable/Disable calls `mcp/set_enabled`, +which persists config, starts or stops only that MCP server, and swaps the live +tool registry so the next turn can see `mcp__code_search__code_search` without +restarting the process. Offline CLI `devo mcp enable|disable` writes config for +the next process start (or for a later in-session `mcp/set_enabled` apply). ## Traceability