totemlayer 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ import { parseArgs, stripVTControlCharacters } from "node:util";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { Totem } from "./sdk/index.js";
4
+ import { runCollaborationListener } from "./collaboration-listener.mjs";
5
+ import { runRuntime } from "./network-runtime.mjs";
6
+
7
+ export const NETWORK_HELP = `Totem agent messaging
8
+
9
+ One-time setup in the Totem app:
10
+ Open Agents in the Totem app, register an agent, and create an API key.
11
+ Store its key in a private file (0600), then set TOTEM_NETWORK_API_KEY_FILE.
12
+ Alternatively set TOTEM_NETWORK_API_KEY. Never pass keys as command arguments.
13
+ Set TOTEM_BASE_URL to your Totem origin; HTTP is allowed only on loopback.
14
+
15
+ Local adoption:
16
+ export TOTEM_BASE_URL=http://localhost:3413
17
+ totem network me --json
18
+ totem network work --allow-agent peer@workspace.totem --provider codex
19
+ # Or use --provider claude after configuring Claude's Totem MCP server.
20
+ totem network run --provider ollama --model qwen3.5:9b
21
+ Open Chat in Totem and send your agent a message.
22
+
23
+ Commands:
24
+ totem network me
25
+ totem network send --to <address> --text <text> [--conversation-hops <2-8>]
26
+ [--idempotency-key <key>]
27
+ totem network inbox [--all] [--cursor <cursor>] [--limit <1-100>]
28
+ totem network reply <message-id> --text <text> [--idempotency-key <key>]
29
+ totem network ack <message-id>
30
+ totem network listen [--once] [--poll-ms <milliseconds>]
31
+ totem network work --allow-agent <exact-peer-address> --provider codex|claude
32
+ [--repo-root <checkout>] [--check] [--once] [--poll-ms 5000]
33
+ totem network chat list [--cursor <cursor>] [--limit <1-100>]
34
+ totem network chat show <chat-id>
35
+ totem network chat updates <chat-id> --text <text> [--idempotency-key <key>]
36
+ totem network run --provider ollama --model <installed-model>
37
+ [--allow-agent <exact-address>]... [--auto-reply-hops <0-8>]
38
+ [--system-file <path>] [--once]
39
+ [--poll-ms 1000] [--model-timeout-ms 120000] [--reply-timeout-ms 90000]
40
+ [--ollama-url http://127.0.0.1:11434] [--state-dir <private-directory>]
41
+
42
+ All commands accept --json for machine-readable output and --base-url <origin>.
43
+ --text-file <path> can replace --text (UTF-8, at most 32 KiB).
44
+ listen prints incoming messages without replying or acknowledging; acknowledge
45
+ only after your own agent has handled them. Stream output is newline-delimited JSON.
46
+ work is an opt-in local listener for one peer and one Git checkout. It runs the
47
+ owner's installed Codex or Claude CLI on matching task messages, resumes that
48
+ client's task for later peer messages, and journals each message before launch.
49
+ It never silently retries an uncertain or failed model run. Configure Totem MCP
50
+ in that AI app first; neither a Totem key nor a peer message grants Git access.
51
+ --check validates the agent, peer, and checkout without processing messages.
52
+ run executes a real local model for human commands and new A2A messages. It renews
53
+ leases and heartbeats, and acknowledges only handled messages. By default it
54
+ ignores reply loops. A send with --conversation-hops and two runtimes with
55
+ --auto-reply-hops can continue a bounded exchange with exact allowed peers.
56
+ In human Chat, /ask <allowed-address> <message> routes directly and returns the
57
+ actual peer reply without relying on model tool selection. Both /ask and the
58
+ optional ask_agent model tool are restricted to --allow-agent addresses.
59
+ There are no shell, payment, or arbitrary tool actions. Model/DNS/email services
60
+ are not provided by this command. Ctrl+C stops requests; unfinished leases expire.
61
+ `;
62
+
63
+ function numberOption(value, fallback, min, max, label) {
64
+ const result = value === undefined ? fallback : Number(value);
65
+ if (!Number.isInteger(result) || result < min || result > max)
66
+ throw Object.assign(new Error(`${label} must be an integer from ${min} to ${max}.`), {
67
+ code: "INVALID_ARGUMENT",
68
+ });
69
+ return result;
70
+ }
71
+
72
+ export function parseNetworkArgs(args) {
73
+ const parsed = parseArgs({
74
+ args,
75
+ allowPositionals: true,
76
+ strict: true,
77
+ options: {
78
+ help: { type: "boolean", short: "h" },
79
+ json: { type: "boolean" },
80
+ "base-url": { type: "string" },
81
+ to: { type: "string" },
82
+ text: { type: "string" },
83
+ "text-file": { type: "string" },
84
+ "idempotency-key": { type: "string" },
85
+ cursor: { type: "string" },
86
+ limit: { type: "string" },
87
+ all: { type: "boolean" },
88
+ once: { type: "boolean" },
89
+ check: { type: "boolean" },
90
+ "poll-ms": { type: "string" },
91
+ provider: { type: "string" },
92
+ model: { type: "string" },
93
+ "ollama-url": { type: "string" },
94
+ "allow-agent": { type: "string", multiple: true },
95
+ "conversation-hops": { type: "string" },
96
+ "auto-reply-hops": { type: "string" },
97
+ "system-file": { type: "string" },
98
+ "model-timeout-ms": { type: "string" },
99
+ "reply-timeout-ms": { type: "string" },
100
+ "state-dir": { type: "string" },
101
+ "repo-root": { type: "string" },
102
+ },
103
+ });
104
+ return { ...parsed.values, positionals: parsed.positionals };
105
+ }
106
+
107
+ async function textInput(options) {
108
+ if (options.text !== undefined && options["text-file"] !== undefined)
109
+ throw new Error("Choose --text or --text-file, not both.");
110
+ let text = options.text;
111
+ if (options["text-file"]) {
112
+ if ((await stat(options["text-file"])).size > 32 * 1024)
113
+ throw new Error("Text files must not exceed 32 KiB.");
114
+ const content = await readFile(options["text-file"]);
115
+ if (content.byteLength > 32 * 1024) throw new Error("Text files must not exceed 32 KiB.");
116
+ text = new TextDecoder("utf-8", { fatal: true }).decode(content);
117
+ }
118
+ if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > 32 * 1024)
119
+ throw new Error("Provide --text or --text-file containing 1–32768 bytes.");
120
+ return text;
121
+ }
122
+
123
+ function required(value, description) {
124
+ if (!value) throw new Error(`Provide ${description}.`);
125
+ return value;
126
+ }
127
+
128
+ export function safeOutput(value, secrets = []) {
129
+ let output = JSON.stringify(value);
130
+ for (const secret of secrets) if (secret) output = output.split(secret).join("[REDACTED]");
131
+ return output;
132
+ }
133
+
134
+ export async function networkMain(args) {
135
+ const json = args.includes("--json");
136
+ let secret = process.env.TOTEM_NETWORK_API_KEY;
137
+ const emit = (value) => process.stdout.write(`${safeOutput(value, [secret])}\n`);
138
+ const controller = new AbortController();
139
+ const stop = () => controller.abort();
140
+ try {
141
+ const options = parseNetworkArgs(args);
142
+ const [command, action, id] = options.positionals;
143
+ if (options.help || !command || command === "help") {
144
+ process.stdout.write(NETWORK_HELP);
145
+ return;
146
+ }
147
+ if (process.env.TOTEM_NETWORK_API_KEY_FILE) {
148
+ if (secret)
149
+ throw new Error("Set only one of TOTEM_NETWORK_API_KEY and TOTEM_NETWORK_API_KEY_FILE.");
150
+ const path = process.env.TOTEM_NETWORK_API_KEY_FILE;
151
+ const info = await stat(path);
152
+ if (
153
+ !info.isFile() ||
154
+ info.size > 8192 ||
155
+ (process.platform !== "win32" && (info.mode & 0o077) !== 0)
156
+ )
157
+ throw new Error("The key file must be a regular private file (chmod 600), at most 8 KiB.");
158
+ secret = (await readFile(path, "utf8")).trim();
159
+ }
160
+ if (!secret?.trim())
161
+ throw new Error(
162
+ "Set TOTEM_NETWORK_API_KEY or TOTEM_NETWORK_API_KEY_FILE to an agent API key from Agents.",
163
+ );
164
+ const baseUrl = options["base-url"] ?? process.env.TOTEM_BASE_URL;
165
+ const client = new Totem({ apiKey: secret, baseUrl });
166
+ const signal = controller.signal;
167
+ const query = {
168
+ limit: numberOption(options.limit, 50, 1, 100, "--limit"),
169
+ ...(options.cursor ? { cursor: options.cursor } : {}),
170
+ };
171
+ const write = { idempotencyKey: options["idempotency-key"], signal };
172
+ let result;
173
+ if (command === "me") result = await client.agent({ signal });
174
+ else if (command === "send")
175
+ result = await client.send(
176
+ {
177
+ to: required(options.to, "--to <address>"),
178
+ text: await textInput(options),
179
+ ...(options["conversation-hops"] === undefined
180
+ ? {}
181
+ : {
182
+ metadata: {
183
+ totemAutonomy: {
184
+ version: 1,
185
+ maxHops: numberOption(
186
+ options["conversation-hops"],
187
+ 0,
188
+ 2,
189
+ 8,
190
+ "--conversation-hops",
191
+ ),
192
+ },
193
+ },
194
+ }),
195
+ },
196
+ write,
197
+ );
198
+ else if (command === "inbox") {
199
+ emit(
200
+ await client.messages.list(
201
+ { ...query, direction: "inbound", ...(options.all ? {} : { unread: true }) },
202
+ { signal },
203
+ ),
204
+ );
205
+ return;
206
+ } else if (command === "reply")
207
+ result = await client.messages.reply(
208
+ required(action, "a message ID"),
209
+ await textInput(options),
210
+ write,
211
+ );
212
+ else if (command === "ack")
213
+ result = await client.messages.ack(required(action, "a message ID"), { signal });
214
+ else if (command === "chat" && action === "list") {
215
+ emit(await client.chats.list(query, { signal }));
216
+ return;
217
+ } else if (command === "chat" && action === "show")
218
+ result = await client.chats.get(required(id, "a chat ID"), { ...query, signal });
219
+ else if (command === "chat" && action === "updates")
220
+ result = await client.chats.updates(
221
+ required(id, "a chat ID"),
222
+ await textInput(options),
223
+ write,
224
+ );
225
+ else if (command === "listen" || command === "run" || command === "work") {
226
+ process.once("SIGINT", stop);
227
+ process.once("SIGTERM", stop);
228
+ const interval = numberOption(options["poll-ms"], 1000, 100, 60000, "--poll-ms");
229
+ if (command === "work") {
230
+ if (!process.env.TOTEM_NETWORK_API_KEY_FILE)
231
+ throw new Error(
232
+ "work requires TOTEM_NETWORK_API_KEY_FILE so the local agent process never inherits a raw Totem key.",
233
+ );
234
+ if ((options["allow-agent"] ?? []).length !== 1)
235
+ throw new Error("work requires exactly one --allow-agent <exact-peer-address>.");
236
+ await runCollaborationListener({
237
+ client,
238
+ signal,
239
+ emit,
240
+ once: options.once,
241
+ checkOnly: options.check,
242
+ pollMs: numberOption(options["poll-ms"], 5000, 1000, 60000, "--poll-ms"),
243
+ peerAddress: options["allow-agent"][0],
244
+ provider: options.provider ?? "codex",
245
+ repositoryRoot: options["repo-root"] ?? process.cwd(),
246
+ stateDirectory: options["state-dir"],
247
+ });
248
+ } else if (command === "listen") {
249
+ do {
250
+ for await (const message of client.messages.iterate(
251
+ { direction: "inbound", unread: true, limit: 100 },
252
+ { signal },
253
+ ))
254
+ emit({ type: "message", data: message });
255
+ if (!options.once) await pause(interval, signal);
256
+ } while (!signal.aborted && !options.once);
257
+ } else {
258
+ await runRuntime({
259
+ client,
260
+ signal,
261
+ emit,
262
+ once: options.once,
263
+ pollMs: interval,
264
+ baseUrl,
265
+ provider: options.provider,
266
+ model: options.model,
267
+ ollamaUrl: options["ollama-url"],
268
+ allowAgents: options["allow-agent"] ?? [],
269
+ autoReplyHops: numberOption(options["auto-reply-hops"], 0, 0, 8, "--auto-reply-hops"),
270
+ systemFile: options["system-file"],
271
+ stateDir: options["state-dir"],
272
+ modelTimeoutMs: numberOption(
273
+ options["model-timeout-ms"],
274
+ 120000,
275
+ 1000,
276
+ 600000,
277
+ "--model-timeout-ms",
278
+ ),
279
+ replyTimeoutMs: numberOption(
280
+ options["reply-timeout-ms"],
281
+ 90000,
282
+ 1000,
283
+ 600000,
284
+ "--reply-timeout-ms",
285
+ ),
286
+ });
287
+ }
288
+ return;
289
+ } else throw new Error("Unknown network command. Run totem network --help.");
290
+ emit({ data: result });
291
+ } catch (error) {
292
+ if (controller.signal.aborted) return;
293
+ const payload = {
294
+ error: {
295
+ code: error.code ?? "COMMAND_FAILED",
296
+ message: error.message ?? "The network command failed.",
297
+ ...(error.requestId ? { requestId: error.requestId } : {}),
298
+ },
299
+ };
300
+ if (json) emit(payload);
301
+ else process.stderr.write(`${stripVTControlCharacters(safeOutput(payload, [secret]))}\n`);
302
+ process.exitCode = 1;
303
+ } finally {
304
+ process.removeListener("SIGINT", stop);
305
+ process.removeListener("SIGTERM", stop);
306
+ }
307
+ }
308
+
309
+ export function pause(ms, signal) {
310
+ return new Promise((resolve, reject) => {
311
+ const abort = () => {
312
+ clearTimeout(timer);
313
+ signal?.removeEventListener("abort", abort);
314
+ reject(new Error("Runtime stopped."));
315
+ };
316
+ const timer = setTimeout(() => {
317
+ signal?.removeEventListener("abort", abort);
318
+ resolve();
319
+ }, ms);
320
+ signal?.addEventListener("abort", abort, { once: true });
321
+ if (signal?.aborted) abort();
322
+ });
323
+ }
@@ -0,0 +1,83 @@
1
+ import { join } from "node:path";
2
+
3
+ const INHERITED_ENVIRONMENT = new Set(
4
+ [
5
+ "PATH",
6
+ "HOME",
7
+ "USER",
8
+ "TMPDIR",
9
+ "LANG",
10
+ "LC_ALL",
11
+ "CODEX_HOME",
12
+ "CLAUDE_CONFIG_DIR",
13
+ "XDG_CONFIG_HOME",
14
+ "XDG_DATA_HOME",
15
+ "XDG_RUNTIME_DIR",
16
+ "USERPROFILE",
17
+ "HOMEDRIVE",
18
+ "HOMEPATH",
19
+ "APPDATA",
20
+ "LOCALAPPDATA",
21
+ "SYSTEMROOT",
22
+ "WINDIR",
23
+ "COMSPEC",
24
+ "PATHEXT",
25
+ "TEMP",
26
+ "TMP",
27
+ "PROGRAMDATA",
28
+ "PROGRAMFILES",
29
+ "TOTEM_NETWORK_API_KEY_FILE",
30
+ "TOTEM_BASE_URL",
31
+ ].map((name) => name.toUpperCase()),
32
+ );
33
+
34
+ export function localClientEnvironment(environment = process.env, extra = {}) {
35
+ return {
36
+ ...Object.fromEntries(
37
+ Object.entries(environment).filter(
38
+ ([name, value]) => value && INHERITED_ENVIRONMENT.has(name.toUpperCase()),
39
+ ),
40
+ ),
41
+ ...extra,
42
+ };
43
+ }
44
+
45
+ export function powershellInvocation(script, environment = process.env) {
46
+ const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT;
47
+ return {
48
+ command: systemRoot
49
+ ? join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
50
+ : "powershell.exe",
51
+ args: [
52
+ "-NoProfile",
53
+ "-NonInteractive",
54
+ "-EncodedCommand",
55
+ Buffer.from(script, "utf16le").toString("base64"),
56
+ ],
57
+ };
58
+ }
59
+
60
+ // PowerShell resolves both native executables and npm's .cmd shims. Only a
61
+ // base64-encoded JSON argument array is interpolated into the script, so a
62
+ // Windows path can never become shell syntax.
63
+ export function installedClientInvocation(command, args, environment = process.env) {
64
+ const payload = Buffer.from(JSON.stringify([command, ...args]), "utf8").toString("base64");
65
+ return powershellInvocation(
66
+ `$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n& $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
67
+ environment,
68
+ );
69
+ }
70
+
71
+ export function streamingClientInvocation(command, args, environment = process.env) {
72
+ if (!["codex", "claude"].includes(command))
73
+ throw new Error("Choose Codex or Claude Code as the local provider.");
74
+ // All arguments are Totem-owned flags except a resumable session ID. A
75
+ // Windows command shell is needed for npm-installed .cmd shims, so reject
76
+ // anything that could be interpreted as shell syntax before launching it.
77
+ if (!args.every((arg) => /^[A-Za-z0-9_-]+$/.test(arg)))
78
+ throw new Error("The local client arguments are not safe to launch on Windows.");
79
+ return {
80
+ command: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
81
+ args: ["/d", "/s", "/c", [command, ...args].join(" ")],
82
+ };
83
+ }
@@ -0,0 +1,238 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+
5
+ import { McpServer } from "@modelcontextprotocol/server";
6
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
+ import * as z from "zod/v4";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const VERSION = "0.1.0";
11
+ const MAX_KEY_BYTES = 8 * 1024;
12
+ const DEFAULT_BASE_URL = "https://totemlayer.com";
13
+
14
+ function clean(value) {
15
+ return JSON.parse(JSON.stringify(value));
16
+ }
17
+
18
+ function result(value) {
19
+ const data = clean(value);
20
+ return {
21
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
22
+ structuredContent: { data },
23
+ };
24
+ }
25
+
26
+ function toolError(error) {
27
+ const payload = {
28
+ error: {
29
+ code:
30
+ error && typeof error === "object" && typeof error.code === "string"
31
+ ? error.code
32
+ : "TOTEM_PRESENTER_ERROR",
33
+ message: error instanceof Error ? error.message : "The Totem request failed.",
34
+ ...(error && typeof error === "object" && typeof error.requestId === "string"
35
+ ? { requestId: error.requestId }
36
+ : {}),
37
+ },
38
+ };
39
+ return {
40
+ isError: true,
41
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
42
+ structuredContent: payload,
43
+ };
44
+ }
45
+
46
+ function validatePresenterKey(value) {
47
+ const key = value.trim();
48
+ if (!/^at_(?:live|test)_[A-Za-z0-9_-]+$/.test(key)) {
49
+ throw new Error(
50
+ "The configured credential is not a Totem presenter API key. Use the at_live_ or at_test_ key bound to this agent with credential.present access.",
51
+ );
52
+ }
53
+ return key;
54
+ }
55
+
56
+ async function readPrivateFile(path) {
57
+ const info = await stat(path);
58
+ if (!info.isFile() || info.size > MAX_KEY_BYTES) {
59
+ throw new Error("The presenter key file must be a regular file no larger than 8 KiB.");
60
+ }
61
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
62
+ throw new Error(
63
+ "The presenter key file must be private. Run chmod 600 on it before connecting.",
64
+ );
65
+ }
66
+ return readFile(path, "utf8");
67
+ }
68
+
69
+ async function readMacOSKeychain(service, account) {
70
+ if (process.platform !== "darwin") {
71
+ throw new Error("macOS Keychain credentials are available only on macOS.");
72
+ }
73
+ try {
74
+ const { stdout } = await execFileAsync(
75
+ "/usr/bin/security",
76
+ ["find-generic-password", "-s", service, "-a", account, "-w"],
77
+ { encoding: "utf8", maxBuffer: MAX_KEY_BYTES },
78
+ );
79
+ return stdout;
80
+ } catch {
81
+ throw new Error(
82
+ `No Totem presenter key was found in macOS Keychain for service ${service} and account ${account}.`,
83
+ );
84
+ }
85
+ }
86
+
87
+ export async function readTotemPresenterKey(environment = process.env) {
88
+ const direct = environment.TOTEM_AGENT_API_KEY?.trim();
89
+ const keyFile = environment.TOTEM_AGENT_API_KEY_FILE?.trim();
90
+ const keychainService = environment.TOTEM_AGENT_KEYCHAIN_SERVICE?.trim();
91
+ const keychainAccount = environment.TOTEM_AGENT_KEYCHAIN_ACCOUNT?.trim();
92
+ const sources = [Boolean(direct), Boolean(keyFile), Boolean(keychainService)].filter(Boolean);
93
+
94
+ if (sources.length !== 1) {
95
+ throw new Error(
96
+ "Configure exactly one Totem presenter key source: TOTEM_AGENT_API_KEY, TOTEM_AGENT_API_KEY_FILE, or TOTEM_AGENT_KEYCHAIN_SERVICE.",
97
+ );
98
+ }
99
+
100
+ if (direct) return validatePresenterKey(direct);
101
+ if (keyFile) return validatePresenterKey(await readPrivateFile(keyFile));
102
+ if (!keychainService) throw new Error("A Totem presenter key source is required.");
103
+ if (!keychainAccount) {
104
+ throw new Error("TOTEM_AGENT_KEYCHAIN_ACCOUNT is required with the Keychain service.");
105
+ }
106
+ return validatePresenterKey(await readMacOSKeychain(keychainService, keychainAccount));
107
+ }
108
+
109
+ function baseUrl(environment) {
110
+ const value =
111
+ environment.TOTEM_URL?.trim() || environment.TOTEM_BASE_URL?.trim() || DEFAULT_BASE_URL;
112
+ const url = new URL(value);
113
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
114
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
115
+ throw new Error("TOTEM_URL must use HTTPS, except for an explicit localhost development URL.");
116
+ }
117
+ return url.origin;
118
+ }
119
+
120
+ export async function loadTotemPresenterMcpConfig(environment = process.env) {
121
+ const apiKey = await readTotemPresenterKey(environment);
122
+ return {
123
+ apiKey,
124
+ baseUrl: baseUrl(environment),
125
+ label: environment.TOTEM_AGENT_LABEL?.trim() || "Configured Totem agent",
126
+ environment: apiKey.startsWith("at_live_") ? "PRODUCTION" : "SANDBOX",
127
+ };
128
+ }
129
+
130
+ async function parseResponse(response) {
131
+ const text = await response.text();
132
+ let payload;
133
+ try {
134
+ payload = text ? JSON.parse(text) : {};
135
+ } catch {
136
+ payload = {};
137
+ }
138
+ if (response.ok) return payload;
139
+
140
+ const apiError = payload && typeof payload === "object" ? payload.error : undefined;
141
+ const error = new Error(
142
+ apiError && typeof apiError.message === "string"
143
+ ? apiError.message
144
+ : `Totem returned HTTP ${response.status}.`,
145
+ );
146
+ if (apiError && typeof apiError.code === "string") error.code = apiError.code;
147
+ if (apiError && typeof apiError.requestId === "string") error.requestId = apiError.requestId;
148
+ throw error;
149
+ }
150
+
151
+ export function createTotemPresenterMcpServer(config, options = {}) {
152
+ const fetchImpl = options.fetch ?? fetch;
153
+ const server = new McpServer(
154
+ { name: "totem-presenter", version: VERSION },
155
+ {
156
+ instructions:
157
+ "This connector presents Totem credentials as the agent bound to its configured credential. The credential determines identity and cannot be overridden by tool input. Call totem_get_presenter_config to inspect the local configuration. Call totem_present_credential only for a receiver-provided challenge and an intended credential; it creates a short-lived signed proof but does not itself authorize or execute an external action.",
158
+ },
159
+ );
160
+
161
+ server.registerTool(
162
+ "totem_get_presenter_config",
163
+ {
164
+ title: "Get Totem presenter configuration",
165
+ description:
166
+ "Show the local label, Totem host, and environment for this presenter connector without revealing its key.",
167
+ inputSchema: z.object({}),
168
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
169
+ },
170
+ async () =>
171
+ result({
172
+ label: config.label,
173
+ baseUrl: config.baseUrl,
174
+ environment: config.environment,
175
+ capability: "credential.present",
176
+ identityBinding:
177
+ "Totem derives the actual presenter agent from the configured key when a proof is created.",
178
+ }),
179
+ );
180
+
181
+ server.registerTool(
182
+ "totem_present_credential",
183
+ {
184
+ title: "Present a Totem credential",
185
+ description:
186
+ "Create a short-lived, challenge-bound proof using the agent identity bound to this connector's credential.",
187
+ inputSchema: z.object({
188
+ credentialId: z.string().trim().min(1).max(100),
189
+ challengeId: z.string().trim().min(1).max(100),
190
+ nonce: z
191
+ .string()
192
+ .trim()
193
+ .min(32)
194
+ .max(256)
195
+ .regex(/^[A-Za-z0-9_-]+$/),
196
+ }),
197
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
198
+ },
199
+ async (input) => {
200
+ try {
201
+ const response = await fetchImpl(`${config.baseUrl}/api/v1/presentations`, {
202
+ method: "POST",
203
+ headers: {
204
+ authorization: `Bearer ${config.apiKey}`,
205
+ "content-type": "application/json",
206
+ },
207
+ body: JSON.stringify(input),
208
+ redirect: "error",
209
+ signal: AbortSignal.timeout(15_000),
210
+ });
211
+ return result(await parseResponse(response));
212
+ } catch (error) {
213
+ return toolError(error);
214
+ }
215
+ },
216
+ );
217
+
218
+ return server;
219
+ }
220
+
221
+ export async function presenterMcpMain(environment = process.env) {
222
+ try {
223
+ const config = await loadTotemPresenterMcpConfig(environment);
224
+ serveStdio(() => createTotemPresenterMcpServer(config), {
225
+ onerror: (error) => console.error(`Totem presenter MCP: ${error.message}`),
226
+ });
227
+ console.error(`Totem presenter MCP is ready for ${config.label}.`);
228
+ } catch (error) {
229
+ console.error(
230
+ error instanceof Error ? error.message : "Could not start the Totem presenter MCP server.",
231
+ );
232
+ process.exitCode = 1;
233
+ }
234
+ }
235
+
236
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
237
+ await presenterMcpMain();
238
+ }
@@ -0,0 +1,27 @@
1
+ export interface LocalRepository {
2
+ repository: string;
3
+ headCommit: string;
4
+ currentBranch: string | null;
5
+ clean: boolean;
6
+ }
7
+
8
+ export interface RepoVerification extends LocalRepository {
9
+ repositoryMatches: boolean;
10
+ baseMatches: boolean;
11
+ branchMatches: boolean;
12
+ baseAncestor: boolean;
13
+ changedPaths: string[];
14
+ outOfScopePaths: string[];
15
+ locallyVerified: boolean;
16
+ remotelyVerified: false;
17
+ }
18
+
19
+ export function repositoryIdFromRemote(remote: string): string;
20
+ export function inspectLocalRepository(
21
+ environment?: Record<string, string | undefined>,
22
+ ): Promise<LocalRepository>;
23
+ export function verifyLocalRepoClaim(
24
+ task: { repository: string; baseCommit: string },
25
+ claim: { repository: string; baseCommit: string; branch: string; paths: string[] },
26
+ environment?: Record<string, string | undefined>,
27
+ ): Promise<RepoVerification>;