diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 8e95b003..ab709536 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -14,7 +14,10 @@ use domain::ports::{ ToolSpec, }; use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; -use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider}; +use infrastructure::{ + FsWorkspaceToolProvider, ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider, + WorkspaceToolProvider, +}; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; @@ -29,6 +32,7 @@ pub struct AppOpenAiToolInvoker { ticket_tools: Arc, template_tools: Arc, managed_temp_tools: Arc, + workspace_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -112,6 +116,7 @@ impl AppOpenAiToolInvoker { ticket_tools, template_tools, managed_temp_tools, + workspace_tools: Arc::new(FsWorkspaceToolProvider::new()), } } } @@ -232,6 +237,24 @@ impl ToolInvoker for AppOpenAiToolInvoker { ToolInvocationError::Execution(format!("JSON managed temp tool: {e}")) }); } + if infrastructure::orchestrator::mcp::tools::is_workspace_tool(name) { + let mut workspace_arguments = value; + if let Some(arguments) = workspace_arguments.as_object_mut() { + arguments.remove(PROJECT_ROOT_ARG); + arguments.remove(REQUESTER_ARG); + } + let value = self + .workspace_tools + .handle_workspace_tool(&project, &requester, name, workspace_arguments) + .await + .map_err(|e| { + let detail = + serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); + ToolInvocationError::Execution(detail) + })?; + return serde_json::to_string(&value) + .map_err(|e| ToolInvocationError::Execution(format!("JSON workspace tool: {e}"))); + } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 0e842f2d..1e12e4dd 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -111,16 +111,17 @@ use infrastructure::{ FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, - ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, - PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, - TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, + IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, + LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, + ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, + SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, + VectorMemoryRecall, WorkspaceToolProvider, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -2184,6 +2185,8 @@ pub struct BackendCore { pub(crate) managed_temp_tool_provider: Arc, /// MCP provider for public plugin administration tools. pub(crate) plugin_tool_provider: Arc, + /// MCP provider for the read-only `idea_workspace_*` tools. + pub(crate) workspace_tool_provider: Arc, /// Open an ephemeral AI assistant chat bound to one ticket. pub open_ticket_assistant: Arc, /// Close an ephemeral AI assistant chat bound to one ticket. @@ -3112,6 +3115,8 @@ impl BackendCore { let managed_temp_tool_binder = Arc::new(LateBoundManagedTempToolProvider::new()); let managed_temp_tool_provider: Arc = managed_temp_tool_binder.clone(); + let workspace_tool_provider: Arc = + Arc::new(FsWorkspaceToolProvider::new()); let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port))); @@ -4296,16 +4301,19 @@ impl BackendCore { .with_spawn_background_command(Arc::clone(&spawn_background_command)) .with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)), ); - openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( - Arc::clone(&orchestrator_service), - Arc::clone(&store_port), - Arc::clone(&tool_policy_registry) as Arc, - Arc::clone(&mcp_tool_permission_store_port), - Arc::clone(&ticket_tool_provider), - Arc::clone(&template_tool_provider), - Arc::clone(&managed_temp_tool_provider), - Arc::clone(&plugin_tool_provider), - )) as Arc); + openai_tool_invoker.bind(Arc::new( + AppOpenAiToolInvoker::new( + Arc::clone(&orchestrator_service), + Arc::clone(&store_port), + Arc::clone(&tool_policy_registry) as Arc, + Arc::clone(&mcp_tool_permission_store_port), + Arc::clone(&ticket_tool_provider), + Arc::clone(&template_tool_provider), + Arc::clone(&managed_temp_tool_provider), + Arc::clone(&plugin_tool_provider), + ) + .with_workspace_tools(Arc::clone(&workspace_tool_provider)), + ) as Arc); let stop_live_agent = Arc::new( StopAgentRuntime::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) @@ -4444,6 +4452,7 @@ impl BackendCore { ticket_tool_provider, template_tool_provider, managed_temp_tool_provider, + workspace_tool_provider, open_ticket_assistant, close_ticket_assistant, tool_policy_registry, @@ -4793,6 +4802,7 @@ impl BackendCore { .with_template_tools(Arc::clone(&self.template_tool_provider)) .with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider)) .with_plugin_tools(Arc::clone(&self.plugin_tool_provider)) + .with_workspace_tools(Arc::clone(&self.workspace_tool_provider)) .with_tool_policies(Arc::clone(&self.tool_policy_registry)) .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), endpoint, diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 865e2d2d..fde14870 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -1,7 +1,8 @@ //! ToolInvoker for the OpenAI-compatible adapter. //! //! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que -//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même +//! le serveur MCP : même catalogue et même policy. Les outils spécialisés +//! utilisent leurs providers dédiés ; les commandes d'orchestration passent par //! `OrchestratorService::dispatch`. use std::str::FromStr; @@ -15,7 +16,10 @@ use domain::ports::{ }; use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::orchestrator::mcp::PluginToolProvider; -use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider}; +use infrastructure::{ + FsWorkspaceToolProvider, ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider, + WorkspaceToolProvider, +}; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; @@ -31,6 +35,7 @@ pub struct AppOpenAiToolInvoker { template_tools: Arc, managed_temp_tools: Arc, plugin_tools: Arc, + workspace_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -116,8 +121,16 @@ impl AppOpenAiToolInvoker { template_tools, managed_temp_tools, plugin_tools, + workspace_tools: Arc::new(FsWorkspaceToolProvider::new()), } } + + /// Replaces the default filesystem workspace adapter at the composition root. + #[must_use] + pub fn with_workspace_tools(mut self, workspace_tools: Arc) -> Self { + self.workspace_tools = workspace_tools; + self + } } #[async_trait] @@ -249,6 +262,24 @@ impl ToolInvoker for AppOpenAiToolInvoker { return serde_json::to_string(&value) .map_err(|e| ToolInvocationError::Execution(format!("JSON plugin tool: {e}"))); } + if infrastructure::orchestrator::mcp::tools::is_workspace_tool(name) { + let mut workspace_arguments = value; + if let Some(arguments) = workspace_arguments.as_object_mut() { + arguments.remove(PROJECT_ROOT_ARG); + arguments.remove(REQUESTER_ARG); + } + let value = self + .workspace_tools + .handle_workspace_tool(&project, &requester, name, workspace_arguments) + .await + .map_err(|e| { + let detail = + serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); + ToolInvocationError::Execution(detail) + })?; + return serde_json::to_string(&value) + .map_err(|e| ToolInvocationError::Execution(format!("JSON workspace tool: {e}"))); + } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { @@ -961,6 +992,71 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + #[tokio::test] + async fn openai_workspace_tools_are_listed_and_use_the_workspace_provider() { + let temp = + std::env::temp_dir().join(format!("idea-openai-workspace-tools-{}", Uuid::new_v4())); + std::fs::create_dir_all(temp.join("src")).unwrap(); + std::fs::write(temp.join("src/lib.rs"), "pub fn workspace_marker() {}\n").unwrap(); + let root = temp.to_string_lossy().into_owned(); + let workspace_project = Project::new( + ProjectId::from_uuid(Uuid::from_u128(341)), + "workspace-tools", + ProjectPath::new(root.clone()).unwrap(), + RemoteRef::local(), + 1_000, + ) + .unwrap(); + let core = crate::BackendCore::build(temp.join("state")); + let requester = AgentId::from_uuid(Uuid::from_u128(341)).to_string(); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(workspace_project)) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(ProjectMcpToolPermissions::default()), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + fake_managed_temp_tools(), + fake_plugin_tools(), + ); + + let tools = invoker + .tools_for_context(&root, &requester) + .await + .expect("default read-only catalogue"); + let names = tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + for name in [ + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", + ] { + assert!(names.contains(&name), "missing {name}"); + } + + let result = invoker + .call( + "idea_workspace_search", + &json!({ + PROJECT_ROOT_ARG: root, + REQUESTER_ARG: requester, + "query": "workspace_marker", + "path": "src", + }) + .to_string(), + ) + .await + .expect("workspace search should use its dedicated provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["matches"][0]["path"], "src/lib.rs"); + assert_eq!(result["matches"][0]["line"], 1); + + let _ = std::fs::remove_dir_all(temp); + } + #[tokio::test] async fn openai_tools_list_matches_durable_agent_policy() { let temp = diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index f9241099..a434b0fc 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -85,9 +85,9 @@ pub use model_server::{ LocalManagedProcess, }; pub use orchestrator::mcp::{ - ManagedTempToolError, ManagedTempToolProvider, McpServer, MemoryTransport, StdioTransport, - TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider, - ToolPolicyRegistry, + FsWorkspaceToolProvider, ManagedTempToolError, ManagedTempToolProvider, McpServer, + MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider, TicketToolError, + TicketToolProvider, ToolPolicyRegistry, WorkspaceToolError, WorkspaceToolProvider, }; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index d88e25a3..3d13942c 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -37,6 +37,7 @@ pub mod templates; pub mod tickets; pub mod tools; pub mod transport; +pub mod workspace; pub use jsonrpc::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, @@ -49,3 +50,4 @@ pub use templates::{TemplateToolError, TemplateToolProvider}; pub use tickets::{TicketToolError, TicketToolProvider}; pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError}; pub use transport::{MemoryTransport, StdioTransport}; +pub use workspace::{FsWorkspaceToolProvider, WorkspaceToolError, WorkspaceToolProvider}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 8a06a180..1f6690ee 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -1,15 +1,14 @@ //! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4). //! //! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**: -//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the -//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build -//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It -//! invents **no semantics** and **never re-routes** replies. +//! another entry door onto the *same* [`OrchestratorService::dispatch`] for +//! orchestration commands. Capability-specific tools are handled by injected +//! providers without being disguised as [`OrchestratorCommand`] variants. //! //! It implements the strict MCP minimum over JSON-RPC 2.0: //! - `initialize` → advertise protocol version + tool capability, //! - `tools/list` → the [`tools::catalogue`], -//! - `tools/call` → map → `dispatch` → MCP tool result, +//! - `tools/call` → dedicated provider or orchestration dispatch → MCP tool result, //! - notifications (e.g. `notifications/initialized`) → accepted, no reply. //! //! It receives `Arc` and the target [`Project`] by injection @@ -40,13 +39,14 @@ use super::policy::ToolPolicyRegistry; use super::templates::TemplateToolProvider; use super::tickets::TicketToolProvider; use super::tools::{self, ToolMapError}; +use super::workspace::WorkspaceToolProvider; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; /// Compact MCP instructions advertised at initialization. The first 512 chars are /// intentionally self-contained because some clients truncate this field. -const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; +const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: explore project files -> idea_workspace_*; ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// @@ -87,6 +87,8 @@ pub struct McpServer { managed_temp_tools: Option>, /// Optional public plugin administration provider. plugin_tools: Option>, + /// Optional read-only project workspace provider. + workspace_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, /// Optional durable MCP tool permission store. When wired, absence of project or @@ -110,6 +112,7 @@ impl McpServer { template_tools: None, managed_temp_tools: None, plugin_tools: None, + workspace_tools: None, tool_policies: None, mcp_tool_permissions: None, } @@ -167,6 +170,13 @@ impl McpServer { self } + /// Attaches the read-only project workspace provider. + #[must_use] + pub fn with_workspace_tools(mut self, workspace_tools: Arc) -> Self { + self.workspace_tools = Some(workspace_tools); + self + } + /// Attaches the requester-scoped MCP tool policy registry. #[must_use] pub fn with_tool_policies(mut self, tool_policies: Arc) -> Self { @@ -204,6 +214,7 @@ impl McpServer { template_tools: self.template_tools.clone(), managed_temp_tools: self.managed_temp_tools.clone(), plugin_tools: self.plugin_tools.clone(), + workspace_tools: self.workspace_tools.clone(), tool_policies: self.tool_policies.clone(), mcp_tool_permissions: self.mcp_tool_permissions.clone(), } @@ -454,9 +465,8 @@ impl McpServer { )) } - /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and - /// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP - /// tool result. + /// `tools/call`: route provider-backed tools directly, or map orchestration + /// tools to an [`OrchestratorCommand`] and dispatch them. async fn tools_call(&self, params: Value) -> Result { let name = params .get("name") @@ -682,6 +692,32 @@ impl McpServer { }; } + if tools::is_workspace_tool(&name) { + let result = match &self.workspace_tools { + Some(provider) => { + provider + .handle_workspace_tool(&self.project, &self.requester, &name, arguments) + .await + } + None => Err(super::workspace::WorkspaceToolError::new( + "notConfigured", + "workspace tools are not configured", + )), + }; + self.publish_processed(&name, result.is_ok()); + return match result { + Ok(value) => { + let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned()); + Ok(tool_result_text(&text, false)) + } + Err(err) => { + let text = + serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string()); + Ok(tool_result_text(&text, true)) + } + }; + } + // The handshake-provided requester is still passed to the mapper for tools that // need peer identity. let command = match tools::map_tool_call(&name, &arguments, &self.requester) { diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 02690f42..14122413 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -1,12 +1,10 @@ -//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping. +//! The IdeA MCP **tool catalogue** and the tool dispatch classification. //! -//! Each tool here is a thin, typed face over an **already-existing** -//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur -//! MCP n'invente aucune sémantique »). The mapping builds an -//! [`OrchestratorRequest`] from the tool arguments and lets -//! [`OrchestratorRequest::validate`] be the **single source of truth** for -//! validation — the very same path the filesystem watcher takes. No tool -//! validates anything itself; no tool reaches a use case directly. +//! Orchestration tools remain thin, typed faces over an **already-existing** +//! [`OrchestratorCommand`]. Capability-specific tools (tickets, templates, +//! plugins, managed temporary storage, and the project workspace) are routed to +//! their dedicated providers by the server. The catalogue itself contains no +//! filesystem or application logic. //! //! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`) //! @@ -63,6 +61,10 @@ pub const READ_ONLY_TOOLS: &[&str] = &[ "idea_managed_temp_resolve", "idea_scheduled_task_list", "idea_scheduled_task_read", + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", ]; /// Canonical write/action MCP tools denied by default. @@ -172,6 +174,10 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_scheduled_task_delete" | "idea_scheduled_task_run_now" | "idea_scheduled_task_set_enabled" + | "idea_workspace_map" + | "idea_workspace_list" + | "idea_workspace_read" + | "idea_workspace_search" ) || is_ticket_tool(tool) || is_template_tool(tool) || is_managed_temp_tool(tool) @@ -202,6 +208,12 @@ pub fn is_plugin_tool(tool: &str) -> bool { super::plugins::is_plugin_tool(tool) } +/// Whether `tool` is a public read-only workspace tool. +#[must_use] +pub fn is_workspace_tool(tool: &str) -> bool { + super::workspace::is_workspace_tool(tool) +} + /// The full catalogue advertised on `tools/list`. /// /// Exactly the tools whose mapping target already exists as an @@ -572,6 +584,7 @@ pub fn catalogue() -> Vec { tools.extend(super::templates::catalogue()); tools.extend(super::managed_temp::catalogue()); tools.extend(super::plugins::catalogue()); + tools.extend(super::workspace::catalogue()); tools } @@ -953,6 +966,22 @@ mod tests { ); } + #[test] + fn workspace_tools_are_read_only_and_return_inline_results() { + for tool in [ + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", + ] { + assert_eq!(tool_access(tool), Some(McpToolAccess::Read)); + assert!(tool_returns_reply(tool)); + assert!(catalogue() + .into_iter() + .any(|definition| definition.name == tool)); + } + } + #[test] fn catalogue_tools_have_explicit_read_or_write_access() { let catalogue_names = catalogue() diff --git a/crates/infrastructure/src/orchestrator/mcp/workspace.rs b/crates/infrastructure/src/orchestrator/mcp/workspace.rs new file mode 100644 index 00000000..6c6cbf52 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/workspace.rs @@ -0,0 +1,980 @@ +//! Read-only MCP tools for exploring the current project workspace. + +use std::cmp::Ordering; +use std::fs; +use std::io::{self, Read}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use domain::Project; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::tools::ToolDef; + +const MAP_DEFAULT_DEPTH: usize = 2; +const MAP_MAX_DEPTH: usize = 4; +const MAP_DEFAULT_ENTRIES: usize = 300; +const MAP_MAX_ENTRIES: usize = 1_500; +const LIST_DEFAULT_DEPTH: usize = 1; +const LIST_MAX_DEPTH: usize = 3; +const LIST_DEFAULT_ENTRIES: usize = 200; +const LIST_MAX_ENTRIES: usize = 1_000; +const READ_DEFAULT_BYTES: usize = 200_000; +const READ_MAX_BYTES: usize = 1_000_000; +const SEARCH_DEFAULT_RESULTS: usize = 100; +const SEARCH_MAX_RESULTS: usize = 500; +const SEARCH_MAX_FILE_BYTES: u64 = 1_000_000; +const SEARCH_MAX_TOTAL_BYTES: u64 = 32_000_000; +const SEARCH_MAX_FILES: usize = 10_000; +const SAMPLE_BYTES: usize = 512; +const SAMPLE_CHARS: usize = 200; + +/// Error returned by a read-only workspace tool. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{code}: {message}")] +pub struct WorkspaceToolError { + /// Stable machine-readable code. + pub code: &'static str, + /// Human-readable message that never exposes a host-absolute path. + pub message: String, +} + +impl WorkspaceToolError { + pub(crate) fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// Serialises the error as a camelCase JSON object. + #[must_use] + pub fn to_value(&self) -> Value { + json!({ "code": self.code, "message": self.message }) + } +} + +/// Provider seam used by both MCP and OpenAI-compatible tool dispatch. +#[async_trait] +pub trait WorkspaceToolProvider: Send + Sync { + /// Executes one workspace tool in the supplied project. + async fn handle_workspace_tool( + &self, + project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result; +} + +/// Filesystem-backed, read-only workspace provider. +#[derive(Debug, Default)] +pub struct FsWorkspaceToolProvider; + +impl FsWorkspaceToolProvider { + /// Creates the provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl WorkspaceToolProvider for FsWorkspaceToolProvider { + async fn handle_workspace_tool( + &self, + project: &Project, + _requester: &str, + name: &str, + arguments: Value, + ) -> Result { + let project = project.clone(); + let name = name.to_owned(); + tokio::task::spawn_blocking(move || match name.as_str() { + "idea_workspace_map" => workspace_map(&project, parse(arguments)?), + "idea_workspace_list" => workspace_list(&project, parse(arguments)?), + "idea_workspace_read" => workspace_read(&project, parse(arguments)?), + "idea_workspace_search" => workspace_search(&project, parse(arguments)?), + _ => Err(WorkspaceToolError::new( + "notFound", + format!("unknown workspace tool `{name}`"), + )), + }) + .await + .map_err(|_| { + WorkspaceToolError::new("readFailed", "workspace operation could not complete") + })? + } +} + +/// Returns true when `name` is a public workspace tool. +#[must_use] +pub fn is_workspace_tool(name: &str) -> bool { + matches!( + name, + "idea_workspace_map" + | "idea_workspace_list" + | "idea_workspace_read" + | "idea_workspace_search" + ) +} + +/// Public workspace tool definitions advertised by `tools/list`. +#[must_use] +pub fn catalogue() -> Vec { + vec![ + ToolDef { + name: "idea_workspace_map", + description: "Build a bounded structural map of the project workspace, including key folders, notable files, and optional short text samples.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "default": "." }, + "depth": { "type": "integer", "minimum": 0, "maximum": MAP_MAX_DEPTH, "default": MAP_DEFAULT_DEPTH }, + "includeHidden": { "type": "boolean", "default": false }, + "maxEntries": { "type": "integer", "minimum": 1, "maximum": MAP_MAX_ENTRIES, "default": MAP_DEFAULT_ENTRIES }, + "includeSamples": { "type": "boolean", "default": true } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_workspace_list", + description: "List files, folders, and other elements in the project workspace with bounded depth and result count.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "default": "." }, + "depth": { "type": "integer", "minimum": 0, "maximum": LIST_MAX_DEPTH, "default": LIST_DEFAULT_DEPTH }, + "includeHidden": { "type": "boolean", "default": false }, + "limit": { "type": "integer", "minimum": 1, "maximum": LIST_MAX_ENTRIES, "default": LIST_DEFAULT_ENTRIES } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_workspace_read", + description: "Read bounded UTF-8 text content from one file in the project workspace.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "minLength": 1 }, + "maxBytes": { "type": "integer", "minimum": 1, "maximum": READ_MAX_BYTES, "default": READ_DEFAULT_BYTES } + }, + "required": ["path"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_workspace_search", + description: "Search text in project workspace files and return bounded line matches with short previews.", + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "default": "." }, + "maxResults": { "type": "integer", "minimum": 1, "maximum": SEARCH_MAX_RESULTS, "default": SEARCH_DEFAULT_RESULTS }, + "includeHidden": { "type": "boolean", "default": false } + }, + "required": ["query"], + "additionalProperties": false + }), + }, + ] +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MapArgs { + #[serde(default = "default_path")] + path: String, + #[serde(default = "default_map_depth")] + depth: usize, + #[serde(default)] + include_hidden: bool, + #[serde(default = "default_map_entries")] + max_entries: usize, + #[serde(default = "default_true")] + include_samples: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ListArgs { + #[serde(default = "default_path")] + path: String, + #[serde(default = "default_list_depth")] + depth: usize, + #[serde(default)] + include_hidden: bool, + #[serde(default = "default_list_entries")] + limit: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReadArgs { + path: String, + #[serde(default = "default_read_bytes")] + max_bytes: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SearchArgs { + query: String, + #[serde(default = "default_path")] + path: String, + #[serde(default = "default_search_results")] + max_results: usize, + #[serde(default)] + include_hidden: bool, +} + +fn default_path() -> String { + ".".to_owned() +} +fn default_map_depth() -> usize { + MAP_DEFAULT_DEPTH +} +fn default_map_entries() -> usize { + MAP_DEFAULT_ENTRIES +} +fn default_list_depth() -> usize { + LIST_DEFAULT_DEPTH +} +fn default_list_entries() -> usize { + LIST_DEFAULT_ENTRIES +} +fn default_read_bytes() -> usize { + READ_DEFAULT_BYTES +} +fn default_search_results() -> usize { + SEARCH_DEFAULT_RESULTS +} +fn default_true() -> bool { + true +} + +fn parse Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value).map_err(|error| { + WorkspaceToolError::new( + "invalidArguments", + format!("invalid workspace tool arguments: {error}"), + ) + }) +} + +fn validate_range( + value: usize, + min: usize, + max: usize, + field: &str, +) -> Result<(), WorkspaceToolError> { + if (min..=max).contains(&value) { + Ok(()) + } else { + Err(WorkspaceToolError::new( + "invalidArguments", + format!("`{field}` must be between {min} and {max}"), + )) + } +} + +struct ResolvedPath { + root: PathBuf, + target: PathBuf, + relative: String, +} + +fn resolve(project: &Project, raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() || raw.contains('\0') || looks_absolute_any_platform(raw) { + return Err(WorkspaceToolError::new( + "pathNotAllowed", + "path must be relative to the project workspace", + )); + } + let supplied = Path::new(raw); + if supplied.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) || raw.split(['/', '\\']).any(|component| component == "..") + { + return Err(WorkspaceToolError::new( + "pathNotAllowed", + "path must stay within the project workspace", + )); + } + + let root = PathBuf::from(project.root.as_str()) + .canonicalize() + .map_err(|_| { + WorkspaceToolError::new("workspaceUnavailable", "project workspace is unavailable") + })?; + let target = root + .join(supplied) + .canonicalize() + .map_err(|error| io_error(error, raw))?; + if !target.starts_with(&root) { + return Err(WorkspaceToolError::new( + "pathNotAllowed", + "path must stay within the project workspace", + )); + } + let relative = relative_label(&root, &target)?; + Ok(ResolvedPath { + root, + target, + relative, + }) +} + +fn looks_absolute_any_platform(raw: &str) -> bool { + let bytes = raw.as_bytes(); + Path::new(raw).is_absolute() + || raw.starts_with('/') + || raw.starts_with('\\') + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) +} + +fn relative_label(root: &Path, path: &Path) -> Result { + let relative = path.strip_prefix(root).map_err(|_| { + WorkspaceToolError::new( + "pathNotAllowed", + "path must stay within the project workspace", + ) + })?; + if relative.as_os_str().is_empty() { + return Ok(".".to_owned()); + } + Ok(relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/")) +} + +fn io_error(error: io::Error, path: &str) -> WorkspaceToolError { + let code = if error.kind() == io::ErrorKind::NotFound { + "notFound" + } else { + "readFailed" + }; + WorkspaceToolError::new(code, format!("cannot access workspace path `{path}`")) +} + +fn is_hidden(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.') && name != "." && name != "..") +} + +fn sorted_children(path: &Path, include_hidden: bool) -> Result, WorkspaceToolError> { + let mut children = fs::read_dir(path) + .map_err(|_| WorkspaceToolError::new("readFailed", "cannot list workspace directory"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| include_hidden || !is_hidden(path)) + .collect::>(); + children.sort_by(|left, right| stable_path_cmp(left, right)); + Ok(children) +} + +fn stable_path_cmp(left: &Path, right: &Path) -> Ordering { + left.file_name() + .unwrap_or_default() + .to_string_lossy() + .cmp(&right.file_name().unwrap_or_default().to_string_lossy()) +} + +fn kind_and_metadata(path: &Path) -> Result<(&'static str, fs::Metadata), WorkspaceToolError> { + let metadata = fs::symlink_metadata(path) + .map_err(|_| WorkspaceToolError::new("readFailed", "cannot inspect workspace element"))?; + let kind = if metadata.file_type().is_symlink() { + "other" + } else if metadata.is_dir() { + "directory" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + Ok((kind, metadata)) +} + +fn workspace_list(project: &Project, args: ListArgs) -> Result { + validate_range(args.depth, 0, LIST_MAX_DEPTH, "depth")?; + validate_range(args.limit, 1, LIST_MAX_ENTRIES, "limit")?; + let resolved = resolve(project, &args.path)?; + if !resolved.target.is_dir() { + return Err(WorkspaceToolError::new( + "notDirectory", + "workspace path is not a directory", + )); + } + let mut entries = Vec::new(); + let mut truncated = false; + collect_list( + &resolved.root, + &resolved.target, + 0, + args.depth, + args.include_hidden, + args.limit, + &mut entries, + &mut truncated, + )?; + Ok(json!({ "root": resolved.relative, "entries": entries, "truncated": truncated })) +} + +#[allow(clippy::too_many_arguments)] +fn collect_list( + root: &Path, + directory: &Path, + parent_depth: usize, + max_depth: usize, + include_hidden: bool, + limit: usize, + entries: &mut Vec, + truncated: &mut bool, +) -> Result<(), WorkspaceToolError> { + if parent_depth >= max_depth { + return Ok(()); + } + for path in sorted_children(directory, include_hidden)? { + if entries.len() >= limit { + *truncated = true; + return Ok(()); + } + let (kind, metadata) = kind_and_metadata(&path)?; + let relative = relative_label(root, &path)?; + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let modified_at_ms = metadata.modified().ok().and_then(epoch_ms); + let mut entry = serde_json::Map::from_iter([ + ("path".to_owned(), json!(relative)), + ("name".to_owned(), json!(name)), + ("kind".to_owned(), json!(kind)), + ]); + if metadata.is_file() { + entry.insert("sizeBytes".to_owned(), json!(metadata.len())); + } + if let Some(modified_at_ms) = modified_at_ms { + entry.insert("modifiedAtMs".to_owned(), json!(modified_at_ms)); + } + entries.push(Value::Object(entry)); + if kind == "directory" { + collect_list( + root, + &path, + parent_depth + 1, + max_depth, + include_hidden, + limit, + entries, + truncated, + )?; + if *truncated { + return Ok(()); + } + } + } + Ok(()) +} + +fn epoch_ms(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) +} + +fn workspace_map(project: &Project, args: MapArgs) -> Result { + validate_range(args.depth, 0, MAP_MAX_DEPTH, "depth")?; + validate_range(args.max_entries, 1, MAP_MAX_ENTRIES, "maxEntries")?; + let resolved = resolve(project, &args.path)?; + if !resolved.target.is_dir() { + return Err(WorkspaceToolError::new( + "notDirectory", + "workspace path is not a directory", + )); + } + let mut state = MapState::default(); + collect_map(&resolved.root, &resolved.target, 0, &args, &mut state)?; + Ok(json!({ + "root": resolved.relative, + "generatedAtMs": epoch_ms(SystemTime::now()).unwrap_or(0), + "summary": { + "directories": state.directories, + "files": state.files, + "other": state.other, + "maxDepthReached": state.max_depth_reached, + "notablePaths": state.notable_paths, + }, + "entries": state.entries, + "truncated": state.truncated, + "limits": { "depth": args.depth, "maxEntries": args.max_entries } + })) +} + +#[derive(Default)] +struct MapState { + directories: usize, + files: usize, + other: usize, + max_depth_reached: usize, + notable_paths: Vec, + entries: Vec, + truncated: bool, +} + +fn collect_map( + root: &Path, + directory: &Path, + parent_depth: usize, + args: &MapArgs, + state: &mut MapState, +) -> Result<(), WorkspaceToolError> { + if parent_depth >= args.depth { + return Ok(()); + } + for path in sorted_children(directory, args.include_hidden)? { + if state.entries.len() >= args.max_entries { + state.truncated = true; + return Ok(()); + } + let depth = parent_depth + 1; + let (kind, metadata) = kind_and_metadata(&path)?; + let relative = relative_label(root, &path)?; + let children = (kind == "directory") + .then(|| sorted_children(&path, args.include_hidden).map(|items| items.len())) + .transpose()?; + let sample = (args.include_samples && kind == "file") + .then(|| text_sample(&path)) + .transpose()? + .flatten(); + match kind { + "directory" => state.directories += 1, + "file" => state.files += 1, + _ => state.other += 1, + } + state.max_depth_reached = state.max_depth_reached.max(depth); + if is_notable(&path) && state.notable_paths.len() < 25 { + state.notable_paths.push(relative.clone()); + } + let mut entry = serde_json::Map::from_iter([ + ("path".to_owned(), json!(relative)), + ("kind".to_owned(), json!(kind)), + ("depth".to_owned(), json!(depth)), + ]); + if let Some(children) = children { + entry.insert("children".to_owned(), json!(children)); + } + if metadata.is_file() { + entry.insert("sizeBytes".to_owned(), json!(metadata.len())); + } + if let Some(sample) = sample { + entry.insert("sample".to_owned(), json!(sample)); + } + state.entries.push(Value::Object(entry)); + if kind == "directory" { + collect_map(root, &path, depth, args, state)?; + if state.truncated { + return Ok(()); + } + } + } + Ok(()) +} + +fn is_notable(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + matches!( + name.to_ascii_lowercase().as_str(), + "readme" + | "readme.md" + | "cargo.toml" + | "package.json" + | "pyproject.toml" + | "go.mod" + | "pom.xml" + | "build.gradle" + | "makefile" + | "dockerfile" + ) + }) +} + +fn text_sample(path: &Path) -> Result, WorkspaceToolError> { + let bytes = read_prefix(path, SAMPLE_BYTES)?; + if bytes.contains(&0) { + return Ok(None); + } + let Ok(text) = std::str::from_utf8(&bytes) else { + return Ok(None); + }; + let sample = text.chars().take(SAMPLE_CHARS).collect::(); + Ok((!sample.is_empty()).then_some(sample)) +} + +fn workspace_read(project: &Project, args: ReadArgs) -> Result { + validate_range(args.max_bytes, 1, READ_MAX_BYTES, "maxBytes")?; + let resolved = resolve(project, &args.path)?; + let metadata = fs::metadata(&resolved.target) + .map_err(|_| WorkspaceToolError::new("readFailed", "cannot inspect workspace file"))?; + if !metadata.is_file() { + return Err(WorkspaceToolError::new( + "notFile", + "workspace path is not a file", + )); + } + let mut bytes = read_prefix(&resolved.target, args.max_bytes.saturating_add(1))?; + let truncated = metadata.len() > args.max_bytes as u64 || bytes.len() > args.max_bytes; + bytes.truncate(args.max_bytes); + if bytes.contains(&0) { + return Err(WorkspaceToolError::new( + "binaryFile", + "workspace file is not UTF-8 text", + )); + } + let content = match std::str::from_utf8(&bytes) { + Ok(text) => text.to_owned(), + Err(error) if truncated && error.error_len().is_none() => { + bytes.truncate(error.valid_up_to()); + String::from_utf8(bytes).expect("valid UTF-8 prefix") + } + Err(_) => { + return Err(WorkspaceToolError::new( + "binaryFile", + "workspace file is not UTF-8 text", + )); + } + }; + Ok(json!({ + "path": resolved.relative, + "content": content, + "encoding": "utf8", + "truncated": truncated, + "sizeBytes": metadata.len(), + })) +} + +fn read_prefix(path: &Path, maximum: usize) -> Result, WorkspaceToolError> { + let file = fs::File::open(path) + .map_err(|_| WorkspaceToolError::new("readFailed", "cannot open workspace file"))?; + let mut bytes = Vec::with_capacity(maximum.min(64 * 1024)); + file.take(maximum as u64) + .read_to_end(&mut bytes) + .map_err(|_| WorkspaceToolError::new("readFailed", "cannot read workspace file"))?; + Ok(bytes) +} + +fn workspace_search(project: &Project, args: SearchArgs) -> Result { + validate_range(args.max_results, 1, SEARCH_MAX_RESULTS, "maxResults")?; + if args.query.is_empty() || args.query.len() > 1_000 { + return Err(WorkspaceToolError::new( + "invalidArguments", + "`query` must contain between 1 and 1000 bytes", + )); + } + let resolved = resolve(project, &args.path)?; + let mut files = Vec::new(); + let mut truncated = false; + collect_search_files( + &resolved.target, + args.include_hidden, + &mut files, + &mut truncated, + )?; + files.sort_by(|left, right| left.to_string_lossy().cmp(&right.to_string_lossy())); + let mut matches = Vec::new(); + let mut scanned_bytes = 0_u64; + for path in files { + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.len() > SEARCH_MAX_FILE_BYTES { + truncated = true; + continue; + } + if scanned_bytes.saturating_add(metadata.len()) > SEARCH_MAX_TOTAL_BYTES { + truncated = true; + break; + } + scanned_bytes = scanned_bytes.saturating_add(metadata.len()); + let bytes = read_prefix(&path, SEARCH_MAX_FILE_BYTES as usize + 1)?; + if bytes.contains(&0) { + continue; + } + let Ok(content) = std::str::from_utf8(&bytes) else { + continue; + }; + for (index, line) in content.lines().enumerate() { + if line.contains(&args.query) { + if matches.len() >= args.max_results { + truncated = true; + break; + } + matches.push(json!({ + "path": relative_label(&resolved.root, &path)?, + "line": index + 1, + "preview": bounded_preview(line), + })); + } + } + if truncated && matches.len() >= args.max_results { + break; + } + } + Ok(json!({ "query": args.query, "matches": matches, "truncated": truncated })) +} + +fn collect_search_files( + path: &Path, + include_hidden: bool, + files: &mut Vec, + truncated: &mut bool, +) -> Result<(), WorkspaceToolError> { + let (kind, _) = kind_and_metadata(path)?; + if kind == "file" { + files.push(path.to_path_buf()); + return Ok(()); + } + if kind != "directory" { + return Ok(()); + } + for child in sorted_children(path, include_hidden)? { + if files.len() >= SEARCH_MAX_FILES { + *truncated = true; + return Ok(()); + } + collect_search_files(&child, include_hidden, files, truncated)?; + if *truncated { + return Ok(()); + } + } + Ok(()) +} + +fn bounded_preview(line: &str) -> String { + const MAX_PREVIEW_CHARS: usize = 500; + line.chars().take(MAX_PREVIEW_CHARS).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ProjectId, ProjectPath, RemoteRef}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(1); + + struct TempWorkspace(PathBuf); + + impl TempWorkspace { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "idea-workspace-tools-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn project(&self) -> Project { + Project::new( + ProjectId::new_random(), + "Workspace", + ProjectPath::new(self.0.to_string_lossy()).unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap() + } + } + + impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[tokio::test] + async fn read_is_bounded_and_rejects_unsafe_or_binary_paths() { + let temp = TempWorkspace::new(); + fs::write(temp.0.join("text.txt"), "éclair and more").unwrap(); + fs::write(temp.0.join("binary.bin"), [0, 159, 146, 150]).unwrap(); + let provider = FsWorkspaceToolProvider::new(); + let value = provider + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_read", + json!({"path":"text.txt", "maxBytes": 2}), + ) + .await + .unwrap(); + assert_eq!(value["content"], "é"); + assert_eq!(value["truncated"], true); + assert_eq!(value["sizeBytes"], 16); + + for path in ["../outside", "/tmp/outside", "C:\\outside"] { + let error = provider + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_read", + json!({"path":path}), + ) + .await + .unwrap_err(); + assert_eq!(error.code, "pathNotAllowed"); + assert!(!error.message.contains(temp.0.to_string_lossy().as_ref())); + } + let binary = provider + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_read", + json!({"path":"binary.bin"}), + ) + .await + .unwrap_err(); + assert_eq!(binary.code, "binaryFile"); + } + + #[cfg(unix)] + #[tokio::test] + async fn explicit_symlink_escape_is_rejected() { + use std::os::unix::fs::symlink; + let temp = TempWorkspace::new(); + symlink(std::env::temp_dir(), temp.0.join("outside")).unwrap(); + let error = FsWorkspaceToolProvider::new() + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_list", + json!({"path":"outside"}), + ) + .await + .unwrap_err(); + assert_eq!(error.code, "pathNotAllowed"); + } + + #[tokio::test] + async fn map_and_list_are_stable_relative_and_bounded() { + let temp = TempWorkspace::new(); + fs::create_dir_all(temp.0.join("src/deep")).unwrap(); + fs::write(temp.0.join("README.md"), "Project overview").unwrap(); + fs::write(temp.0.join("src/a.rs"), "fn alpha() {}").unwrap(); + fs::write(temp.0.join("src/deep/b.rs"), "fn beta() {}").unwrap(); + fs::write(temp.0.join("z.txt"), "last").unwrap(); + let provider = FsWorkspaceToolProvider::new(); + let map = provider + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_map", + json!({"depth": 2, "maxEntries": 3}), + ) + .await + .unwrap(); + assert_eq!(map["truncated"], true); + assert_eq!(map["limits"], json!({"depth":2,"maxEntries":3})); + assert!(map["entries"] + .as_array() + .unwrap() + .iter() + .all(|entry| { !entry["path"].as_str().unwrap().starts_with('/') })); + assert_eq!(map["entries"][0]["path"], "README.md"); + + let listed = provider + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_list", + json!({"depth": 1, "limit": 2}), + ) + .await + .unwrap(); + assert_eq!(listed["truncated"], true); + assert_eq!(listed["entries"].as_array().unwrap().len(), 2); + } + + #[tokio::test] + async fn search_skips_hidden_and_binary_files_and_caps_results() { + let temp = TempWorkspace::new(); + fs::write(temp.0.join("a.txt"), "needle one\nneedle two\n").unwrap(); + fs::write(temp.0.join(".hidden"), "needle hidden").unwrap(); + fs::write(temp.0.join("binary"), b"needle\0binary").unwrap(); + let value = FsWorkspaceToolProvider::new() + .handle_workspace_tool( + &temp.project(), + "agent", + "idea_workspace_search", + json!({"query":"needle", "maxResults":1}), + ) + .await + .unwrap(); + assert_eq!(value["matches"].as_array().unwrap().len(), 1); + assert_eq!(value["matches"][0]["path"], "a.txt"); + assert_eq!(value["matches"][0]["line"], 1); + assert_eq!(value["truncated"], true); + } + + #[tokio::test] + async fn arguments_above_public_limits_are_rejected() { + let temp = TempWorkspace::new(); + fs::write(temp.0.join("text.txt"), "content").unwrap(); + let provider = FsWorkspaceToolProvider::new(); + for (name, arguments, field) in [ + ( + "idea_workspace_map", + json!({"depth": MAP_MAX_DEPTH + 1}), + "depth", + ), + ( + "idea_workspace_map", + json!({"maxEntries": MAP_MAX_ENTRIES + 1}), + "maxEntries", + ), + ( + "idea_workspace_list", + json!({"depth": LIST_MAX_DEPTH + 1}), + "depth", + ), + ( + "idea_workspace_list", + json!({"limit": LIST_MAX_ENTRIES + 1}), + "limit", + ), + ( + "idea_workspace_read", + json!({"path":"text.txt", "maxBytes": READ_MAX_BYTES + 1}), + "maxBytes", + ), + ( + "idea_workspace_search", + json!({"query":"content", "maxResults": SEARCH_MAX_RESULTS + 1}), + "maxResults", + ), + ] { + let error = provider + .handle_workspace_tool(&temp.project(), "agent", name, arguments) + .await + .unwrap_err(); + assert_eq!(error.code, "invalidArguments"); + assert!(error.message.contains(field)); + } + } +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index cff93377..601b3727 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -64,8 +64,8 @@ use infrastructure::orchestrator::mcp::{ TicketToolProvider, }; use infrastructure::{ - InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, - SystemMillisClock, ToolPolicyRegistry, + FsWorkspaceToolProvider, InMemoryConversationRegistry, InMemoryMailbox, McpServer, + MediatedInbox, MemoryTransport, SystemMillisClock, ToolPolicyRegistry, }; use serde_json::{json, Value}; @@ -824,6 +824,11 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { // Public plugin administration tools. "idea_plugin_install_from_directory", "idea_plugin_reload", + // Read-only project workspace tools (#341). + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", ] { assert!( names.contains(&expected), @@ -833,8 +838,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 50, - "exactly the fifty exposed idea_* tools; got {names:?}" + 61, + "exactly the sixty-one exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. @@ -940,6 +945,14 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { assert!(names.contains(&"idea_ticket_list")); assert!(names.contains(&"idea_template_list")); assert!(names.contains(&"idea_template_read")); + for tool in [ + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", + ] { + assert!(names.contains(&tool), "read-only fallback omitted {tool}"); + } assert!(!names.contains(&"idea_memory_write")); assert!(!names.contains(&"idea_ask_agent")); assert!(!names.contains(&"idea_ticket_update_carnet")); @@ -953,6 +966,76 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { assert!(!names.contains(&"idea_project_cancel_agent")); } +#[tokio::test] +async fn workspace_tools_call_the_dedicated_provider_and_obey_explicit_policy() { + let root = std::env::temp_dir().join(format!("idea-mcp-workspace-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("note.txt"), "alpha needle\nbeta\n").unwrap(); + let scoped_project = Project::new( + ProjectId::from_uuid(Uuid::from_u128(1001)), + "workspace", + ProjectPath::new(root.to_string_lossy()).unwrap(), + RemoteRef::local(), + 0, + ) + .unwrap(); + let (service, _s) = build_service(FakeContexts::new()); + let agent = AgentId::from_uuid(Uuid::from_u128(341)); + let allowed = McpServer::new(Arc::clone(&service), scoped_project.clone()) + .with_workspace_tools(Arc::new(FsWorkspaceToolProvider::new())) + .with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new( + ProjectMcpToolPermissions::default(), + ))) + .for_requester(agent.to_string()); + let response = allowed + .handle_raw(&tools_call( + 341, + "idea_workspace_search", + json!({"query":"needle"}), + )) + .await + .expect("workspace reply"); + assert!(response.error.is_none()); + let result = response.result.unwrap(); + assert_eq!(result["isError"], false); + let payload: Value = serde_json::from_str(result_text(&result)).unwrap(); + assert_eq!(payload["matches"][0]["path"], "note.txt"); + + let denied = McpServer::new(service, scoped_project) + .with_workspace_tools(Arc::new(FsWorkspaceToolProvider::new())) + .with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(allow_doc( + agent, + &["idea_memory_read"], + )))) + .for_requester(agent.to_string()); + let listed = denied + .handle_raw( + &serde_json::to_vec(&json!({ + "jsonrpc":"2.0", "id":342, "method":"tools/list" + })) + .unwrap(), + ) + .await + .unwrap() + .result + .unwrap(); + assert!(!listed["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| { tool["name"] == "idea_workspace_search" })); + let rejected = denied + .handle_raw(&tools_call( + 343, + "idea_workspace_search", + json!({"query":"needle"}), + )) + .await + .unwrap(); + assert!(rejected.error.unwrap().message.contains("not permitted")); + let _ = std::fs::remove_dir_all(root); +} + #[tokio::test] async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_actionable() { let (service, _s) = build_service(FakeContexts::new()); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 98016b6b..270ea299 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -3185,6 +3185,10 @@ export class MockPermissionGateway implements PermissionGateway { "idea_template_list", "idea_template_read", "idea_managed_temp_resolve", + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", ], writeActionTools: [ "idea_project_ask_agent", diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx index b91fb918..298b4fb3 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx @@ -120,6 +120,28 @@ describe("McpPanel", () => { })).toBeTruthy(); }); + it("surfaces native workspace tools as the Outils du projet group", async () => { + await renderPanel(); + await openMcpToolsTab(); + + expect(screen.getByRole("button", { name: /Outils du projet, 4 sur 4 autorisés/ })).toBeTruthy(); + expect( + screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."), + ).toBeTruthy(); + expect(screen.getByRole("checkbox", { + name: /Cartographier le projet, idea_workspace_map$/, + })).toBeTruthy(); + expect(screen.getByRole("checkbox", { + name: /Lister les éléments du projet, idea_workspace_list$/, + })).toBeTruthy(); + expect(screen.getByRole("checkbox", { + name: /Lire un élément du projet, idea_workspace_read$/, + })).toBeTruthy(); + expect(screen.getByRole("checkbox", { + name: /Rechercher dans le projet, idea_workspace_search$/, + })).toBeTruthy(); + }); + it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); @@ -190,14 +212,14 @@ describe("McpPanel", () => { await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); - expect(screen.getByText(/16 lectures autorisées · 0 écritures autorisées/)).toBeTruthy(); + expect(screen.getByText(/20 lectures autorisées · 0 écritures autorisées/)).toBeTruthy(); const writeCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/, }); fireEvent.click(writeCheckbox); - expect(screen.getByText(/16 lectures autorisées · 1 écriture autorisée/)).toBeTruthy(); + expect(screen.getByText(/20 lectures autorisées · 1 écriture autorisée/)).toBeTruthy(); expect(screen.getByText("Modifications non enregistrées")).toBeTruthy(); expect(screen.getByText("Ajouté")).toBeTruthy(); }); @@ -230,7 +252,7 @@ describe("McpPanel", () => { "idea_ticket_update_carnet", ]), ); - expect(calledPolicy?.allowedTools).toHaveLength(17); + expect(calledPolicy?.allowedTools).toHaveLength(21); // Persisted: the left-column badge flips to Override, and Réinitialiser appears. const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx index 120cf5fe..7f1a895d 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx @@ -20,6 +20,7 @@ import { useProjectPlugins } from "@/features/plugins/useProjectPlugins"; import { Button, Input, Panel, cn } from "@/shared"; import { mcpToolGroupLabel, + mcpToolGroupDescription, mcpToolGroupsForCatalogue, mcpToolLabel, } from "./mcpToolGroups"; @@ -35,7 +36,7 @@ type McpTarget = | { type: "project" } | { type: "agent"; agentId: string; agentName: string }; -const READ_GROUP_IDS = new Set(["readProject", "readTickets"]); +const READ_GROUP_IDS = new Set(["workspace", "readProject", "readTickets"]); function toggleTool(list: string[], tool: string): string[] { return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool]; @@ -590,7 +591,7 @@ function McpToolEditor({ } interface ToolGroupSectionProps { - group: { id: string; label: string; tools: string[] }; + group: { id: string; label: string; description?: string; tools: string[] }; expanded: boolean; onToggle: () => void; catalogue: McpToolCatalogue; @@ -618,6 +619,7 @@ function ToolGroupSection({ const { tr } = useI18n(); const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length; const total = group.tools.length; + const description = mcpToolGroupDescription(group, tr); return (
@@ -628,7 +630,10 @@ function ToolGroupSection({ onClick={onToggle} className="flex min-h-[32px] w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-raised" > - {mcpToolGroupLabel(group, tr)} + + {mcpToolGroupLabel(group, tr)} + {description && {description}} + {allowedCount}/{total} {tr("autorisés", "allowed")} diff --git a/frontend/src/features/permissions/mcpToolGroups.ts b/frontend/src/features/permissions/mcpToolGroups.ts index 49865c89..1acb3eec 100644 --- a/frontend/src/features/permissions/mcpToolGroups.ts +++ b/frontend/src/features/permissions/mcpToolGroups.ts @@ -12,6 +12,7 @@ import type { McpToolCatalogue } from "@/domain"; export interface McpToolGroupDef { id: string; label: string; + description?: string; tools: string[]; } type Tr = (french: string, english: string) => string; @@ -19,6 +20,17 @@ const fr: Tr = (french) => french; /** Domain groups, in display order (carnet #82 — Conception UX/F). */ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [ + { + id: "workspace", + label: "Outils du projet", + description: "Permet à l’agent d’explorer, rechercher et lire les fichiers du projet.", + tools: [ + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", + ], + }, { id: "crossProject", label: "IdeA inter-projets", @@ -93,6 +105,10 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [ /** Short, human French label per exact MCP tool name. */ export const MCP_TOOL_LABELS: Record = { + idea_workspace_map: "Cartographier le projet", + idea_workspace_list: "Lister les éléments du projet", + idea_workspace_read: "Lire un élément du projet", + idea_workspace_search: "Rechercher dans le projet", idea_project_list: "Lister les projets IdeA", idea_project_read_context: "Lire le contexte d'un autre projet", idea_project_list_agents: "Lister les agents d'un autre projet", @@ -127,6 +143,7 @@ export const MCP_TOOL_LABELS: Record = { }; const MCP_TOOL_LABELS_EN: Record = { + idea_workspace_map: "Map the project", idea_workspace_list: "List project items", idea_workspace_read: "Read a project item", idea_workspace_search: "Search the project", idea_project_list: "List IdeA projects", idea_project_read_context: "Read another project's context", idea_project_list_agents: "List agents in another project", idea_project_ask_agent: "Ask an agent in another project", idea_project_cancel_agent: "Cancel a cross-project request", idea_list_agents: "List agents", idea_context_read: "Read an agent's context", idea_memory_read: "Read project memory", idea_skill_read: "Read a skill", idea_workstate_read: "Read work state", idea_ticket_read: "Read a ticket", idea_ticket_list: "List tickets", idea_ticket_read_carnet: "Read ticket notes", idea_sprint_list: "List sprints", idea_ask_agent: "Delegate to an agent", idea_ask_agents: "Ask several agents", idea_launch_agent: "Launch an agent", idea_stop_agent: "Stop an agent", idea_update_context: "Update an agent's context", idea_context_propose: "Propose context", idea_memory_write: "Write project memory", idea_workstate_set: "Write work state", idea_create_skill: "Create a skill", @@ -134,10 +151,24 @@ const MCP_TOOL_LABELS_EN: Record = { }; export function mcpToolGroupLabel(group: McpToolGroupDef, tr: Tr = fr): string { - const english: Record = { crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" }; + const english: Record = { workspace: "Project tools", crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" }; return tr(group.label, english[group.id] ?? group.label); } +export function mcpToolGroupDescription( + group: McpToolGroupDef, + tr: Tr = fr, +): string | undefined { + if (!group.description) return undefined; + if (group.id === "workspace") { + return tr( + group.description, + "Lets the agent explore, search, and read project files.", + ); + } + return group.description; +} + /** Short human label for `tool`, falling back to the exact name if unmapped. */ export function mcpToolLabel(tool: string, tr: Tr = fr): string { const french = MCP_TOOL_LABELS[tool]; diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx index affb4aff..4e6f0f15 100644 --- a/frontend/src/features/templates/TemplateEditor.tsx +++ b/frontend/src/features/templates/TemplateEditor.tsx @@ -12,6 +12,7 @@ import type { } from "@/domain"; import { mcpToolGroupLabel, + mcpToolGroupDescription, mcpToolGroupsForCatalogue, mcpToolLabel, } from "@/features/permissions/mcpToolGroups"; @@ -72,6 +73,9 @@ export function TemplateEditor({ const [allowedTools, setAllowedTools] = useState( template?.mcpToolPermissions?.allowedTools ?? [], ); + const [mcpOverrideActive, setMcpOverrideActive] = useState( + template?.mcpToolPermissions != null, + ); const [tab, setTab] = useState("edit"); const toolGroups = useMemo( () => mcpToolGroupsForCatalogue(mcpCatalogue), @@ -103,8 +107,9 @@ export function TemplateEditor({ defaultAgentName: defaultAgentName.trim() || null, defaultContactable, systemPermissions: network ? { network } : null, - mcpToolPermissions: - allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null, + mcpToolPermissions: mcpOverrideActive + ? { allowedTools: [...allowedTools] } + : null, }); } @@ -299,39 +304,65 @@ export function TemplateEditor({

{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}

- {allowedTools.length} {tr("autorisé(s)", "allowed")} + + {mcpOverrideActive + ? `${allowedTools.length} ${tr("autorisé(s)", "allowed")}` + : tr("Héritage du projet", "Project inheritance")} +
+ {allMcpTools.length === 0 ? (

{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}

) : ( <> - {allowedTools.length === 0 && ( + {mcpOverrideActive && allowedTools.length === 0 && (

{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}

)}
- {toolGroups.map((group) => ( -
-

{mcpToolGroupLabel(group, tr)}

- {group.tools.map((tool) => ( - - ))} -
- ))} + {toolGroups.map((group) => { + const description = mcpToolGroupDescription(group, tr); + return ( +
+
+

{mcpToolGroupLabel(group, tr)}

+ {description &&

{description}

} +
+ {group.tools.map((tool) => ( + + ))} +
+ ); + })}
)} diff --git a/frontend/src/features/templates/templatePresentation.ts b/frontend/src/features/templates/templatePresentation.ts index b43ee0dc..e6b84d7d 100644 --- a/frontend/src/features/templates/templatePresentation.ts +++ b/frontend/src/features/templates/templatePresentation.ts @@ -50,7 +50,8 @@ export function systemPermissionsSummary( } export function mcpPermissionsSummary(policy?: McpToolPolicy | null, tr: Tr = fr): string { - const tools = policy?.allowedTools ?? []; + if (policy == null) return `MCP: ${tr("héritage du projet", "project inheritance")}`; + const tools = policy.allowedTools; if (tools.length === 0) return `MCP: ${tr("aucun outil autorisé", "no tool allowed")}`; if (tools.length <= 2) return `MCP: ${tools.map((tool) => mcpToolLabel(tool, tr)).join(", ")}`; return `MCP: ${tools.length} ${tr("outils autorisés", "tools allowed")}`; diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx index e490827a..64f5d9fb 100644 --- a/frontend/src/features/templates/templates.test.tsx +++ b/frontend/src/features/templates/templates.test.tsx @@ -182,6 +182,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { }); chooseDropdownOption("Permission réseau système du modèle", "Réseau autorisé"); fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets")); + fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet")); fireEvent.click(screen.getByLabelText("Outil MCP du modèle idea_ticket_read")); fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" })); @@ -267,9 +268,76 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { const fallback = screen.getByLabelText( "Outil MCP du modèle idea_future_read", ) as HTMLInputElement; + expect(fallback.disabled).toBe(true); + fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet")); expect(fallback.disabled).toBe(false); }); + it("groups the native workspace tools in the template editor", async () => { + const permission = new MockPermissionGateway(); + permission.setMcpToolCatalogue({ + readOnlyTools: [ + "idea_workspace_map", + "idea_workspace_list", + "idea_workspace_read", + "idea_workspace_search", + ], + writeActionTools: [], + }); + renderTemplatesPanel({ permission }); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" })); + + expect(await screen.findByText("Outils du projet")).toBeTruthy(); + expect( + screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."), + ).toBeTruthy(); + expect(screen.getByText("Cartographier le projet")).toBeTruthy(); + expect(screen.getByText("Lister les éléments du projet")).toBeTruthy(); + expect(screen.getByText("Lire un élément du projet")).toBeTruthy(); + expect(screen.getByText("Rechercher dans le projet")).toBeTruthy(); + }); + + it("distinguishes project inheritance from an explicit empty MCP override", async () => { + const agent = new MockAgentGateway(); + const tmpl = new MockTemplateGateway(agent); + renderTemplatesPanel({ agent, template: tmpl }); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" })); + await screen.findByLabelText("Nom du modèle"); + fireEvent.change(screen.getByLabelText("Nom du modèle"), { + target: { value: "Inherited tools" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" })); + + await screen.findByText("Inherited tools"); + expect((await tmpl.listTemplates())[0].mcpToolPermissions).toBeNull(); + expect(screen.getByText(/MCP: héritage du projet/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" })); + await screen.findByLabelText("Nom du modèle"); + fireEvent.change(screen.getByLabelText("Nom du modèle"), { + target: { value: "No tools" }, + }); + fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet")); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" })); + + await screen.findByText("No tools"); + expect((await tmpl.listTemplates())[1].mcpToolPermissions).toEqual({ allowedTools: [] }); + expect(screen.getByText(/MCP: aucun outil autorisé/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Modifier No tools" })); + const inherit = await screen.findByLabelText( + "Hériter des outils MCP du projet", + ) as HTMLInputElement; + expect(inherit.checked).toBe(false); + expect( + (screen.getByLabelText("Outil MCP du modèle idea_ticket_read") as HTMLInputElement).disabled, + ).toBe(false); + }); + it("the Create button is disabled when the name is empty", async () => { renderTemplatesPanel(); await waitForTemplatesIdle(); @@ -340,6 +408,8 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { "Outil MCP du modèle idea_ticket_read", ) as HTMLInputElement; expect(checkbox.checked).toBe(false); + expect(checkbox.disabled).toBe(true); + fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet")); fireEvent.click(checkbox); expect(checkbox.checked).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));