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:
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
980
crates/infrastructure/src/orchestrator/mcp/workspace.rs
Normal file
980
crates/infrastructure/src/orchestrator/mcp/workspace.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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")}`;
|
||||
|
||||
@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user