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:
@ -3185,6 +3185,10 @@ export class MockPermissionGateway implements PermissionGateway {
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
"idea_managed_temp_resolve",
|
||||
"idea_workspace_map",
|
||||
"idea_workspace_list",
|
||||
"idea_workspace_read",
|
||||
"idea_workspace_search",
|
||||
],
|
||||
writeActionTools: [
|
||||
"idea_project_ask_agent",
|
||||
|
||||
@ -120,6 +120,28 @@ describe("McpPanel", () => {
|
||||
})).toBeTruthy();
|
||||
});
|
||||
|
||||
it("surfaces native workspace tools as the Outils du projet group", async () => {
|
||||
await renderPanel();
|
||||
await openMcpToolsTab();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Outils du projet, 4 sur 4 autorisés/ })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", {
|
||||
name: /Cartographier le projet, idea_workspace_map$/,
|
||||
})).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", {
|
||||
name: /Lister les éléments du projet, idea_workspace_list$/,
|
||||
})).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", {
|
||||
name: /Lire un élément du projet, idea_workspace_read$/,
|
||||
})).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", {
|
||||
name: /Rechercher dans le projet, idea_workspace_search$/,
|
||||
})).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const permission = new MockPermissionGateway();
|
||||
@ -190,14 +212,14 @@ describe("McpPanel", () => {
|
||||
await selectAgentTarget("DevFrontend");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||
|
||||
expect(screen.getByText(/16 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
|
||||
expect(screen.getByText(/20 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
|
||||
|
||||
const writeCheckbox = screen.getByRole("checkbox", {
|
||||
name: /idea_ticket_update_carnet$/,
|
||||
});
|
||||
fireEvent.click(writeCheckbox);
|
||||
|
||||
expect(screen.getByText(/16 lectures autorisées · 1 écriture autorisée/)).toBeTruthy();
|
||||
expect(screen.getByText(/20 lectures autorisées · 1 écriture autorisée/)).toBeTruthy();
|
||||
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
|
||||
expect(screen.getByText("Ajouté")).toBeTruthy();
|
||||
});
|
||||
@ -230,7 +252,7 @@ describe("McpPanel", () => {
|
||||
"idea_ticket_update_carnet",
|
||||
]),
|
||||
);
|
||||
expect(calledPolicy?.allowedTools).toHaveLength(17);
|
||||
expect(calledPolicy?.allowedTools).toHaveLength(21);
|
||||
|
||||
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
|
||||
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||
|
||||
@ -20,6 +20,7 @@ import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import {
|
||||
mcpToolGroupLabel,
|
||||
mcpToolGroupDescription,
|
||||
mcpToolGroupsForCatalogue,
|
||||
mcpToolLabel,
|
||||
} from "./mcpToolGroups";
|
||||
@ -35,7 +36,7 @@ type McpTarget =
|
||||
| { type: "project" }
|
||||
| { type: "agent"; agentId: string; agentName: string };
|
||||
|
||||
const READ_GROUP_IDS = new Set(["readProject", "readTickets"]);
|
||||
const READ_GROUP_IDS = new Set(["workspace", "readProject", "readTickets"]);
|
||||
|
||||
function toggleTool(list: string[], tool: string): string[] {
|
||||
return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool];
|
||||
@ -590,7 +591,7 @@ function McpToolEditor({
|
||||
}
|
||||
|
||||
interface ToolGroupSectionProps {
|
||||
group: { id: string; label: string; tools: string[] };
|
||||
group: { id: string; label: string; description?: string; tools: string[] };
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
catalogue: McpToolCatalogue;
|
||||
@ -618,6 +619,7 @@ function ToolGroupSection({
|
||||
const { tr } = useI18n();
|
||||
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
|
||||
const total = group.tools.length;
|
||||
const description = mcpToolGroupDescription(group, tr);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border">
|
||||
@ -628,7 +630,10 @@ function ToolGroupSection({
|
||||
onClick={onToggle}
|
||||
className="flex min-h-[32px] w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-raised"
|
||||
>
|
||||
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
|
||||
{description && <span className="text-xs text-muted">{description}</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{allowedCount}/{total} {tr("autorisés", "allowed")}
|
||||
</span>
|
||||
|
||||
@ -12,6 +12,7 @@ import type { McpToolCatalogue } from "@/domain";
|
||||
export interface McpToolGroupDef {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tools: string[];
|
||||
}
|
||||
type Tr = (french: string, english: string) => string;
|
||||
@ -19,6 +20,17 @@ const fr: Tr = (french) => french;
|
||||
|
||||
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
|
||||
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
||||
{
|
||||
id: "workspace",
|
||||
label: "Outils du projet",
|
||||
description: "Permet à l’agent d’explorer, rechercher et lire les fichiers du projet.",
|
||||
tools: [
|
||||
"idea_workspace_map",
|
||||
"idea_workspace_list",
|
||||
"idea_workspace_read",
|
||||
"idea_workspace_search",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "crossProject",
|
||||
label: "IdeA inter-projets",
|
||||
@ -93,6 +105,10 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
||||
|
||||
/** Short, human French label per exact MCP tool name. */
|
||||
export const MCP_TOOL_LABELS: Record<string, string> = {
|
||||
idea_workspace_map: "Cartographier le projet",
|
||||
idea_workspace_list: "Lister les éléments du projet",
|
||||
idea_workspace_read: "Lire un élément du projet",
|
||||
idea_workspace_search: "Rechercher dans le projet",
|
||||
idea_project_list: "Lister les projets IdeA",
|
||||
idea_project_read_context: "Lire le contexte d'un autre projet",
|
||||
idea_project_list_agents: "Lister les agents d'un autre projet",
|
||||
@ -127,6 +143,7 @@ export const MCP_TOOL_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const MCP_TOOL_LABELS_EN: Record<string, string> = {
|
||||
idea_workspace_map: "Map the project", idea_workspace_list: "List project items", idea_workspace_read: "Read a project item", idea_workspace_search: "Search the project",
|
||||
idea_project_list: "List IdeA projects", idea_project_read_context: "Read another project's context", idea_project_list_agents: "List agents in another project", idea_project_ask_agent: "Ask an agent in another project", idea_project_cancel_agent: "Cancel a cross-project request",
|
||||
idea_list_agents: "List agents", idea_context_read: "Read an agent's context", idea_memory_read: "Read project memory", idea_skill_read: "Read a skill", idea_workstate_read: "Read work state", idea_ticket_read: "Read a ticket", idea_ticket_list: "List tickets", idea_ticket_read_carnet: "Read ticket notes", idea_sprint_list: "List sprints",
|
||||
idea_ask_agent: "Delegate to an agent", idea_ask_agents: "Ask several agents", idea_launch_agent: "Launch an agent", idea_stop_agent: "Stop an agent", idea_update_context: "Update an agent's context", idea_context_propose: "Propose context", idea_memory_write: "Write project memory", idea_workstate_set: "Write work state", idea_create_skill: "Create a skill",
|
||||
@ -134,10 +151,24 @@ const MCP_TOOL_LABELS_EN: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function mcpToolGroupLabel(group: McpToolGroupDef, tr: Tr = fr): string {
|
||||
const english: Record<string, string> = { crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" };
|
||||
const english: Record<string, string> = { workspace: "Project tools", crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" };
|
||||
return tr(group.label, english[group.id] ?? group.label);
|
||||
}
|
||||
|
||||
export function mcpToolGroupDescription(
|
||||
group: McpToolGroupDef,
|
||||
tr: Tr = fr,
|
||||
): string | undefined {
|
||||
if (!group.description) return undefined;
|
||||
if (group.id === "workspace") {
|
||||
return tr(
|
||||
group.description,
|
||||
"Lets the agent explore, search, and read project files.",
|
||||
);
|
||||
}
|
||||
return group.description;
|
||||
}
|
||||
|
||||
/** Short human label for `tool`, falling back to the exact name if unmapped. */
|
||||
export function mcpToolLabel(tool: string, tr: Tr = fr): string {
|
||||
const french = MCP_TOOL_LABELS[tool];
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import {
|
||||
mcpToolGroupLabel,
|
||||
mcpToolGroupDescription,
|
||||
mcpToolGroupsForCatalogue,
|
||||
mcpToolLabel,
|
||||
} from "@/features/permissions/mcpToolGroups";
|
||||
@ -72,6 +73,9 @@ export function TemplateEditor({
|
||||
const [allowedTools, setAllowedTools] = useState<string[]>(
|
||||
template?.mcpToolPermissions?.allowedTools ?? [],
|
||||
);
|
||||
const [mcpOverrideActive, setMcpOverrideActive] = useState(
|
||||
template?.mcpToolPermissions != null,
|
||||
);
|
||||
const [tab, setTab] = useState<EditorTab>("edit");
|
||||
const toolGroups = useMemo(
|
||||
() => mcpToolGroupsForCatalogue(mcpCatalogue),
|
||||
@ -103,8 +107,9 @@ export function TemplateEditor({
|
||||
defaultAgentName: defaultAgentName.trim() || null,
|
||||
defaultContactable,
|
||||
systemPermissions: network ? { network } : null,
|
||||
mcpToolPermissions:
|
||||
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
||||
mcpToolPermissions: mcpOverrideActive
|
||||
? { allowedTools: [...allowedTools] }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -299,39 +304,65 @@ export function TemplateEditor({
|
||||
<h3 className="text-xs font-medium text-muted">
|
||||
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
|
||||
</h3>
|
||||
<span className="text-xs text-faint">{allowedTools.length} {tr("autorisé(s)", "allowed")}</span>
|
||||
<span className="text-xs text-faint">
|
||||
{mcpOverrideActive
|
||||
? `${allowedTools.length} ${tr("autorisé(s)", "allowed")}`
|
||||
: tr("Héritage du projet", "Project inheritance")}
|
||||
</span>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}
|
||||
checked={!mcpOverrideActive}
|
||||
onChange={(event) => setMcpOverrideActive(!event.target.checked)}
|
||||
disabled={busy}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span>{tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{tr("Le modèle utilise les outils autorisés par le projet.", "The template uses the tools allowed by the project.")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{allMcpTools.length === 0 ? (
|
||||
<p className="text-sm text-muted">{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}</p>
|
||||
) : (
|
||||
<>
|
||||
{allowedTools.length === 0 && (
|
||||
{mcpOverrideActive && allowedTools.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{toolGroups.map((group) => (
|
||||
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
||||
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
|
||||
{group.tools.map((tool) => (
|
||||
<label
|
||||
key={tool}
|
||||
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
|
||||
checked={allowed.has(tool)}
|
||||
onChange={() => toggleTool(tool)}
|
||||
disabled={busy}
|
||||
className="h-4 w-4 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{toolGroups.map((group) => {
|
||||
const description = mcpToolGroupDescription(group, tr);
|
||||
return (
|
||||
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
|
||||
{description && <p className="text-xs text-muted">{description}</p>}
|
||||
</div>
|
||||
{group.tools.map((tool) => (
|
||||
<label
|
||||
key={tool}
|
||||
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
|
||||
checked={allowed.has(tool)}
|
||||
onChange={() => toggleTool(tool)}
|
||||
disabled={busy || !mcpOverrideActive}
|
||||
className="h-4 w-4 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -50,7 +50,8 @@ export function systemPermissionsSummary(
|
||||
}
|
||||
|
||||
export function mcpPermissionsSummary(policy?: McpToolPolicy | null, tr: Tr = fr): string {
|
||||
const tools = policy?.allowedTools ?? [];
|
||||
if (policy == null) return `MCP: ${tr("héritage du projet", "project inheritance")}`;
|
||||
const tools = policy.allowedTools;
|
||||
if (tools.length === 0) return `MCP: ${tr("aucun outil autorisé", "no tool allowed")}`;
|
||||
if (tools.length <= 2) return `MCP: ${tools.map((tool) => mcpToolLabel(tool, tr)).join(", ")}`;
|
||||
return `MCP: ${tools.length} ${tr("outils autorisés", "tools allowed")}`;
|
||||
|
||||
@ -182,6 +182,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
||||
});
|
||||
chooseDropdownOption("Permission réseau système du modèle", "Réseau autorisé");
|
||||
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
|
||||
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||
fireEvent.click(screen.getByLabelText("Outil MCP du modèle idea_ticket_read"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||
|
||||
@ -267,9 +268,76 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
||||
const fallback = screen.getByLabelText(
|
||||
"Outil MCP du modèle idea_future_read",
|
||||
) as HTMLInputElement;
|
||||
expect(fallback.disabled).toBe(true);
|
||||
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||
expect(fallback.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("groups the native workspace tools in the template editor", async () => {
|
||||
const permission = new MockPermissionGateway();
|
||||
permission.setMcpToolCatalogue({
|
||||
readOnlyTools: [
|
||||
"idea_workspace_map",
|
||||
"idea_workspace_list",
|
||||
"idea_workspace_read",
|
||||
"idea_workspace_search",
|
||||
],
|
||||
writeActionTools: [],
|
||||
});
|
||||
renderTemplatesPanel({ permission });
|
||||
await waitForTemplatesIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
|
||||
|
||||
expect(await screen.findByText("Outils du projet")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Cartographier le projet")).toBeTruthy();
|
||||
expect(screen.getByText("Lister les éléments du projet")).toBeTruthy();
|
||||
expect(screen.getByText("Lire un élément du projet")).toBeTruthy();
|
||||
expect(screen.getByText("Rechercher dans le projet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("distinguishes project inheritance from an explicit empty MCP override", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const tmpl = new MockTemplateGateway(agent);
|
||||
renderTemplatesPanel({ agent, template: tmpl });
|
||||
await waitForTemplatesIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
|
||||
await screen.findByLabelText("Nom du modèle");
|
||||
fireEvent.change(screen.getByLabelText("Nom du modèle"), {
|
||||
target: { value: "Inherited tools" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||
|
||||
await screen.findByText("Inherited tools");
|
||||
expect((await tmpl.listTemplates())[0].mcpToolPermissions).toBeNull();
|
||||
expect(screen.getByText(/MCP: héritage du projet/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un modèle" }));
|
||||
await screen.findByLabelText("Nom du modèle");
|
||||
fireEvent.change(screen.getByLabelText("Nom du modèle"), {
|
||||
target: { value: "No tools" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||
|
||||
await screen.findByText("No tools");
|
||||
expect((await tmpl.listTemplates())[1].mcpToolPermissions).toEqual({ allowedTools: [] });
|
||||
expect(screen.getByText(/MCP: aucun outil autorisé/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Modifier No tools" }));
|
||||
const inherit = await screen.findByLabelText(
|
||||
"Hériter des outils MCP du projet",
|
||||
) as HTMLInputElement;
|
||||
expect(inherit.checked).toBe(false);
|
||||
expect(
|
||||
(screen.getByLabelText("Outil MCP du modèle idea_ticket_read") as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("the Create button is disabled when the name is empty", async () => {
|
||||
renderTemplatesPanel();
|
||||
await waitForTemplatesIdle();
|
||||
@ -340,6 +408,8 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
||||
"Outil MCP du modèle idea_ticket_read",
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.disabled).toBe(true);
|
||||
fireEvent.click(screen.getByLabelText("Hériter des outils MCP du projet"));
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le modèle" }));
|
||||
|
||||
Reference in New Issue
Block a user