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:
@@ -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,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user