feat: registry MCP + readonly-filter (v0.7.0)

Barrière n°1 de la lecture seule : seuls les outils readOnlyHint=true sont
exposés (fail-safe, palier de risque déduit côté code). Registry connecte
n8n + Portainer en HTTP authentifié et produit des ToolHandle read-only.
Retrait d'exactOptionalPropertyTypes pour interop SDK MCP (reste strict).

Palier de risque : reversible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:13:03 +02:00
parent be1ad76966
commit bfce952817
4 changed files with 164 additions and 1 deletions
+11
View File
@@ -6,6 +6,17 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.6.0] — 2026-06-23
### Added ### Added
- Client Ollama `/api/chat` (`src/llm/ollama.ts`) : modèles cloud via proxy, - Client Ollama `/api/chat` (`src/llm/ollama.ts`) : modèles cloud via proxy,
+42
View File
@@ -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<T extends McpToolLike>(tools: T[]): T[] {
return tools.filter(isReadOnly);
}
+111
View File
@@ -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.<tool>`) 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<void> {
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<ToolHandle[]> {
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<string, unknown>) ?? {
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<string, unknown>): Promise<string> {
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<void> {
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<string, unknown>;
if (obj.type === "text" && typeof obj.text === "string") {
parts.push(obj.text);
} else {
parts.push(`[${String(obj.type)}]`);
}
}
}
return parts.join("\n");
}
-1
View File
@@ -9,7 +9,6 @@
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,