// 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(); } /** * #725: Inbound triggers — n8n or any system that sends a request hands this agent a task. * One token per trigger, shown once; each delivery becomes a task whose content is framed as * foreign content. Nothing goes back: ClawdDotNet gets no right to change workflows. */ export function erstelleTriggerKarte(agent) { const liste = erstelleElement("div", { klasse: "mcp-token-liste" }); const bezeichnung = erstelleElement("input", { type: "text", placeholder: t("trigger.labelPlaceholder"), maxlength: "60" }); const proStunde = erstelleElement("input", { type: "number", min: "1", max: "600", step: "1", value: "30", "aria-label": t("trigger.perHour") }); const ausgeben = erstelleElement("button", { type: "button", klasse: "schaltflaeche primaer", text: t("trigger.issue"), onclick: async () => { ausgeben.disabled = true; try { const neu = await api("/triggers", { method: "POST", body: { agentId: agent.agentId, label: bezeichnung.value.trim(), perHour: Number(proStunde.value) } }); bezeichnung.value = ""; zeigeNeuenAusloeser(neu); await lade(); } catch (err) { zeigeMeldung(t("mcp.failed", { error: err.message }), "gefahr"); } finally { ausgeben.disabled = false; } } }); async function lade() { try { const alle = (await api("/triggers")) || []; const eigene = alle.filter((x) => x.agentId.toLowerCase() === agent.agentId.toLowerCase()); liste.replaceChildren(...(eigene.length === 0 ? [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("trigger.none") })] : eigene.map(zeile))); } catch (err) { liste.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: err.message })); } } function zeile(trigger) { const status = trigger.active ? t("trigger.status", { perHour: trigger.perHour, received: trigger.received, last: trigger.lastUsedAt ? formatiereDatumZeit(trigger.lastUsedAt) : "—" }) : t("mcp.revoked", { time: formatiereDatumZeit(trigger.revokedAt) }); const aktionen = erstelleElement("div", { klasse: "werkzeug-aktionen" }); if (trigger.active) { aktionen.append(erstelleElement("button", { type: "button", klasse: "schaltflaeche flach klein", text: t("mcp.revoke"), onclick: async () => { try { await api(`/triggers/${encodeURIComponent(trigger.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${trigger.active ? "" : " mcp-token-inaktiv"}` }, erstelleElement("div", { klasse: "werkzeug-info" }, erstelleElement("strong", { text: trigger.label || trigger.id }), erstelleElement("div", { klasse: "werkzeug-beschreibung gedaempft", text: status })), aktionen); } lade(); return erstelleKarte(t("trigger.title"), [ erstelleElement("p", { klasse: "gedaempft", text: t("trigger.hint") }), liste, erstelleElement("div", { klasse: "mcp-token-formular" }, bezeichnung, proStunde, ausgeben) ]); } /** The trigger's token and address once — with how n8n (or anything else) calls it. */ function zeigeNeuenAusloeser(neu) { const instanz = holeAktiveInstanz(); const adresse = `${location.origin}/hooks/${instanz ? `${encodeURIComponent(instanz)}/` : ""}trigger`; const beispiel = `curl -X POST "${adresse}" \\\n -H "Authorization: Bearer ${neu.token}" \\\n -H "Content-Type: application/json" \\\n -d '{"title":"New order","order":"4711"}'`; 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 inhalt = erstelleElement("div", { klasse: "mcp-token-neu" }, erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("mcp.shownOnce")}` }), erstelleElement("label", { text: t("trigger.address") }), feld(adresse, 1), erstelleElement("label", { text: t("mcp.token") }), feld(neu.token, 2), erstelleElement("label", { text: t("trigger.example") }), erstelleElement("p", { klasse: "gedaempft text-klein", text: t("trigger.exampleHint") }), feld(beispiel, 5)); const fertig = erstelleElement("button", { type: "button", klasse: "schaltflaeche primaer", text: t("common.close"), onclick: () => modal.schliessen() }); const modal = erstelleModal(t("trigger.issuedTitle", { agent: neu.info.agentId }), inhalt, [fertig]); modal.oeffnen(); }