ClawdDotNet als MCP-Server: durch dieselben Pruefstellen (#713)

Eine fremde Laufzeit oder der eigene Claude Code des Menschen spricht per MCP
mit der Instanz. Der Server bietet genau die Werkzeuge des Agenten hinter dem
Token an; jeder Aufruf laeuft durch die Engine wie ein Aufruf des eigenen
Modells (Rechtetor, Vorpruefung, Werkzeugrechte, Staging, Audit mit Quelle
mcp, Rahmung), und der Aufrufer gilt als Leser fremder Inhalte (#715).

Eigener Socket mcp.sock statt manager.sock: ueber den Instanz-Socket antwortet
die Verwaltung ohne Token, ueber mcp.sock gibt es nur /mcp und nur mit
MCP-Token. Lauf-Tokens nur im Speicher, enden mit dem Lauf; persoenliche Tokens
(hoechstens 90 Tage, widerrufbar, nur als SHA-256) in der Agentenansicht.
Transporte: HTTP auf mcp.sock, Weboberflaeche und Manager (/mcp/{instanz}),
stdio ueber ClawdDotNet --mcp-bridge (Token nur aus Umgebung oder 0600-Datei).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 17:28:47 +02:00
co-authored by Claude Opus 5.5
parent b5f65b8cf7
commit cb46289bb6
25 changed files with 1672 additions and 12 deletions
@@ -856,6 +856,40 @@ button.klein {
vertical-align: middle;
}
/* #713: external access over MCP — personal tokens of an agent. */
.mcp-token-formular {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.mcp-token-formular input {
flex: 1 1 180px;
min-width: 0;
}
.mcp-token-inaktiv {
opacity: 0.55;
}
.mcp-token-neu {
display: flex;
flex-direction: column;
gap: 8px;
}
.mcp-token-neu button {
align-self: flex-start;
}
.mcp-token-feld {
width: 100%;
font-family: var(--schrift-mono, monospace);
font-size: 12px;
resize: vertical;
}
/* ─── Ansicht: Aufgaben & Jobs ─── */
.reiter-leiste {
@@ -922,5 +922,28 @@
"cases.scope": "Gilt für",
"cases.scopeAll": "die ganze Instanz",
"cases.decidedBy": "Entschieden von",
"cases.failed": "Fehlgeschlagen: {error}"
"cases.failed": "Fehlgeschlagen: {error}",
"mcp.title": "Externer Zugang (MCP)",
"mcp.hint": "Mit einem persönlichen Token arbeitet dein eigenes Programm, etwa Claude Code, als dieser Agent: mit genau seinen Werkzeugen und Rechten. Jeder Aufruf wird protokolliert, und was eine Freigabe braucht, wird dir wie gewohnt vorgelegt.",
"mcp.label": "Name",
"mcp.labelPlaceholder": "z. B. mein Laptop",
"mcp.days": "Gültig für",
"mcp.daysOption": "{n} Tage",
"mcp.issue": "Token ausgeben",
"mcp.none": "Noch keine Tokens.",
"mcp.unnamed": "(ohne Namen)",
"mcp.activeUntil": "gültig bis {time}",
"mcp.revoked": "widerrufen {time}",
"mcp.expired": "abgelaufen {time}",
"mcp.revoke": "Widerrufen",
"mcp.revokedMessage": "Token widerrufen.",
"mcp.failed": "Fehlgeschlagen: {error}",
"mcp.issuedTitle": "Token für {agent}",
"mcp.shownOnce": "Dieses Token wird nur jetzt angezeigt. Die Instanz behält nur einen Fingerabdruck davon; geht es verloren, gib ein neues aus.",
"mcp.token": "Token",
"mcp.config": "Konfiguration für Claude Code",
"mcp.configHint": "In die .mcp.json des Projekts, in dem du arbeitest. Sie enthält das Token: die Datei nicht in die Versionsverwaltung aufnehmen.",
"mcp.copy": "Kopieren",
"mcp.copied": "Kopiert.",
"mcp.copyFailed": "Kopieren ist hier nicht erlaubt — Text markieren und von Hand kopieren."
}
@@ -922,5 +922,28 @@
"cases.scope": "Binds",
"cases.scopeAll": "the whole instance",
"cases.decidedBy": "Decided by",
"cases.failed": "Failed: {error}"
"cases.failed": "Failed: {error}",
"mcp.title": "External access (MCP)",
"mcp.hint": "A personal token lets your own client, such as Claude Code, work as this agent: with exactly its tools and rights. Every call is audited, and what needs approval is put to you as usual.",
"mcp.label": "Name",
"mcp.labelPlaceholder": "e.g. my laptop",
"mcp.days": "Valid for",
"mcp.daysOption": "{n} days",
"mcp.issue": "Issue token",
"mcp.none": "No tokens yet.",
"mcp.unnamed": "(unnamed)",
"mcp.activeUntil": "valid until {time}",
"mcp.revoked": "revoked {time}",
"mcp.expired": "expired {time}",
"mcp.revoke": "Revoke",
"mcp.revokedMessage": "Token revoked.",
"mcp.failed": "Failed: {error}",
"mcp.issuedTitle": "Token for {agent}",
"mcp.shownOnce": "This token is shown only now. The instance keeps just a fingerprint of it; if it is lost, issue a new one.",
"mcp.token": "Token",
"mcp.config": "Configuration for Claude Code",
"mcp.configHint": "Put this into the .mcp.json of the project you work in. It contains the token: keep the file out of version control.",
"mcp.copy": "Copy",
"mcp.copied": "Copied.",
"mcp.copyFailed": "Copying is not allowed here — select the text and copy it by hand."
}
@@ -5,6 +5,7 @@
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import { t } from "../modules/i18n.js";
import { erstelleMcpKarte } from "./mcp-access.js";
import {
erstelleElement,
erstelleIcon,
@@ -329,7 +330,8 @@ async function rendereDetails(agentId) {
const berichtswegKarte = erstelleKarte(t("agents.reportingLine"), [berichtswegText, verschaerfenZeile]);
detailContainer.append(basisKarte, werkzeugeKarte, berichtswegKarte);
// #713: A human's own client working as this agent, through the MCP server.
detailContainer.append(basisKarte, werkzeugeKarte, berichtswegKarte, erstelleMcpKarte(agent));
}
function oeffneNeuerAgentDialog() {
@@ -0,0 +1,155 @@
// ClawdDotNet — external access to an agent over MCP (#713)
//
// A personal token lets a human's own client (Claude Code, for instance) act as one agent:
// with exactly its tools and rights, every call audited, staging as for the agent itself.
// The token is shown once, when it is issued; the instance keeps only its hash.
import { api, holeAktiveInstanz } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
/** The card for one agent: its tokens, and issuing a new one. */
export function erstelleMcpKarte(agent) {
const liste = erstelleElement("div", { klasse: "mcp-token-liste" });
const bezeichnung = erstelleElement("input", {
type: "text",
placeholder: t("mcp.labelPlaceholder"),
"aria-label": t("mcp.label"),
maxlength: "60"
});
const dauer = erstelleElement("select", { "aria-label": t("mcp.days") });
for (const tage of [7, 30, 90]) {
dauer.append(erstelleElement("option", { value: String(tage), text: t("mcp.daysOption", { n: tage }) }));
}
dauer.value = "30";
const ausgeben = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche primaer",
text: t("mcp.issue"),
onclick: async () => {
ausgeben.disabled = true;
try {
const neu = await api("/mcp/tokens", {
method: "POST",
body: { agentId: agent.agentId, label: bezeichnung.value.trim(), days: Number(dauer.value) }
});
bezeichnung.value = "";
zeigeNeuesToken(neu);
await lade();
} catch (err) {
zeigeMeldung(t("mcp.failed", { error: err.message }), "gefahr");
} finally {
ausgeben.disabled = false;
}
}
});
const formular = erstelleElement("div", { klasse: "mcp-token-formular" }, bezeichnung, dauer, ausgeben);
async function lade() {
try {
const alle = (await api("/mcp/tokens")) || [];
const eigene = alle.filter((x) => x.agentId.toLowerCase() === agent.agentId.toLowerCase());
liste.replaceChildren(...(eigene.length === 0
? [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcp.none") })]
: eigene.map(zeile)));
} catch (err) {
liste.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: err.message }));
}
}
function zeile(token) {
const status = token.active
? t("mcp.activeUntil", { time: formatiereDatumZeit(token.expiresAt) })
: token.revokedAt
? t("mcp.revoked", { time: formatiereDatumZeit(token.revokedAt) })
: t("mcp.expired", { time: formatiereDatumZeit(token.expiresAt) });
const aktionen = erstelleElement("div", { klasse: "werkzeug-aktionen" });
if (token.active) {
aktionen.append(erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach klein",
text: t("mcp.revoke"),
onclick: async () => {
try {
await api(`/mcp/tokens/${encodeURIComponent(token.id)}`, { method: "DELETE" });
zeigeMeldung(t("mcp.revokedMessage"), "info");
await lade();
} catch (err) {
zeigeMeldung(t("mcp.failed", { error: err.message }), "gefahr");
}
}
}));
}
return erstelleElement("div", { klasse: `werkzeug-zeile${token.active ? "" : " mcp-token-inaktiv"}` },
erstelleElement("div", { klasse: "werkzeug-info" },
erstelleElement("strong", { text: token.label || t("mcp.unnamed") }),
erstelleElement("div", { klasse: "werkzeug-beschreibung gedaempft", text: status })),
aktionen);
}
lade();
return erstelleKarte(t("mcp.title"), [
erstelleElement("p", { klasse: "gedaempft", text: t("mcp.hint") }),
liste,
formular
]);
}
/** The token once, and how to use it — it cannot be shown again. */
function zeigeNeuesToken(neu) {
const instanz = holeAktiveInstanz();
const adresse = `${location.origin}/mcp${instanz ? `/${encodeURIComponent(instanz)}` : ""}`;
const name = `clawd-${neu.info.agentId}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
const konfiguration = JSON.stringify({
mcpServers: {
[name]: { type: "http", url: adresse, headers: { Authorization: `Bearer ${neu.token}` } }
}
}, null, 2);
const feld = (wert, zeilen) => {
const f = erstelleElement("textarea", { readonly: true, rows: String(zeilen), klasse: "mcp-token-feld" });
f.value = wert;
f.addEventListener("focus", () => f.select());
return f;
};
const kopieren = (wert) => async () => {
try {
await navigator.clipboard.writeText(wert);
zeigeMeldung(t("mcp.copied"), "info");
} catch {
zeigeMeldung(t("mcp.copyFailed"), "warnung");
}
};
const inhalt = erstelleElement("div", { klasse: "mcp-token-neu" },
erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("mcp.shownOnce")}` }),
erstelleElement("label", { text: t("mcp.token") }),
feld(neu.token, 2),
erstelleElement("button", { type: "button", klasse: "schaltflaeche flach klein", text: t("mcp.copy"), onclick: kopieren(neu.token) }),
erstelleElement("label", { text: t("mcp.config") }),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcp.configHint") }),
feld(konfiguration, 9),
erstelleElement("button", { type: "button", klasse: "schaltflaeche flach klein", text: t("mcp.copy"), onclick: kopieren(konfiguration) }));
const fertig = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche primaer",
text: t("common.close"),
onclick: () => modal.schliessen()
});
const modal = erstelleModal(t("mcp.issuedTitle", { agent: neu.info.agentId }), inhalt, [fertig]);
modal.oeffnen();
}