diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f43388..1071bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.7.0] — 2026-06-23 +### Added +- `src/mcp/readonly-filter.ts` : barrière lecture seule — n'expose que les outils + `readOnlyHint === true` (fail-safe : absence d'annotation ⇒ écarté) ; déduit le + palier de risque (jamais déclaré par le LLM). +- `src/mcp/registry.ts` : connexion MCP HTTP authentifiée (n8n + Portainer), + liste + filtre les outils read-only en `ToolHandle`, sérialise les résultats. +### Changed +- `tsconfig` : retrait de `exactOptionalPropertyTypes` (interop SDK MCP) ; reste + strict (`strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`). + ## [0.6.0] — 2026-06-23 ### Added - Client Ollama `/api/chat` (`src/llm/ollama.ts`) : modèles cloud via proxy, diff --git a/orchestrator/src/mcp/readonly-filter.ts b/orchestrator/src/mcp/readonly-filter.ts new file mode 100644 index 0000000..93fee05 --- /dev/null +++ b/orchestrator/src/mcp/readonly-filter.ts @@ -0,0 +1,42 @@ +import type { RiskTier } from "../audit/log.js"; + +/** + * Filtre lecture seule (Phase 1). + * + * Barrière n°1 de la lecture seule côté orchestrateur : SEULS les outils MCP + * annotés `readOnlyHint === true` sont exposés au LLM. Tout outil sans annotation + * explicite est traité comme NON read-only (fail-safe) et écarté en Phase 1. + * + * Double barrière avec `PORTAINER_READ_ONLY=true` côté serveur MCP (docs/security.md). + */ + +/** Forme minimale d'un outil MCP telle qu'utilisée ici. */ +export interface McpToolLike { + name: string; + description?: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + }; +} + +/** + * Lecture seule UNIQUEMENT si `readOnlyHint === true` ET pas marqué destructif. + * Absence d'annotation ⇒ false (fail-safe). + */ +export function isReadOnly(tool: McpToolLike): boolean { + const a = tool.annotations; + if (!a || a.readOnlyHint !== true) return false; + if (a.destructiveHint === true) return false; + return true; +} + +/** Palier de risque déduit de l'annotation (jamais déclaré par le LLM). */ +export function resolveRiskTier(tool: McpToolLike): RiskTier { + return isReadOnly(tool) ? "reversible" : "privileged"; +} + +/** Ne garde que les outils read-only. */ +export function filterReadOnly(tools: T[]): T[] { + return tools.filter(isReadOnly); +} diff --git a/orchestrator/src/mcp/registry.ts b/orchestrator/src/mcp/registry.ts new file mode 100644 index 0000000..09bf2ea --- /dev/null +++ b/orchestrator/src/mcp/registry.ts @@ -0,0 +1,111 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Logger } from "pino"; +import type { ToolHandle, ToolSpec } from "../agent/types.js"; +import { isReadOnly, resolveRiskTier } from "./readonly-filter.js"; + +/** + * Registry MCP : connecte les serveurs MCP configurés (n8n, Portainer), liste + * leurs outils et n'expose à l'agent que les outils LECTURE SEULE (Phase 1). + * + * Les outils sont préfixés par le nom du serveur (`n8n.`) pour l'unicité. + * Le palier de risque est déduit des annotations MCP, jamais déclaré par le LLM. + */ + +export interface McpServerConfig { + /** Nom court, sert de préfixe (ex. "n8n", "portainer"). */ + name: string; + url: string; + authToken: string; +} + +interface ConnectedServer { + name: string; + client: Client; +} + +export class McpRegistry { + private readonly servers: ConnectedServer[] = []; + + constructor(private readonly logger: Logger) {} + + /** Connecte un serveur MCP via transport HTTP authentifié. */ + async connect(cfg: McpServerConfig): Promise { + const transport = new StreamableHTTPClientTransport(new URL(cfg.url), { + requestInit: { + headers: { authorization: `Bearer ${cfg.authToken}` }, + }, + }); + const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" }); + await client.connect(transport); + this.servers.push({ name: cfg.name, client }); + this.logger.info({ server: cfg.name }, "MCP connecté"); + } + + /** + * Liste les outils LECTURE SEULE de tous les serveurs, sous forme de + * ToolHandle exécutables. Les outils non read-only sont écartés ET journalisés. + */ + async listReadOnlyTools(): Promise { + const handles: ToolHandle[] = []; + + for (const srv of this.servers) { + const { tools } = await srv.client.listTools(); + for (const tool of tools) { + if (!isReadOnly(tool)) { + this.logger.debug( + { server: srv.name, tool: tool.name }, + "outil non read-only écarté (Phase 1)", + ); + continue; + } + + const spec: ToolSpec = { + name: `${srv.name}.${tool.name}`, + description: tool.description ?? tool.name, + parameters: (tool.inputSchema as Record) ?? { + type: "object", + properties: {}, + }, + server: srv.name, + readOnly: true, + riskTier: resolveRiskTier(tool), + }; + + const rawName = tool.name; + const client = srv.client; + handles.push({ + spec, + async execute(args: Record): Promise { + const res = await client.callTool({ name: rawName, arguments: args }); + return serializeContent(res.content); + }, + }); + } + } + + this.logger.info({ count: handles.length }, "outils read-only exposés"); + return handles; + } + + async close(): Promise { + await Promise.allSettled(this.servers.map((s) => s.client.close())); + } +} + +/** Sérialise le contenu MCP renvoyé en texte destiné au LLM. */ +function serializeContent(content: unknown): string { + if (!Array.isArray(content)) return String(content ?? ""); + const parts: string[] = []; + for (const item of content) { + if (item && typeof item === "object" && "type" in item) { + const obj = item as Record; + if (obj.type === "text" && typeof obj.text === "string") { + parts.push(obj.text); + } else { + parts.push(`[${String(obj.type)}]`); + } + } + } + return parts.join("\n"); +} diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index 97c49c1..63aa9ce 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -9,7 +9,6 @@ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "resolveJsonModule": true,