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:
2026-09-12 19:17:09 +02:00
parent ea42d95f2e
commit 7b712f2b62
16 changed files with 1503 additions and 80 deletions

View File

@ -3185,6 +3185,10 @@ export class MockPermissionGateway implements PermissionGateway {
"idea_template_list",
"idea_template_read",
"idea_managed_temp_resolve",
"idea_workspace_map",
"idea_workspace_list",
"idea_workspace_read",
"idea_workspace_search",
],
writeActionTools: [
"idea_project_ask_agent",

View File

@ -120,6 +120,28 @@ describe("McpPanel", () => {
})).toBeTruthy();
});
it("surfaces native workspace tools as the Outils du projet group", async () => {
await renderPanel();
await openMcpToolsTab();
expect(screen.getByRole("button", { name: /Outils du projet, 4 sur 4 autorisés/ })).toBeTruthy();
expect(
screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."),
).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Cartographier le projet, idea_workspace_map$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Lister les éléments du projet, idea_workspace_list$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Lire un élément du projet, idea_workspace_read$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Rechercher dans le projet, idea_workspace_search$/,
})).toBeTruthy();
});
it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
@ -190,14 +212,14 @@ describe("McpPanel", () => {
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
expect(screen.getByText(/16 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
expect(screen.getByText(/20 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
});
fireEvent.click(writeCheckbox);
expect(screen.getByText(/16 lectures autorisées · 1 écriture autorisée/)).toBeTruthy();
expect(screen.getByText(/20 lectures autorisées · 1 écriture autorisée/)).toBeTruthy();
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
expect(screen.getByText("Ajouté")).toBeTruthy();
});
@ -230,7 +252,7 @@ describe("McpPanel", () => {
"idea_ticket_update_carnet",
]),
);
expect(calledPolicy?.allowedTools).toHaveLength(17);
expect(calledPolicy?.allowedTools).toHaveLength(21);
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });

View File

@ -20,6 +20,7 @@ import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { Button, Input, Panel, cn } from "@/shared";
import {
mcpToolGroupLabel,
mcpToolGroupDescription,
mcpToolGroupsForCatalogue,
mcpToolLabel,
} from "./mcpToolGroups";
@ -35,7 +36,7 @@ type McpTarget =
| { type: "project" }
| { type: "agent"; agentId: string; agentName: string };
const READ_GROUP_IDS = new Set(["readProject", "readTickets"]);
const READ_GROUP_IDS = new Set(["workspace", "readProject", "readTickets"]);
function toggleTool(list: string[], tool: string): string[] {
return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool];
@ -590,7 +591,7 @@ function McpToolEditor({
}
interface ToolGroupSectionProps {
group: { id: string; label: string; tools: string[] };
group: { id: string; label: string; description?: string; tools: string[] };
expanded: boolean;
onToggle: () => void;
catalogue: McpToolCatalogue;
@ -618,6 +619,7 @@ function ToolGroupSection({
const { tr } = useI18n();
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
const total = group.tools.length;
const description = mcpToolGroupDescription(group, tr);
return (
<div className="rounded-md border border-border">
@ -628,7 +630,10 @@ function ToolGroupSection({
onClick={onToggle}
className="flex min-h-[32px] w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-raised"
>
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
{description && <span className="text-xs text-muted">{description}</span>}
</span>
<span className="text-xs text-muted">
{allowedCount}/{total} {tr("autorisés", "allowed")}
</span>

View File

@ -12,6 +12,7 @@ import type { McpToolCatalogue } from "@/domain";
export interface McpToolGroupDef {
id: string;
label: string;
description?: string;
tools: string[];
}
type Tr = (french: string, english: string) => string;
@ -19,6 +20,17 @@ const fr: Tr = (french) => french;
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
{
id: "workspace",
label: "Outils du projet",
description: "Permet à l’agent d’explorer, rechercher et lire les fichiers du projet.",
tools: [
"idea_workspace_map",
"idea_workspace_list",
"idea_workspace_read",
"idea_workspace_search",
],
},
{
id: "crossProject",
label: "IdeA inter-projets",
@ -93,6 +105,10 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
/** Short, human French label per exact MCP tool name. */
export const MCP_TOOL_LABELS: Record<string, string> = {
idea_workspace_map: "Cartographier le projet",
idea_workspace_list: "Lister les éléments du projet",
idea_workspace_read: "Lire un élément du projet",
idea_workspace_search: "Rechercher dans le projet",
idea_project_list: "Lister les projets IdeA",
idea_project_read_context: "Lire le contexte d'un autre projet",
idea_project_list_agents: "Lister les agents d'un autre projet",
@ -127,6 +143,7 @@ export const MCP_TOOL_LABELS: Record<string, string> = {
};
const MCP_TOOL_LABELS_EN: Record<string, string> = {
idea_workspace_map: "Map the project", idea_workspace_list: "List project items", idea_workspace_read: "Read a project item", idea_workspace_search: "Search the project",
idea_project_list: "List IdeA projects", idea_project_read_context: "Read another project's context", idea_project_list_agents: "List agents in another project", idea_project_ask_agent: "Ask an agent in another project", idea_project_cancel_agent: "Cancel a cross-project request",
idea_list_agents: "List agents", idea_context_read: "Read an agent's context", idea_memory_read: "Read project memory", idea_skill_read: "Read a skill", idea_workstate_read: "Read work state", idea_ticket_read: "Read a ticket", idea_ticket_list: "List tickets", idea_ticket_read_carnet: "Read ticket notes", idea_sprint_list: "List sprints",
idea_ask_agent: "Delegate to an agent", idea_ask_agents: "Ask several agents", idea_launch_agent: "Launch an agent", idea_stop_agent: "Stop an agent", idea_update_context: "Update an agent's context", idea_context_propose: "Propose context", idea_memory_write: "Write project memory", idea_workstate_set: "Write work state", idea_create_skill: "Create a skill",
@ -134,10 +151,24 @@ const MCP_TOOL_LABELS_EN: Record<string, string> = {
};
export function mcpToolGroupLabel(group: McpToolGroupDef, tr: Tr = fr): string {
const english: Record<string, string> = { crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" };
const english: Record<string, string> = { workspace: "Project tools", crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" };
return tr(group.label, english[group.id] ?? group.label);
}
export function mcpToolGroupDescription(
group: McpToolGroupDef,
tr: Tr = fr,
): string | undefined {
if (!group.description) return undefined;
if (group.id === "workspace") {
return tr(
group.description,
"Lets the agent explore, search, and read project files.",
);
}
return group.description;
}
/** Short human label for `tool`, falling back to the exact name if unmapped. */
export function mcpToolLabel(tool: string, tr: Tr = fr): string {
const french = MCP_TOOL_LABELS[tool];

View File

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

View File

@ -50,7 +50,8 @@ export function systemPermissionsSummary(
}
export function mcpPermissionsSummary(policy?: McpToolPolicy | null, tr: Tr = fr): string {
const tools = policy?.allowedTools ?? [];
if (policy == null) return `MCP: ${tr("héritage du projet", "project inheritance")}`;
const tools = policy.allowedTools;
if (tools.length === 0) return `MCP: ${tr("aucun outil autorisé", "no tool allowed")}`;
if (tools.length <= 2) return `MCP: ${tools.map((tool) => mcpToolLabel(tool, tr)).join(", ")}`;
return `MCP: ${tools.length} ${tr("outils autorisés", "tools allowed")}`;

View File

@ -182,6 +182,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
});
chooseDropdownOption("Permission réseau système du modèle", "Réseau autorisé");
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
fireEvent.click(screen.getByLabelText("Outil MCP du modèle idea_ticket_read"));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
@ -267,9 +268,76 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
const fallback = screen.getByLabelText(
"Outil MCP du modèle idea_future_read",
) as HTMLInputElement;
expect(fallback.disabled).toBe(true);
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
expect(fallback.disabled).toBe(false);
});
it("groups the native workspace tools in the template editor", async () => {
const permission = new MockPermissionGateway();
permission.setMcpToolCatalogue({
readOnlyTools: [
"idea_workspace_map",
"idea_workspace_list",
"idea_workspace_read",
"idea_workspace_search",
],
writeActionTools: [],
});
renderTemplatesPanel({ permission });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
expect(await screen.findByText("Outils du projet")).toBeTruthy();
expect(
screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."),
).toBeTruthy();
expect(screen.getByText("Cartographier le projet")).toBeTruthy();
expect(screen.getByText("Lister les éléments du projet")).toBeTruthy();
expect(screen.getByText("Lire un élément du projet")).toBeTruthy();
expect(screen.getByText("Rechercher dans le projet")).toBeTruthy();
});
it("distinguishes project inheritance from an explicit empty MCP override", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
await screen.findByLabelText("Nom du modèle");
fireEvent.change(screen.getByLabelText("Nom du modèle"), {
target: { value: "Inherited tools" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
await screen.findByText("Inherited tools");
expect((await tmpl.listTemplates())[0].mcpToolPermissions).toBeNull();
expect(screen.getByText(/MCP: héritage du projet/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
await screen.findByLabelText("Nom du modèle");
fireEvent.change(screen.getByLabelText("Nom du modèle"), {
target: { value: "No tools" },
});
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
await screen.findByText("No tools");
expect((await tmpl.listTemplates())[1].mcpToolPermissions).toEqual({ allowedTools: [] });
expect(screen.getByText(/MCP: aucun outil autorisé/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Modifier No tools" }));
const inherit = await screen.findByLabelText(
"Hériter des outils MCP du projet",
) as HTMLInputElement;
expect(inherit.checked).toBe(false);
expect(
(screen.getByLabelText("Outil MCP du modèle idea_ticket_read") as HTMLInputElement).disabled,
).toBe(false);
});
it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
@ -340,6 +408,8 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
"Outil MCP du modèle idea_ticket_read",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(checkbox.disabled).toBe(true);
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));