pombus 0.1.3 → 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.
- package/README.md +6 -1
- package/dist/bin.js +12 -1
- package/dist/client.js +13 -4
- package/dist/http/do-class.js +77 -0
- package/dist/http/do-entry.js +10 -0
- package/dist/http/oauth-i18n.js +69 -0
- package/dist/http/oauth.js +588 -0
- package/dist/http/root.js +80 -0
- package/dist/http/store-do.js +64 -0
- package/dist/http/worker.js +238 -0
- package/dist/i18n.js +24 -0
- package/dist/init.js +38 -2
- package/dist/server.js +23 -10
- package/dist/tools.js +119 -8
- package/package.json +1 -1
|
@@ -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,26 @@ 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
|
+
},
|
|
53
73
|
init_config_written: {
|
|
54
74
|
"en-US": "MCP config written for ",
|
|
55
75
|
"pt-BR": "Config MCP gravada para ",
|
|
@@ -95,6 +115,10 @@ const MESSAGES = {
|
|
|
95
115
|
const INSTALLER_CODES = [
|
|
96
116
|
"init_prompt_key",
|
|
97
117
|
"init_inbox_created",
|
|
118
|
+
"init_inbox_needs_domain",
|
|
119
|
+
"init_inbox_bad_domain",
|
|
120
|
+
"init_inbox_platform_domain",
|
|
121
|
+
"init_inbox_domain_not_verified",
|
|
98
122
|
"init_config_written",
|
|
99
123
|
"init_key_power",
|
|
100
124
|
"init_restart_client",
|
package/dist/init.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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 { CoreClient, CoreError } from "./client.js";
|
|
10
10
|
import { ScopeBinding } from "./scope.js";
|
|
11
11
|
import { clientTargets, printableCodexToml, printableJson, writeJsonConfig, ConfigWriteError, } from "./clients.js";
|
|
12
12
|
import { humanMessage, resolveLocale } from "./i18n.js";
|
|
@@ -18,6 +18,17 @@ export async function runInit(opts) {
|
|
|
18
18
|
const locale = opts.locale ?? resolveLocale();
|
|
19
19
|
const out = opts.out ?? ((l) => process.stderr.write(l + "\n"));
|
|
20
20
|
const say = (code, suffix = "") => out(humanMessage(code, locale) + suffix);
|
|
21
|
+
// --create-inbox needs --domain: an extra inbox only exists on a verified domain of your own. Refuse
|
|
22
|
+
// BEFORE the key prompt and any request, with the rule spelled out (the core would answer a bare 403).
|
|
23
|
+
const domain = (opts.domain ?? "").trim().toLowerCase().replace(/\.$/, "");
|
|
24
|
+
if (opts.createInbox && !domain) {
|
|
25
|
+
say("init_inbox_needs_domain");
|
|
26
|
+
throw new InitAbort("init_inbox_needs_domain");
|
|
27
|
+
}
|
|
28
|
+
if (opts.createInbox && !looksLikeDomain(domain)) {
|
|
29
|
+
say("init_inbox_bad_domain");
|
|
30
|
+
throw new InitAbort("init_inbox_bad_domain");
|
|
31
|
+
}
|
|
21
32
|
// --print with no key: emit the placeholder block for every known client and stop. No network, no
|
|
22
33
|
// disk. This is the "I just want the snippet" path and the safe landing for uncertain detection.
|
|
23
34
|
const key = (opts.apiKey ?? "").trim() || (opts.print ? "" : await requireKey(opts));
|
|
@@ -35,7 +46,21 @@ export async function runInit(opts) {
|
|
|
35
46
|
let inboxId = scope.fixedInboxId;
|
|
36
47
|
let inboxEmail = null;
|
|
37
48
|
if (opts.createInbox) {
|
|
38
|
-
|
|
49
|
+
let created;
|
|
50
|
+
try {
|
|
51
|
+
created = await client.post("/inboxes", { domain });
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
// The two refusals a person can act on get their own line (the key was fine — /auth/me passed
|
|
55
|
+
// above). Anything else rethrows to the CLI's generic handler.
|
|
56
|
+
const why = e instanceof CoreError ? (e.coreCode ?? e.code) : "";
|
|
57
|
+
const human = INBOX_REFUSALS[why];
|
|
58
|
+
if (human) {
|
|
59
|
+
say(human);
|
|
60
|
+
throw new InitAbort(human);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
39
64
|
inboxId = typeof created.inbox_id === "string" ? created.inbox_id : inboxId;
|
|
40
65
|
inboxEmail = typeof created.email === "string" ? created.email : null;
|
|
41
66
|
if (inboxEmail)
|
|
@@ -149,6 +174,17 @@ function emptyPrintResult() {
|
|
|
149
174
|
printedFor: [],
|
|
150
175
|
};
|
|
151
176
|
}
|
|
177
|
+
/** The core's create-inbox refusals a person can act on -> their human line. `platform_inbox_not_allowed`
|
|
178
|
+
* arrives as a 403 (collapsed to not_found, real code in coreCode); `domain_not_verified` as a 422. */
|
|
179
|
+
const INBOX_REFUSALS = {
|
|
180
|
+
platform_inbox_not_allowed: "init_inbox_platform_domain",
|
|
181
|
+
domain_not_verified: "init_inbox_domain_not_verified",
|
|
182
|
+
};
|
|
183
|
+
/** A basic domain-name shape (labels of letters/digits/hyphens, a dot, a TLD) — catches a typo or a flag
|
|
184
|
+
* swallowed as the value BEFORE any request. The core is the authority on whether it is verified. */
|
|
185
|
+
function looksLikeDomain(d) {
|
|
186
|
+
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);
|
|
187
|
+
}
|
|
152
188
|
/** A clean, key-free abort for a missing prerequisite (the CLI turns it into exit code 1). */
|
|
153
189
|
export class InitAbort extends Error {
|
|
154
190
|
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.
|
|
16
|
+
const SERVER_VERSION = "0.1.4";
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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
|
|
945
|
-
* is absent from ListTools.
|
|
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
|
|
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
|
+
}
|
package/package.json
CHANGED