pombus 0.1.3 → 0.1.5

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,64 @@
1
+ // R2c — the client the Worker uses to reach the OAuth store, which in production lives inside ONE
2
+ // Durable Object (do-class.ts OAuthStoreDO). Every isolate resolves the SAME instance by name, so the
3
+ // three hops of the OAuth flow share state — the isolate-hop `invalid_grant` that blocked a deploy is
4
+ // gone. This file holds NO cloudflare:workers import: it only needs the shape of a DurableObjectNamespace
5
+ // and a stub, so it loads fine under `node --test` (the DO class itself, which does import the runtime,
6
+ // is isolated in do-class.ts and never on the test path). Types are a tiny local declaration because the
7
+ // npx package does not depend on @cloudflare/workers-types (its stack is fixed: tsc + node --test).
8
+ // --- the Worker-side client -----------------------------------------------------------------------
9
+ /** OAuthStoreLike backed by the singleton Durable Object. Each call is a one-line RPC delegation to the
10
+ * stub; all the logic (single-use, expiry, caps, rate-limit) stays in the OAuthStore the DO wraps, so
11
+ * nothing here needs a test of its own beyond the DO shell's by-reading review. */
12
+ export class DurableObjectStore {
13
+ stub;
14
+ constructor(ns) {
15
+ // One fixed name → one DO instance for the whole Worker. Its input gate serializes every method, so
16
+ // the store's get-then-delete (takeCode/takeRequest) stays atomic across concurrent isolates.
17
+ this.stub = ns.get(ns.idFromName("oauth"));
18
+ }
19
+ putRequest(id, r) {
20
+ return this.stub.putRequest(id, r);
21
+ }
22
+ takeRequest(id) {
23
+ return this.stub.takeRequest(id);
24
+ }
25
+ putCode(code, c) {
26
+ return this.stub.putCode(code, c);
27
+ }
28
+ takeCode(code) {
29
+ return this.stub.takeCode(code);
30
+ }
31
+ tokenRoom() {
32
+ return this.stub.tokenRoom();
33
+ }
34
+ putToken(token, t) {
35
+ return this.stub.putToken(token, t);
36
+ }
37
+ getToken(token) {
38
+ return this.stub.getToken(token);
39
+ }
40
+ attachOrg(token, orgId, entry, ownerHash) {
41
+ return this.stub.attachOrg(token, orgId, entry, ownerHash);
42
+ }
43
+ setActiveOrg(token, orgId) {
44
+ return this.stub.setActiveOrg(token, orgId);
45
+ }
46
+ getActiveChild(token) {
47
+ return this.stub.getActiveChild(token);
48
+ }
49
+ putLink(linkToken, accessToken) {
50
+ return this.stub.putLink(linkToken, accessToken);
51
+ }
52
+ takeLink(linkToken) {
53
+ return this.stub.takeLink(linkToken);
54
+ }
55
+ putClient(clientId, redirectUris) {
56
+ return this.stub.putClient(clientId, redirectUris);
57
+ }
58
+ getClient(clientId) {
59
+ return this.stub.getClient(clientId);
60
+ }
61
+ reset() {
62
+ return this.stub.reset();
63
+ }
64
+ }
@@ -0,0 +1,238 @@
1
+ // R3 — the Pombus MCP server over Streamable-HTTP in a Worker, behind a DELEGATING OAuth 2.1 front.
2
+ //
3
+ // The human consent no longer runs here: the Worker 302s to the WEBMAIL (mail.pombus.com) and the
4
+ // webmail — the origin that holds the human's session cookie and the toggle-based consent screen —
5
+ // mints the scoped child key and hands it back via a single-use handoff the Worker redeems over the WEB
6
+ // service binding. The agent never sees the child key; the Worker holds the opaque access token → child
7
+ // key mapping (oauth.ts, a per-org token-set: Model D multi-org).
8
+ //
9
+ // Two credential paths (AgentMail parity):
10
+ // - `authorization: Bearer <access-token>` → the OAuth/multi-org path: resolve the ACTIVE org's child
11
+ // key from the token-set, expose the org tools (list/select_organization).
12
+ // - `x-api-key: <pod-key>` → the key IS the credential (one org, stdio/Cursor/Windsurf
13
+ // style); org tools are NOT exposed. No token-set, no multi-org.
14
+ // - BOTH headers present → 400 invalid_request (ambiguous; refuse).
15
+ //
16
+ // OAuth surfaces (no Bearer required; they ISSUE/complete the Bearer): GET /authorize (302 to webmail),
17
+ // GET /authorize/return (webmail bounce-back), POST /token (redeem). Everything else is the MCP endpoint
18
+ // and is fail-closed: no valid credential → 401, the Server is never built, the core never touched.
19
+ //
20
+ // Transport: WebStandardStreamableHTTPServerTransport (SDK 1.32.1), stateless (one per request).
21
+ //
22
+ // Log hygiene (AGENTS.md §5): this worker logs NOTHING — no access token, child key, handoff, signature,
23
+ // mailbox content or URL.
24
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
25
+ import { CoreClient } from "../client.js";
26
+ import { buildServer } from "../server.js";
27
+ import { store as defaultStore, validateAccessToken, handleAuthorizeGet, handleAuthorizeReturn, handleToken, handleRegister, DEFAULT_CLIENTS, } from "./oauth.js";
28
+ import { DurableObjectStore } from "./store-do.js";
29
+ import { renderRoot } from "./root.js";
30
+ import { pickLocale, parseAcceptLanguage } from "./oauth-i18n.js";
31
+ /** Resolve the OAuth store: the test seam first, else the bound Durable Object, else the module singleton. */
32
+ function resolveStore(env) {
33
+ if (env.__store)
34
+ return env.__store;
35
+ if (env.OAUTH_STORE)
36
+ return new DurableObjectStore(env.OAUTH_STORE);
37
+ return defaultStore;
38
+ }
39
+ /** Resolve the WEB service binding: the test seam first, else the wrangler binding (may be undefined —
40
+ * /token then fail-closes to 503). */
41
+ function resolveWeb(env) {
42
+ return env.__web ?? env.WEB;
43
+ }
44
+ /** Resolve the Bearer ACCESS TOKEN on an MCP request to the child key of its ACTIVE org, or null to
45
+ * refuse. The single fail-closed decision point for the Bearer path; the returned value is the minted
46
+ * child key (NEVER log it). getActiveChild (oauth.ts) is the only source — org↔org isolation hinges on it. */
47
+ export function authorize(req, env) {
48
+ return validateAccessToken(req.headers.get("authorization"), resolveStore(env));
49
+ }
50
+ /** 401 with the RFC 6750 challenge carrying the resource_metadata pointer (RFC 9728). Content-free. */
51
+ function unauthorized(origin) {
52
+ return new Response(null, {
53
+ status: 401,
54
+ headers: {
55
+ "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
56
+ },
57
+ });
58
+ }
59
+ /** 200 JSON for a discovery metadata document. Public, short-cacheable, content-free of any secret. */
60
+ function metadata(doc) {
61
+ return new Response(JSON.stringify(doc), {
62
+ status: 200,
63
+ headers: { "content-type": "application/json", "cache-control": "public, max-age=3600" },
64
+ });
65
+ }
66
+ /** Assemble the OAuth handler dependencies from the Worker env: the fixed client registry, the store,
67
+ * the core seams, and the R3 webmail seams (consent URL, HMAC secret, WEB binding). */
68
+ function oauthDeps(env) {
69
+ const web = resolveWeb(env);
70
+ return {
71
+ clients: DEFAULT_CLIENTS,
72
+ store: resolveStore(env),
73
+ ...(env.__coreFetch !== undefined ? { coreFetch: env.__coreFetch } : {}),
74
+ ...(env.POMBUS_API_BASE !== undefined ? { coreBaseUrl: env.POMBUS_API_BASE } : {}),
75
+ ...(env.WEB_CONSENT_URL !== undefined ? { webConsentUrl: env.WEB_CONSENT_URL } : {}),
76
+ ...(env.MCP_WEB_LINK_SECRET !== undefined ? { webLinkSecret: env.MCP_WEB_LINK_SECRET } : {}),
77
+ ...(web !== undefined ? { web } : {}),
78
+ };
79
+ }
80
+ /** 503 with a Retry-After, no body — the last-resort envelope for ANY unexpected throw inside fetch (a
81
+ * store/DO RPC rejection is the real case). Collapsing every internal failure into one content-free 503
82
+ * denies an availability oracle: 503 is honestly "unavailable", NOT the 401/403 a caller uses to infer
83
+ * credential state, NOR the 200/302 of success. Nothing logged, no .message/.stack serialized. */
84
+ function unavailable() {
85
+ return new Response(null, { status: 503, headers: { "retry-after": "60" } });
86
+ }
87
+ /** Origins allowed to call this Worker from a browser: itself (the webmail return lands here as a
88
+ * top-level navigation, not a fetch, so it carries no Origin anyway) and the origins of the registered
89
+ * OAuth callbacks. The MCP Streamable-HTTP spec requires validating Origin (DNS-rebinding fence).
90
+ * Non-browser clients send no Origin and are judged by their credential alone. */
91
+ export function originAllowed(req) {
92
+ const origin = req.headers.get("origin");
93
+ if (origin === null)
94
+ return true;
95
+ if (origin === new URL(req.url).origin)
96
+ return true;
97
+ return DEFAULT_CLIENTS.some((c) => c.redirectUris.some((u) => new URL(u).origin === origin));
98
+ }
99
+ export default {
100
+ async fetch(req, env) {
101
+ // One top-level try/catch is the last line of defence: the store is a Durable Object reached by RPC,
102
+ // so the `await`s below can REJECT when the DO is unavailable. The catch collapses any such throw
103
+ // into one content-free 503 (see unavailable()). The NORMAL error paths (401/400/302/503) return in
104
+ // their own flows and never fall through.
105
+ try {
106
+ const url = new URL(req.url);
107
+ const path = url.pathname;
108
+ // A foreign browser origin gets a content-free 403 before anything else (OAuth surfaces included).
109
+ if (!originAllowed(req))
110
+ return new Response(null, { status: 403 });
111
+ // --- OAuth surfaces (no Bearer required; they issue/complete the Bearer). AWAIT them so a store
112
+ // RPC rejection inside a handler lands in the top-level catch (→ one 503), not as an un-awaited
113
+ // rejected promise that escapes this try. ---
114
+ if (path === "/authorize") {
115
+ if (req.method === "GET")
116
+ return await handleAuthorizeGet(req, oauthDeps(env));
117
+ // POST /authorize (the R2e paste-key approve) is GONE — the human decides on the webmail now.
118
+ return new Response(null, { status: 405, headers: { allow: "GET" } });
119
+ }
120
+ if (path === "/authorize/return") {
121
+ if (req.method === "GET")
122
+ return await handleAuthorizeReturn(req, oauthDeps(env));
123
+ return new Response(null, { status: 405, headers: { allow: "GET" } });
124
+ }
125
+ if (path === "/token") {
126
+ if (req.method === "POST")
127
+ return await handleToken(req, oauthDeps(env));
128
+ return new Response(null, { status: 405, headers: { allow: "POST" } });
129
+ }
130
+ // --- Discovery (RFC 9728 + RFC 8414) — PUBLIC metadata, origin-derived, no secret. ---
131
+ const origin = url.origin;
132
+ if (path === "/.well-known/oauth-protected-resource") {
133
+ if (req.method !== "GET")
134
+ return new Response(null, { status: 405, headers: { allow: "GET" } });
135
+ return metadata({ resource: origin, authorization_servers: [origin] });
136
+ }
137
+ if (path === "/.well-known/oauth-authorization-server") {
138
+ if (req.method !== "GET")
139
+ return new Response(null, { status: 405, headers: { allow: "GET" } });
140
+ return metadata({
141
+ issuer: origin,
142
+ authorization_endpoint: `${origin}/authorize`,
143
+ token_endpoint: `${origin}/token`,
144
+ registration_endpoint: `${origin}/register`,
145
+ response_types_supported: ["code"],
146
+ grant_types_supported: ["authorization_code"],
147
+ code_challenge_methods_supported: ["S256"],
148
+ token_endpoint_auth_methods_supported: ["none"],
149
+ });
150
+ }
151
+ // --- Dynamic Client Registration (RFC 7591) — PUBLIC. ---
152
+ if (path === "/register") {
153
+ if (req.method === "POST")
154
+ return await handleRegister(req, oauthDeps(env));
155
+ return new Response(null, { status: 405, headers: { allow: "POST" } });
156
+ }
157
+ // --- Friendly root page — ONLY for a human browser (GET + Accept text/html + no credential). A
158
+ // real MCP client POSTs with Accept json/event-stream and a credential, so it never matches. The
159
+ // gate is deliberately narrow so it can never become an auth bypass. ---
160
+ if (req.method === "GET" &&
161
+ !req.headers.get("authorization") &&
162
+ !req.headers.get("x-api-key") &&
163
+ (req.headers.get("accept") ?? "").includes("text/html")) {
164
+ const locale = pickLocale([parseAcceptLanguage(req.headers.get("accept-language"))]);
165
+ return new Response(renderRoot(locale), {
166
+ status: 200,
167
+ headers: {
168
+ "content-type": "text/html; charset=utf-8",
169
+ "x-content-type-options": "nosniff",
170
+ "cache-control": "no-store",
171
+ },
172
+ });
173
+ }
174
+ // --- MCP endpoint (everything else) — requires a valid credential on exactly ONE of the two paths. ---
175
+ const bearer = req.headers.get("authorization");
176
+ const apiKeyHeader = req.headers.get("x-api-key");
177
+ // Both credentials at once is ambiguous (which org/scope?) — refuse rather than pick (fail-closed).
178
+ if (bearer && apiKeyHeader) {
179
+ return new Response(JSON.stringify({ error: "invalid_request" }), {
180
+ status: 400,
181
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
182
+ });
183
+ }
184
+ // The credential the core will see, and whether this is the multi-org (Bearer) path.
185
+ let apiKey;
186
+ let orgTools;
187
+ if (apiKeyHeader) {
188
+ // x-api-key: the pod-key IS the credential (one org, stdio/Cursor style). No token-set lookup,
189
+ // no org tools. A blank header is no credential.
190
+ apiKey = apiKeyHeader.trim() || null;
191
+ orgTools = false;
192
+ }
193
+ else {
194
+ // Bearer: resolve the ACTIVE org's child key from the token-set. Org tools exposed.
195
+ apiKey = await authorize(req, env);
196
+ orgTools = true;
197
+ }
198
+ // Fail-closed: refuse BEFORE building the Server. No credential never resolves scope, never touches
199
+ // the core, never learns whether any inbox exists — the 401 is content-free.
200
+ if (!apiKey)
201
+ return unauthorized(origin);
202
+ const client = new CoreClient({
203
+ apiKey,
204
+ ...(env.POMBUS_API_BASE !== undefined ? { baseUrl: env.POMBUS_API_BASE } : {}),
205
+ ...(env.__coreFetch !== undefined ? { fetchImpl: env.__coreFetch } : {}),
206
+ });
207
+ // On the Bearer path the org tools need the token-set: pass the store + the access token so
208
+ // list/select_organization can read and re-point the set. The access token is parsed from the
209
+ // Authorization header exactly as validateAccessToken did (kept tiny — no second source of truth).
210
+ const orgSession = orgTools ? bearerOrgSession(bearer, resolveStore(env)) : undefined;
211
+ let server;
212
+ try {
213
+ server = await buildServer(client, orgSession ? { orgSession } : {});
214
+ }
215
+ catch {
216
+ // buildServer's only outward call is /auth/me (scope resolve). A failure there → content-free 401
217
+ // (the core's anti-enumeration rule; never distinguishes rejected key from unreachable core).
218
+ return unauthorized(origin);
219
+ }
220
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
221
+ await server.connect(transport);
222
+ return transport.handleRequest(req);
223
+ }
224
+ catch {
225
+ return unavailable();
226
+ }
227
+ },
228
+ };
229
+ /** Build the org-session handle for the Bearer path: the store + the opaque access token the org tools
230
+ * operate on. Returns undefined when the header has no Bearer token (then no org tools). The access
231
+ * token is NEVER logged. */
232
+ function bearerOrgSession(authorizationHeader, store) {
233
+ const m = /^Bearer (.+)$/.exec((authorizationHeader ?? "").trim());
234
+ const token = m?.[1]?.trim();
235
+ if (!token)
236
+ return undefined;
237
+ return { store, accessToken: token };
238
+ }
package/dist/i18n.js CHANGED
@@ -50,6 +50,36 @@ const MESSAGES = {
50
50
  "pt-BR": "Caixa criada: ",
51
51
  "es-419": "Casilla creada: ",
52
52
  },
53
+ init_inbox_needs_domain: {
54
+ "en-US": "--create-inbox needs --domain <your verified domain>: an extra inbox can only be created on a domain you own and have verified in Pombus.",
55
+ "pt-BR": "--create-inbox precisa de --domain <seu domínio verificado>: uma caixa adicional só pode ser criada em um domínio seu, já verificado no Pombus.",
56
+ "es-419": "--create-inbox necesita --domain <tu dominio verificado>: una casilla adicional solo se puede crear en un dominio tuyo, ya verificado en Pombus.",
57
+ },
58
+ init_inbox_bad_domain: {
59
+ "en-US": "--domain must be a domain name such as acme.com (no @, no spaces, no URL).",
60
+ "pt-BR": "--domain precisa ser um nome de domínio, como acme.com (sem @, sem espaços, sem URL).",
61
+ "es-419": "--domain debe ser un nombre de dominio, como acme.com (sin @, sin espacios, sin URL).",
62
+ },
63
+ init_inbox_platform_domain: {
64
+ "en-US": "That is the Pombus domain: your @pombus.com inbox is the one from signup, and an API key cannot create more. Use --domain with a domain you own and have verified.",
65
+ "pt-BR": "Esse é o domínio do Pombus: sua caixa @pombus.com é a do cadastro, e uma chave de API não cria outras. Use --domain com um domínio seu, já verificado.",
66
+ "es-419": "Ese es el dominio de Pombus: tu casilla @pombus.com es la del registro, y una clave de API no crea otras. Usa --domain con un dominio tuyo, ya verificado.",
67
+ },
68
+ init_inbox_domain_not_verified: {
69
+ "en-US": "That domain is not verified in your Pombus space yet. Verify it under Domains, then run init again.",
70
+ "pt-BR": "Esse domínio ainda não está verificado no seu espaço do Pombus. Verifique em Domínios e rode o init de novo.",
71
+ "es-419": "Ese dominio aún no está verificado en tu espacio de Pombus. Verifícalo en Dominios y vuelve a ejecutar init.",
72
+ },
73
+ init_inbox_limit: {
74
+ "en-US": "Your plan's inbox limit is reached. Delete an inbox or upgrade your plan in Pombus, then run init again.",
75
+ "pt-BR": "Você chegou ao limite de caixas do seu plano. Apague uma caixa ou mude de plano no Pombus e rode o init de novo.",
76
+ "es-419": "Llegaste al límite de casillas de tu plan. Borra una casilla o cambia de plan en Pombus y vuelve a ejecutar init.",
77
+ },
78
+ init_inbox_missing_permission: {
79
+ "en-US": "This API key cannot create inboxes. Use a key with the inbox-create permission (Pombus › Keys), or drop --create-inbox.",
80
+ "pt-BR": "Esta chave de API não pode criar caixas. Use uma chave com a permissão de criar caixa (Pombus › Chaves) ou tire o --create-inbox.",
81
+ "es-419": "Esta clave de API no puede crear casillas. Usa una clave con el permiso de crear casilla (Pombus › Claves) o quita --create-inbox.",
82
+ },
53
83
  init_config_written: {
54
84
  "en-US": "MCP config written for ",
55
85
  "pt-BR": "Config MCP gravada para ",
@@ -95,6 +125,12 @@ const MESSAGES = {
95
125
  const INSTALLER_CODES = [
96
126
  "init_prompt_key",
97
127
  "init_inbox_created",
128
+ "init_inbox_needs_domain",
129
+ "init_inbox_bad_domain",
130
+ "init_inbox_platform_domain",
131
+ "init_inbox_domain_not_verified",
132
+ "init_inbox_limit",
133
+ "init_inbox_missing_permission",
98
134
  "init_config_written",
99
135
  "init_key_power",
100
136
  "init_restart_client",
package/dist/init.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // and is reused. The function takes its side-effects as injected dependencies (key source, fetch,
7
7
  // HOME, output sink) so a test drives the whole flow against a fake core and a temp HOME without
8
8
  // ever touching real stdin/stdout/disk.
9
- import { CoreClient } from "./client.js";
9
+ import { domainToASCII } from "node:url";
10
+ import { CoreClient, CoreError } from "./client.js";
10
11
  import { ScopeBinding } from "./scope.js";
11
12
  import { clientTargets, printableCodexToml, printableJson, writeJsonConfig, ConfigWriteError, } from "./clients.js";
12
13
  import { humanMessage, resolveLocale } from "./i18n.js";
@@ -18,6 +19,18 @@ export async function runInit(opts) {
18
19
  const locale = opts.locale ?? resolveLocale();
19
20
  const out = opts.out ?? ((l) => process.stderr.write(l + "\n"));
20
21
  const say = (code, suffix = "") => out(humanMessage(code, locale) + suffix);
22
+ // --create-inbox needs --domain: an extra inbox only exists on a verified domain of your own. Refuse
23
+ // BEFORE the key prompt and any request, with the rule spelled out (the core would answer a bare 403).
24
+ const rawDomain = (opts.domain ?? "").trim();
25
+ if (opts.createInbox && !rawDomain) {
26
+ say("init_inbox_needs_domain");
27
+ throw new InitAbort("init_inbox_needs_domain");
28
+ }
29
+ const domain = normalizeDomain(rawDomain);
30
+ if (opts.createInbox && !looksLikeDomain(domain)) {
31
+ say("init_inbox_bad_domain");
32
+ throw new InitAbort("init_inbox_bad_domain");
33
+ }
21
34
  // --print with no key: emit the placeholder block for every known client and stop. No network, no
22
35
  // disk. This is the "I just want the snippet" path and the safe landing for uncertain detection.
23
36
  const key = (opts.apiKey ?? "").trim() || (opts.print ? "" : await requireKey(opts));
@@ -35,7 +48,21 @@ export async function runInit(opts) {
35
48
  let inboxId = scope.fixedInboxId;
36
49
  let inboxEmail = null;
37
50
  if (opts.createInbox) {
38
- const created = await client.post("/inboxes", {});
51
+ let created;
52
+ try {
53
+ created = await client.post("/inboxes", { domain });
54
+ }
55
+ catch (e) {
56
+ // The refusals a person can act on (INBOX_REFUSALS) get their own line (the key was fine — /auth/me passed
57
+ // above). Anything else rethrows to the CLI's generic handler.
58
+ const why = e instanceof CoreError ? (e.coreCode ?? e.code) : "";
59
+ const human = INBOX_REFUSALS[why];
60
+ if (human) {
61
+ say(human);
62
+ throw new InitAbort(human);
63
+ }
64
+ throw e;
65
+ }
39
66
  inboxId = typeof created.inbox_id === "string" ? created.inbox_id : inboxId;
40
67
  inboxEmail = typeof created.email === "string" ? created.email : null;
41
68
  if (inboxEmail)
@@ -149,6 +176,30 @@ function emptyPrintResult() {
149
176
  printedFor: [],
150
177
  };
151
178
  }
179
+ /** The core's create-inbox refusals a person can act on -> their human line. `platform_inbox_not_allowed`,
180
+ * `inbox_limit` and `missing_permission` arrive as a 403 (collapsed to not_found, real code in coreCode);
181
+ * `domain_not_verified` as a 422. */
182
+ const INBOX_REFUSALS = {
183
+ platform_inbox_not_allowed: "init_inbox_platform_domain",
184
+ domain_not_verified: "init_inbox_domain_not_verified",
185
+ inbox_limit: "init_inbox_limit",
186
+ missing_permission: "init_inbox_missing_permission",
187
+ };
188
+ /** `--domain` as the core stores it: lowercase, no trailing dot, IDN in punycode (`café.com` ->
189
+ * `xn--caf-dma.com`). ASCII is restricted to letters/digits/dot/hyphen BEFORE the conversion, because
190
+ * `domainToASCII` parses a URL host and would silently cut `acme.com/x` down to `acme.com`. The caller
191
+ * passes it already trimmed. */
192
+ export function normalizeDomain(raw) {
193
+ const d = raw.toLowerCase();
194
+ if (!/^[a-z0-9.\-\u{80}-\u{10ffff}]+$/u.test(d))
195
+ return "";
196
+ return domainToASCII(d).replace(/\.$/, "");
197
+ }
198
+ /** A basic domain-name shape (labels of letters/digits/hyphens, a dot, a TLD) — catches a typo or a flag
199
+ * swallowed as the value BEFORE any request. The core is the authority on whether it is verified. */
200
+ function looksLikeDomain(d) {
201
+ return d.length <= 253 && /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/.test(d);
202
+ }
152
203
  /** A clean, key-free abort for a missing prerequisite (the CLI turns it into exit code 1). */
153
204
  export class InitAbort extends Error {
154
205
  code;
package/dist/server.js CHANGED
@@ -11,9 +11,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { CoreClient, CoreError, readApiKey } from "./client.js";
13
13
  import { ScopeBinding, ScopeError } from "./scope.js";
14
- import { specByName, toolDescriptors, callTool, ToolArgError } from "./tools.js";
14
+ import { specByName, toolDescriptors, callTool, callOrgTool, isOrgTool, ToolArgError, } from "./tools.js";
15
15
  const SERVER_NAME = "pombus";
16
- const SERVER_VERSION = "0.1.3";
16
+ const SERVER_VERSION = "0.1.5";
17
17
  /** Build and connect the MCP server over stdio. Fails fast (before connecting) when the key is absent,
18
18
  * so a misconfigured client sees a clear stderr line instead of a server that answers every tool with
19
19
  * an auth error. */
@@ -47,21 +47,34 @@ export async function runMcpServer() {
47
47
  /** Build the Server bound to a given core client. Exposed so tests drive it with a fake core and
48
48
  * without a real stdio transport. Resolves the key's scope up front (one /v0/auth/me call) so the
49
49
  * tool schemas can omit inbox_id for a single-inbox key. */
50
- export async function buildServer(client) {
50
+ export async function buildServer(client, opts = {}) {
51
51
  const scope = await ScopeBinding.resolve(client);
52
+ const orgTools = opts.orgSession !== undefined;
53
+ const ctx = {
54
+ client,
55
+ scope,
56
+ ...(opts.orgSession !== undefined ? { orgSession: opts.orgSession } : {}),
57
+ };
52
58
  const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
53
59
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
54
- tools: toolDescriptors(scope),
60
+ tools: toolDescriptors(scope, { orgTools }),
55
61
  }));
56
62
  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}`);
63
+ const name = req.params.name;
62
64
  const args = (req.params.arguments ?? {});
63
65
  try {
64
- const value = await callTool(spec, args, { client, scope });
66
+ // The org tools (Bearer path only) are resolved outside the REST gate: they carry no core
67
+ // permission. On the x-api-key path orgTools is false, so a call to one is "unknown" below.
68
+ if (orgTools && isOrgTool(name)) {
69
+ const value = await callOrgTool(name, args, ctx);
70
+ return { content: [{ type: "text", text: toText(value) }] };
71
+ }
72
+ // specByName honours the gate: a tool the key may not use is "unknown" here, exactly as it is
73
+ // absent from ListTools — the model can neither see nor call a tool outside its permissions.
74
+ const spec = specByName(name, scope);
75
+ if (!spec)
76
+ return toolError(`unknown tool: ${name}`);
77
+ const value = await callTool(spec, args, ctx);
65
78
  return { content: [{ type: "text", text: toText(value) }] };
66
79
  }
67
80
  catch (e) {
package/dist/tools.js CHANGED
@@ -613,7 +613,8 @@ export const TOOL_TABLE = [
613
613
  {
614
614
  name: "create_inbox",
615
615
  title: "Create inbox",
616
- description: "Create a new inbox in the pod.",
616
+ description: "Create a new inbox in the pod, on one of the pod's own VERIFIED domains (`domain` is required; " +
617
+ "an inbox on the platform domain cannot be created with an API key).",
617
618
  method: "POST",
618
619
  path: "/pods/{pod_id}/inboxes",
619
620
  requiredPerm: "inbox_create",
@@ -622,7 +623,7 @@ export const TOOL_TABLE = [
622
623
  annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
623
624
  params: [
624
625
  { name: "username", in: "body", type: "string" },
625
- { name: "domain", in: "body", type: "string" },
626
+ { name: "domain", in: "body", type: "string", required: true },
626
627
  { name: "display_name", in: "body", type: "string" },
627
628
  ],
628
629
  },
@@ -931,25 +932,135 @@ function gatedTable(scope) {
931
932
  return TOOL_TABLE.filter((t) => isExposed(t, scope));
932
933
  }
933
934
  /** Build the MCP Tool descriptor list for the given scope: the gated subset, read tools carrying the
934
- * anti-injection notice and readOnlyHint. */
935
- export function toolDescriptors(scope) {
936
- return gatedTable(scope).map((t) => ({
935
+ * anti-injection notice and readOnlyHint. On the OAuth/Bearer path (opts.orgTools) the two
936
+ * organization tools are appended — they are NOT in TOOL_TABLE (no REST route, no core permission) and
937
+ * so never leak onto the x-api-key path. */
938
+ export function toolDescriptors(scope, opts = {}) {
939
+ const rest = gatedTable(scope).map((t) => ({
937
940
  name: t.name,
938
941
  title: t.title,
939
942
  description: t.readOnly ? t.description + EXTERNAL_CONTENT_NOTICE : t.description,
940
943
  inputSchema: buildSchema(t, scope),
941
944
  annotations: { title: t.title, readOnlyHint: t.readOnly, ...t.annotations },
942
945
  }));
946
+ return opts.orgTools ? [...rest, ...orgToolDescriptors()] : rest;
943
947
  }
944
- /** Resolve a tool by name, honouring the gate: a tool the key may not use is not found, exactly as it
945
- * is absent from ListTools. This keeps CallTool and ListTools coherent — the gate is one function. */
948
+ /** Resolve a REST tool by name, honouring the gate: a tool the key may not use is not found, exactly as
949
+ * it is absent from ListTools. The org tools are resolved separately (they live outside TOOL_TABLE). */
946
950
  export function specByName(name, scope) {
947
951
  const def = TOOL_TABLE.find((t) => t.name === name);
948
952
  if (!def)
949
953
  return undefined;
950
954
  return isExposed(def, scope) ? def : undefined;
951
955
  }
952
- /** Run a tool's one REST request. Exposed so the server adapter and tests invoke it uniformly. */
956
+ /** Run a REST tool's one request. Exposed so the server adapter and tests invoke it uniformly. */
953
957
  export function callTool(def, args, ctx) {
954
958
  return dispatch(def, args, ctx);
955
959
  }
960
+ // ---- organization tools (R3 multi-org, Bearer path ONLY) -----------------------------------------
961
+ // The two tools that let an agent see and switch which authorized org its token-set is pointed at. They
962
+ // are NOT REST calls and carry no core permission: they read/mutate the token-set in the store via
963
+ // ctx.orgSession. They are exposed ONLY when orgTools is on (the OAuth/Bearer path) — on the x-api-key
964
+ // path there is one org and no set, so they do not exist. The invariant they protect (Model D): the
965
+ // worker never retains the human's identity, so list_organizations lists ONLY the authorized orgs
966
+ // (token.orgs), never the human's whole org universe; select_organization for an org OUTSIDE the set
967
+ // NEVER falls back to another child key — it returns an authorize_url to re-run consent for that org.
968
+ export const ORG_TOOL_NAMES = ["list_organizations", "select_organization"];
969
+ /** The MCP descriptors for the two org tools. */
970
+ export function orgToolDescriptors() {
971
+ return [
972
+ {
973
+ name: "list_organizations",
974
+ title: "List organizations",
975
+ description: "List the organizations this connection is authorized to act for, and which one is active. " +
976
+ "Only organizations you have authorized appear here.",
977
+ inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
978
+ annotations: { title: "List organizations", readOnlyHint: true, openWorldHint: false },
979
+ },
980
+ {
981
+ name: "select_organization",
982
+ title: "Select organization",
983
+ description: "Switch the active organization. Pass an organization id or label from list_organizations. If " +
984
+ "it is one you have already authorized, the switch is instant. If not, this returns " +
985
+ "status 'authorization_required' with an authorize_url: open it (signed in to Pombus) to grant " +
986
+ "this connection access to that organization, then it becomes selectable.",
987
+ inputSchema: {
988
+ type: "object",
989
+ properties: { organization: { type: "string", description: "Organization id or label." } },
990
+ required: ["organization"],
991
+ additionalProperties: false,
992
+ },
993
+ annotations: { title: "Select organization", readOnlyHint: false, openWorldHint: false },
994
+ },
995
+ ];
996
+ }
997
+ /** Is `name` one of the org tools? */
998
+ export function isOrgTool(name) {
999
+ return ORG_TOOL_NAMES.includes(name);
1000
+ }
1001
+ /** Run an org tool against the token-set. Requires ctx.orgSession (the Bearer path); without it the
1002
+ * tool does not exist, so this throws the same "unknown" shape a REST miss would (defense in depth —
1003
+ * it is never reached because the gate hides the tool on the x-api-key path). */
1004
+ export async function callOrgTool(name, args, ctx) {
1005
+ const session = ctx.orgSession;
1006
+ if (!session)
1007
+ throw new ToolArgError("orgSession"); // unreachable on the x-api-key path (tool hidden)
1008
+ const { store, accessToken } = session;
1009
+ if (name === "list_organizations") {
1010
+ const tok = await store.getToken(accessToken);
1011
+ const orgs = tok ? Object.entries(tok.orgs) : [];
1012
+ return {
1013
+ organizations: orgs.map(([orgId, e]) => ({
1014
+ orgId,
1015
+ label: e.label,
1016
+ active: tok ? orgId === tok.activeOrg : false,
1017
+ })),
1018
+ };
1019
+ }
1020
+ // select_organization
1021
+ const requested = str(args["organization"]) ?? "";
1022
+ if (!requested)
1023
+ throw new ToolArgError("organization");
1024
+ const tok = await store.getToken(accessToken);
1025
+ if (!tok)
1026
+ throw new ToolArgError("organization"); // token gone — the agent re-authorizes anyway
1027
+ // Match by orgId first, then by (unique) label — a human passes whichever list_organizations showed.
1028
+ let targetOrgId;
1029
+ if (tok.orgs[requested]) {
1030
+ targetOrgId = requested;
1031
+ }
1032
+ else {
1033
+ const byLabel = Object.entries(tok.orgs).filter(([, e]) => e.label === requested);
1034
+ if (byLabel.length === 1)
1035
+ targetOrgId = byLabel[0][0];
1036
+ }
1037
+ if (targetOrgId) {
1038
+ await store.setActiveOrg(accessToken, targetOrgId);
1039
+ return { status: "active", orgId: targetOrgId };
1040
+ }
1041
+ // The org is NOT in the authorized set. NEVER fall back to another org's child key (SEC4): mint a
1042
+ // single-use link_token tied to THIS access token and hand back an authorize_url the agent re-runs
1043
+ // the OAuth flow against (with its own client_id/redirect/PKCE) carrying the link_token, so /token
1044
+ // attaches the newly consented org to the SAME access token. The url is relative — the MCP client
1045
+ // already knows this server's origin from the connection it is calling.
1046
+ const linkToken = randomToken();
1047
+ await store.putLink(linkToken, accessToken);
1048
+ return {
1049
+ status: "authorization_required",
1050
+ link_token: linkToken,
1051
+ authorize_url: `/authorize?link_token=${encodeURIComponent(linkToken)}`,
1052
+ message: "Start the OAuth authorization flow against authorize_url (append your client_id, redirect_uri, " +
1053
+ "response_type=code, PKCE and state as usual) while signed in to Pombus, to authorize this " +
1054
+ "organization. The link_token ties the new organization to your current connection.",
1055
+ };
1056
+ }
1057
+ /** A cryptographically-random opaque token (32 bytes → 43 base64url chars). Local to the tools layer so
1058
+ * the org link_token does not reach back into oauth.ts; identical generation. */
1059
+ function randomToken() {
1060
+ const b = new Uint8Array(32);
1061
+ crypto.getRandomValues(b);
1062
+ let s = "";
1063
+ for (const x of b)
1064
+ s += String.fromCharCode(x);
1065
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1066
+ }