airmail-mcp 1.0.13 → 1.0.19

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 CHANGED
@@ -12,41 +12,100 @@ This is a lightweight bridge that connects AI clients to Airmail's built-in MCP
12
12
 
13
13
  ## Installation
14
14
 
15
- ### Claude Desktop (MCPB extension)
15
+ ### Published npm package
16
16
 
17
- Install from the [Claude MCP Directory](https://claude.ai/mcp) or download the latest `.mcpb` file from [Releases](https://github.com/Airmail/airmail-mcp/releases) and double-click to install.
17
+ `npx` installs and runs the published package automatically. Users do not need to clone this repository or run `npm install`.
18
18
 
19
- ### Claude Desktop (manual)
19
+ ```bash
20
+ codex mcp remove airmail
21
+ codex mcp add airmail -- npx -y airmail-mcp
22
+ ```
20
23
 
21
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
24
+ Use this for normal installs after the desired version has been published to npm.
25
+
26
+ ### Local development build
27
+
28
+ Use this when testing changes from a local checkout before publishing a new npm version.
29
+
30
+ ```bash
31
+ git clone https://github.com/Airmail/airmail-mcp.git
32
+ cd airmail-mcp
33
+ npm install
34
+ npm run build
35
+ ```
36
+
37
+ Then point your MCP client at the compiled local bridge. Do not commit a machine-specific path in shared docs or config examples; use your own checkout path.
38
+
39
+ **Codex CLI / Codex Desktop**:
40
+
41
+ ```bash
42
+ codex mcp remove airmail
43
+ codex mcp add airmail -- node "$(pwd)/dist/index.js"
44
+ ```
45
+
46
+ **Claude Desktop JSON only**:
22
47
 
23
48
  ```json
24
49
  {
25
50
  "mcpServers": {
26
51
  "airmail": {
27
- "command": "npx",
28
- "args": ["-y", "airmail-mcp"]
52
+ "command": "node",
53
+ "args": ["/absolute/path/to/airmail-mcp/dist/index.js"]
29
54
  }
30
55
  }
31
56
  }
32
57
  ```
33
58
 
34
- The auth token is read automatically from the macOS Keychain. If you set `AIRMAIL_MCP_TOKEN`, the Keychain is not accessed:
59
+ You can also smoke-test the local stdio bridge without installing it into a client:
60
+
61
+ ```bash
62
+ printf '%s\n' \
63
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-05","capabilities":{},"clientInfo":{"name":"Airmail MCP Local Test","version":"1.0"}}}' \
64
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
65
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_inbox","arguments":{"limit":3}}}' \
66
+ | node dist/index.js
67
+ ```
68
+
69
+ Restart the MCP client after changing its config. On first connection, Airmail shows an authorization prompt; click **Allow**.
70
+
71
+ Important: `npx -y airmail-mcp` installs the published npm package, not this local checkout. Use the local `node "$(pwd)/dist/index.js"` command until the package is published.
72
+
73
+ ### GitHub development install
74
+
75
+ Use this when you want the MCP client to install from GitHub instead of a local checkout. This tests pushed GitHub code, not uncommitted local edits.
76
+
77
+ ```bash
78
+ codex mcp remove airmail
79
+ codex mcp add airmail -- npx -y github:Airmail/airmail-mcp#main
80
+ ```
81
+
82
+ Replace `main` with a branch name, tag, or commit SHA when testing a specific version:
83
+
84
+ ```bash
85
+ codex mcp add airmail -- npx -y github:Airmail/airmail-mcp#branch-name
86
+ ```
87
+
88
+ ### Claude Desktop (MCPB extension)
89
+
90
+ Install from the [Claude MCP Directory](https://claude.ai/mcp) or download the latest `.mcpb` file from [Releases](https://github.com/Airmail/airmail-mcp/releases) and double-click to install.
91
+
92
+ ### Claude Desktop (manual)
93
+
94
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
35
95
 
36
96
  ```json
37
97
  {
38
98
  "mcpServers": {
39
99
  "airmail": {
40
100
  "command": "npx",
41
- "args": ["-y", "airmail-mcp"],
42
- "env": {
43
- "AIRMAIL_MCP_TOKEN": "your-token-here"
44
- }
101
+ "args": ["-y", "airmail-mcp"]
45
102
  }
46
103
  }
47
104
  }
48
105
  ```
49
106
 
107
+ On first use, Airmail shows a pairing prompt and issues a per-client token for this bridge. By default that token stays only in bridge memory for the current session.
108
+
50
109
  ### Claude Code
51
110
 
52
111
  ```bash
@@ -112,17 +171,11 @@ Add to `.vscode/mcp.json` in your project:
112
171
 
113
172
  ## Authentication
114
173
 
115
- The bridge reads the auth token automatically from the macOS Keychain no configuration needed. When macOS prompts for Keychain access, click **Always Allow** so it won't ask again.
116
-
117
- If you set `AIRMAIL_MCP_TOKEN`, the Keychain is skipped entirely:
118
-
119
- ```bash
120
- export AIRMAIL_MCP_TOKEN="your-token-here"
121
- ```
174
+ On first use, the bridge asks Airmail to pair this MCP client. Airmail shows an authorization prompt, then returns a per-client token. By default, the bridge keeps that token only in memory for the current process.
122
175
 
123
- To find your token: open Airmail **Preferences MCP** copy the **Auth Token**.
176
+ Airmail MCP does not use a global auth token. Access is pairing-only and can be revoked per client in Airmail's MCP Permissions tab.
124
177
 
125
- ## Tools (97)
178
+ ## Tools (101)
126
179
 
127
180
  ### Email (core)
128
181
  `list_accounts` · `list_folders` · `list_messages` · `get_message` · `list_inbox` · `list_starred` · `list_sent` · `list_trash` · `list_spam` · `search_messages` · `fetch_message_body` · `list_attachments` · `get_attachment` · `get_unread_counts` · `search_contacts` · `get_draft` · `delete_draft` · `get_message_thread` · `list_windows` · `export_eml`
@@ -178,38 +231,37 @@ Tools are organized into capability groups that can be enabled or disabled at ru
178
231
 
179
232
  | Group | Tools | Default |
180
233
  |-------|-------|---------|
181
- | mail | Email read, actions, compose | Always on |
182
- | profile | User profile, triage, behavior stats | On |
183
- | folders | Folder CRUD | On |
184
- | semantic | Semantic search, index status | On |
185
- | calendar | Calendar events, reminders | On |
186
- | contacts | Address book | On |
187
- | preferences | App preferences | On |
188
- | rules | Email rules | On |
189
- | lists | VIP & blocked senders | On |
190
- | smartfolders | Smart folder CRUD | On |
191
- | signatures | Email signatures | On |
192
- | aliases | Email aliases | On |
193
- | accountsettings | Per-account settings, vacation | On |
194
-
234
+ | mail | read, action, compose | Always on |
235
+ | profile | user profile & behavior | On |
236
+ | folders | folder create/rename/delete | On |
237
+ | semantic | semantic search & index | On |
238
+ | calendar | calendar & reminder | On |
239
+ | contacts | address book | On |
240
+ | preferences | app preferences read/write | On |
241
+ | rules | email rules CRUD | On |
242
+ | lists | VIP & blocked sender lists | On |
243
+ | smartfolders | smart folder CRUD | On |
244
+ | signatures | email signature CRUD | On |
245
+ | aliases | email alias CRUD | On |
246
+ | accountsettings | per-account settings & vacation | On |
195
247
  To enable all groups, ask the AI to call `manage_capabilities` with `enable: ["preferences", "rules", "lists", "smartfolders", "signatures", "aliases", "accountsettings"]`.
196
248
 
197
249
  ## Deep links
198
250
 
199
- MCP tool responses include `airmail://` deep links that open Airmail directly to the relevant content.
251
+ MCP tool responses include `airmailmcp://` deep links that open Airmail directly to the relevant content.
200
252
 
201
253
  | Command | URL | Description |
202
254
  |---------|-----|-------------|
203
- | `message` | `airmail://message?mail=...&messageid=...` | Select message in main window |
204
- | `open` | `airmail://open?mail=...&messageid=...` | Open message in reader window |
205
- | `compose` | `airmail://compose?to=...&subject=...` | Open composer with pre-filled content |
206
- | `reply` | `airmail://reply?mail=...&messageid=...` | Reply to a message |
207
- | `draft` | `airmail://draft?mail=...&messageid=...` | Open draft in composer |
208
- | `archive` | `airmail://archive?mail=...&messageid=...` | Archive a message |
209
- | `delete` | `airmail://delete?mail=...&messageid=...` | Move message to trash |
210
- | `view` | `airmail://view?mail=...&folder=...` | Navigate to account/folder |
211
- | `attachment` | `airmail://attachment?mail=...&messageid=...&index=0` | Open an attachment |
212
- | `settings` | `airmail://settings?pref=mcp_server` | Open Preferences pane |
255
+ | `message` | `airmailmcp://message?mail=...&messageid=...` | Select message in main window |
256
+ | `open` | `airmailmcp://open?mail=...&messageid=...` | Open message in reader window |
257
+ | `compose` | `airmailmcp://compose?to=...&subject=...` | Open composer with pre-filled content |
258
+ | `reply` | `airmailmcp://reply?mail=...&messageid=...` | Reply to a message |
259
+ | `draft` | `airmailmcp://draft?mail=...&messageid=...` | Open draft in composer |
260
+ | `archive` | `airmailmcp://archive?mail=...&messageid=...` | Archive a message |
261
+ | `delete` | `airmailmcp://delete?mail=...&messageid=...` | Move message to trash |
262
+ | `view` | `airmailmcp://view?mail=...&folder=...` | Navigate to account/folder |
263
+ | `attachment` | `airmailmcp://attachment?mail=...&messageid=...&index=0` | Open an attachment |
264
+ | `settings` | `airmailmcp://settings?pref=mcp_server` | Open Preferences pane |
213
265
 
214
266
  ## How it works
215
267
 
@@ -223,14 +275,16 @@ This package is a thin transport bridge. All tool logic runs inside Airmail's na
223
275
  2. Forwards them via HTTP POST to Airmail's local MCP server
224
276
  3. Writes responses back to stdout
225
277
 
226
- If Airmail is not running, the bridge will attempt to launch it automatically.
278
+ If Airmail is not running, the bridge exits with a clear error by default. Set
279
+ `AIRMAIL_MCP_AUTO_LAUNCH=1` if you want the bridge to open Airmail automatically.
227
280
 
228
281
  ## Environment variables
229
282
 
230
283
  | Variable | Description | Default |
231
284
  |----------|-------------|---------|
232
- | `AIRMAIL_MCP_TOKEN` | Auth token (optional automatically read from macOS Keychain if not set) | — |
285
+ | `AIRMAIL_MCP_REMEMBER_CLIENT_TOKEN` | Set to `1` to persist the bridge's per-client token in Keychain service `com.airmail.mcp.client`. | — |
233
286
  | `AIRMAIL_MCP_PORT` | MCP server port | `9876` |
287
+ | `AIRMAIL_MCP_AUTO_LAUNCH` | Set to `1`/`true` to launch Airmail when the local MCP server is not reachable | `0` |
234
288
 
235
289
  ## Development
236
290
 
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * collect all data before the FIN arrives.
12
12
  */
13
13
  import { execFileSync, spawnSync } from "child_process";
14
+ import { createHash } from "crypto";
14
15
  import { readFileSync, writeSync } from "fs";
15
16
  import * as net from "net";
16
17
  import { dirname, join } from "path";
@@ -19,6 +20,10 @@ import { fileURLToPath } from "url";
19
20
  // Configuration
20
21
  // ---------------------------------------------------------------------------
21
22
  const AIRMAIL_HOST = "127.0.0.1";
23
+ function envFlag(name) {
24
+ const value = (process.env[name] ?? "").trim().toLowerCase();
25
+ return value === "1" || value === "true" || value === "yes" || value === "on";
26
+ }
22
27
  const AIRMAIL_PORT = (() => {
23
28
  const p = parseInt(process.env.AIRMAIL_MCP_PORT ?? "9876", 10);
24
29
  if (isNaN(p) || p < 1 || p > 65535) {
@@ -41,12 +46,17 @@ const VERSION = (() => {
41
46
  }
42
47
  })();
43
48
  let currentToken = "";
49
+ let clientTokenPromise = null;
44
50
  const RETRY_DELAY_MS = 2000;
45
51
  const MAX_LAUNCH_RETRIES = 5;
46
52
  const REQUEST_TIMEOUT_MS = 120_000;
47
53
  const MAX_STDIN_BUFFER = 10 * 1024 * 1024; // 10 MB — matches server limit
54
+ const REMEMBER_CLIENT_TOKEN = /^(1|true|yes)$/i.test(process.env.AIRMAIL_MCP_REMEMBER_CLIENT_TOKEN ?? "");
55
+ const AUTO_LAUNCH_AIRMAIL = envFlag("AIRMAIL_MCP_AUTO_LAUNCH");
48
56
  /** Resolve parent process code signing Team ID (macOS only). */
49
57
  let parentCodeSignTeamID = null;
58
+ let parentPhysicalIdentity = null;
59
+ let parentBundleIdentifier = null;
50
60
  function resolveParentCodeSign() {
51
61
  try {
52
62
  const ppid = process.ppid;
@@ -60,12 +70,18 @@ function resolveParentCodeSign() {
60
70
  if (appIdx !== -1) {
61
71
  appPath = parentPath.slice(0, appIdx + 4);
62
72
  }
73
+ parentPhysicalIdentity = appIdx !== -1 ? `app:${appPath}` : `path:${parentPath}`;
63
74
  // codesign writes everything to stderr — use spawnSync to capture it
64
75
  const result = spawnSync("codesign", ["-dv", "--verbose=2", appPath], {
65
76
  encoding: "utf-8",
66
77
  stdio: ["pipe", "pipe", "pipe"],
67
78
  });
68
79
  const output = (result.stdout || "") + (result.stderr || "");
80
+ const identifierMatch = output.match(/Identifier=(\S+)/);
81
+ if (identifierMatch) {
82
+ parentBundleIdentifier = identifierMatch[1];
83
+ log(`Parent identity: ${parentBundleIdentifier} (${parentPhysicalIdentity})`);
84
+ }
69
85
  const match = output.match(/TeamIdentifier=(\S+)/);
70
86
  if (match && match[1] !== "not" && match[1] !== "not set") {
71
87
  parentCodeSignTeamID = match[1];
@@ -90,26 +106,57 @@ function log(msg) {
90
106
  function sanitizeHeaderValue(value) {
91
107
  return value.replace(/[\r\n]/g, "");
92
108
  }
93
- function readTokenFromKeychain() {
109
+ function splitClientIdentity(clientName) {
110
+ const idx = clientName.indexOf("/");
111
+ if (idx === -1) {
112
+ return { name: clientName || "airmail-mcp", version: VERSION };
113
+ }
114
+ const name = clientName.slice(0, idx) || "airmail-mcp";
115
+ const version = clientName.slice(idx + 1) || VERSION;
116
+ return { name, version };
117
+ }
118
+ function canonicalClientName(clientName) {
119
+ return splitClientIdentity(clientName).name;
120
+ }
121
+ function clientTokenAccount(clientName) {
122
+ const identity = parentPhysicalIdentity ?? "unknown";
123
+ const hash = createHash("sha256").update(`${canonicalClientName(clientName)}|${identity}`).digest("hex").slice(0, 24);
124
+ return `airmail-mcp:${hash}`;
125
+ }
126
+ function readClientToken(clientName) {
94
127
  try {
95
- const token = execFileSync("security", [
128
+ return execFileSync("security", [
96
129
  "find-generic-password",
97
- "-s", "com.airmail.mcp",
98
- "-a", "com.airmail.mcp.token",
130
+ "-s", "com.airmail.mcp.client",
131
+ "-a", clientTokenAccount(clientName),
99
132
  "-w",
100
133
  ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
101
- if (token) {
102
- log("Auth token read from macOS Keychain.");
103
- }
104
- return token;
105
134
  }
106
135
  catch {
107
- log("Could not read auth token from macOS Keychain. " +
108
- "macOS may prompt you to approve Keychain access — click \"Always Allow\" to avoid this next time. " +
109
- "Alternatively, set the AIRMAIL_MCP_TOKEN environment variable.");
110
136
  return "";
111
137
  }
112
138
  }
139
+ function saveClientToken(clientName, token) {
140
+ execFileSync("security", [
141
+ "add-generic-password",
142
+ "-U",
143
+ "-s", "com.airmail.mcp.client",
144
+ "-a", clientTokenAccount(clientName),
145
+ "-w", token,
146
+ ], { stdio: ["pipe", "pipe", "pipe"] });
147
+ }
148
+ function deleteClientToken(clientName) {
149
+ try {
150
+ execFileSync("security", [
151
+ "delete-generic-password",
152
+ "-s", "com.airmail.mcp.client",
153
+ "-a", clientTokenAccount(clientName),
154
+ ], { stdio: ["pipe", "pipe", "pipe"] });
155
+ }
156
+ catch {
157
+ // Missing token is fine.
158
+ }
159
+ }
113
160
  function sleep(ms) {
114
161
  return new Promise((r) => setTimeout(r, ms));
115
162
  }
@@ -132,7 +179,12 @@ function ping() {
132
179
  async function ensureAirmailRunning() {
133
180
  if (await ping())
134
181
  return;
135
- log("Airmail MCP server not reachable, launching Airmail...");
182
+ if (!AUTO_LAUNCH_AIRMAIL) {
183
+ log("Airmail MCP server is not reachable. Open Airmail and enable MCP in Preferences, " +
184
+ "or set AIRMAIL_MCP_AUTO_LAUNCH=1 to let the bridge launch Airmail.");
185
+ process.exit(1);
186
+ }
187
+ log("Airmail MCP server not reachable, launching Airmail because AIRMAIL_MCP_AUTO_LAUNCH=1...");
136
188
  try {
137
189
  execFileSync("open", ["-a", "Airmail"], { stdio: "ignore" });
138
190
  }
@@ -299,6 +351,12 @@ function forward(body, clientName, token, hasId) {
299
351
  reqHeaders += `Authorization: Bearer ${sanitizeHeaderValue(token)}\r\n`;
300
352
  }
301
353
  reqHeaders += `X-MCP-Client: ${safeClient}\r\n`;
354
+ if (parentPhysicalIdentity) {
355
+ reqHeaders += `X-MCP-Physical-Identity: ${sanitizeHeaderValue(parentPhysicalIdentity)}\r\n`;
356
+ }
357
+ if (parentBundleIdentifier) {
358
+ reqHeaders += `X-MCP-Bundle-ID: ${sanitizeHeaderValue(parentBundleIdentifier)}\r\n`;
359
+ }
302
360
  if (parentCodeSignTeamID) {
303
361
  reqHeaders += `X-MCP-CodeSign: ${sanitizeHeaderValue(parentCodeSignTeamID)}\r\n`;
304
362
  }
@@ -312,6 +370,114 @@ function forward(body, clientName, token, hasId) {
312
370
  sock.on("close", () => finish());
313
371
  });
314
372
  }
373
+ async function pairClient(clientName) {
374
+ if (!parentPhysicalIdentity) {
375
+ throw new Error("Cannot pair without a stable parent process identity.");
376
+ }
377
+ const client = splitClientIdentity(clientName);
378
+ const body = JSON.stringify({
379
+ client_name: client.name,
380
+ client_version: client.version,
381
+ physical_identity: parentPhysicalIdentity,
382
+ team_id: parentCodeSignTeamID ?? "",
383
+ });
384
+ return new Promise((resolve, reject) => {
385
+ const chunks = [];
386
+ let settled = false;
387
+ const timer = setTimeout(() => {
388
+ if (!settled) {
389
+ settled = true;
390
+ sock.destroy();
391
+ reject(new Error("Pairing timed out"));
392
+ }
393
+ }, REQUEST_TIMEOUT_MS);
394
+ function finish(err) {
395
+ clearTimeout(timer);
396
+ if (settled)
397
+ return;
398
+ settled = true;
399
+ if (err && chunks.length === 0) {
400
+ reject(err);
401
+ return;
402
+ }
403
+ const parsed = parseHttpResponse(Buffer.concat(chunks));
404
+ if (!parsed) {
405
+ reject(new Error("Malformed pairing response from Airmail"));
406
+ return;
407
+ }
408
+ if (parsed.statusCode >= 400) {
409
+ reject(new Error(`Pairing failed (HTTP ${parsed.statusCode}): ${parsed.body.slice(0, 200)}`));
410
+ return;
411
+ }
412
+ try {
413
+ const json = JSON.parse(parsed.body);
414
+ if (!json.client_token) {
415
+ reject(new Error("Pairing response did not include a client token"));
416
+ return;
417
+ }
418
+ resolve(json.client_token);
419
+ }
420
+ catch {
421
+ reject(new Error("Pairing response was not JSON"));
422
+ }
423
+ }
424
+ const sock = net.createConnection({ host: AIRMAIL_HOST, port: AIRMAIL_PORT }, () => {
425
+ const bodyBuf = Buffer.from(body, "utf-8");
426
+ let reqHeaders = `POST /mcp/pair HTTP/1.1\r\n`;
427
+ reqHeaders += `Host: ${AIRMAIL_HOST}:${AIRMAIL_PORT}\r\n`;
428
+ reqHeaders += `Content-Type: application/json\r\n`;
429
+ reqHeaders += `Content-Length: ${bodyBuf.length}\r\n`;
430
+ reqHeaders += `Accept: application/json\r\n`;
431
+ reqHeaders += `Connection: close\r\n`;
432
+ reqHeaders += `User-Agent: airmail-mcp/${VERSION}\r\n`;
433
+ reqHeaders += `X-MCP-Client: ${sanitizeHeaderValue(client.name)}\r\n`;
434
+ reqHeaders += `X-MCP-Physical-Identity: ${sanitizeHeaderValue(parentPhysicalIdentity ?? "")}\r\n`;
435
+ if (parentCodeSignTeamID) {
436
+ reqHeaders += `X-MCP-CodeSign: ${sanitizeHeaderValue(parentCodeSignTeamID)}\r\n`;
437
+ }
438
+ reqHeaders += `\r\n`;
439
+ sock.write(Buffer.concat([Buffer.from(reqHeaders), bodyBuf]));
440
+ });
441
+ sock.on("data", (chunk) => chunks.push(chunk));
442
+ sock.on("end", () => finish());
443
+ sock.on("error", (err) => finish(err));
444
+ sock.on("close", () => finish());
445
+ });
446
+ }
447
+ async function ensureClientToken(clientName) {
448
+ if (currentToken)
449
+ return;
450
+ if (clientTokenPromise) {
451
+ await clientTokenPromise;
452
+ return;
453
+ }
454
+ clientTokenPromise = (async () => {
455
+ if (REMEMBER_CLIENT_TOKEN) {
456
+ const storedToken = readClientToken(clientName);
457
+ if (storedToken) {
458
+ currentToken = storedToken;
459
+ log("Client token loaded from bridge Keychain.");
460
+ return;
461
+ }
462
+ }
463
+ log("No client token found; requesting pairing approval from Airmail.");
464
+ const token = await pairClient(clientName);
465
+ currentToken = token;
466
+ if (REMEMBER_CLIENT_TOKEN) {
467
+ saveClientToken(clientName, token);
468
+ log("Client token saved to bridge Keychain.");
469
+ }
470
+ else {
471
+ log("Client token kept in memory for this bridge session.");
472
+ }
473
+ })();
474
+ try {
475
+ await clientTokenPromise;
476
+ }
477
+ finally {
478
+ clientTokenPromise = null;
479
+ }
480
+ }
315
481
  // ---------------------------------------------------------------------------
316
482
  // stdio ↔ HTTP bridge
317
483
  // ---------------------------------------------------------------------------
@@ -328,7 +494,7 @@ async function processMessage(line) {
328
494
  }
329
495
  catch {
330
496
  log(`Invalid JSON: ${line.slice(0, 200)}`);
331
- return;
497
+ return false;
332
498
  }
333
499
  const hasId = parsed.id !== undefined;
334
500
  // Extract client identity from initialize for X-MCP-Client header
@@ -339,36 +505,36 @@ async function processMessage(line) {
339
505
  }
340
506
  }
341
507
  try {
342
- const response = await forward(line, resolvedClientName, currentToken, hasId);
508
+ const authClientName = canonicalClientName(resolvedClientName);
509
+ await ensureClientToken(authClientName);
510
+ const response = await forward(line, authClientName, currentToken, hasId);
343
511
  if (response) {
344
512
  process.stdout.write(response + "\n");
345
513
  }
514
+ return true;
346
515
  }
347
516
  catch (err) {
348
517
  const msg = err instanceof Error ? err.message : String(err);
349
- // Re-read token from Keychain on 401 and retry once
518
+ // Client token may have been revoked or bound to an old identity; re-pair once.
350
519
  if (msg.includes("HTTP 401")) {
351
- if (!process.env.AIRMAIL_MCP_TOKEN) {
352
- const newToken = readTokenFromKeychain();
353
- if (newToken && newToken !== currentToken) {
354
- log("Token rotated in Keychain, retrying with new token.");
355
- currentToken = newToken;
356
- try {
357
- const response = await forward(line, resolvedClientName, currentToken, hasId);
358
- if (response) {
359
- process.stdout.write(response + "\n");
360
- }
361
- return;
362
- }
363
- catch {
364
- // Fall through to error handling
365
- }
520
+ const authClientName = canonicalClientName(resolvedClientName);
521
+ if (REMEMBER_CLIENT_TOKEN) {
522
+ deleteClientToken(authClientName);
523
+ }
524
+ currentToken = "";
525
+ try {
526
+ await ensureClientToken(authClientName);
527
+ const response = await forward(line, authClientName, currentToken, hasId);
528
+ if (response) {
529
+ process.stdout.write(response + "\n");
366
530
  }
531
+ return true;
367
532
  }
368
- log("Authentication failed (HTTP 401). The auth token is missing or invalid.\n" +
369
- " \u2192 Open Airmail \u2192 Preferences \u2192 MCP and copy the current Auth Token\n" +
370
- " \u2192 Set it as: export AIRMAIL_MCP_TOKEN=\"your-token-here\"\n" +
371
- " \u2192 Or approve the macOS Keychain access prompt if it appears.");
533
+ catch {
534
+ // Fall through to error handling
535
+ }
536
+ log("Authentication failed (HTTP 401). The client pairing token is missing, invalid, or revoked.\n" +
537
+ " \u2192 Approve the Airmail pairing prompt, or revoke the client in Airmail Preferences > MCP > Permissions and pair again.");
372
538
  }
373
539
  if (hasId) {
374
540
  // Sanitize error message — don't forward raw server responses that may contain tokens
@@ -381,6 +547,7 @@ async function processMessage(line) {
381
547
  else {
382
548
  log(`Notification error: ${msg}`);
383
549
  }
550
+ return false;
384
551
  }
385
552
  }
386
553
  async function main() {
@@ -389,32 +556,10 @@ async function main() {
389
556
  log("Airmail MCP is macOS-only.");
390
557
  process.exit(1);
391
558
  }
392
- // Resolve auth token — done inside main() so logs are captured
393
- const envToken = (process.env.AIRMAIL_MCP_TOKEN ?? "").trim();
394
- // Skip env var if empty or unresolved template placeholder
395
- const isValidEnvToken = envToken.length > 0
396
- && !envToken.startsWith("${")
397
- && envToken !== "undefined"
398
- && envToken !== "null";
399
- if (isValidEnvToken) {
400
- currentToken = envToken;
401
- log(`Auth token provided via AIRMAIL_MCP_TOKEN (${envToken.length} chars).`);
402
- }
403
- else {
404
- if (envToken)
405
- log(`AIRMAIL_MCP_TOKEN ignored (placeholder: "${envToken.slice(0, 20)}...").`);
406
- log("Trying macOS Keychain...");
407
- currentToken = readTokenFromKeychain();
408
- }
409
- if (!currentToken) {
410
- log("WARNING: no auth token found. Requests will fail with 401.\n" +
411
- " 1. Open Airmail \u2192 Preferences \u2192 MCP and copy the Auth Token\n" +
412
- " 2. Set it as: export AIRMAIL_MCP_TOKEN=\"your-token-here\"\n" +
413
- " Or approve the macOS Keychain prompt when it appears.");
414
- }
415
559
  resolveParentCodeSign();
560
+ log(`Bridge will use per-client pairing (${REMEMBER_CLIENT_TOKEN ? "remembered Keychain token" : "memory-only token"}).`);
416
561
  await ensureAirmailRunning();
417
- log(`Bridge ready \u2014 Airmail MCP at ${AIRMAIL_HOST}:${AIRMAIL_PORT} (token: ${currentToken ? "present" : "MISSING"})`);
562
+ log(`Bridge ready \u2014 Airmail MCP at ${AIRMAIL_HOST}:${AIRMAIL_PORT} (pairing mode)`);
418
563
  // Handle stdout errors (broken pipe)
419
564
  process.stdout.on("error", (err) => {
420
565
  if (err.code === "EPIPE") {
@@ -487,7 +632,9 @@ async function main() {
487
632
  }
488
633
  if (parsed.method === "initialize") {
489
634
  const p = processMessage(line)
490
- .then(() => {
635
+ .then((ok) => {
636
+ if (!ok)
637
+ throw new Error("initialize failed");
491
638
  initialized = true;
492
639
  // Flush queued messages
493
640
  for (const queued of pendingAfterInit) {
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "airmail-mcp",
4
4
  "display_name": "Airmail",
5
- "version": "1.0.0",
5
+ "version": "1.0.16",
6
6
  "description": "Manage emails, calendars, contacts, and more from Claude using Airmail's MCP server.",
7
7
  "long_description": "Airmail MCP connects Claude to the Airmail email client for macOS. Read, search, compose, and organize emails. Manage calendars and reminders. Search contacts. The bridge connects locally to Airmail on your Mac. Data retrieved by AI tools is processed by your chosen AI provider.",
8
8
  "author": {
@@ -27,8 +27,9 @@
27
27
  "${__dirname}/dist/index.js"
28
28
  ],
29
29
  "env": {
30
- "AIRMAIL_MCP_TOKEN": "${user_config.auth_token}",
31
- "AIRMAIL_MCP_PORT": "${user_config.port}"
30
+ "AIRMAIL_MCP_PORT": "${user_config.port}",
31
+ "AIRMAIL_MCP_REMEMBER_CLIENT_TOKEN": "${user_config.remember_client_token}",
32
+ "AIRMAIL_MCP_AUTO_LAUNCH": "${user_config.auto_launch}"
32
33
  }
33
34
  }
34
35
  },
@@ -38,6 +39,10 @@
38
39
  "name": "manage_capabilities",
39
40
  "description": "Enable/disable tool groups to manage context."
40
41
  },
42
+ {
43
+ "name": "start_convo",
44
+ "description": "Start a new conversation session and get a convo_id for request correlation."
45
+ },
41
46
  {
42
47
  "name": "get_account_settings",
43
48
  "description": "Get per-account settings."
@@ -362,6 +367,14 @@
362
367
  "name": "export_eml",
363
368
  "description": "Export message as .eml (RFC 822) file saved to disk (returns file_path)."
364
369
  },
370
+ {
371
+ "name": "list_operations",
372
+ "description": "List current email operations (move, copy, delete, send, etc.) with their status (planned, executing, executed, failed, canceled, reverted)."
373
+ },
374
+ {
375
+ "name": "list_activity",
376
+ "description": "List current email connections and sync activity."
377
+ },
365
378
  {
366
379
  "name": "semantic_search",
367
380
  "description": "Search by meaning via vector embeddings (macOS 14+)."
@@ -370,6 +383,10 @@
370
383
  "name": "semantic_index_status",
371
384
  "description": "Check/trigger semantic index."
372
385
  },
386
+ {
387
+ "name": "get_navigation_link",
388
+ "description": "Get an airmailmcp:// deep link to navigate the user to a specific part of Airmail."
389
+ },
373
390
  {
374
391
  "name": "list_rules",
375
392
  "description": "List all email rules with name, guid, enabled, conditions/actions summary, direction."
@@ -434,19 +451,25 @@
434
451
  ],
435
452
  "license": "MIT",
436
453
  "user_config": {
437
- "auth_token": {
438
- "type": "string",
439
- "title": "Auth Token",
440
- "description": "Bearer token from Airmail Preferences > MCP. If not set, the bridge will try to read it from the macOS Keychain (requires user approval).",
441
- "sensitive": true,
442
- "required": false
443
- },
444
454
  "port": {
445
455
  "type": "string",
446
456
  "title": "MCP Server Port",
447
457
  "description": "Port number for Airmail's local MCP server. Only change if you modified the default port in Airmail.",
448
458
  "required": false,
449
459
  "default": "9876"
460
+ },
461
+ "remember_client_token": {
462
+ "type": "string",
463
+ "title": "Remember Client Token",
464
+ "description": "Set to 1 to persist the bridge's per-client pairing token in Keychain. Leave empty for memory-only tokens.",
465
+ "required": false
466
+ },
467
+ "auto_launch": {
468
+ "type": "string",
469
+ "title": "Auto-launch Airmail",
470
+ "description": "Set to 1 to let the bridge open Airmail when the local MCP server is not reachable.",
471
+ "required": false,
472
+ "default": "0"
450
473
  }
451
474
  },
452
475
  "compatibility": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airmail-mcp",
3
- "version": "1.0.13",
3
+ "version": "1.0.19",
4
4
  "mcpName": "io.github.airmail/airmail-mcp",
5
5
  "description": "Manage emails, calendars, contacts, and more from Claude using Airmail's MCP server.",
6
6
  "main": "dist/index.js",
@@ -18,6 +18,7 @@
18
18
  "build": "tsc",
19
19
  "watch": "tsc --watch",
20
20
  "sync-tools": "node scripts/sync-tools.mjs",
21
+ "prepare": "npm run build",
21
22
  "prepublishOnly": "npm run sync-tools && npm run build"
22
23
  },
23
24
  "keywords": [