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:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user