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 +101 -47
- package/dist/index.js +206 -59
- package/manifest.json +33 -10
- package/package.json +2 -1
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
|
-
###
|
|
15
|
+
### Published npm package
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
`npx` installs and runs the published package automatically. Users do not need to clone this repository or run `npm install`.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
```bash
|
|
20
|
+
codex mcp remove airmail
|
|
21
|
+
codex mcp add airmail -- npx -y airmail-mcp
|
|
22
|
+
```
|
|
20
23
|
|
|
21
|
-
|
|
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": "
|
|
28
|
-
"args": ["
|
|
52
|
+
"command": "node",
|
|
53
|
+
"args": ["/absolute/path/to/airmail-mcp/dist/index.js"]
|
|
29
54
|
}
|
|
30
55
|
}
|
|
31
56
|
}
|
|
32
57
|
```
|
|
33
58
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 |
|
|
182
|
-
| profile |
|
|
183
|
-
| folders |
|
|
184
|
-
| semantic |
|
|
185
|
-
| calendar |
|
|
186
|
-
| contacts |
|
|
187
|
-
| preferences |
|
|
188
|
-
| rules |
|
|
189
|
-
| lists | VIP & blocked
|
|
190
|
-
| smartfolders |
|
|
191
|
-
| signatures |
|
|
192
|
-
| aliases |
|
|
193
|
-
| accountsettings |
|
|
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 `
|
|
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` | `
|
|
204
|
-
| `open` | `
|
|
205
|
-
| `compose` | `
|
|
206
|
-
| `reply` | `
|
|
207
|
-
| `draft` | `
|
|
208
|
-
| `archive` | `
|
|
209
|
-
| `delete` | `
|
|
210
|
-
| `view` | `
|
|
211
|
-
| `attachment` | `
|
|
212
|
-
| `settings` | `
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
128
|
+
return execFileSync("security", [
|
|
96
129
|
"find-generic-password",
|
|
97
|
-
"-s", "com.airmail.mcp",
|
|
98
|
-
"-a",
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
518
|
+
// Client token may have been revoked or bound to an old identity; re-pair once.
|
|
350
519
|
if (msg.includes("HTTP 401")) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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} (
|
|
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.
|
|
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
|
-
"
|
|
31
|
-
"
|
|
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.
|
|
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": [
|