merge(infrastructure,backend,app-tauri,frontend): feature #341 — tools MCP workspace natifs read-only idea_workspace_map/list/read/search, activés par défaut, configurables surfaces MCP/templates (QA verte)

This commit is contained in:
2026-09-12 19:17:16 +02:00
16 changed files with 1503 additions and 80 deletions

View File

@ -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<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
workspace_tools: Arc<dyn WorkspaceToolProvider>,
}
/// 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) => {

View File

@ -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<dyn ManagedTempToolProvider>,
/// MCP provider for public plugin administration tools.
pub(crate) plugin_tool_provider: Arc<dyn PluginToolProvider>,
/// MCP provider for the read-only `idea_workspace_*` tools.
pub(crate) workspace_tool_provider: Arc<dyn WorkspaceToolProvider>,
/// Open an ephemeral AI assistant chat bound to one ticket.
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
/// 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<dyn ManagedTempToolProvider> =
managed_temp_tool_binder.clone();
let workspace_tool_provider: Arc<dyn WorkspaceToolProvider> =
Arc::new(FsWorkspaceToolProvider::new());
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
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<dyn AgentToolPolicyStore>,
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<dyn ToolInvoker>);
openai_tool_invoker.bind(Arc::new(
AppOpenAiToolInvoker::new(
Arc::clone(&orchestrator_service),
Arc::clone(&store_port),
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
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<dyn ToolInvoker>);
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,

View File

@ -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<dyn TemplateToolProvider>,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
plugin_tools: Arc<dyn PluginToolProvider>,
workspace_tools: Arc<dyn WorkspaceToolProvider>,
}
/// 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<dyn WorkspaceToolProvider>) -> 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<dyn ProjectStore>,
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::<Vec<_>>();
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 =

View File

@ -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,

View File

@ -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};

View File

@ -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<OrchestratorService>` 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<Arc<dyn ManagedTempToolProvider>>,
/// Optional public plugin administration provider.
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
/// Optional read-only project workspace provider.
workspace_tools: Option<Arc<dyn WorkspaceToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// 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<dyn WorkspaceToolProvider>) -> 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<ToolPolicyRegistry>) -> 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<Value, JsonRpcError> {
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) {

View File

@ -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<ToolDef> {
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()

View File

@ -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<String>) -> 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<Value, WorkspaceToolError>;
}
/// 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<Value, WorkspaceToolError> {
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<ToolDef> {
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<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, WorkspaceToolError> {
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<ResolvedPath, WorkspaceToolError> {
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<String, WorkspaceToolError> {
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::<Vec<_>>()
.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<Vec<PathBuf>, 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::<Vec<_>>();
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<Value, WorkspaceToolError> {
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<Value>,
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<u64> {
time.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis() as u64)
}
fn workspace_map(project: &Project, args: MapArgs) -> Result<Value, WorkspaceToolError> {
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<String>,
entries: Vec<Value>,
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<Option<String>, 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::<String>();
Ok((!sample.is_empty()).then_some(sample))
}
fn workspace_read(project: &Project, args: ReadArgs) -> Result<Value, WorkspaceToolError> {
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<Vec<u8>, 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<Value, WorkspaceToolError> {
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<PathBuf>,
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));
}
}
}

View File

@ -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());

View File

@ -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",

View File

@ -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" });

View File

@ -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 (
<div className="rounded-md border border-border">
@ -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"
>
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
{description && <span className="text-xs text-muted">{description}</span>}
</span>
<span className="text-xs text-muted">
{allowedCount}/{total} {tr("autorisés", "allowed")}
</span>

View File

@ -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<string, string> = {
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<string, string> = {
};
const MCP_TOOL_LABELS_EN: Record<string, string> = {
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<string, string> = {
};
export function mcpToolGroupLabel(group: McpToolGroupDef, tr: Tr = fr): string {
const english: Record<string, string> = { 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<string, string> = { 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];

View File

@ -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<string[]>(
template?.mcpToolPermissions?.allowedTools ?? [],
);
const [mcpOverrideActive, setMcpOverrideActive] = useState(
template?.mcpToolPermissions != null,
);
const [tab, setTab] = useState<EditorTab>("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({
<h3 className="text-xs font-medium text-muted">
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
</h3>
<span className="text-xs text-faint">{allowedTools.length} {tr("autorisé(s)", "allowed")}</span>
<span className="text-xs text-faint">
{mcpOverrideActive
? `${allowedTools.length} ${tr("autorisé(s)", "allowed")}`
: tr("Héritage du projet", "Project inheritance")}
</span>
</div>
<label className="flex items-start gap-2 text-sm text-content">
<input
type="checkbox"
aria-label={tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}
checked={!mcpOverrideActive}
onChange={(event) => setMcpOverrideActive(!event.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span>{tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}</span>
<span className="text-xs text-muted">
{tr("Le modèle utilise les outils autorisés par le projet.", "The template uses the tools allowed by the project.")}
</span>
</span>
</label>
{allMcpTools.length === 0 ? (
<p className="text-sm text-muted">{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}</p>
) : (
<>
{allowedTools.length === 0 && (
{mcpOverrideActive && allowedTools.length === 0 && (
<p className="text-sm text-muted">
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{toolGroups.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
{group.tools.map((tool) => (
<label
key={tool}
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
>
<input
type="checkbox"
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
</label>
))}
</div>
))}
{toolGroups.map((group) => {
const description = mcpToolGroupDescription(group, tr);
return (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5">
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
{description && <p className="text-xs text-muted">{description}</p>}
</div>
{group.tools.map((tool) => (
<label
key={tool}
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
>
<input
type="checkbox"
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy || !mcpOverrideActive}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
</label>
))}
</div>
);
})}
</div>
</>
)}

View File

@ -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")}`;

View File

@ -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" }));