pombus 0.0.1 → 0.1.1

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.
package/dist/i18n.js ADDED
@@ -0,0 +1,141 @@
1
+ // i18n — human-facing error messages in the three mandated locales (AGENTS.md §2): en-US, pt-BR,
2
+ // es-419. Never hardcode a user-visible string; it comes from this dictionary keyed by a stable,
3
+ // locale-free error code. Tool errors returned TO THE MODEL are protocol text and stay en-US fixed;
4
+ // only messages a human reads (e.g. the server refusing to start, printed to stderr) are localized.
5
+ //
6
+ // Locale selection: POMBUS_LOCALE wins, then LANG/LC_ALL, else en-US. We match on the language (and
7
+ // region for the Spanish variant) prefix, so "pt_BR.UTF-8" -> pt-BR and "es_MX" -> es-419.
8
+ const LOCALES = ["en-US", "pt-BR", "es-419"];
9
+ // Every entry here MUST be reachable by a real human path. Two human paths exist:
10
+ // (1) the server boot failure a person sees on stderr when `pombus mcp` fails to come up, and
11
+ // (2) the `pombus init` / `pombus doctor` installer, which talks to a human at the terminal.
12
+ // Codes that only ever reach the MODEL (inside a tool call) are NOT human i18n — they stay en-US
13
+ // protocol text in server.ts (`error: <code>`), never in this dict.
14
+ // Boot-reachable codes (path 1):
15
+ // - no_api_key: the key env var is unset (server.ts, before any request).
16
+ // - auth_failed: /v0/auth/me rejected the key at boot (invalid/expired).
17
+ // - network: the core was unreachable at boot.
18
+ // - unknown: the last-resort fallback for a boot error with no mapped code (bin.ts catch).
19
+ // Installer-reachable codes (path 2): see INSTALLER_CODES below.
20
+ const MESSAGES = {
21
+ no_api_key: {
22
+ "en-US": "POMBUS_API_KEY is not set. Export your Pombus API key and try again.",
23
+ "pt-BR": "POMBUS_API_KEY não está definida. Exporte sua chave de API do Pombus e tente de novo.",
24
+ "es-419": "POMBUS_API_KEY no está definida. Exporta tu clave de API de Pombus e inténtalo de nuevo.",
25
+ },
26
+ auth_failed: {
27
+ "en-US": "Could not authenticate with Pombus. Check that POMBUS_API_KEY is valid.",
28
+ "pt-BR": "Não foi possível autenticar no Pombus. Verifique se POMBUS_API_KEY é válida.",
29
+ "es-419": "No se pudo autenticar en Pombus. Verifica que POMBUS_API_KEY sea válida.",
30
+ },
31
+ network: {
32
+ "en-US": "Could not reach the Pombus API. Check your connection and try again.",
33
+ "pt-BR": "Não foi possível acessar a API do Pombus. Verifique sua conexão e tente de novo.",
34
+ "es-419": "No se pudo conectar con la API de Pombus. Revisa tu conexión e inténtalo de nuevo.",
35
+ },
36
+ unknown: {
37
+ "en-US": "Something went wrong talking to Pombus.",
38
+ "pt-BR": "Algo deu errado na comunicação com o Pombus.",
39
+ "es-419": "Algo salió mal al comunicarse con Pombus.",
40
+ },
41
+ // ---- installer: `pombus init` / `pombus doctor` human lines (path 2) ----
42
+ // init progress / results
43
+ init_prompt_key: {
44
+ "en-US": "Paste your Pombus API key (input hidden): ",
45
+ "pt-BR": "Cole sua chave de API do Pombus (entrada oculta): ",
46
+ "es-419": "Pega tu clave de API de Pombus (entrada oculta): ",
47
+ },
48
+ init_inbox_created: {
49
+ "en-US": "Created inbox: ",
50
+ "pt-BR": "Caixa criada: ",
51
+ "es-419": "Casilla creada: ",
52
+ },
53
+ init_config_written: {
54
+ "en-US": "MCP config written for ",
55
+ "pt-BR": "Config MCP gravada para ",
56
+ "es-419": "Configuración MCP guardada para ",
57
+ },
58
+ init_restart_client: {
59
+ "en-US": "Restart your client to load the Pombus MCP server.",
60
+ "pt-BR": "Reinicie seu cliente para carregar o servidor MCP do Pombus.",
61
+ "es-419": "Reinicia tu cliente para cargar el servidor MCP de Pombus.",
62
+ },
63
+ init_done: {
64
+ "en-US": "Done. One command and it's running.",
65
+ "pt-BR": "Pronto. Um comando e tá rodando.",
66
+ "es-419": "Listo. Un comando y está funcionando.",
67
+ },
68
+ // detection / print fallback
69
+ init_no_client: {
70
+ "en-US": "No supported client config was found to write safely. Printing the MCP block below — paste it into your client's config yourself.",
71
+ "pt-BR": "Nenhuma config de cliente compatível foi encontrada para gravar com segurança. Imprimindo o bloco MCP abaixo — cole na config do seu cliente.",
72
+ "es-419": "No se encontró una configuración de cliente compatible para escribir con seguridad. Imprimiendo el bloque MCP abajo — pégalo en la configuración de tu cliente.",
73
+ },
74
+ init_print_only_client: {
75
+ "en-US": "This client's config format is not written automatically (to avoid corrupting it). Paste the block below into its config file.",
76
+ "pt-BR": "O formato de config deste cliente não é gravado automaticamente (para não corrompê-lo). Cole o bloco abaixo no arquivo de config dele.",
77
+ "es-419": "El formato de configuración de este cliente no se escribe automáticamente (para no dañarlo). Pega el bloque de abajo en su archivo de configuración.",
78
+ },
79
+ // doctor report lines
80
+ doctor_ok: {
81
+ "en-US": "Your Pombus API key is valid. Scope: ",
82
+ "pt-BR": "Sua chave de API do Pombus é válida. Escopo: ",
83
+ "es-419": "Tu clave de API de Pombus es válida. Alcance: ",
84
+ },
85
+ };
86
+ // The installer human codes — the single source the anti-orphan guard unions with the boot-reachable
87
+ // set. A code here with no caller in init.ts/doctor.ts is still dictionary theater; a code used by the
88
+ // installer with no entry here is a missing string. Keeping the list explicit makes the guard
89
+ // mechanical without reparsing TypeScript.
90
+ const INSTALLER_CODES = [
91
+ "init_prompt_key",
92
+ "init_inbox_created",
93
+ "init_config_written",
94
+ "init_restart_client",
95
+ "init_done",
96
+ "init_no_client",
97
+ "init_print_only_client",
98
+ "doctor_ok",
99
+ ];
100
+ /** The installer-reachable human codes (path 2). Exposed so the anti-orphan contract test can union
101
+ * them with the boot-reachable set and prove the dictionary has no orphan and no missing string. */
102
+ export function installerCodes() {
103
+ return INSTALLER_CODES;
104
+ }
105
+ /** Map a boot-time CoreError code to the human i18n code a person should see. A boot failure is one
106
+ * of exactly two things to a human: the key was rejected (any 401/403/404 at /v0/auth/me collapses to
107
+ * `not_found`, which at startup means "bad key" -> auth_failed) or the core was unreachable
108
+ * (`network`). Anything else is `unknown`. This is the bridge that makes auth_failed/network
109
+ * reachable — without it they were dictionary theater. */
110
+ export function humanCodeForBoot(coreErrorCode) {
111
+ if (coreErrorCode === "network")
112
+ return "network";
113
+ if (coreErrorCode === "not_found")
114
+ return "auth_failed";
115
+ return "unknown";
116
+ }
117
+ /** Resolve the active locale from the environment. */
118
+ export function resolveLocale(env = process.env) {
119
+ const raw = (env["POMBUS_LOCALE"] ?? env["LC_ALL"] ?? env["LANG"] ?? "").toLowerCase();
120
+ if (raw.startsWith("pt"))
121
+ return "pt-BR";
122
+ if (raw.startsWith("es"))
123
+ return "es-419";
124
+ return "en-US";
125
+ }
126
+ /** The human message for a stable error code, in the active locale. Unknown codes fall back to the
127
+ * `unknown` entry — never a missing string. */
128
+ export function humanMessage(code, locale = resolveLocale()) {
129
+ const entry = MESSAGES[code] ?? MESSAGES["unknown"];
130
+ return entry[locale];
131
+ }
132
+ /** Exposed for the contract test: the dictionary must cover all three locales for every code. */
133
+ export function allLocales() {
134
+ return LOCALES;
135
+ }
136
+ export function messageCodes() {
137
+ return Object.keys(MESSAGES);
138
+ }
139
+ export function messagesForTest() {
140
+ return MESSAGES;
141
+ }
package/dist/init.js ADDED
@@ -0,0 +1,154 @@
1
+ // `pombus init` — the one command. Reads a key, resolves its scope, optionally creates an inbox, and
2
+ // writes the MCP server block into the detected client's config (merge, atomic, 0600 — clients.ts),
3
+ // persisting EXACTLY the key the user provided. i18n throughout.
4
+ //
5
+ // This module is a THIN orchestrator: every piece with weight (the HTTP, the write) lives elsewhere
6
+ // and is reused. The function takes its side-effects as injected dependencies (key source, fetch,
7
+ // HOME, output sink) so a test drives the whole flow against a fake core and a temp HOME without
8
+ // ever touching real stdin/stdout/disk.
9
+ import { CoreClient } from "./client.js";
10
+ import { ScopeBinding } from "./scope.js";
11
+ import { clientTargets, printableCodexToml, printableJson, writeJsonConfig, ConfigWriteError, } from "./clients.js";
12
+ import { humanMessage, resolveLocale } from "./i18n.js";
13
+ /** A placeholder shown in --print output instead of a real key, so a user can paste the block and fill
14
+ * the key in themselves without a secret ever reaching the terminal scrollback. */
15
+ const KEY_PLACEHOLDER = "<YOUR_POMBUS_API_KEY>";
16
+ export async function runInit(opts) {
17
+ const locale = opts.locale ?? resolveLocale();
18
+ const out = opts.out ?? ((l) => process.stderr.write(l + "\n"));
19
+ const say = (code, suffix = "") => out(humanMessage(code, locale) + suffix);
20
+ // --print with no key: emit the placeholder block for every known client and stop. No network, no
21
+ // disk. This is the "I just want the snippet" path and the safe landing for uncertain detection.
22
+ const key = (opts.apiKey ?? "").trim() || (opts.print ? "" : await requireKey(opts));
23
+ if (opts.print && !key) {
24
+ printBlocks(KEY_PLACEHOLDER, opts, out, locale);
25
+ return emptyPrintResult();
26
+ }
27
+ const client = new CoreClient({
28
+ apiKey: key,
29
+ ...(opts.baseUrl !== undefined ? { baseUrl: opts.baseUrl } : {}),
30
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
31
+ });
32
+ const scope = await ScopeBinding.resolve(client); // throws CoreError on a bad key / unreachable core
33
+ // Optionally create an inbox and show the address.
34
+ let inboxId = scope.fixedInboxId;
35
+ let inboxEmail = null;
36
+ if (opts.createInbox) {
37
+ const created = await client.post("/inboxes", {});
38
+ inboxId = typeof created.inbox_id === "string" ? created.inbox_id : inboxId;
39
+ inboxEmail = typeof created.email === "string" ? created.email : null;
40
+ if (inboxEmail)
41
+ say("init_inbox_created", inboxEmail);
42
+ }
43
+ // The key that lands on disk is exactly the one the user provided (env or prompt) — the agent is the
44
+ // account's full operator, so it runs with the key the user chose to give it.
45
+ // --print mode: show the real-target block(s) and write nothing.
46
+ if (opts.print) {
47
+ printBlocks(key, opts, out, locale);
48
+ return {
49
+ printed: true,
50
+ inboxId,
51
+ inboxEmail,
52
+ written: [],
53
+ printedFor: targetsFor(opts).map((t) => t.id),
54
+ };
55
+ }
56
+ // Write to each targeted client. A write that fails safely (corrupt/outside-home) becomes a print
57
+ // fallback for THAT client, never an aborted install.
58
+ const written = [];
59
+ const printedFor = [];
60
+ const targets = targetsFor(opts);
61
+ for (const t of targets) {
62
+ if (t.mode === "print") {
63
+ say("init_print_only_client");
64
+ out(blockFor(t, key));
65
+ printedFor.push(t.id);
66
+ continue;
67
+ }
68
+ try {
69
+ writeJsonConfig(t.path, homeOf(opts), key);
70
+ say("init_config_written", t.label);
71
+ written.push(t.id);
72
+ }
73
+ catch (e) {
74
+ if (e instanceof ConfigWriteError) {
75
+ say("init_print_only_client");
76
+ out(blockFor(t, key));
77
+ printedFor.push(t.id);
78
+ }
79
+ else {
80
+ throw e;
81
+ }
82
+ }
83
+ }
84
+ if (written.length > 0) {
85
+ say("init_restart_client");
86
+ say("init_done");
87
+ }
88
+ else if (printedFor.length === 0) {
89
+ // No target at all resolved — detection was too uncertain to touch anything: print for everyone.
90
+ say("init_no_client");
91
+ out(printableJson(key));
92
+ }
93
+ return {
94
+ printed: false,
95
+ inboxId,
96
+ inboxEmail,
97
+ written,
98
+ printedFor,
99
+ };
100
+ }
101
+ /** The client targets for this run: one by id, or all known clients. */
102
+ function targetsFor(opts) {
103
+ const all = clientTargets(homeOf(opts));
104
+ if (!opts.client)
105
+ return all;
106
+ const one = all.find((t) => t.id === opts.client);
107
+ return one ? [one] : [];
108
+ }
109
+ function homeOf(opts) {
110
+ return opts.home ?? "";
111
+ }
112
+ /** Print the MCP block for each targeted client in its own format (JSON for most, TOML for Codex). */
113
+ function printBlocks(key, opts, out, locale) {
114
+ const targets = targetsFor(opts);
115
+ if (targets.length === 0) {
116
+ out(humanMessage("init_no_client", locale));
117
+ out(printableJson(key));
118
+ return;
119
+ }
120
+ for (const t of targets)
121
+ out(blockFor(t, key));
122
+ }
123
+ /** The pasteable block for one client in its native format. */
124
+ function blockFor(t, key) {
125
+ return t.id === "codex" ? printableCodexToml(key) : printableJson(key);
126
+ }
127
+ /** Obtain the key when none was in the environment: the prompt (interactive) must be supplied. */
128
+ async function requireKey(opts) {
129
+ if (!opts.promptKey) {
130
+ // No key and no way to ask: fail closed with the same code the server uses for a missing key.
131
+ const locale = opts.locale ?? resolveLocale();
132
+ (opts.out ?? ((l) => process.stderr.write(l + "\n")))(humanMessage("no_api_key", locale));
133
+ throw new InitAbort("no_api_key");
134
+ }
135
+ return (await opts.promptKey()).trim();
136
+ }
137
+ function emptyPrintResult() {
138
+ return {
139
+ printed: true,
140
+ inboxId: null,
141
+ inboxEmail: null,
142
+ written: [],
143
+ printedFor: [],
144
+ };
145
+ }
146
+ /** A clean, key-free abort for a missing prerequisite (the CLI turns it into exit code 1). */
147
+ export class InitAbort extends Error {
148
+ code;
149
+ constructor(code) {
150
+ super(code);
151
+ this.name = "InitAbort";
152
+ this.code = code;
153
+ }
154
+ }
package/dist/scope.js ADDED
@@ -0,0 +1,118 @@
1
+ // Scope binding — the core of F4's access rule (strategy doc 4.2/4.3, adoption spec line 104):
2
+ // the model NEVER supplies a raw inbox_id that escapes the API key's scope.
3
+ //
4
+ // On boot the server calls GET /v0/auth/me and learns the key's scope:
5
+ // - inbox / domain key -> a single inbox is implied. The inbox_id is DERIVED from the scope and the
6
+ // tools that need one do NOT accept it as an argument at all. The model cannot address any other
7
+ // inbox because the parameter does not exist in the tool schema.
8
+ // - pod / organization key -> the key can see many inboxes. `list_inboxes` enumerates the ones the
9
+ // key may reach; a tool that needs an inbox_id accepts only an id the server has seen in that
10
+ // enumeration, validated against the allowed set before any request leaves the process.
11
+ //
12
+ // The core already fences every route by scope (authz_inbox), so this layer is defense in depth plus
13
+ // an honest tool schema: the key's authority — not the model's argument — decides which inbox is read.
14
+ /** The resolved binding the tool layer consults. `fixedInboxId` set => single-inbox key: tools drop
15
+ * the inbox_id parameter entirely. Otherwise => multi-inbox key: tools require a listed inbox_id,
16
+ * checked via `allows`. `permissions` drives the catalog gate; `podId` lets pod-scoped tools derive
17
+ * the pod from the key's own scope instead of taking it as a free model argument. */
18
+ export class ScopeBinding {
19
+ scopeType;
20
+ fixedInboxId;
21
+ // The key's permission set, verbatim from /auth/me. null = master (full access); an object = scoped.
22
+ permissions;
23
+ // The pod the key is bound to, when it is pod/inbox/domain-scoped. An org (master) key has none, so a
24
+ // pod-scoped tool must then take a validated pod_id argument. Pod-scoped tools NEVER take a free
25
+ // pod_id when this is set — the key's own pod is used, so the model can't address another tenant.
26
+ podId;
27
+ // The set of inbox ids the key has been observed to reach, grown lazily by list_inboxes. A pod/org
28
+ // key can reach ids not yet listed; `allows` therefore also confirms unknown ids against the core
29
+ // (fail-closed) rather than rejecting them for being unseen.
30
+ seen = new Set();
31
+ constructor(scopeType, fixedInboxId, permissions, podId) {
32
+ this.scopeType = scopeType;
33
+ this.fixedInboxId = fixedInboxId;
34
+ this.permissions = permissions;
35
+ this.podId = podId;
36
+ }
37
+ /** True when the key binds exactly one inbox (inbox or domain... no: domain reaches many). Only an
38
+ * inbox-scoped key is truly single. A domain key reaches every inbox of its domain, so it is
39
+ * multi-inbox like a pod key. */
40
+ get isSingleInbox() {
41
+ return this.fixedInboxId !== null;
42
+ }
43
+ /** The catalog gate: does the key hold the permission a tool requires? A master key (permissions ===
44
+ * null) holds everything. A scoped key holds `perm` when its object has it true — with ONE mirror of
45
+ * the core's rule (api.py:1331-1334): message_send satisfies message_reply, so a key that can send
46
+ * can also reply. Tools whose gate is false are never REGISTERED (hidden, not registered-then-403). */
47
+ gateAllows(requiredPerm) {
48
+ if (this.permissions === null)
49
+ return true; // master key
50
+ if (requiredPerm === null)
51
+ return true; // a tool with no gated route (none scope)
52
+ if (this.permissions[requiredPerm] === true)
53
+ return true;
54
+ if (requiredPerm === "message_reply" && this.permissions["message_send"] === true)
55
+ return true;
56
+ return false;
57
+ }
58
+ /** Call /v0/auth/me and build the binding. An inbox key yields a fixed inbox_id; every other scope
59
+ * is multi-inbox and the inbox_id must be supplied-and-validated per call. Captures the key's
60
+ * permissions (catalog gate) and pod_id (pod-scoped tools derive the pod from the scope). */
61
+ static async resolve(client) {
62
+ const me = await client.get("/auth/me");
63
+ const permissions = me.permissions ?? null;
64
+ const podId = me.pod_id ?? null;
65
+ if (me.scope_type === "inbox" && me.inbox_id) {
66
+ return new ScopeBinding("inbox", me.inbox_id, permissions, podId);
67
+ }
68
+ // domain / pod / organization: no single implied inbox.
69
+ return new ScopeBinding(me.scope_type, null, permissions, podId);
70
+ }
71
+ /** Record inbox ids returned by list_inboxes so later calls can validate against the enumeration. */
72
+ remember(ids) {
73
+ for (const id of ids)
74
+ this.seen.add(id);
75
+ }
76
+ /** Resolve the inbox_id a tool will act on. For a single-inbox key the model's argument (if any) is
77
+ * IGNORED and the fixed id is used — the model can never widen past its scope. For a multi-inbox
78
+ * key the argument is required and must be an id the key can reach: known from a prior list, or
79
+ * confirmed against the core (which fences by scope and 404s an inbox outside it). Returns the id,
80
+ * or throws a stable `not_found` (never distinguishing "unknown" from "forbidden"). */
81
+ async resolveInboxId(client, requested) {
82
+ if (this.fixedInboxId !== null)
83
+ return this.fixedInboxId;
84
+ const id = (requested ?? "").trim();
85
+ if (!id)
86
+ throw new ScopeError("inbox_required");
87
+ if (this.seen.has(id))
88
+ return id;
89
+ // Not seen yet. Confirm it against the core: get_inbox 404s (via not_found) for any inbox the key
90
+ // may not reach, so a hostile id never turns into a cross-tenant read. On success, remember it.
91
+ await client.get(`/inboxes/${encodeURIComponent(id)}`); // throws CoreError('not_found') if out of scope
92
+ this.seen.add(id);
93
+ return id;
94
+ }
95
+ /** Resolve the pod_id a pod-scoped tool acts on. Same discipline as resolveInboxId: when the key is
96
+ * itself pod/inbox/domain-scoped the model's argument is IGNORED and the key's own pod is used — it
97
+ * cannot address another tenant. Only a master (org) key, which binds no single pod, must supply a
98
+ * pod_id; it is then validated against the core (get_pod 404s -> not_found for an unknown pod) so a
99
+ * forged id never turns into a cross-tenant action. */
100
+ async resolvePodId(client, requested) {
101
+ if (this.podId !== null)
102
+ return this.podId;
103
+ const id = (requested ?? "").trim();
104
+ if (!id)
105
+ throw new ScopeError("pod_required");
106
+ await client.get(`/pods/${encodeURIComponent(id)}`); // throws CoreError('not_found') if out of scope
107
+ return id;
108
+ }
109
+ }
110
+ /** A scope-layer failure the tool layer maps to an i18n message for a human. Locale-free, key-free. */
111
+ export class ScopeError extends Error {
112
+ code;
113
+ constructor(code) {
114
+ super(code);
115
+ this.name = "ScopeError";
116
+ this.code = code;
117
+ }
118
+ }
package/dist/server.js ADDED
@@ -0,0 +1,94 @@
1
+ // The MCP server — a thin stdio adapter over the tool catalog. It registers the tools the scope
2
+ // allows, dispatches CallTool to the matching handler, and translates failures into a tool error the
3
+ // model can read. It never reimplements core logic; every call is one REST request.
4
+ //
5
+ // Log hygiene (AGENTS.md §5, audited by pombus-sec): the stdio stream IS the MCP channel, so nothing
6
+ // but protocol frames may touch stdout. Diagnostics go to stderr, and NOTHING we print ever includes
7
+ // the API key — the key lives only in the client's Authorization header, never in an argument, a URL,
8
+ // or an error message surfaced here.
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
+ import { CoreClient, CoreError, readApiKey } from "./client.js";
13
+ import { ScopeBinding, ScopeError } from "./scope.js";
14
+ import { specByName, toolDescriptors, callTool, ToolArgError } from "./tools.js";
15
+ const SERVER_NAME = "pombus";
16
+ const SERVER_VERSION = "0.1.0";
17
+ /** Build and connect the MCP server over stdio. Fails fast (before connecting) when the key is absent,
18
+ * so a misconfigured client sees a clear stderr line instead of a server that answers every tool with
19
+ * an auth error. */
20
+ export async function runMcpServer() {
21
+ const { humanMessage, humanCodeForBoot } = await import("./i18n.js");
22
+ const apiKey = readApiKey();
23
+ if (!apiKey) {
24
+ // Human-facing, localized, to stderr — never stdout (would corrupt the MCP stream).
25
+ process.stderr.write(humanMessage("no_api_key") + "\n");
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ const client = new CoreClient({ apiKey });
30
+ let server;
31
+ try {
32
+ // buildServer resolves the key's scope (one /v0/auth/me call). A human runs the server, so a boot
33
+ // failure here is human-facing: localize the REAL cause — a rejected key (auth_failed) or an
34
+ // unreachable core (network) — instead of a generic line. CoreError already carries only a safe,
35
+ // key-free code.
36
+ server = await buildServer(client);
37
+ }
38
+ catch (e) {
39
+ const code = e instanceof CoreError ? humanCodeForBoot(e.code) : "unknown";
40
+ process.stderr.write(humanMessage(code) + "\n");
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ const transport = new StdioServerTransport();
45
+ await server.connect(transport);
46
+ }
47
+ /** Build the Server bound to a given core client. Exposed so tests drive it with a fake core and
48
+ * without a real stdio transport. Resolves the key's scope up front (one /v0/auth/me call) so the
49
+ * tool schemas can omit inbox_id for a single-inbox key. */
50
+ export async function buildServer(client) {
51
+ const scope = await ScopeBinding.resolve(client);
52
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
53
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
54
+ tools: toolDescriptors(scope),
55
+ }));
56
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
57
+ // specByName honours the gate: a tool the key may not use is "unknown" here, exactly as it is
58
+ // absent from ListTools — the model can neither see nor call a tool outside its permissions.
59
+ const spec = specByName(req.params.name, scope);
60
+ if (!spec)
61
+ return toolError(`unknown tool: ${req.params.name}`);
62
+ const args = (req.params.arguments ?? {});
63
+ try {
64
+ const value = await callTool(spec, args, { client, scope });
65
+ return { content: [{ type: "text", text: toText(value) }] };
66
+ }
67
+ catch (e) {
68
+ return toolError(modelError(e));
69
+ }
70
+ });
71
+ return server;
72
+ }
73
+ /** Serialize a handler result for the model. undefined (e.g. a 204) becomes an explicit ok marker. */
74
+ function toText(value) {
75
+ if (value === undefined)
76
+ return JSON.stringify({ ok: true });
77
+ return JSON.stringify(value);
78
+ }
79
+ /** A CallTool result flagged as an error (isError), carrying model-facing en-US text. */
80
+ function toolError(text) {
81
+ return { isError: true, content: [{ type: "text", text }] };
82
+ }
83
+ /** Map a thrown error to a stable, locale-free string for the model. Never leaks the key or a URL:
84
+ * CoreError/ScopeError/ToolArgError already carry only a safe code; anything else collapses to a
85
+ * generic message so an unexpected throw can't smuggle a secret into the tool output. */
86
+ function modelError(e) {
87
+ if (e instanceof CoreError)
88
+ return `error: ${e.code}`;
89
+ if (e instanceof ScopeError)
90
+ return `error: ${e.code}`;
91
+ if (e instanceof ToolArgError)
92
+ return `error: ${e.code}`;
93
+ return "error: internal";
94
+ }
@@ -0,0 +1,117 @@
1
+ // Test doubles — a fake core (a FetchLike that records every request and returns programmed replies)
2
+ // and a captured-request shape the tests assert against. Not a *.test.ts, so the test runner does not
3
+ // execute it as a suite. All fixtures are written from scratch (AGENTS.md §9): invented ids, no real
4
+ // data.
5
+ export class FakeCore {
6
+ requests = [];
7
+ routes = [];
8
+ /** Register a route. Routes are matched in order; the first match wins. */
9
+ on(method, match, reply) {
10
+ this.routes.push({ method, match, reply });
11
+ return this;
12
+ }
13
+ /** A FetchLike bound to this fake. Records the request, then returns the first matching route's
14
+ * reply, or a 404 when nothing matches (so an unexpected call fails closed, like the real core). */
15
+ fetch = async (rawUrl, init) => {
16
+ const url = new URL(rawUrl);
17
+ const headers = lowerHeaders(init.headers);
18
+ let body;
19
+ if (typeof init.body === "string" && init.body.length > 0) {
20
+ try {
21
+ body = JSON.parse(init.body);
22
+ }
23
+ catch {
24
+ body = init.body;
25
+ }
26
+ }
27
+ const captured = { method: init.method ?? "GET", url: rawUrl, headers, body };
28
+ this.requests.push(captured);
29
+ const route = this.routes.find((r) => r.method === captured.method && r.match(url));
30
+ const reply = route
31
+ ? typeof route.reply === "function"
32
+ ? route.reply(captured, url)
33
+ : route.reply
34
+ : { status: 404, json: { code: "not_found" } };
35
+ return makeResponse(reply);
36
+ };
37
+ }
38
+ function lowerHeaders(h) {
39
+ const out = {};
40
+ if (!h)
41
+ return out;
42
+ if (h instanceof Headers) {
43
+ h.forEach((v, k) => (out[k.toLowerCase()] = v));
44
+ }
45
+ else if (Array.isArray(h)) {
46
+ for (const pair of h) {
47
+ const k = pair[0];
48
+ const v = pair[1];
49
+ if (k !== undefined && v !== undefined)
50
+ out[k.toLowerCase()] = v;
51
+ }
52
+ }
53
+ else {
54
+ for (const [k, v] of Object.entries(h))
55
+ out[k.toLowerCase()] = String(v);
56
+ }
57
+ return out;
58
+ }
59
+ function makeResponse(reply) {
60
+ const status = reply.status ?? 200;
61
+ const hasBody = reply.json !== undefined;
62
+ // A 204/205 MUST have a null body (undici throws on any body, even ""), matching the real core's
63
+ // no-content replies (e.g. a DELETE).
64
+ const nullBodyStatus = status === 204 || status === 205 || status === 304;
65
+ const body = hasBody ? JSON.stringify(reply.json) : nullBodyStatus ? null : "";
66
+ return new Response(body, {
67
+ status,
68
+ headers: hasBody ? { "content-type": "application/json" } : {},
69
+ });
70
+ }
71
+ /** An /auth/me reply for an inbox-scoped key (single implied inbox). */
72
+ export function authMeInbox(inboxId) {
73
+ return {
74
+ json: {
75
+ scope_type: "inbox",
76
+ scope_id: inboxId,
77
+ organization_id: "org_test",
78
+ pod_id: "pod_test",
79
+ inbox_id: inboxId,
80
+ api_key_id: "key_test",
81
+ permissions: null,
82
+ },
83
+ };
84
+ }
85
+ /** An /auth/me reply for a pod-scoped key (reaches many inboxes). */
86
+ export function authMePod() {
87
+ return {
88
+ json: {
89
+ scope_type: "pod",
90
+ scope_id: "pod_test",
91
+ organization_id: "org_test",
92
+ pod_id: "pod_test",
93
+ inbox_id: null,
94
+ api_key_id: "key_test",
95
+ permissions: null,
96
+ },
97
+ };
98
+ }
99
+ /** A POST /v0/inboxes reply (the shape the core returns: inbox_id + email). Invented ids only (§9). */
100
+ export function createdInbox(inboxId, email) {
101
+ return { json: { inbox_id: inboxId, email } };
102
+ }
103
+ /** An /auth/me reply for a SCOPED pod key — a real permissions object drives the catalog gate. Pass
104
+ * the permissions the human granted the key; everything else uses invented ids (§9). */
105
+ export function authMeScoped(permissions, overrides = {}) {
106
+ return {
107
+ json: {
108
+ scope_type: overrides.scope_type ?? "pod",
109
+ scope_id: "pod_test",
110
+ organization_id: "org_test",
111
+ pod_id: overrides.pod_id === undefined ? "pod_test" : overrides.pod_id,
112
+ inbox_id: overrides.inbox_id ?? null,
113
+ api_key_id: "key_test",
114
+ permissions,
115
+ },
116
+ };
117
+ }