feat(infrastructure,backend,app-tauri,frontend): tools MCP workspace natifs read-only idea_workspace_map/list/read/search, activés par défaut et configurables dans les permissions MCP et les templates — #341 (QA verte)
This commit is contained in:
@ -14,7 +14,10 @@ use domain::ports::{
|
|||||||
ToolSpec,
|
ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
use infrastructure::{
|
||||||
|
FsWorkspaceToolProvider, ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider,
|
||||||
|
WorkspaceToolProvider,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
@ -29,6 +32,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
template_tools: Arc<dyn TemplateToolProvider>,
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||||
|
workspace_tools: Arc<dyn WorkspaceToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||||
@ -112,6 +116,7 @@ impl AppOpenAiToolInvoker {
|
|||||||
ticket_tools,
|
ticket_tools,
|
||||||
template_tools,
|
template_tools,
|
||||||
managed_temp_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}"))
|
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)
|
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||||
|
|||||||
@ -111,16 +111,17 @@ use infrastructure::{
|
|||||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
||||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
||||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer,
|
||||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
|
||||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
|
||||||
ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
VectorMemoryRecall, WorkspaceToolProvider, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -2184,6 +2185,8 @@ pub struct BackendCore {
|
|||||||
pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>,
|
pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>,
|
||||||
/// MCP provider for public plugin administration tools.
|
/// MCP provider for public plugin administration tools.
|
||||||
pub(crate) plugin_tool_provider: Arc<dyn PluginToolProvider>,
|
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.
|
/// Open an ephemeral AI assistant chat bound to one ticket.
|
||||||
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
||||||
/// Close an ephemeral AI assistant chat bound to one ticket.
|
/// 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_binder = Arc::new(LateBoundManagedTempToolProvider::new());
|
||||||
let managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider> =
|
let managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider> =
|
||||||
managed_temp_tool_binder.clone();
|
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_registry = Arc::new(ToolPolicyRegistry::new());
|
||||||
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
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)));
|
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
|
||||||
@ -4296,7 +4301,8 @@ impl BackendCore {
|
|||||||
.with_spawn_background_command(Arc::clone(&spawn_background_command))
|
.with_spawn_background_command(Arc::clone(&spawn_background_command))
|
||||||
.with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)),
|
.with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)),
|
||||||
);
|
);
|
||||||
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
|
openai_tool_invoker.bind(Arc::new(
|
||||||
|
AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&orchestrator_service),
|
Arc::clone(&orchestrator_service),
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
||||||
@ -4305,7 +4311,9 @@ impl BackendCore {
|
|||||||
Arc::clone(&template_tool_provider),
|
Arc::clone(&template_tool_provider),
|
||||||
Arc::clone(&managed_temp_tool_provider),
|
Arc::clone(&managed_temp_tool_provider),
|
||||||
Arc::clone(&plugin_tool_provider),
|
Arc::clone(&plugin_tool_provider),
|
||||||
)) as Arc<dyn ToolInvoker>);
|
)
|
||||||
|
.with_workspace_tools(Arc::clone(&workspace_tool_provider)),
|
||||||
|
) as Arc<dyn ToolInvoker>);
|
||||||
|
|
||||||
let stop_live_agent = Arc::new(
|
let stop_live_agent = Arc::new(
|
||||||
StopAgentRuntime::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
|
StopAgentRuntime::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
|
||||||
@ -4444,6 +4452,7 @@ impl BackendCore {
|
|||||||
ticket_tool_provider,
|
ticket_tool_provider,
|
||||||
template_tool_provider,
|
template_tool_provider,
|
||||||
managed_temp_tool_provider,
|
managed_temp_tool_provider,
|
||||||
|
workspace_tool_provider,
|
||||||
open_ticket_assistant,
|
open_ticket_assistant,
|
||||||
close_ticket_assistant,
|
close_ticket_assistant,
|
||||||
tool_policy_registry,
|
tool_policy_registry,
|
||||||
@ -4793,6 +4802,7 @@ impl BackendCore {
|
|||||||
.with_template_tools(Arc::clone(&self.template_tool_provider))
|
.with_template_tools(Arc::clone(&self.template_tool_provider))
|
||||||
.with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider))
|
.with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider))
|
||||||
.with_plugin_tools(Arc::clone(&self.plugin_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_tool_policies(Arc::clone(&self.tool_policy_registry))
|
||||||
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
//! ToolInvoker for the OpenAI-compatible adapter.
|
//! ToolInvoker for the OpenAI-compatible adapter.
|
||||||
//!
|
//!
|
||||||
//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que
|
//! 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`.
|
//! `OrchestratorService::dispatch`.
|
||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@ -15,7 +16,10 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::orchestrator::mcp::PluginToolProvider;
|
use infrastructure::orchestrator::mcp::PluginToolProvider;
|
||||||
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
|
use infrastructure::{
|
||||||
|
FsWorkspaceToolProvider, ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider,
|
||||||
|
WorkspaceToolProvider,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
@ -31,6 +35,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
template_tools: Arc<dyn TemplateToolProvider>,
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
||||||
plugin_tools: Arc<dyn PluginToolProvider>,
|
plugin_tools: Arc<dyn PluginToolProvider>,
|
||||||
|
workspace_tools: Arc<dyn WorkspaceToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||||
@ -116,8 +121,16 @@ impl AppOpenAiToolInvoker {
|
|||||||
template_tools,
|
template_tools,
|
||||||
managed_temp_tools,
|
managed_temp_tools,
|
||||||
plugin_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]
|
#[async_trait]
|
||||||
@ -249,6 +262,24 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
return serde_json::to_string(&value)
|
return serde_json::to_string(&value)
|
||||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON plugin tool: {e}")));
|
.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)
|
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||||
@ -961,6 +992,71 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(temp);
|
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]
|
#[tokio::test]
|
||||||
async fn openai_tools_list_matches_durable_agent_policy() {
|
async fn openai_tools_list_matches_durable_agent_policy() {
|
||||||
let temp =
|
let temp =
|
||||||
|
|||||||
@ -85,9 +85,9 @@ pub use model_server::{
|
|||||||
LocalManagedProcess,
|
LocalManagedProcess,
|
||||||
};
|
};
|
||||||
pub use orchestrator::mcp::{
|
pub use orchestrator::mcp::{
|
||||||
ManagedTempToolError, ManagedTempToolProvider, McpServer, MemoryTransport, StdioTransport,
|
FsWorkspaceToolProvider, ManagedTempToolError, ManagedTempToolProvider, McpServer,
|
||||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider, TicketToolError,
|
||||||
ToolPolicyRegistry,
|
TicketToolProvider, ToolPolicyRegistry, WorkspaceToolError, WorkspaceToolProvider,
|
||||||
};
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ pub mod templates;
|
|||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod workspace;
|
||||||
|
|
||||||
pub use jsonrpc::{
|
pub use jsonrpc::{
|
||||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||||
@ -49,3 +50,4 @@ pub use templates::{TemplateToolError, TemplateToolProvider};
|
|||||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||||
pub use transport::{MemoryTransport, StdioTransport};
|
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).
|
//! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4).
|
||||||
//!
|
//!
|
||||||
//! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**:
|
//! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**:
|
||||||
//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the
|
//! another entry door onto the *same* [`OrchestratorService::dispatch`] for
|
||||||
//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build
|
//! orchestration commands. Capability-specific tools are handled by injected
|
||||||
//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It
|
//! providers without being disguised as [`OrchestratorCommand`] variants.
|
||||||
//! invents **no semantics** and **never re-routes** replies.
|
|
||||||
//!
|
//!
|
||||||
//! It implements the strict MCP minimum over JSON-RPC 2.0:
|
//! It implements the strict MCP minimum over JSON-RPC 2.0:
|
||||||
//! - `initialize` → advertise protocol version + tool capability,
|
//! - `initialize` → advertise protocol version + tool capability,
|
||||||
//! - `tools/list` → the [`tools::catalogue`],
|
//! - `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.
|
//! - notifications (e.g. `notifications/initialized`) → accepted, no reply.
|
||||||
//!
|
//!
|
||||||
//! It receives `Arc<OrchestratorService>` and the target [`Project`] by injection
|
//! It receives `Arc<OrchestratorService>` and the target [`Project`] by injection
|
||||||
@ -40,13 +39,14 @@ use super::policy::ToolPolicyRegistry;
|
|||||||
use super::templates::TemplateToolProvider;
|
use super::templates::TemplateToolProvider;
|
||||||
use super::tickets::TicketToolProvider;
|
use super::tickets::TicketToolProvider;
|
||||||
use super::tools::{self, ToolMapError};
|
use super::tools::{self, ToolMapError};
|
||||||
|
use super::workspace::WorkspaceToolProvider;
|
||||||
|
|
||||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||||
|
|
||||||
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
||||||
/// intentionally self-contained because some clients truncate this field.
|
/// 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`].
|
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||||
///
|
///
|
||||||
@ -87,6 +87,8 @@ pub struct McpServer {
|
|||||||
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
||||||
/// Optional public plugin administration provider.
|
/// Optional public plugin administration provider.
|
||||||
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
|
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.
|
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||||
@ -110,6 +112,7 @@ impl McpServer {
|
|||||||
template_tools: None,
|
template_tools: None,
|
||||||
managed_temp_tools: None,
|
managed_temp_tools: None,
|
||||||
plugin_tools: None,
|
plugin_tools: None,
|
||||||
|
workspace_tools: None,
|
||||||
tool_policies: None,
|
tool_policies: None,
|
||||||
mcp_tool_permissions: None,
|
mcp_tool_permissions: None,
|
||||||
}
|
}
|
||||||
@ -167,6 +170,13 @@ impl McpServer {
|
|||||||
self
|
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.
|
/// Attaches the requester-scoped MCP tool policy registry.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||||
@ -204,6 +214,7 @@ impl McpServer {
|
|||||||
template_tools: self.template_tools.clone(),
|
template_tools: self.template_tools.clone(),
|
||||||
managed_temp_tools: self.managed_temp_tools.clone(),
|
managed_temp_tools: self.managed_temp_tools.clone(),
|
||||||
plugin_tools: self.plugin_tools.clone(),
|
plugin_tools: self.plugin_tools.clone(),
|
||||||
|
workspace_tools: self.workspace_tools.clone(),
|
||||||
tool_policies: self.tool_policies.clone(),
|
tool_policies: self.tool_policies.clone(),
|
||||||
mcp_tool_permissions: self.mcp_tool_permissions.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
|
/// `tools/call`: route provider-backed tools directly, or map orchestration
|
||||||
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
|
/// tools to an [`OrchestratorCommand`] and dispatch them.
|
||||||
/// tool result.
|
|
||||||
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
|
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
|
||||||
let name = params
|
let name = params
|
||||||
.get("name")
|
.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
|
// The handshake-provided requester is still passed to the mapper for tools that
|
||||||
// need peer identity.
|
// need peer identity.
|
||||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
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**
|
//! Orchestration tools remain thin, typed faces over an **already-existing**
|
||||||
//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur
|
//! [`OrchestratorCommand`]. Capability-specific tools (tickets, templates,
|
||||||
//! MCP n'invente aucune sémantique »). The mapping builds an
|
//! plugins, managed temporary storage, and the project workspace) are routed to
|
||||||
//! [`OrchestratorRequest`] from the tool arguments and lets
|
//! their dedicated providers by the server. The catalogue itself contains no
|
||||||
//! [`OrchestratorRequest::validate`] be the **single source of truth** for
|
//! filesystem or application logic.
|
||||||
//! validation — the very same path the filesystem watcher takes. No tool
|
|
||||||
//! validates anything itself; no tool reaches a use case directly.
|
|
||||||
//!
|
//!
|
||||||
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
|
//! ## 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_managed_temp_resolve",
|
||||||
"idea_scheduled_task_list",
|
"idea_scheduled_task_list",
|
||||||
"idea_scheduled_task_read",
|
"idea_scheduled_task_read",
|
||||||
|
"idea_workspace_map",
|
||||||
|
"idea_workspace_list",
|
||||||
|
"idea_workspace_read",
|
||||||
|
"idea_workspace_search",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Canonical write/action MCP tools denied by default.
|
/// 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_delete"
|
||||||
| "idea_scheduled_task_run_now"
|
| "idea_scheduled_task_run_now"
|
||||||
| "idea_scheduled_task_set_enabled"
|
| "idea_scheduled_task_set_enabled"
|
||||||
|
| "idea_workspace_map"
|
||||||
|
| "idea_workspace_list"
|
||||||
|
| "idea_workspace_read"
|
||||||
|
| "idea_workspace_search"
|
||||||
) || is_ticket_tool(tool)
|
) || is_ticket_tool(tool)
|
||||||
|| is_template_tool(tool)
|
|| is_template_tool(tool)
|
||||||
|| is_managed_temp_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)
|
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`.
|
/// The full catalogue advertised on `tools/list`.
|
||||||
///
|
///
|
||||||
/// Exactly the tools whose mapping target already exists as an
|
/// 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::templates::catalogue());
|
||||||
tools.extend(super::managed_temp::catalogue());
|
tools.extend(super::managed_temp::catalogue());
|
||||||
tools.extend(super::plugins::catalogue());
|
tools.extend(super::plugins::catalogue());
|
||||||
|
tools.extend(super::workspace::catalogue());
|
||||||
tools
|
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]
|
#[test]
|
||||||
fn catalogue_tools_have_explicit_read_or_write_access() {
|
fn catalogue_tools_have_explicit_read_or_write_access() {
|
||||||
let catalogue_names = catalogue()
|
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,
|
TicketToolProvider,
|
||||||
};
|
};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
FsWorkspaceToolProvider, InMemoryConversationRegistry, InMemoryMailbox, McpServer,
|
||||||
SystemMillisClock, ToolPolicyRegistry,
|
MediatedInbox, MemoryTransport, SystemMillisClock, ToolPolicyRegistry,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@ -824,6 +824,11 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
// Public plugin administration tools.
|
// Public plugin administration tools.
|
||||||
"idea_plugin_install_from_directory",
|
"idea_plugin_install_from_directory",
|
||||||
"idea_plugin_reload",
|
"idea_plugin_reload",
|
||||||
|
// Read-only project workspace tools (#341).
|
||||||
|
"idea_workspace_map",
|
||||||
|
"idea_workspace_list",
|
||||||
|
"idea_workspace_read",
|
||||||
|
"idea_workspace_search",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -833,8 +838,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
50,
|
61,
|
||||||
"exactly the fifty exposed idea_* tools; got {names:?}"
|
"exactly the sixty-one exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// 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_ticket_list"));
|
||||||
assert!(names.contains(&"idea_template_list"));
|
assert!(names.contains(&"idea_template_list"));
|
||||||
assert!(names.contains(&"idea_template_read"));
|
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_memory_write"));
|
||||||
assert!(!names.contains(&"idea_ask_agent"));
|
assert!(!names.contains(&"idea_ask_agent"));
|
||||||
assert!(!names.contains(&"idea_ticket_update_carnet"));
|
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"));
|
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]
|
#[tokio::test]
|
||||||
async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_actionable() {
|
async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_actionable() {
|
||||||
let (service, _s) = build_service(FakeContexts::new());
|
let (service, _s) = build_service(FakeContexts::new());
|
||||||
|
|||||||
@ -3185,6 +3185,10 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
"idea_template_list",
|
"idea_template_list",
|
||||||
"idea_template_read",
|
"idea_template_read",
|
||||||
"idea_managed_temp_resolve",
|
"idea_managed_temp_resolve",
|
||||||
|
"idea_workspace_map",
|
||||||
|
"idea_workspace_list",
|
||||||
|
"idea_workspace_read",
|
||||||
|
"idea_workspace_search",
|
||||||
],
|
],
|
||||||
writeActionTools: [
|
writeActionTools: [
|
||||||
"idea_project_ask_agent",
|
"idea_project_ask_agent",
|
||||||
|
|||||||
@ -120,6 +120,28 @@ describe("McpPanel", () => {
|
|||||||
})).toBeTruthy();
|
})).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 () => {
|
it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => {
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const permission = new MockPermissionGateway();
|
const permission = new MockPermissionGateway();
|
||||||
@ -190,14 +212,14 @@ describe("McpPanel", () => {
|
|||||||
await selectAgentTarget("DevFrontend");
|
await selectAgentTarget("DevFrontend");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
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", {
|
const writeCheckbox = screen.getByRole("checkbox", {
|
||||||
name: /idea_ticket_update_carnet$/,
|
name: /idea_ticket_update_carnet$/,
|
||||||
});
|
});
|
||||||
fireEvent.click(writeCheckbox);
|
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("Modifications non enregistrées")).toBeTruthy();
|
||||||
expect(screen.getByText("Ajouté")).toBeTruthy();
|
expect(screen.getByText("Ajouté")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@ -230,7 +252,7 @@ describe("McpPanel", () => {
|
|||||||
"idea_ticket_update_carnet",
|
"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.
|
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
|
||||||
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
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 { Button, Input, Panel, cn } from "@/shared";
|
||||||
import {
|
import {
|
||||||
mcpToolGroupLabel,
|
mcpToolGroupLabel,
|
||||||
|
mcpToolGroupDescription,
|
||||||
mcpToolGroupsForCatalogue,
|
mcpToolGroupsForCatalogue,
|
||||||
mcpToolLabel,
|
mcpToolLabel,
|
||||||
} from "./mcpToolGroups";
|
} from "./mcpToolGroups";
|
||||||
@ -35,7 +36,7 @@ type McpTarget =
|
|||||||
| { type: "project" }
|
| { type: "project" }
|
||||||
| { type: "agent"; agentId: string; agentName: string };
|
| { 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[] {
|
function toggleTool(list: string[], tool: string): string[] {
|
||||||
return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool];
|
return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool];
|
||||||
@ -590,7 +591,7 @@ function McpToolEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ToolGroupSectionProps {
|
interface ToolGroupSectionProps {
|
||||||
group: { id: string; label: string; tools: string[] };
|
group: { id: string; label: string; description?: string; tools: string[] };
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
catalogue: McpToolCatalogue;
|
catalogue: McpToolCatalogue;
|
||||||
@ -618,6 +619,7 @@ function ToolGroupSection({
|
|||||||
const { tr } = useI18n();
|
const { tr } = useI18n();
|
||||||
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
|
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
|
||||||
const total = group.tools.length;
|
const total = group.tools.length;
|
||||||
|
const description = mcpToolGroupDescription(group, tr);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border">
|
<div className="rounded-md border border-border">
|
||||||
@ -628,7 +630,10 @@ function ToolGroupSection({
|
|||||||
onClick={onToggle}
|
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"
|
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="flex min-w-0 flex-col">
|
||||||
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
|
<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">
|
<span className="text-xs text-muted">
|
||||||
{allowedCount}/{total} {tr("autorisés", "allowed")}
|
{allowedCount}/{total} {tr("autorisés", "allowed")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type { McpToolCatalogue } from "@/domain";
|
|||||||
export interface McpToolGroupDef {
|
export interface McpToolGroupDef {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
tools: string[];
|
tools: string[];
|
||||||
}
|
}
|
||||||
type Tr = (french: string, english: string) => 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). */
|
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
|
||||||
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
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",
|
id: "crossProject",
|
||||||
label: "IdeA inter-projets",
|
label: "IdeA inter-projets",
|
||||||
@ -93,6 +105,10 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
|||||||
|
|
||||||
/** Short, human French label per exact MCP tool name. */
|
/** Short, human French label per exact MCP tool name. */
|
||||||
export const MCP_TOOL_LABELS: Record<string, string> = {
|
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_list: "Lister les projets IdeA",
|
||||||
idea_project_read_context: "Lire le contexte d'un autre projet",
|
idea_project_read_context: "Lire le contexte d'un autre projet",
|
||||||
idea_project_list_agents: "Lister les agents 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> = {
|
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_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_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",
|
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 {
|
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);
|
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. */
|
/** Short human label for `tool`, falling back to the exact name if unmapped. */
|
||||||
export function mcpToolLabel(tool: string, tr: Tr = fr): string {
|
export function mcpToolLabel(tool: string, tr: Tr = fr): string {
|
||||||
const french = MCP_TOOL_LABELS[tool];
|
const french = MCP_TOOL_LABELS[tool];
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import {
|
import {
|
||||||
mcpToolGroupLabel,
|
mcpToolGroupLabel,
|
||||||
|
mcpToolGroupDescription,
|
||||||
mcpToolGroupsForCatalogue,
|
mcpToolGroupsForCatalogue,
|
||||||
mcpToolLabel,
|
mcpToolLabel,
|
||||||
} from "@/features/permissions/mcpToolGroups";
|
} from "@/features/permissions/mcpToolGroups";
|
||||||
@ -72,6 +73,9 @@ export function TemplateEditor({
|
|||||||
const [allowedTools, setAllowedTools] = useState<string[]>(
|
const [allowedTools, setAllowedTools] = useState<string[]>(
|
||||||
template?.mcpToolPermissions?.allowedTools ?? [],
|
template?.mcpToolPermissions?.allowedTools ?? [],
|
||||||
);
|
);
|
||||||
|
const [mcpOverrideActive, setMcpOverrideActive] = useState(
|
||||||
|
template?.mcpToolPermissions != null,
|
||||||
|
);
|
||||||
const [tab, setTab] = useState<EditorTab>("edit");
|
const [tab, setTab] = useState<EditorTab>("edit");
|
||||||
const toolGroups = useMemo(
|
const toolGroups = useMemo(
|
||||||
() => mcpToolGroupsForCatalogue(mcpCatalogue),
|
() => mcpToolGroupsForCatalogue(mcpCatalogue),
|
||||||
@ -103,8 +107,9 @@ export function TemplateEditor({
|
|||||||
defaultAgentName: defaultAgentName.trim() || null,
|
defaultAgentName: defaultAgentName.trim() || null,
|
||||||
defaultContactable,
|
defaultContactable,
|
||||||
systemPermissions: network ? { network } : null,
|
systemPermissions: network ? { network } : null,
|
||||||
mcpToolPermissions:
|
mcpToolPermissions: mcpOverrideActive
|
||||||
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
? { allowedTools: [...allowedTools] }
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,21 +304,46 @@ export function TemplateEditor({
|
|||||||
<h3 className="text-xs font-medium text-muted">
|
<h3 className="text-xs font-medium text-muted">
|
||||||
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
|
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
|
||||||
</h3>
|
</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>
|
</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 ? (
|
{allMcpTools.length === 0 ? (
|
||||||
<p className="text-sm text-muted">{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}</p>
|
<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">
|
<p className="text-sm text-muted">
|
||||||
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
|
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{toolGroups.map((group) => (
|
{toolGroups.map((group) => {
|
||||||
|
const description = mcpToolGroupDescription(group, tr);
|
||||||
|
return (
|
||||||
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
<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>
|
<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) => (
|
{group.tools.map((tool) => (
|
||||||
<label
|
<label
|
||||||
key={tool}
|
key={tool}
|
||||||
@ -324,14 +354,15 @@ export function TemplateEditor({
|
|||||||
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
|
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
|
||||||
checked={allowed.has(tool)}
|
checked={allowed.has(tool)}
|
||||||
onChange={() => toggleTool(tool)}
|
onChange={() => toggleTool(tool)}
|
||||||
disabled={busy}
|
disabled={busy || !mcpOverrideActive}
|
||||||
className="h-4 w-4 shrink-0 accent-primary"
|
className="h-4 w-4 shrink-0 accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
|
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -50,7 +50,8 @@ export function systemPermissionsSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mcpPermissionsSummary(policy?: McpToolPolicy | null, tr: Tr = fr): string {
|
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 === 0) return `MCP: ${tr("aucun outil autorisé", "no tool allowed")}`;
|
||||||
if (tools.length <= 2) return `MCP: ${tools.map((tool) => mcpToolLabel(tool, tr)).join(", ")}`;
|
if (tools.length <= 2) return `MCP: ${tools.map((tool) => mcpToolLabel(tool, tr)).join(", ")}`;
|
||||||
return `MCP: ${tools.length} ${tr("outils autorisés", "tools allowed")}`;
|
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é");
|
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("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.getByLabelText("Outil MCP du modèle idea_ticket_read"));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||||
|
|
||||||
@ -267,9 +268,76 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
const fallback = screen.getByLabelText(
|
const fallback = screen.getByLabelText(
|
||||||
"Outil MCP du modèle idea_future_read",
|
"Outil MCP du modèle idea_future_read",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
expect(fallback.disabled).toBe(true);
|
||||||
|
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||||
expect(fallback.disabled).toBe(false);
|
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 () => {
|
it("the Create button is disabled when the name is empty", async () => {
|
||||||
renderTemplatesPanel();
|
renderTemplatesPanel();
|
||||||
await waitForTemplatesIdle();
|
await waitForTemplatesIdle();
|
||||||
@ -340,6 +408,8 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
"Outil MCP du modèle idea_ticket_read",
|
"Outil MCP du modèle idea_ticket_read",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(checkbox.checked).toBe(false);
|
expect(checkbox.checked).toBe(false);
|
||||||
|
expect(checkbox.disabled).toBe(true);
|
||||||
|
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
expect(checkbox.checked).toBe(true);
|
expect(checkbox.checked).toBe(true);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user