@atomicmail/mcp 0.1.0 → 0.2.0

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.
Files changed (49) hide show
  1. package/README.md +45 -145
  2. package/esm/lib/src/agent-auth-http.d.ts +26 -0
  3. package/esm/lib/src/agent-auth-http.d.ts.map +1 -0
  4. package/esm/lib/src/agent-auth-http.js +76 -0
  5. package/esm/{mcp/src/credentials.d.ts → lib/src/agent-credentials-store.d.ts} +3 -2
  6. package/esm/lib/src/agent-credentials-store.d.ts.map +1 -0
  7. package/esm/{mcp/src/credentials.js → lib/src/agent-credentials-store.js} +19 -16
  8. package/esm/lib/src/agent-help-content.d.ts +4 -0
  9. package/esm/lib/src/agent-help-content.d.ts.map +1 -0
  10. package/esm/lib/src/agent-help-content.js +236 -0
  11. package/esm/lib/src/agent-jmap.d.ts +49 -0
  12. package/esm/lib/src/agent-jmap.d.ts.map +1 -0
  13. package/esm/lib/src/agent-jmap.js +130 -0
  14. package/esm/lib/src/agent-jwt.d.ts +14 -0
  15. package/esm/lib/src/agent-jwt.d.ts.map +1 -0
  16. package/esm/lib/src/agent-jwt.js +29 -0
  17. package/esm/lib/src/agent-pow.d.ts +5 -0
  18. package/esm/lib/src/agent-pow.d.ts.map +1 -0
  19. package/esm/lib/src/agent-pow.js +49 -0
  20. package/esm/lib/src/agent-resolve-config.d.ts +24 -0
  21. package/esm/lib/src/agent-resolve-config.d.ts.map +1 -0
  22. package/esm/lib/src/agent-resolve-config.js +70 -0
  23. package/esm/lib/src/agent-session.d.ts +62 -0
  24. package/esm/lib/src/agent-session.d.ts.map +1 -0
  25. package/esm/lib/src/agent-session.js +206 -0
  26. package/esm/lib/src/agent-vars.d.ts +23 -0
  27. package/esm/lib/src/agent-vars.d.ts.map +1 -0
  28. package/esm/lib/src/agent-vars.js +65 -0
  29. package/esm/mcp/src/main.js +31 -61
  30. package/esm/mcp/src/tools/help.d.ts +3 -0
  31. package/esm/mcp/src/tools/help.d.ts.map +1 -0
  32. package/esm/mcp/src/tools/help.js +22 -0
  33. package/esm/mcp/src/tools/jmap.d.ts +2 -2
  34. package/esm/mcp/src/tools/jmap.d.ts.map +1 -1
  35. package/esm/mcp/src/tools/jmap.js +53 -140
  36. package/esm/mcp/src/tools/register.d.ts +2 -2
  37. package/esm/mcp/src/tools/register.d.ts.map +1 -1
  38. package/esm/mcp/src/tools/register.js +9 -45
  39. package/package.json +1 -1
  40. package/esm/mcp/src/auth-session.d.ts +0 -88
  41. package/esm/mcp/src/auth-session.d.ts.map +0 -1
  42. package/esm/mcp/src/auth-session.js +0 -378
  43. package/esm/mcp/src/credentials.d.ts.map +0 -1
  44. package/esm/mcp/src/docs-content.d.ts +0 -4
  45. package/esm/mcp/src/docs-content.d.ts.map +0 -1
  46. package/esm/mcp/src/docs-content.js +0 -405
  47. package/esm/mcp/src/tools/docs.d.ts +0 -3
  48. package/esm/mcp/src/tools/docs.d.ts.map +0 -1
  49. package/esm/mcp/src/tools/docs.js +0 -22
@@ -1,378 +0,0 @@
1
- // AuthSession — stateful PoW + capability JWT manager for the MCP server.
2
- //
3
- // Mirrors the protocol implemented in skill/scripts/lib/auth.ts (the
4
- // @atomic-mail/agent-skill CLIs) and uses the same on-disk layout
5
- // (credentials.json + session.jwt + capability.jwt) so the MCP and the skill
6
- // CLIs can share a single credential directory.
7
- //
8
- // Tools call session.getCapabilityToken() before every JMAP request. The
9
- // session manager:
10
- //
11
- // • Decodes JWT exp claims to detect impending expiry.
12
- // • Re-runs the full PoW + /api/v1/session handshake when the session JWT
13
- // is missing or near expiry.
14
- // • Refreshes the capability JWT via /api/v1/capability when it is missing
15
- // or near expiry.
16
- // • Persists rotated tokens to disk so a restarted MCP process (or a
17
- // concurrent atomic-mail-jmap CLI invocation) sees the latest values.
18
- import process from "node:process";
19
- import { scrypt } from "node:crypto";
20
- import { DEFAULT_POW_SCRYPT_SALT_HEX } from "../../lib/src/consts.js";
21
- import { defaultFilesFromOutDir, tryReadCredentials, tryReadJwtFile, writeCredentials, writeJwtFile, } from "./credentials.js";
22
- // ── PoW + JWT primitives (mirror skill/scripts/lib/auth.ts) ────────────────
23
- const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
24
- const POW_HASH_BYTES = 64;
25
- export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
26
- export const CAPABILITY_TTL_MS = 2 * 60 * 1000;
27
- // Refresh window: how close to expiry before we re-issue. Server-side TTL is
28
- // strict so we rotate before the wire-side deadline.
29
- export const SESSION_SAFETY_MARGIN_MS = 60_000;
30
- export const CAPABILITY_SAFETY_MARGIN_MS = 20_000;
31
- export function decodeJwtPayload(jwt) {
32
- const parts = jwt.split(".");
33
- if (parts.length < 2) {
34
- throw new Error("Malformed JWT: expected at least 2 dot-separated segments.");
35
- }
36
- const payloadB64Url = parts[1];
37
- const padLen = (4 - (payloadB64Url.length % 4)) % 4;
38
- const base64 = payloadB64Url
39
- .replace(/-/g, "+")
40
- .replace(/_/g, "/")
41
- .padEnd(payloadB64Url.length + padLen, "=");
42
- return JSON.parse(atob(base64));
43
- }
44
- export function isJwtExpired(jwt, marginMs) {
45
- try {
46
- const { exp } = decodeJwtPayload(jwt);
47
- if (typeof exp !== "number")
48
- return true;
49
- return Date.now() >= exp * 1000 - marginMs;
50
- }
51
- catch {
52
- return true;
53
- }
54
- }
55
- function bytesToHex(bytes) {
56
- let hex = "";
57
- for (let i = 0; i < bytes.length; i++) {
58
- hex += bytes[i].toString(16).padStart(2, "0");
59
- }
60
- return hex;
61
- }
62
- function hasLeadingZeroBits(hash, bits) {
63
- if (bits > hash.length * 8)
64
- return false;
65
- const fullBytes = Math.floor(bits / 8);
66
- const remainingBits = bits % 8;
67
- for (let i = 0; i < fullBytes; i++) {
68
- if (hash[i] !== 0)
69
- return false;
70
- }
71
- if (remainingBits > 0) {
72
- const mask = (0xff << (8 - remainingBits)) & 0xff;
73
- if ((hash[fullBytes] & mask) !== 0)
74
- return false;
75
- }
76
- return true;
77
- }
78
- // auth-service feeds the SCRYPT_SALT_HEX string directly to node:crypto's
79
- // scrypt as the `salt` argument (i.e. the UTF-8 bytes of the hex string,
80
- // NOT the decoded hex bytes). We mirror that so client and server derive
81
- // the same digest.
82
- function scryptHash(data, salt) {
83
- const bytes = new TextEncoder().encode(data);
84
- return new Promise((resolve, reject) => {
85
- scrypt(bytes, salt, POW_HASH_BYTES, SCRYPT_PARAMS, (err, derived) => {
86
- if (err)
87
- return reject(err);
88
- resolve(new Uint8Array(derived));
89
- });
90
- });
91
- }
92
- async function solvePow(challenge, difficulty, salt) {
93
- let nonce = 0n;
94
- while (true) {
95
- const digest = await scryptHash(`${challenge}:${nonce}`, salt);
96
- if (hasLeadingZeroBits(digest, difficulty)) {
97
- return { powHex: bytesToHex(digest), nonce: nonce.toString() };
98
- }
99
- nonce++;
100
- }
101
- }
102
- async function postJson(url, body, headers = {}) {
103
- const res = await fetch(url, {
104
- method: "POST",
105
- headers: {
106
- ...(body ? { "Content-Type": "application/json" } : {}),
107
- ...headers,
108
- },
109
- body: body ? JSON.stringify(body) : undefined,
110
- });
111
- const text = await res.text();
112
- const path = (() => {
113
- try {
114
- return new URL(url).pathname;
115
- }
116
- catch {
117
- return url;
118
- }
119
- })();
120
- if (!res.ok) {
121
- throw new Error(`auth-service ${path} returned ${res.status}: ${text}`);
122
- }
123
- try {
124
- return JSON.parse(text);
125
- }
126
- catch {
127
- throw new Error(`auth-service ${path} returned non-JSON body: ${text}`);
128
- }
129
- }
130
- async function fetchChallenge(authUrl) {
131
- const data = await postJson(`${authUrl}/api/v1/challenge`, undefined);
132
- if (typeof data.challengeJWT !== "string") {
133
- throw new Error("Challenge response missing challengeJWT.");
134
- }
135
- const payload = decodeJwtPayload(data.challengeJWT);
136
- if (typeof payload.jti !== "string" ||
137
- typeof payload.difficulty !== "number") {
138
- throw new Error("Challenge JWT payload malformed (missing jti or difficulty).");
139
- }
140
- return {
141
- challengeJWT: data.challengeJWT,
142
- challenge: payload.jti,
143
- difficulty: payload.difficulty,
144
- };
145
- }
146
- async function exchangeSession(authUrl, body) {
147
- const data = await postJson(`${authUrl}/api/v1/session`, { ...body });
148
- if (typeof data.sessionJWT !== "string") {
149
- throw new Error("Session response missing sessionJWT.");
150
- }
151
- return {
152
- sessionJWT: data.sessionJWT,
153
- apiKey: typeof data.apiKey === "string" ? data.apiKey : undefined,
154
- };
155
- }
156
- async function fetchCapabilityJwt(authUrl, sessionJWT) {
157
- const data = await postJson(`${authUrl}/api/v1/capability`, undefined, { Authorization: `Bearer ${sessionJWT}` });
158
- if (typeof data.capabilityJWT !== "string") {
159
- throw new Error("Capability response missing capabilityJWT.");
160
- }
161
- return data.capabilityJWT;
162
- }
163
- async function performPoWAndSession(input) {
164
- const { challengeJWT, challenge, difficulty } = await fetchChallenge(input.authUrl);
165
- const { powHex, nonce } = await solvePow(challenge, difficulty, input.scryptSalt);
166
- return exchangeSession(input.authUrl, {
167
- challengeJWT,
168
- powHex,
169
- nonce,
170
- apiKey: input.apiKey,
171
- username: input.username,
172
- });
173
- }
174
- /**
175
- * Resolve credential directory from (in priority order):
176
- * 1. ATOMIC_MAIL_CREDENTIALS_DIR env var
177
- * 2. ~/.atomicmail/ (POSIX) or %USERPROFILE%/.atomicmail (Windows)
178
- */
179
- export function resolveCredentialDir() {
180
- const fromEnv = process.env.ATOMIC_MAIL_CREDENTIALS_DIR;
181
- if (fromEnv && fromEnv.length > 0)
182
- return fromEnv;
183
- const home = process.env.HOME || process.env.USERPROFILE;
184
- if (!home) {
185
- throw new Error("Cannot determine default credential directory: HOME and USERPROFILE " +
186
- "are both unset. Set ATOMIC_MAIL_CREDENTIALS_DIR explicitly.");
187
- }
188
- return `${home.replace(/[\\/]+$/, "")}/.atomicmail`;
189
- }
190
- /**
191
- * Resolve server configuration from:
192
- * 1. credentials.json in the credential directory (if present and valid).
193
- * 2. ATOMIC_MAIL_* environment variables (overlay on top of (1) so env
194
- * always wins on a per-field basis).
195
- *
196
- * The auth and api base URLs MUST be resolvable from at least one source.
197
- * PoW scrypt salt defaults to the deployment constant when not set in env or
198
- * credentials.json.
199
- * The api key is optional; without it, the agent must call the register tool
200
- * before any JMAP request.
201
- */
202
- export async function resolveConfig() {
203
- const credentialDir = resolveCredentialDir();
204
- const files = defaultFilesFromOutDir(credentialDir);
205
- const fileCreds = await tryReadCredentials(files.credentialsFile);
206
- const env = process.env;
207
- const envAuthUrl = env.ATOMIC_MAIL_AUTH_URL;
208
- const envApiUrl = env.ATOMIC_MAIL_API_URL;
209
- const envSalt = env.ATOMIC_MAIL_SCRYPT_SALT;
210
- const envApiKey = env.ATOMIC_MAIL_API_KEY;
211
- const authUrl = envAuthUrl ?? fileCreds?.authUrl;
212
- const apiUrl = envApiUrl ?? fileCreds?.apiUrl;
213
- const scryptSalt = envSalt ?? fileCreds?.scryptSalt ??
214
- DEFAULT_POW_SCRYPT_SALT_HEX;
215
- const apiKey = envApiKey ?? fileCreds?.apiKey;
216
- const inboxId = fileCreds?.inboxId;
217
- const missing = [];
218
- if (!authUrl)
219
- missing.push("ATOMIC_MAIL_AUTH_URL");
220
- if (!apiUrl)
221
- missing.push("ATOMIC_MAIL_API_URL");
222
- if (missing.length > 0) {
223
- throw new Error(`Missing required configuration: ${missing.join(", ")}. ` +
224
- `Provide these via environment variables, or place a populated ` +
225
- `credentials.json in '${credentialDir}' (run atomic-mail-signup ` +
226
- `first, or set ATOMIC_MAIL_CREDENTIALS_DIR to point at an existing ` +
227
- `credential directory).`);
228
- }
229
- const usingFile = fileCreds !== undefined;
230
- const usingEnv = !!(envAuthUrl || envApiUrl || envSalt || envApiKey);
231
- const source = usingFile && usingEnv
232
- ? "mixed"
233
- : usingFile
234
- ? "credentials-file"
235
- : usingEnv
236
- ? "env"
237
- : "incomplete";
238
- return {
239
- authUrl: authUrl.replace(/\/+$/, ""),
240
- apiUrl: apiUrl.replace(/\/+$/, ""),
241
- scryptSalt: scryptSalt,
242
- apiKey,
243
- inboxId,
244
- credentialDir,
245
- files,
246
- source,
247
- };
248
- }
249
- export class AuthSession {
250
- authUrl;
251
- apiUrl;
252
- scryptSalt;
253
- apiKey;
254
- inboxId;
255
- credentialDir;
256
- files;
257
- sessionJWT;
258
- capabilityJWT;
259
- constructor(cfg) {
260
- this.authUrl = cfg.authUrl.replace(/\/+$/, "");
261
- this.apiUrl = cfg.apiUrl.replace(/\/+$/, "");
262
- this.scryptSalt = cfg.scryptSalt;
263
- this.apiKey = cfg.apiKey;
264
- this.inboxId = cfg.inboxId;
265
- this.credentialDir = cfg.credentialDir;
266
- this.files = cfg.files;
267
- }
268
- /** Construct a session and load any previously persisted JWTs from disk. */
269
- static async create(cfg) {
270
- const session = new AuthSession(cfg);
271
- await session.loadFromDisk();
272
- return session;
273
- }
274
- get hasApiKey() {
275
- return this.apiKey !== undefined && this.apiKey.length > 0;
276
- }
277
- get currentInboxId() {
278
- return this.inboxId;
279
- }
280
- async loadFromDisk() {
281
- this.sessionJWT = await tryReadJwtFile(this.files.sessionFile);
282
- this.capabilityJWT = await tryReadJwtFile(this.files.capabilityFile);
283
- }
284
- /**
285
- * Full PoW signup. Persists credentials.json + session.jwt + capability.jwt
286
- * to disk and updates in-memory state. Throws if an API key is already
287
- * configured (refuse to clobber an existing account).
288
- */
289
- async signup(username) {
290
- if (this.hasApiKey) {
291
- throw new Error("An API key is already configured. Refusing to overwrite an " +
292
- "existing account; remove credentials.json (or unset " +
293
- "ATOMIC_MAIL_API_KEY) before registering a new one.");
294
- }
295
- const result = await performPoWAndSession({
296
- authUrl: this.authUrl,
297
- scryptSalt: this.scryptSalt,
298
- username,
299
- });
300
- if (!result.apiKey) {
301
- throw new Error("Signup did not return an API key — this indicates a server bug.");
302
- }
303
- this.apiKey = result.apiKey;
304
- this.sessionJWT = result.sessionJWT;
305
- await writeJwtFile(this.files.sessionFile, this.sessionJWT);
306
- const capability = await fetchCapabilityJwt(this.authUrl, this.sessionJWT);
307
- this.capabilityJWT = capability;
308
- await writeJwtFile(this.files.capabilityFile, capability);
309
- const claims = decodeJwtPayload(capability);
310
- if (typeof claims.inboxId !== "string" || claims.inboxId.length === 0) {
311
- throw new Error("Capability JWT missing inboxId claim after signup.");
312
- }
313
- this.inboxId = claims.inboxId;
314
- const creds = {
315
- apiKey: this.apiKey,
316
- inboxId: this.inboxId,
317
- authUrl: this.authUrl,
318
- apiUrl: this.apiUrl,
319
- scryptSalt: this.scryptSalt,
320
- };
321
- await writeCredentials(this.files.credentialsFile, creds);
322
- return { apiKey: this.apiKey, inboxId: this.inboxId };
323
- }
324
- /**
325
- * Get a valid capability JWT, rotating session/capability tokens as needed.
326
- * This is the primary method tool handlers call before every JMAP request.
327
- */
328
- async getCapabilityToken() {
329
- if (this.capabilityJWT &&
330
- !isJwtExpired(this.capabilityJWT, CAPABILITY_SAFETY_MARGIN_MS)) {
331
- return this.capabilityJWT;
332
- }
333
- await this.ensureSession();
334
- if (!this.sessionJWT) {
335
- throw new Error("Internal: ensureSession() left sessionJWT unset.");
336
- }
337
- const cap = await fetchCapabilityJwt(this.authUrl, this.sessionJWT);
338
- this.capabilityJWT = cap;
339
- await writeJwtFile(this.files.capabilityFile, cap);
340
- // Capability also carries the canonical inboxId — refresh our cached copy
341
- // so the agent always has the latest value via currentInboxId.
342
- try {
343
- const claims = decodeJwtPayload(cap);
344
- if (typeof claims.inboxId === "string" && claims.inboxId.length > 0) {
345
- this.inboxId = claims.inboxId;
346
- }
347
- }
348
- catch {
349
- // Non-fatal: capability is still usable as a Bearer token.
350
- }
351
- return cap;
352
- }
353
- async ensureSession() {
354
- if (this.sessionJWT &&
355
- !isJwtExpired(this.sessionJWT, SESSION_SAFETY_MARGIN_MS)) {
356
- return;
357
- }
358
- if (!this.apiKey) {
359
- throw new Error("No API key configured and no valid session on disk. Either call " +
360
- "the 'register' tool to create a new account, set ATOMIC_MAIL_API_KEY, " +
361
- "or place a credentials.json into the credential directory.");
362
- }
363
- const result = await performPoWAndSession({
364
- authUrl: this.authUrl,
365
- scryptSalt: this.scryptSalt,
366
- apiKey: this.apiKey,
367
- });
368
- this.sessionJWT = result.sessionJWT;
369
- // After session rotation, the previous capability is no longer recognized
370
- // by auth-service (challenge cache reset), so force a refresh next.
371
- this.capabilityJWT = undefined;
372
- await writeJwtFile(this.files.sessionFile, this.sessionJWT);
373
- }
374
- /** Clean shutdown. No-op today; reserved for future cleanup. */
375
- destroy() {
376
- // Nothing to clean up — all state is on disk + in-memory only.
377
- }
378
- }
@@ -1 +0,0 @@
1
- {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../../../src/mcp/src/credentials.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAOjE;AAMD,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAiCxE;AAED,gFAAgF;AAChF,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAOlC;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B"}
@@ -1,4 +0,0 @@
1
- export declare const TOPICS: Record<string, string>;
2
- export declare const TOPIC_LIST: string[];
3
- export declare function getDocs(topic?: string): string;
4
- //# sourceMappingURL=docs-content.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"docs-content.d.ts","sourceRoot":"","sources":["../../../src/mcp/src/docs-content.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA+YzC,CAAC;AAEF,eAAO,MAAM,UAAU,UAAsB,CAAC;AAE9C,wBAAgB,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9C"}