Something outside hands an agent a task - an n8n workflow on a mail, a
schedule, a form, or any system that sends an HTTP request.
- Core/Triggers/InboundTriggers: one token per trigger and agent, kept as a
hash and shown once; a rate per hour; deliveries up to 64 KB. Each
delivery becomes a task whose content is framed as foreign content with its
origin; every delivery, accepted or refused, is in the audit (source
"trigger").
- POST /hooks/trigger on the instance (MCP socket, own token, no browsers);
the manager forwards /hooks/{instance}/trigger. /api/triggers to issue,
list and revoke.
- Agent view: a triggers card with the address and an n8n/curl example.
- Nothing writes back: no right to create or change workflows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
// 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();
|
|
}
|