MCP-Client fuer HTTP-Server: externe Werkzeuge durch dieselben Pruefstellen (#197)
- Core/Mcp/McpClient: Streamable HTTP (MCP 2025-06-18) - initialize, tools/list with paging, tools/call; answers as JSON or event stream; session kept and opened again when the server forgets it; answers capped at 4 MB. - McpServerTool: one server is one tool (mcp_<id>); its remote tools are the operations, so rights, preflight, staging, lead approvals, votes, audit and budget apply. On approval by default unless the human trusts an operation; reads foreign content; the server's descriptions are cut short and marked. - McpConnections: registers, refreshes every 15 minutes, follows changes. - InstanceConfig.mcpServers with protected headers; /api/mcp-servers; settings card "External MCP servers"; tool catalog includes the servers. - stdio servers are not part of this (sandbox runner, separate step). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
44844a41a0
commit
caf4323012
@@ -2637,6 +2637,16 @@ button.org-einheit .org-name {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* #197: an external MCP server with its tools. */
|
||||
.mcp-server {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* #691: a template before it comes in — every agent with its full texts. */
|
||||
.org-vorlage-agent {
|
||||
border-left: 3px solid var(--accent);
|
||||
|
||||
@@ -1100,6 +1100,28 @@
|
||||
"mcp.copy": "Kopieren",
|
||||
"mcp.copied": "Kopiert.",
|
||||
"mcp.copyFailed": "Kopieren ist hier nicht erlaubt — Text markieren und von Hand kopieren.",
|
||||
"mcpServers.title": "Externe MCP-Server",
|
||||
"mcpServers.hint": "Jeder Server wird ein Werkzeug (mcp_<id>), das du Agenten wie jedes andere gibst — etwa die Workflows, die du in n8n freigegeben hast. Jeder Aufruf läuft durch Werkzeugrechte, Freigaben und Audit und steht auf Freigabe, solange du die Operation unten nicht als vertraut einträgst. Ergebnisse und Beschreibungen sind Fremdinhalt. Kopfzeilenwerte werden geschützt gespeichert und nie angezeigt.",
|
||||
"mcpServers.none": "Kein externer MCP-Server.",
|
||||
"mcpServers.add": "Server hinzufügen",
|
||||
"mcpServers.id": "ID (ergibt den Werkzeugnamen)",
|
||||
"mcpServers.name": "Name",
|
||||
"mcpServers.url": "Adresse (Streamable HTTP)",
|
||||
"mcpServers.token": "Bearer-Token (optional)",
|
||||
"mcpServers.tokenPlaceholder": "wird geschützt gespeichert",
|
||||
"mcpServers.trusted": "Ohne Freigabe",
|
||||
"mcpServers.trustedPlaceholder": "eine Operation je Zeile",
|
||||
"mcpServers.header": "Kopfzeile setzen",
|
||||
"mcpServers.headerValue": "Wert — leer entfernt sie",
|
||||
"mcpServers.headers": "Kopfzeilen: {names}",
|
||||
"mcpServers.toolsCount": "{n} Werkzeug(e)",
|
||||
"mcpServers.unreachable": "nicht erreichbar",
|
||||
"mcpServers.off": "aus",
|
||||
"mcpServers.refreshed": "Werkzeuge vom {time}",
|
||||
"mcpServers.refresh": "Aktualisieren",
|
||||
"mcpServers.disable": "Ausschalten",
|
||||
"mcpServers.enable": "Einschalten",
|
||||
"mcpServers.remove": "Entfernen",
|
||||
"trigger.title": "Eingehende Auslöser (n8n und andere)",
|
||||
"trigger.hint": "Etwas von außen startet Arbeit für diesen Agenten: ein n8n-Workflow auf eine Mail, einen Zeitplan oder ein Formular — oder jedes System, das eine HTTP-Anfrage schickt. Jede Lieferung wird eine Aufgabe für diesen Agenten; ihr Inhalt ist Fremdinhalt, nie Anweisung. Ein Token je Auslöser, mit Obergrenze je Stunde.",
|
||||
"trigger.labelPlaceholder": "z. B. n8n neue Bestellungen",
|
||||
|
||||
@@ -1100,6 +1100,28 @@
|
||||
"mcp.copy": "Copy",
|
||||
"mcp.copied": "Copied.",
|
||||
"mcp.copyFailed": "Copying is not allowed here — select the text and copy it by hand.",
|
||||
"mcpServers.title": "External MCP servers",
|
||||
"mcpServers.hint": "Each server becomes one tool (mcp_<id>) that you give to agents like any other — for instance the workflows you released in n8n. Every call goes through tool rights, approvals and the audit, and stands on approval unless you trust the operation below. Its results and descriptions are foreign content. Header values are stored protected and never shown.",
|
||||
"mcpServers.none": "No external MCP server.",
|
||||
"mcpServers.add": "Add server",
|
||||
"mcpServers.id": "Id (gives the tool name)",
|
||||
"mcpServers.name": "Name",
|
||||
"mcpServers.url": "Address (Streamable HTTP)",
|
||||
"mcpServers.token": "Bearer token (optional)",
|
||||
"mcpServers.tokenPlaceholder": "stored protected",
|
||||
"mcpServers.trusted": "Without approval",
|
||||
"mcpServers.trustedPlaceholder": "one operation per line",
|
||||
"mcpServers.header": "Set a header",
|
||||
"mcpServers.headerValue": "value — empty removes it",
|
||||
"mcpServers.headers": "Headers: {names}",
|
||||
"mcpServers.toolsCount": "{n} tool(s)",
|
||||
"mcpServers.unreachable": "not reachable",
|
||||
"mcpServers.off": "off",
|
||||
"mcpServers.refreshed": "tools as of {time}",
|
||||
"mcpServers.refresh": "Refresh",
|
||||
"mcpServers.disable": "Switch off",
|
||||
"mcpServers.enable": "Switch on",
|
||||
"mcpServers.remove": "Remove",
|
||||
"trigger.title": "Inbound triggers (n8n and others)",
|
||||
"trigger.hint": "Something outside starts work for this agent: an n8n workflow on a mail, a schedule or a form — or any system that sends an HTTP request. Each delivery becomes a task for this agent; its content is data from outside, never instructions. One token per trigger, capped per hour.",
|
||||
"trigger.labelPlaceholder": "e.g. n8n new orders",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// ClawdDotNet — external MCP servers as tools (#197)
|
||||
//
|
||||
// A human adds a server; it becomes one tool (mcp_<id>) that agents are given like any other.
|
||||
// Its calls go through rights, staging, approvals and audit — on approval by default, unless an
|
||||
// operation is trusted here. Header values (the server's token) are written, never shown.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
import { erstelleElement, erstelleKarte, erstellePlakette, formatiereDatumZeit, zeigeMeldung } from "../modules/dom.js";
|
||||
|
||||
export function erstelleMcpServerKarte() {
|
||||
const liste = erstelleElement("div", { klasse: "mcp-server-liste" });
|
||||
|
||||
async function lade() {
|
||||
let servers = [];
|
||||
try { servers = (await api("/mcp-servers")) || []; } catch (err) {
|
||||
liste.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: err.message }));
|
||||
return;
|
||||
}
|
||||
liste.replaceChildren(...(servers.length
|
||||
? servers.map(server)
|
||||
: [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.none") })]));
|
||||
}
|
||||
|
||||
async function senden(pfad, methode, body) {
|
||||
try {
|
||||
const ergebnis = await api(pfad, { method: methode, body });
|
||||
zeigeMeldung(ergebnis?.message ?? t("common.saved"), ergebnis?.ok === false ? "warnung" : "erfolg", 6000);
|
||||
await lade();
|
||||
return true;
|
||||
} catch (err) {
|
||||
zeigeMeldung(err.message, "gefahr", 8000);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function server(s) {
|
||||
const pfad = `/mcp-servers/${encodeURIComponent(s.id)}`;
|
||||
const vertraut = erstelleElement("textarea", { rows: 2, placeholder: t("mcpServers.trustedPlaceholder") });
|
||||
vertraut.value = (s.withoutApproval ?? []).join("\n");
|
||||
const kopfName = erstelleElement("input", { type: "text", placeholder: "Authorization" });
|
||||
const kopfWert = erstelleElement("input", { type: "password", autocomplete: "new-password", placeholder: t("mcpServers.headerValue") });
|
||||
|
||||
const zustand = s.problem
|
||||
? erstellePlakette(t("mcpServers.unreachable"), "gefahr")
|
||||
: s.enabled ? erstellePlakette(t("mcpServers.toolsCount", { n: s.tools.length }), "erfolg") : erstellePlakette(t("mcpServers.off"), "neutral");
|
||||
|
||||
return erstelleElement("div", { klasse: "mcp-server" },
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("strong", { text: s.name }),
|
||||
erstelleElement("code", { text: s.toolName }),
|
||||
zustand),
|
||||
erstelleElement("div", { klasse: "gedaempft text-klein",
|
||||
text: `${s.url}${s.refreshedAt ? " · " + t("mcpServers.refreshed", { time: formatiereDatumZeit(s.refreshedAt) }) : ""}${s.problem ? " · " + s.problem : ""}` }),
|
||||
s.tools.length
|
||||
? erstelleElement("ul", { klasse: "org-liste" }, ...s.tools.map((tool) => erstelleElement("li", { klasse: "text-klein" },
|
||||
erstelleElement("code", { text: tool.name }),
|
||||
erstelleElement("span", { klasse: "gedaempft", text: tool.description ? ` — ${tool.description}` : "" }))))
|
||||
: "",
|
||||
erstelleElement("div", { klasse: "gedaempft text-klein",
|
||||
text: t("mcpServers.headers", { names: s.headerNames.length ? s.headerNames.join(", ") : "—" }) }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("mcpServers.trusted") }), vertraut,
|
||||
erstelleElement("label", { text: t("mcpServers.header") }),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" }, kopfName, kopfWert)),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "klein", text: t("common.save"),
|
||||
onclick: () => {
|
||||
const headers = kopfName.value.trim() ? { [kopfName.value.trim()]: kopfWert.value } : null;
|
||||
senden(pfad, "PUT", { withoutApproval: vertraut.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean), headers });
|
||||
}
|
||||
}),
|
||||
erstelleElement("button", { type: "button", klasse: "flach klein", text: t("mcpServers.refresh"), onclick: () => senden(`${pfad}/refresh`, "POST") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: s.enabled ? t("mcpServers.disable") : t("mcpServers.enable"),
|
||||
onclick: () => senden(pfad, "PUT", { enabled: !s.enabled })
|
||||
}),
|
||||
erstelleElement("button", { type: "button", klasse: "flach klein", text: t("mcpServers.remove"), onclick: () => senden(pfad, "DELETE") })));
|
||||
}
|
||||
|
||||
const id = erstelleElement("input", { type: "text", placeholder: "n8n", maxlength: "31" });
|
||||
const name = erstelleElement("input", { type: "text", placeholder: "n8n workflows" });
|
||||
const url = erstelleElement("input", { type: "url", placeholder: "https://n8n.example/mcp/…" });
|
||||
const token = erstelleElement("input", { type: "password", autocomplete: "new-password", placeholder: t("mcpServers.tokenPlaceholder") });
|
||||
|
||||
lade();
|
||||
|
||||
return erstelleKarte(t("mcpServers.title"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.hint") }),
|
||||
liste,
|
||||
erstelleElement("h4", { text: t("mcpServers.add") }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("mcpServers.id") }), id,
|
||||
erstelleElement("label", { text: t("mcpServers.name") }), name,
|
||||
erstelleElement("label", { text: t("mcpServers.url") }), url,
|
||||
erstelleElement("label", { text: t("mcpServers.token") }), token),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "primaer klein", text: t("mcpServers.add"),
|
||||
onclick: async () => {
|
||||
const ok = await senden("/mcp-servers", "POST", {
|
||||
id: id.value.trim(), name: name.value.trim(), url: url.value.trim(),
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : null
|
||||
});
|
||||
if (ok) { id.value = ""; name.value = ""; url.value = ""; token.value = ""; }
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
erstelleIcon,
|
||||
PFADE
|
||||
} from "../modules/dom.js";
|
||||
import { erstelleMcpServerKarte } from "./mcp-servers.js";
|
||||
|
||||
export async function rendereEinstellungen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
@@ -249,6 +250,9 @@ export async function rendereEinstellungen() {
|
||||
instanzGitter.append(erstelleKarte(t("settings.runtimeAndSubsystems"), [dlSysteme]));
|
||||
|
||||
instanzSektion.append(instanzGitter);
|
||||
|
||||
// #197: external MCP servers — only a running instance knows them.
|
||||
if (instanzInfo) instanzSektion.append(erstelleMcpServerKarte());
|
||||
}
|
||||
|
||||
layout.append(instanzSektion);
|
||||
|
||||
Reference in New Issue
Block a user