decoy-mcp-server 1.0.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.
- package/README.md +54 -0
- package/dist/cxf-structured.d.ts +12 -0
- package/dist/cxf-structured.js +33 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +1409 -0
- package/dist/tools/alerts.d.ts +9 -0
- package/dist/tools/alerts.js +111 -0
- package/dist/tools/auth.d.ts +9 -0
- package/dist/tools/auth.js +90 -0
- package/dist/tools/decoys.d.ts +9 -0
- package/dist/tools/decoys.js +293 -0
- package/dist/tools/forwarding-emails.d.ts +9 -0
- package/dist/tools/forwarding-emails.js +179 -0
- package/dist/tools/messages.d.ts +9 -0
- package/dist/tools/messages.js +113 -0
- package/dist/tools/stats.d.ts +9 -0
- package/dist/tools/stats.js +61 -0
- package/dist/tools/subdomain.d.ts +9 -0
- package/dist/tools/subdomain.js +302 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Decoy MCP Server v2.2 — Streamable HTTP transport
|
|
4
|
+
*
|
|
5
|
+
* Setup: first run opens decoys.me/connect?code=... in the user's browser → scan QR
|
|
6
|
+
* with the Decoy iOS app → approved token saved to ~/.decoy/mcp-token and reused
|
|
7
|
+
* on subsequent runs. No localhost setup page — the public website is the kiosk.
|
|
8
|
+
*
|
|
9
|
+
* Auth priority:
|
|
10
|
+
* 1. DECOY_AGENT_TOKEN env var (explicit override)
|
|
11
|
+
* 2. ~/.decoy/mcp-token config file (saved after first pairing)
|
|
12
|
+
* 3. Interactive pairing via decoys.me/connect (first-time setup)
|
|
13
|
+
*
|
|
14
|
+
* ENV:
|
|
15
|
+
* DECOY_AGENT_TOKEN Explicit agent token (skips pairing)
|
|
16
|
+
* DECOY_AGENT_API_URL Base URL for agent API (default: https://api.decoys.me/api/agent)
|
|
17
|
+
* DECOY_CONNECT_URL Frontend pairing page (default: https://decoys.me/connect)
|
|
18
|
+
* DECOY_AGENT_FOR Slug for ?for= param so the website shows the right agent
|
|
19
|
+
* name (e.g. claude-code, cursor, gemini, codex). Default: mcp
|
|
20
|
+
* PORT HTTP port (default: 3001)
|
|
21
|
+
* DEV_MODE "true" → use dev-api.decoys.me
|
|
22
|
+
*/
|
|
23
|
+
import express from 'express';
|
|
24
|
+
import { randomUUID, generateKeyPairSync, privateDecrypt, createDecipheriv, createCipheriv, randomBytes, publicEncrypt, createPublicKey, constants } from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import { exec } from 'node:child_process';
|
|
29
|
+
import { pathToFileURL } from 'node:url';
|
|
30
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
31
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
32
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import { cxfItemToStructured } from './cxf-structured.js';
|
|
35
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
36
|
+
const DEV_MODE = process.env.DEV_MODE === 'true';
|
|
37
|
+
const DEFAULT_API_URL = DEV_MODE
|
|
38
|
+
? 'https://dev-api.decoys.me/api/agent'
|
|
39
|
+
: 'https://api.decoys.me/api/agent';
|
|
40
|
+
const AGENT_API_URL = process.env.DECOY_AGENT_API_URL ?? DEFAULT_API_URL;
|
|
41
|
+
const CONNECT_API_URL = AGENT_API_URL.replace(/\/agent$/, '/connect');
|
|
42
|
+
const CONNECT_FRONTEND_URL = process.env.DECOY_CONNECT_URL ?? 'https://decoys.me/connect';
|
|
43
|
+
const AGENT_FOR = process.env.DECOY_AGENT_FOR ?? 'mcp';
|
|
44
|
+
const PORT = Number(process.env.PORT ?? 3001);
|
|
45
|
+
const TOKEN_FILE = path.join(os.homedir(), '.decoy', 'mcp-token');
|
|
46
|
+
const KEY_FILE = path.join(os.homedir(), '.decoy', 'mcp-agent-key.json');
|
|
47
|
+
function loadOrGenerateKeyPair() {
|
|
48
|
+
// Try load existing keypair from disk
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(KEY_FILE)) {
|
|
51
|
+
const raw = fs.readFileSync(KEY_FILE, 'utf-8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed.publicKeySpki && parsed.privateKeyPem)
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* fall through to generate */ }
|
|
58
|
+
// Generate new RSA-2048 keypair
|
|
59
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
60
|
+
modulusLength: 2048,
|
|
61
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
62
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
63
|
+
});
|
|
64
|
+
const pair = {
|
|
65
|
+
publicKeySpki: publicKey.toString('base64'),
|
|
66
|
+
privateKeyPem: privateKey,
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
|
|
70
|
+
fs.writeFileSync(KEY_FILE, JSON.stringify(pair), { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
console.error('[Decoy] Warning: could not save agent keypair to', KEY_FILE, e);
|
|
74
|
+
}
|
|
75
|
+
return pair;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decrypt content produced by CryptoService.encrypt(data:withRSAPublicKeyBase64:).
|
|
79
|
+
* Envelope format: [2-byte key length (BE)][RSA-OAEP-SHA256 encrypted AES key][12-byte IV][ciphertext][16-byte auth tag]
|
|
80
|
+
*/
|
|
81
|
+
function decryptAgentContent(ciphertextBase64, privateKeyPem) {
|
|
82
|
+
const buf = Buffer.from(ciphertextBase64, 'base64');
|
|
83
|
+
const keyLen = buf.readUInt16BE(0);
|
|
84
|
+
const encryptedAesKey = buf.subarray(2, 2 + keyLen);
|
|
85
|
+
const iv = buf.subarray(2 + keyLen, 2 + keyLen + 12);
|
|
86
|
+
const ciphertextWithTag = buf.subarray(2 + keyLen + 12);
|
|
87
|
+
const authTag = ciphertextWithTag.subarray(ciphertextWithTag.length - 16);
|
|
88
|
+
const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - 16);
|
|
89
|
+
const aesKey = privateDecrypt({ key: privateKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, encryptedAesKey);
|
|
90
|
+
const decipher = createDecipheriv('aes-256-gcm', aesKey, iv);
|
|
91
|
+
decipher.setAuthTag(authTag);
|
|
92
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Like decryptAgentContent but returns raw bytes — used to unwrap the vault-key
|
|
96
|
+
* grant (GET /api/agent/vault-key), which is the same RSA-OAEP+AES-GCM envelope
|
|
97
|
+
* but carries 32 raw key bytes rather than UTF-8.
|
|
98
|
+
*/
|
|
99
|
+
function decryptAgentContentRaw(ciphertextBase64, privateKeyPem) {
|
|
100
|
+
const buf = Buffer.from(ciphertextBase64, 'base64');
|
|
101
|
+
const keyLen = buf.readUInt16BE(0);
|
|
102
|
+
const encryptedAesKey = buf.subarray(2, 2 + keyLen);
|
|
103
|
+
const iv = buf.subarray(2 + keyLen, 2 + keyLen + 12);
|
|
104
|
+
const ctWithTag = buf.subarray(2 + keyLen + 12);
|
|
105
|
+
const authTag = ctWithTag.subarray(ctWithTag.length - 16);
|
|
106
|
+
const ciphertext = ctWithTag.subarray(0, ctWithTag.length - 16);
|
|
107
|
+
const aesKey = privateDecrypt({ key: privateKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, encryptedAesKey);
|
|
108
|
+
const decipher = createDecipheriv('aes-256-gcm', aesKey, iv);
|
|
109
|
+
decipher.setAuthTag(authTag);
|
|
110
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
111
|
+
}
|
|
112
|
+
// ── SealedBlob VAULT codec (Phase 3b) ─────────────────────────────────────────
|
|
113
|
+
// Vendored from canonical decoy-api/lib/sealed-blob.ts — MUST stay byte-identical.
|
|
114
|
+
// VAULT layout: [0]=0x01 version [1]=0x01 mode [2..13]=nonce [14..]=AES-256-GCM(ct)+tag.
|
|
115
|
+
const SEALED_BLOB_VERSION = 0x01;
|
|
116
|
+
const SEAL_MODE_VAULT = 0x01;
|
|
117
|
+
export function openVault(blobBase64, vaultKey) {
|
|
118
|
+
const blob = Buffer.from(blobBase64, 'base64');
|
|
119
|
+
if (blob.length <= 14 || blob[0] !== SEALED_BLOB_VERSION || blob[1] !== SEAL_MODE_VAULT) {
|
|
120
|
+
throw new Error('not a VAULT-mode SealedBlob');
|
|
121
|
+
}
|
|
122
|
+
const nonce = blob.subarray(2, 14);
|
|
123
|
+
const ctWithTag = blob.subarray(14);
|
|
124
|
+
const tag = ctWithTag.subarray(ctWithTag.length - 16);
|
|
125
|
+
const ct = ctWithTag.subarray(0, ctWithTag.length - 16);
|
|
126
|
+
const decipher = createDecipheriv('aes-256-gcm', vaultKey, nonce);
|
|
127
|
+
decipher.setAuthTag(tag);
|
|
128
|
+
const out = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
129
|
+
return JSON.parse(out.toString('utf-8'));
|
|
130
|
+
}
|
|
131
|
+
export function sealVault(value, vaultKey) {
|
|
132
|
+
const nonce = randomBytes(12);
|
|
133
|
+
const cipher = createCipheriv('aes-256-gcm', vaultKey, nonce);
|
|
134
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(value), 'utf-8')), cipher.final()]);
|
|
135
|
+
const tag = cipher.getAuthTag();
|
|
136
|
+
return Buffer.concat([Buffer.from([SEALED_BLOB_VERSION, SEAL_MODE_VAULT]), nonce, ct, tag]).toString('base64');
|
|
137
|
+
}
|
|
138
|
+
// ── Token resolution ──────────────────────────────────────────────────────────
|
|
139
|
+
function loadSavedToken() {
|
|
140
|
+
try {
|
|
141
|
+
if (fs.existsSync(TOKEN_FILE))
|
|
142
|
+
return fs.readFileSync(TOKEN_FILE, 'utf-8').trim() || null;
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
function saveToken(token) {
|
|
148
|
+
try {
|
|
149
|
+
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
|
|
150
|
+
fs.writeFileSync(TOKEN_FILE, token + '\n', { mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
console.error('[Decoy] Warning: could not save token to', TOKEN_FILE, e);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function openBrowser(url) {
|
|
157
|
+
const cmd = process.platform === 'win32' ? `start "" "${url}"`
|
|
158
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
159
|
+
: `xdg-open "${url}"`;
|
|
160
|
+
exec(cmd, (err) => {
|
|
161
|
+
if (err)
|
|
162
|
+
console.error('[Decoy] Could not open browser automatically — visit:', url);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* First-time setup: creates a connect session, opens decoys.me/connect?code=... in
|
|
167
|
+
* the user's browser, then resolves when the iOS app approves. Returns the agent
|
|
168
|
+
* token. The website renders the branded QR and polls status; the MCP polls here
|
|
169
|
+
* independently so both sides converge on approval.
|
|
170
|
+
*/
|
|
171
|
+
// Scopes the bridge requests at pairing — one per tool family it exposes.
|
|
172
|
+
// (Colon-form legacy aliases; the server maps them to canonical capabilities.)
|
|
173
|
+
const MCP_REQUESTED_SCOPES = [
|
|
174
|
+
'decoys:read', 'decoys:write',
|
|
175
|
+
'inbox:read', 'inbox:content',
|
|
176
|
+
'forwarding:read', 'forwarding:write',
|
|
177
|
+
'alerts:read', 'alerts:write',
|
|
178
|
+
'profile:read', 'profile:write',
|
|
179
|
+
'accounts:read', 'accounts:write',
|
|
180
|
+
];
|
|
181
|
+
async function runBrowserPairing(keyPair) {
|
|
182
|
+
const createRes = await fetch(`${CONNECT_API_URL}`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
name: process.env.DECOY_AGENT_NAME || 'MCP Server',
|
|
187
|
+
platform: 'mcp',
|
|
188
|
+
agent_public_key: keyPair.publicKeySpki,
|
|
189
|
+
// Request scopes for ALL the tool families this bridge exposes. The
|
|
190
|
+
// server's DEFAULT_MCP_SCOPES historically omitted accounts, so a default
|
|
191
|
+
// pairing couldn't use account_* tools at all. The user still approves
|
|
192
|
+
// (and may downgrade) this set in the app.
|
|
193
|
+
scopes: MCP_REQUESTED_SCOPES,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
if (!createRes.ok)
|
|
197
|
+
throw new Error(`Failed to start pairing session: HTTP ${createRes.status}`);
|
|
198
|
+
const { code, expires_at } = await createRes.json();
|
|
199
|
+
// Hand off to the public, branded pairing page on decoys.me
|
|
200
|
+
const pairingUrl = `${CONNECT_FRONTEND_URL}?code=${encodeURIComponent(code)}&for=${encodeURIComponent(AGENT_FOR)}`;
|
|
201
|
+
console.error(`\n🔑 Decoy MCP — open this page to pair:`);
|
|
202
|
+
console.error(` ${pairingUrl}\n`);
|
|
203
|
+
openBrowser(pairingUrl);
|
|
204
|
+
// Poll server-side until approved
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const expiresAt = new Date(expires_at);
|
|
207
|
+
const interval = setInterval(async () => {
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(`${CONNECT_API_URL}?code=${code}`);
|
|
210
|
+
if (!res.ok)
|
|
211
|
+
return;
|
|
212
|
+
const { status, access_token } = await res.json();
|
|
213
|
+
if (status === 'approved' && access_token) {
|
|
214
|
+
clearInterval(interval);
|
|
215
|
+
saveToken(access_token);
|
|
216
|
+
console.error('✅ Paired! Token saved to', TOKEN_FILE);
|
|
217
|
+
resolve(access_token);
|
|
218
|
+
}
|
|
219
|
+
else if (status === 'denied') {
|
|
220
|
+
clearInterval(interval);
|
|
221
|
+
reject(new Error('Pairing was denied in the Decoy app.'));
|
|
222
|
+
}
|
|
223
|
+
else if (status === 'expired' || Date.now() > expiresAt.getTime()) {
|
|
224
|
+
clearInterval(interval);
|
|
225
|
+
reject(new Error('Pairing code expired. Restart the server to try again.'));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch { /* transient, retry */ }
|
|
229
|
+
}, 2000);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async function resolveToken(keyPair) {
|
|
233
|
+
if (process.env.DECOY_AGENT_TOKEN?.trim()) {
|
|
234
|
+
console.error('[Decoy] Using token from DECOY_AGENT_TOKEN env var');
|
|
235
|
+
return process.env.DECOY_AGENT_TOKEN.trim();
|
|
236
|
+
}
|
|
237
|
+
const saved = loadSavedToken();
|
|
238
|
+
if (saved) {
|
|
239
|
+
console.error('[Decoy] Using saved token from', TOKEN_FILE);
|
|
240
|
+
return saved;
|
|
241
|
+
}
|
|
242
|
+
return runBrowserPairing(keyPair);
|
|
243
|
+
}
|
|
244
|
+
// ── Agent API client ──────────────────────────────────────────────────────────
|
|
245
|
+
function makeClient(agentToken) {
|
|
246
|
+
async function call(path, method = 'GET', body) {
|
|
247
|
+
const res = await fetch(`${AGENT_API_URL}${path}`, {
|
|
248
|
+
method,
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
Authorization: `Bearer ${agentToken}`,
|
|
252
|
+
},
|
|
253
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
254
|
+
});
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
const err = data.error ?? `HTTP ${res.status}`;
|
|
258
|
+
throw new Error(err);
|
|
259
|
+
}
|
|
260
|
+
return data;
|
|
261
|
+
}
|
|
262
|
+
return { call };
|
|
263
|
+
}
|
|
264
|
+
// Structured (multi-part) field input for account_create / account_update.
|
|
265
|
+
// `values` keys are the CXF member names: address →
|
|
266
|
+
// streetAddress/apt/city/territory/postalCode/country; credit-card →
|
|
267
|
+
// number/fullName/expiryDate/verificationNumber. The MCP stamps an `id` per
|
|
268
|
+
// entry (iOS StructuredFieldBlob.id is required — a missing id makes the device
|
|
269
|
+
// decode the whole array to nil) before sealing into the blob.
|
|
270
|
+
const STRUCTURED_INPUT_SCHEMA = z.array(z.object({
|
|
271
|
+
kind: z.enum(['address', 'credit-card']),
|
|
272
|
+
label: z.string().max(100).optional(),
|
|
273
|
+
values: z.record(z.string(), z.string()),
|
|
274
|
+
})).max(20);
|
|
275
|
+
function normalizeStructured(input) {
|
|
276
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
277
|
+
return null;
|
|
278
|
+
return input.map((s) => ({
|
|
279
|
+
id: randomUUID(),
|
|
280
|
+
kind: s.kind,
|
|
281
|
+
label: s.label ?? null,
|
|
282
|
+
values: s.values ?? {},
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
export function buildMcpServer(agentToken, privateKeyPem, deps = {}) {
|
|
286
|
+
const server = new McpServer({ name: 'decoy', version: '2.1.0' });
|
|
287
|
+
const call = deps.call ?? makeClient(agentToken).call;
|
|
288
|
+
const ok = (data) => ({
|
|
289
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
290
|
+
});
|
|
291
|
+
// ── SealedBlob (Phase 3b): vault key + account decrypt/seal ─────────────────
|
|
292
|
+
// The vault key is granted to this agent at pairing (wrapped to our public key).
|
|
293
|
+
// We unwrap it ONCE with our local private key and cache it in memory — the
|
|
294
|
+
// Decoy server never holds it, so account metadata is decrypted only here.
|
|
295
|
+
let vaultKeyCache = null;
|
|
296
|
+
async function defaultGetVaultKey() {
|
|
297
|
+
if (vaultKeyCache)
|
|
298
|
+
return vaultKeyCache;
|
|
299
|
+
if (!privateKeyPem)
|
|
300
|
+
return null;
|
|
301
|
+
try {
|
|
302
|
+
const resp = await call('/vault-key');
|
|
303
|
+
vaultKeyCache = decryptAgentContentRaw(resp.wrapped_vault_key, privateKeyPem);
|
|
304
|
+
return vaultKeyCache;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null; // 404 = no grant → operate on opaque ids only
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const getVaultKey = deps.getVaultKey ?? defaultGetVaultKey;
|
|
311
|
+
// CXF dual-read (identity-ontology P1): account blobs may now be a CXF v1.0
|
|
312
|
+
// Item (cxf-v1.0-rd-20250313) instead of the legacy flat metadata object.
|
|
313
|
+
// Vendored mappers (this package can't import ../../lib). Password is folded
|
|
314
|
+
// into basic-auth but intentionally NOT surfaced here — it's a secret, gated
|
|
315
|
+
// to the dedicated credential path, never in default account reads.
|
|
316
|
+
function isCxfItem(v) {
|
|
317
|
+
return !!v && typeof v === 'object' && Array.isArray(v.credentials) && typeof v.type === 'string';
|
|
318
|
+
}
|
|
319
|
+
function cxfItemToFields(item) {
|
|
320
|
+
const creds = item.credentials || [];
|
|
321
|
+
const ba = creds.find(c => c?.type === 'basic-auth');
|
|
322
|
+
const cf = creds.find(c => c?.type === 'custom-fields');
|
|
323
|
+
const notesField = (cf?.fields || []).find((f) => f?.designation === 'notes' || f?.label === 'Notes');
|
|
324
|
+
const categoryExt = (item.extensions || []).find((e) => e?.name === 'decoys.me/category');
|
|
325
|
+
const urls = ba?.urls || [];
|
|
326
|
+
// NOTE: `structured` (address/credit-card) is intentionally NOT surfaced here.
|
|
327
|
+
// This feeds the agent-facing account_get/account_list reads, and a card's
|
|
328
|
+
// number/CVV are secrets — gated like the password (see decryptAccount), never
|
|
329
|
+
// in default reads. The re-seal source (decryptAccountFull) adds it separately.
|
|
330
|
+
return {
|
|
331
|
+
service_name: item.title === 'Untitled' ? null : item.title,
|
|
332
|
+
service_url: urls[0] ?? null,
|
|
333
|
+
login_url: urls[1] ?? null,
|
|
334
|
+
username: ba?.username?.value ?? null,
|
|
335
|
+
category: categoryExt?.value ?? null,
|
|
336
|
+
notes: notesField?.value ?? null,
|
|
337
|
+
tags: (Array.isArray(item.tags) && item.tags.length) ? item.tags : null,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// Agent-facing safe projection of a FLAT v1 blob. CRITICAL: a flat blob holds
|
|
341
|
+
// password / customFields / structured at top level. account_update re-seals as
|
|
342
|
+
// flat v1, so without this projection the very next account_get/account_list
|
|
343
|
+
// (or the update's own response) would leak the password + card data to the
|
|
344
|
+
// agent context. Mirror cxfItemToFields' key set exactly so flat and CXF reads
|
|
345
|
+
// expose the SAME safe fields — secrets stay gated to the dedicated paths.
|
|
346
|
+
const SAFE_META_KEYS = ['service_name', 'service_url', 'login_url', 'username', 'category', 'notes', 'tags'];
|
|
347
|
+
function flatToSafeFields(decoded) {
|
|
348
|
+
const out = {};
|
|
349
|
+
for (const k of SAFE_META_KEYS)
|
|
350
|
+
if (decoded[k] !== undefined)
|
|
351
|
+
out[k] = decoded[k];
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
/** Merge decrypted metadata over a row; strips the blob. Legacy plaintext rows pass through. */
|
|
355
|
+
async function decryptAccount(row) {
|
|
356
|
+
const blob = row.encrypted_data;
|
|
357
|
+
if (!blob) {
|
|
358
|
+
const { encrypted_data, ...rest } = row;
|
|
359
|
+
return rest;
|
|
360
|
+
}
|
|
361
|
+
const vk = await getVaultKey();
|
|
362
|
+
if (!vk)
|
|
363
|
+
return { id: row.id, agent_label: row.agent_label ?? null, _encrypted: true,
|
|
364
|
+
_note: 'No vault-key grant on this agent — re-pair from a device to decrypt.' };
|
|
365
|
+
try {
|
|
366
|
+
const decoded = openVault(blob, vk);
|
|
367
|
+
const meta = isCxfItem(decoded) ? cxfItemToFields(decoded) : flatToSafeFields(decoded);
|
|
368
|
+
const { encrypted_data, ...rest } = row;
|
|
369
|
+
return { ...rest, ...meta };
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return { ...row, _decrypt_failed: true };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Like decryptAccount but INCLUDES the password — for internal re-seal only
|
|
376
|
+
* (account_update read-modify-write); never returned to the agent context. */
|
|
377
|
+
async function decryptAccountFull(row) {
|
|
378
|
+
const blob = row.encrypted_data;
|
|
379
|
+
if (!blob) {
|
|
380
|
+
const { encrypted_data, ...rest } = row;
|
|
381
|
+
return rest;
|
|
382
|
+
}
|
|
383
|
+
const vk = await getVaultKey();
|
|
384
|
+
if (!vk)
|
|
385
|
+
return {};
|
|
386
|
+
try {
|
|
387
|
+
const decoded = openVault(blob, vk);
|
|
388
|
+
if (isCxfItem(decoded)) {
|
|
389
|
+
const creds = decoded.credentials || [];
|
|
390
|
+
const ba = creds.find(c => c?.type === 'basic-auth');
|
|
391
|
+
const cf = creds.find(c => c?.type === 'custom-fields');
|
|
392
|
+
const kindFromCxf = (ft, d) => {
|
|
393
|
+
if (ft === 'email')
|
|
394
|
+
return 'email';
|
|
395
|
+
if (ft === 'number')
|
|
396
|
+
return 'number';
|
|
397
|
+
if (ft === 'date')
|
|
398
|
+
return 'date';
|
|
399
|
+
if (ft === 'concealed-string')
|
|
400
|
+
return 'secret';
|
|
401
|
+
if (ft === 'string' && d === 'tel')
|
|
402
|
+
return 'phone';
|
|
403
|
+
if (ft === 'string' && d === 'url')
|
|
404
|
+
return 'url';
|
|
405
|
+
if (ft === 'string' && d === 'cf-note')
|
|
406
|
+
return 'note';
|
|
407
|
+
return 'custom';
|
|
408
|
+
};
|
|
409
|
+
const userFields = ((cf?.fields) || [])
|
|
410
|
+
.filter((f) => f?.designation !== 'notes')
|
|
411
|
+
.map((f) => ({
|
|
412
|
+
id: f.id ?? '', label: f.label ?? '', value: f.value,
|
|
413
|
+
secret: f.fieldType === 'concealed-string',
|
|
414
|
+
kind: kindFromCxf(f.fieldType, f.designation),
|
|
415
|
+
}));
|
|
416
|
+
const structured = cxfItemToStructured(decoded);
|
|
417
|
+
return {
|
|
418
|
+
...cxfItemToFields(decoded),
|
|
419
|
+
password: ba?.password?.value ?? null,
|
|
420
|
+
customFields: userFields.length ? userFields : null,
|
|
421
|
+
// Full structured (incl. card number/CVV) — this object is the re-seal
|
|
422
|
+
// source and is NEVER returned to the agent context; omitting it dropped
|
|
423
|
+
// the user's address/card on any metadata update.
|
|
424
|
+
structured: structured.length ? structured : null,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return decoded; // legacy v1 flat object already includes password + customFields + structured
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return {};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Page through /accounts applying only non-content (server-safe) filters. */
|
|
434
|
+
async function fetchAllAccounts(serverParams, cap = 500) {
|
|
435
|
+
const out = [];
|
|
436
|
+
let cursor;
|
|
437
|
+
do {
|
|
438
|
+
const p = new URLSearchParams(serverParams);
|
|
439
|
+
p.set('limit', '100');
|
|
440
|
+
if (cursor)
|
|
441
|
+
p.set('cursor', cursor);
|
|
442
|
+
const page = await call(`/accounts?${p}`);
|
|
443
|
+
out.push(...(page.accounts ?? []));
|
|
444
|
+
cursor = page.pagination?.next_cursor;
|
|
445
|
+
} while (cursor && out.length < cap);
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
// ── Decoy management ────────────────────────────────────────────────────────
|
|
449
|
+
server.tool('decoy_list', 'List decoy email identities with filtering, sorting, and pagination.', {
|
|
450
|
+
status: z.enum(['active', 'inactive', 'all']).default('active')
|
|
451
|
+
.describe('Filter by status (default: active)'),
|
|
452
|
+
search: z.string().optional()
|
|
453
|
+
.describe('Search agent_label (e.g. "nike")'),
|
|
454
|
+
sort_by: z.enum(['created_at', 'updated_at']).default('created_at')
|
|
455
|
+
.describe('Sort column (default: created_at)'),
|
|
456
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
457
|
+
.describe('Sort direction (default: desc)'),
|
|
458
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
459
|
+
.describe('Max results (default: 20, max: 100)'),
|
|
460
|
+
cursor: z.string().optional()
|
|
461
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
462
|
+
created_after: z.string().optional()
|
|
463
|
+
.describe('ISO8601 — only decoys created after this date'),
|
|
464
|
+
created_before: z.string().optional()
|
|
465
|
+
.describe('ISO8601 — only decoys created before this date'),
|
|
466
|
+
}, async ({ status, search, sort_by, order, limit, cursor, created_after, created_before }) => {
|
|
467
|
+
const params = new URLSearchParams({ status, sort_by, order, limit: String(limit) });
|
|
468
|
+
if (search)
|
|
469
|
+
params.set('search', search);
|
|
470
|
+
if (cursor)
|
|
471
|
+
params.set('cursor', cursor);
|
|
472
|
+
if (created_after)
|
|
473
|
+
params.set('created_after', created_after);
|
|
474
|
+
if (created_before)
|
|
475
|
+
params.set('created_before', created_before);
|
|
476
|
+
return ok(await call(`/decoys?${params}`));
|
|
477
|
+
});
|
|
478
|
+
server.tool('decoy_get', 'Get a single decoy identity by ID. Returns agent_label if set.', {
|
|
479
|
+
decoy_id: z.string().uuid().describe('The decoy ID'),
|
|
480
|
+
}, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}`)));
|
|
481
|
+
server.tool('decoy_create', 'Create a new decoy email identity for a service', {
|
|
482
|
+
service_name: z.string().min(1).max(200).describe('Name of the service (e.g. "Nike", "OpenSource Newsletter"). Required, 1-200 chars.'),
|
|
483
|
+
service_url: z.string().url().max(2000).optional().describe('URL of the service (must include https://)'),
|
|
484
|
+
avatar: z.string().optional()
|
|
485
|
+
.describe('Avatar variant name. Options: cool, chef, space, wizard, pirate, dj, baseball, floral-crown, bucket, chic-berret, chic-big-hat, chic-headband, headphones, cowboy, detective, explorer, jester, luchador, ninja, pilot, pinkrock, queen, samurai, ski, snorkle, steampunk, super, viking, beekeeper, firefighter, mariachi, nurse, pharoh, robot, aqua, farmer, gladiator, knight, painter, angel. Random if omitted.'),
|
|
486
|
+
label: z.string().max(200).optional()
|
|
487
|
+
.describe('Short plaintext reference tag visible to agents (e.g. "nike-promo"). Not user PII — stored unencrypted. Max 200 chars.'),
|
|
488
|
+
forward_enabled: z.boolean().optional()
|
|
489
|
+
.describe('Whether to forward emails to a real email address (default: false)'),
|
|
490
|
+
forwarding_email_id: z.string().uuid().optional()
|
|
491
|
+
.describe('ID of the forwarding email address to use (from forwarding_get). Required if forward_enabled is true.'),
|
|
492
|
+
}, async ({ service_name, service_url, avatar, label, forward_enabled, forwarding_email_id }) => ok(await call('/decoys', 'POST', {
|
|
493
|
+
service_name,
|
|
494
|
+
...(service_url && { service_url }),
|
|
495
|
+
...(avatar && { avatar }),
|
|
496
|
+
...(label && { label }),
|
|
497
|
+
...(forward_enabled && forwarding_email_id && { forward_enabled: true, forwarding_email_id }),
|
|
498
|
+
})));
|
|
499
|
+
server.tool('decoy_update', 'Update a decoy\'s avatar, service name, or label. Only provided fields are changed; omitted fields are left unchanged. ' +
|
|
500
|
+
'When changing avatar or service_name, the server re-encrypts metadata with the user\'s public key.', {
|
|
501
|
+
decoy_id: z.string().uuid().describe('The decoy ID to update'),
|
|
502
|
+
avatar: z.string().optional()
|
|
503
|
+
.describe('New avatar variant name. Options: cool, chef, space, wizard, pirate, dj, baseball, floral-crown, bucket, chic-berret, chic-big-hat, chic-headband, headphones, cowboy, detective, explorer, jester, luchador, ninja, pilot, pinkrock, queen, samurai, ski, snorkle, steampunk, super, viking, beekeeper, firefighter, mariachi, nurse, pharoh, robot, aqua, farmer, gladiator, knight, painter, angel'),
|
|
504
|
+
service_name: z.string().min(1).max(200).optional()
|
|
505
|
+
.describe('New service name (1-200 chars)'),
|
|
506
|
+
label: z.string().max(200).optional()
|
|
507
|
+
.describe('New label for the decoy (agent-visible plaintext tag, max 200 chars)'),
|
|
508
|
+
}, async ({ decoy_id, avatar, service_name, label }) => ok(await call(`/decoys/${decoy_id}`, 'PATCH', {
|
|
509
|
+
...(avatar && { avatar }),
|
|
510
|
+
...(service_name && { service_name }),
|
|
511
|
+
...(label !== undefined && { label }),
|
|
512
|
+
})));
|
|
513
|
+
server.tool('decoy_disable', 'Disable a decoy email so it stops receiving mail. Reversible — use decoy_enable to turn it back on. ' +
|
|
514
|
+
'The decoy row and its history are preserved.', { decoy_id: z.string().uuid().describe('The decoy ID to disable') }, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}`, 'PATCH', { status: 'inactive' })));
|
|
515
|
+
server.tool('decoy_enable', 'Re-enable a previously disabled decoy so it starts receiving mail again.', { decoy_id: z.string().uuid().describe('The decoy ID to enable') }, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}`, 'PATCH', { status: 'active' })));
|
|
516
|
+
server.tool('decoy_burn', 'Permanently delete a decoy and its message history. IRREVERSIBLE. Use decoy_disable to just stop mail while preserving data.', { decoy_id: z.string().uuid().describe('The decoy ID to burn (hard-delete)') }, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}`, 'DELETE')));
|
|
517
|
+
// ── Batch operations ────────────────────────────────────────────────────────
|
|
518
|
+
server.tool('decoy_burn_batch', 'Permanently delete multiple decoys in one call. IRREVERSIBLE. Max 20 per request. Use decoy_disable for a reversible per-decoy toggle.', {
|
|
519
|
+
decoy_ids: z.array(z.string().uuid()).min(1).max(20)
|
|
520
|
+
.describe('Array of decoy IDs to burn (hard-delete, max 20)'),
|
|
521
|
+
}, async ({ decoy_ids }) => ok(await call('/decoys/batch/burn', 'POST', { decoy_ids })));
|
|
522
|
+
server.tool('decoy_pause_batch', 'Disable email forwarding on multiple decoy identities in one call. Max 20 per request. ' +
|
|
523
|
+
'Does not disable the decoy itself — mail still arrives, just is not forwarded.', {
|
|
524
|
+
decoy_ids: z.array(z.string().uuid()).min(1).max(20)
|
|
525
|
+
.describe('Array of decoy IDs to pause forwarding on (max 20)'),
|
|
526
|
+
}, async ({ decoy_ids }) => ok(await call('/decoys/batch/pause', 'POST', { decoy_ids })));
|
|
527
|
+
// ── Notification settings ───────────────────────────────────────────────────
|
|
528
|
+
server.tool('decoy_notifications_get', 'Get notification settings for a decoy identity', { decoy_id: z.string().uuid().describe('The decoy ID') }, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}/notifications`)));
|
|
529
|
+
server.tool('decoy_notifications_update', 'Update notification settings for a decoy identity', {
|
|
530
|
+
decoy_id: z.string().uuid().describe('The decoy ID'),
|
|
531
|
+
notify_all_messages: z.boolean()
|
|
532
|
+
.describe('When true, push notifications are sent for every message (not just alerts)'),
|
|
533
|
+
}, async ({ decoy_id, notify_all_messages }) => ok(await call(`/decoys/${decoy_id}/notifications`, 'PATCH', { notify_all_messages })));
|
|
534
|
+
// ── Inbox ───────────────────────────────────────────────────────────────────
|
|
535
|
+
server.tool('inbox_list', 'List messages with filtering, sorting, and pagination. Returns metadata only — no email bodies.', {
|
|
536
|
+
decoy_id: z.string().optional()
|
|
537
|
+
.describe('Filter to a single decoy ID (legacy — prefer decoy_ids)'),
|
|
538
|
+
decoy_ids: z.array(z.string()).optional()
|
|
539
|
+
.describe('Filter to specific decoy IDs (omit for all inboxes)'),
|
|
540
|
+
is_read: z.boolean().optional()
|
|
541
|
+
.describe('Filter by read status (true = read, false = unread)'),
|
|
542
|
+
channel: z.enum(['email', 'sms', 'call']).optional()
|
|
543
|
+
.describe('Filter by channel'),
|
|
544
|
+
direction: z.enum(['inbound', 'outbound']).optional()
|
|
545
|
+
.describe('Filter by direction'),
|
|
546
|
+
sort_by: z.enum(['received_at']).default('received_at')
|
|
547
|
+
.describe('Sort column (default: received_at)'),
|
|
548
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
549
|
+
.describe('Sort direction (default: desc)'),
|
|
550
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
551
|
+
.describe('Max results (default: 20, max: 100)'),
|
|
552
|
+
cursor: z.string().optional()
|
|
553
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
554
|
+
received_after: z.string().optional()
|
|
555
|
+
.describe('ISO8601 — only messages received after this date'),
|
|
556
|
+
received_before: z.string().optional()
|
|
557
|
+
.describe('ISO8601 — only messages received before this date'),
|
|
558
|
+
sender_domain: z.string().optional()
|
|
559
|
+
.describe('Filter by sender email domain (parsed from smtp_message_id, e.g. "nike.com")'),
|
|
560
|
+
}, async ({ decoy_id, decoy_ids, is_read, channel, direction, sort_by, order, limit, cursor, received_after, received_before, sender_domain }) => {
|
|
561
|
+
const params = new URLSearchParams({ sort_by, order, limit: String(limit) });
|
|
562
|
+
const ids = decoy_ids ?? (decoy_id ? [decoy_id] : []);
|
|
563
|
+
if (ids.length > 0)
|
|
564
|
+
params.set('decoy_ids', ids.join(','));
|
|
565
|
+
if (is_read !== undefined)
|
|
566
|
+
params.set('is_read', String(is_read));
|
|
567
|
+
if (channel)
|
|
568
|
+
params.set('channel', channel);
|
|
569
|
+
if (direction)
|
|
570
|
+
params.set('direction', direction);
|
|
571
|
+
if (cursor)
|
|
572
|
+
params.set('cursor', cursor);
|
|
573
|
+
if (received_after)
|
|
574
|
+
params.set('received_after', received_after);
|
|
575
|
+
if (received_before)
|
|
576
|
+
params.set('received_before', received_before);
|
|
577
|
+
if (sender_domain)
|
|
578
|
+
params.set('sender_domain', sender_domain);
|
|
579
|
+
return ok(await call(`/inbox?${params}`));
|
|
580
|
+
});
|
|
581
|
+
server.tool('inbox_get', 'Get metadata for a single message. Does not return email body content.', { message_id: z.string().uuid().describe('The message ID') }, async ({ message_id }) => ok(await call(`/inbox/messages/${message_id}`)));
|
|
582
|
+
server.tool('message_reply', 'Reply to an existing inbound message. Sends from the decoy\'s email address. ' +
|
|
583
|
+
'The reply is threaded (In-Reply-To + References headers) with the original message. ' +
|
|
584
|
+
'Use message_read first to get the subject and context for a meaningful reply. ' +
|
|
585
|
+
'Pass to/cc/bcc to Reply-All (include additional recipients); when omitted, the reply goes ' +
|
|
586
|
+
'to the original sender only. Combined recipients (to + cc + bcc) must not exceed 10. ' +
|
|
587
|
+
'Requires inbox:write scope. At least one of body_text or body_html is required.', {
|
|
588
|
+
message_id: z.string().uuid().describe('The message ID to reply to (from inbox_list or inbox_get)'),
|
|
589
|
+
subject: z.string().min(1).max(200)
|
|
590
|
+
.describe('Reply subject line (required, 1-200 chars — typically "Re: <original subject>")'),
|
|
591
|
+
body_text: z.string().max(51200).optional()
|
|
592
|
+
.describe('Plain-text reply body (required if body_html not provided, max 50KB)'),
|
|
593
|
+
body_html: z.string().max(102400).optional()
|
|
594
|
+
.describe('HTML reply body (overrides body_text if both provided, max 100KB)'),
|
|
595
|
+
to: z.union([z.string().email(), z.array(z.string().email()).min(1).max(10)]).optional()
|
|
596
|
+
.describe('Optional Reply-All override: recipient(s) instead of the original sender'),
|
|
597
|
+
cc: z.union([z.string().email(), z.array(z.string().email()).max(10)]).optional()
|
|
598
|
+
.describe('Optional Cc recipient(s) for Reply-All'),
|
|
599
|
+
bcc: z.union([z.string().email(), z.array(z.string().email()).max(10)]).optional()
|
|
600
|
+
.describe('Optional Bcc recipient(s)'),
|
|
601
|
+
}, async ({ message_id, body_text, body_html, subject, to, cc, bcc }) => ok(await call(`/inbox/messages/${message_id}/reply`, 'POST', {
|
|
602
|
+
subject,
|
|
603
|
+
...(body_text && { body_text }),
|
|
604
|
+
...(body_html && { body_html }),
|
|
605
|
+
...(to && { to }),
|
|
606
|
+
...(cc && { cc }),
|
|
607
|
+
...(bcc && { bcc }),
|
|
608
|
+
})));
|
|
609
|
+
server.tool('message_send', 'Send a new email from a decoy identity (not a reply — starts a new conversation). ' +
|
|
610
|
+
'Supports to, cc, and bcc recipients. Combined recipients (to + cc + bcc) must not exceed 10. ' +
|
|
611
|
+
'Requires inbox:write scope. Rate limited to 50 outbound emails per hour. ' +
|
|
612
|
+
'At least one of body_text or body_html is required.', {
|
|
613
|
+
decoy_id: z.string().uuid().describe('The decoy ID to send from'),
|
|
614
|
+
to: z.union([z.string().email(), z.array(z.string().email()).min(1).max(10)])
|
|
615
|
+
.describe('Recipient email address(es) — string or array of strings'),
|
|
616
|
+
subject: z.string().min(1).max(200).describe('Email subject line (required, 1-200 chars)'),
|
|
617
|
+
body_text: z.string().max(51200).optional()
|
|
618
|
+
.describe('Plain-text email body (required if body_html not provided, max 50KB)'),
|
|
619
|
+
body_html: z.string().max(102400).optional()
|
|
620
|
+
.describe('HTML email body (overrides body_text if both provided, max 100KB)'),
|
|
621
|
+
cc: z.union([z.string().email(), z.array(z.string().email()).max(10)]).optional()
|
|
622
|
+
.describe('Optional Cc recipient(s) — string or array of strings'),
|
|
623
|
+
bcc: z.union([z.string().email(), z.array(z.string().email()).max(10)]).optional()
|
|
624
|
+
.describe('Optional Bcc recipient(s) — string or array of strings'),
|
|
625
|
+
}, async ({ decoy_id, to, subject, body_text, body_html, cc, bcc }) => ok(await call('/inbox/send', 'POST', {
|
|
626
|
+
decoy_id,
|
|
627
|
+
to,
|
|
628
|
+
subject,
|
|
629
|
+
...(body_text && { body_text }),
|
|
630
|
+
...(body_html && { body_html }),
|
|
631
|
+
...(cc && { cc }),
|
|
632
|
+
...(bcc && { bcc }),
|
|
633
|
+
})));
|
|
634
|
+
server.tool('message_toggle_read', 'Mark a message as read or unread. Updates the is_read flag on the message.', {
|
|
635
|
+
message_id: z.string().uuid().describe('The message ID'),
|
|
636
|
+
is_read: z.boolean().describe('true to mark as read, false to mark as unread'),
|
|
637
|
+
}, async ({ message_id, is_read }) => ok(await call(`/inbox/messages/${message_id}`, 'PATCH', { is_read })));
|
|
638
|
+
server.tool('message_delete', 'Permanently delete a message. The message must belong to a decoy owned by the user.', {
|
|
639
|
+
message_id: z.string().uuid().describe('The message ID to delete'),
|
|
640
|
+
}, async ({ message_id }) => ok(await call(`/inbox/messages/${message_id}`, 'DELETE')));
|
|
641
|
+
server.tool('decoy_stats', 'Get per-decoy message statistics: total/unread counts, activity timestamps, and outbound metrics.', { decoy_id: z.string().uuid().describe('The decoy ID') }, async ({ decoy_id }) => ok(await call(`/decoys/${decoy_id}/stats`)));
|
|
642
|
+
// ── Forwarding ──────────────────────────────────────────────────────────────
|
|
643
|
+
server.tool('forwarding_get', 'Get the current email forwarding state for a decoy identity', { decoy_id: z.string().uuid().describe('The decoy ID') }, async ({ decoy_id }) => ok(await call(`/forwarding/${decoy_id}`)));
|
|
644
|
+
server.tool('forwarding_enable', 'Enable email forwarding for a decoy identity to a configured forwarding address', {
|
|
645
|
+
decoy_id: z.string().uuid().describe('The decoy ID'),
|
|
646
|
+
forwarding_email_id: z.string().uuid().describe('ID of the forwarding email address to send to'),
|
|
647
|
+
}, async ({ decoy_id, forwarding_email_id }) => ok(await call(`/forwarding/${decoy_id}`, 'PUT', { forward_enabled: true, forwarding_email_id })));
|
|
648
|
+
server.tool('forwarding_disable', 'Disable email forwarding for a decoy identity', { decoy_id: z.string().uuid().describe('The decoy ID') }, async ({ decoy_id }) => ok(await call(`/forwarding/${decoy_id}`, 'PUT', { forward_enabled: false, forwarding_email_id: null })));
|
|
649
|
+
// ── Forwarding emails (CRUD) ───────────────────────────────────────────────
|
|
650
|
+
server.tool('forwarding_email_list', 'List all forwarding email addresses configured for the user. ' +
|
|
651
|
+
'These are the real email addresses that decoy emails can be forwarded to.', {}, async () => ok(await call('/forwarding-emails')));
|
|
652
|
+
server.tool('forwarding_email_add', 'Add a new forwarding email address. A verification email will be sent. ' +
|
|
653
|
+
'The email must be verified before it can be used for forwarding.', {
|
|
654
|
+
email: z.string().email().describe('The email address to add for forwarding (e.g. "john@gmail.com")'),
|
|
655
|
+
}, async ({ email }) => ok(await call('/forwarding-emails', 'POST', { email })));
|
|
656
|
+
server.tool('forwarding_email_delete', 'Remove a forwarding email address. Cannot delete if any decoys are actively using it for forwarding.', {
|
|
657
|
+
id: z.string().uuid().describe('The forwarding email ID to delete'),
|
|
658
|
+
}, async ({ id }) => ok(await call(`/forwarding-emails?id=${id}`, 'DELETE')));
|
|
659
|
+
// ── Alerts ──────────────────────────────────────────────────────────────────
|
|
660
|
+
server.tool('alert_list', 'List alerts with filtering, sorting, and pagination.', {
|
|
661
|
+
decoy_id: z.string().optional()
|
|
662
|
+
.describe('Filter to a specific decoy (omit for all)'),
|
|
663
|
+
type: z.string().optional()
|
|
664
|
+
.describe('Filter by alert type (e.g. security, money, hygiene)'),
|
|
665
|
+
include_dismissed: z.boolean().default(false)
|
|
666
|
+
.describe('Include dismissed alerts (default: false)'),
|
|
667
|
+
sort_by: z.enum(['created_at']).default('created_at')
|
|
668
|
+
.describe('Sort column (default: created_at)'),
|
|
669
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
670
|
+
.describe('Sort direction (default: desc)'),
|
|
671
|
+
limit: z.number().int().min(1).max(100).default(50)
|
|
672
|
+
.describe('Max results (default: 50, max: 100)'),
|
|
673
|
+
cursor: z.string().optional()
|
|
674
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
675
|
+
created_after: z.string().optional()
|
|
676
|
+
.describe('ISO8601 — only alerts created after this date'),
|
|
677
|
+
created_before: z.string().optional()
|
|
678
|
+
.describe('ISO8601 — only alerts created before this date'),
|
|
679
|
+
}, async ({ decoy_id, type, include_dismissed, sort_by, order, limit, cursor, created_after, created_before }) => {
|
|
680
|
+
const params = new URLSearchParams({ sort_by, order, limit: String(limit) });
|
|
681
|
+
if (decoy_id)
|
|
682
|
+
params.set('decoy_id', decoy_id);
|
|
683
|
+
if (type)
|
|
684
|
+
params.set('type', type);
|
|
685
|
+
if (include_dismissed)
|
|
686
|
+
params.set('include_dismissed', 'true');
|
|
687
|
+
if (cursor)
|
|
688
|
+
params.set('cursor', cursor);
|
|
689
|
+
if (created_after)
|
|
690
|
+
params.set('created_after', created_after);
|
|
691
|
+
if (created_before)
|
|
692
|
+
params.set('created_before', created_before);
|
|
693
|
+
return ok(await call(`/alerts?${params}`));
|
|
694
|
+
});
|
|
695
|
+
server.tool('alert_dismiss', 'Dismiss an alert by ID', { alert_id: z.string().uuid().describe('The alert ID to dismiss') }, async ({ alert_id }) => ok(await call(`/alerts?id=${alert_id}`, 'DELETE')));
|
|
696
|
+
server.tool('alert_dismiss_all', 'Dismiss all undismissed alerts for the user in one call. Returns count of dismissed alerts.', {}, async () => ok(await call('/alerts/batch/dismiss', 'POST', {})));
|
|
697
|
+
// ── Rules ───────────────────────────────────────────────────────────────────
|
|
698
|
+
server.tool('rule_list', 'List notification rules for a decoy identity with optional filtering and pagination.', {
|
|
699
|
+
decoy_id: z.string().uuid().describe('The decoy ID'),
|
|
700
|
+
is_enabled: z.boolean().optional()
|
|
701
|
+
.describe('Filter by enabled status'),
|
|
702
|
+
sort_by: z.enum(['created_at', 'trigger_count']).default('created_at')
|
|
703
|
+
.describe('Sort column (default: created_at)'),
|
|
704
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
705
|
+
.describe('Sort direction (default: desc)'),
|
|
706
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
707
|
+
.describe('Max results (default: 20, max: 100)'),
|
|
708
|
+
cursor: z.string().optional()
|
|
709
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
710
|
+
}, async ({ decoy_id, is_enabled, sort_by, order, limit, cursor }) => {
|
|
711
|
+
const params = new URLSearchParams({ decoy_id, sort_by, order, limit: String(limit) });
|
|
712
|
+
if (is_enabled !== undefined)
|
|
713
|
+
params.set('is_enabled', String(is_enabled));
|
|
714
|
+
if (cursor)
|
|
715
|
+
params.set('cursor', cursor);
|
|
716
|
+
return ok(await call(`/rules?${params}`));
|
|
717
|
+
});
|
|
718
|
+
server.tool('rule_create', 'Create a notification rule for a decoy identity (e.g. alert when sender matches a pattern)', {
|
|
719
|
+
decoy_id: z.string().uuid().describe('The decoy ID'),
|
|
720
|
+
label: z.string().min(1).max(200).describe('Human-readable rule label (1-200 chars)'),
|
|
721
|
+
pattern: z.string().min(1).max(200).describe('Sender or subject pattern to match (1-200 chars)'),
|
|
722
|
+
}, async ({ decoy_id, label, pattern }) => ok(await call('/rules', 'POST', { decoy_id, label, pattern })));
|
|
723
|
+
server.tool('rule_update', 'Update a notification rule\'s label and/or pattern', {
|
|
724
|
+
rule_id: z.string().uuid().describe('The rule ID to update'),
|
|
725
|
+
label: z.string().min(1).max(200).optional().describe('New human-readable rule label (1-200 chars)'),
|
|
726
|
+
pattern: z.string().min(1).optional().describe('New sender or subject pattern to match'),
|
|
727
|
+
}, async ({ rule_id, label, pattern }) => {
|
|
728
|
+
const body = {};
|
|
729
|
+
if (label !== undefined)
|
|
730
|
+
body.label = label;
|
|
731
|
+
if (pattern !== undefined)
|
|
732
|
+
body.pattern = pattern;
|
|
733
|
+
return ok(await call(`/rules?id=${rule_id}`, 'PATCH', body));
|
|
734
|
+
});
|
|
735
|
+
server.tool('rule_delete', 'Delete a notification rule by ID', { rule_id: z.string().uuid().describe('The rule ID to delete') }, async ({ rule_id }) => ok(await call(`/rules?id=${rule_id}`, 'DELETE')));
|
|
736
|
+
// ── Personal Info ──────────────────────────────────────────────────────────
|
|
737
|
+
server.tool('personal_info_list', 'List personal info entries (emails, phones, cards, addresses). Returns types, IDs, and counts — not decrypted content.', {}, async () => ok(await call('/personal-info')));
|
|
738
|
+
server.tool('personal_info_add', 'Add a reusable personal-info entry (email, phone, card, address, name, or date of birth). ' +
|
|
739
|
+
'Sealed locally with the user\'s vault key — the server stores only ciphertext and the iPhone reads it natively.', {
|
|
740
|
+
type: z.enum(['email', 'phone', 'card', 'address', 'name', 'birthday']).describe('Type of personal info'),
|
|
741
|
+
data: z.union([
|
|
742
|
+
z.object({
|
|
743
|
+
email: z.string().describe('Email address'),
|
|
744
|
+
label: z.string().optional().describe('Optional label'),
|
|
745
|
+
}),
|
|
746
|
+
z.object({
|
|
747
|
+
phone: z.string().describe('Phone number'),
|
|
748
|
+
label: z.string().optional().describe('Optional label'),
|
|
749
|
+
}),
|
|
750
|
+
z.object({
|
|
751
|
+
number: z.string().describe('Card number (only the last 4 are stored)'),
|
|
752
|
+
expiration: z.string().describe('Expiration date (e.g. "12/25")'),
|
|
753
|
+
cvv: z.string().optional().describe('CVV code (not stored)'),
|
|
754
|
+
label: z.string().optional().describe('Optional label'),
|
|
755
|
+
}),
|
|
756
|
+
z.object({
|
|
757
|
+
street: z.string().describe('Street address'),
|
|
758
|
+
city: z.string().describe('City'),
|
|
759
|
+
state: z.string().describe('State/province'),
|
|
760
|
+
zip: z.string().describe('ZIP/postal code'),
|
|
761
|
+
country: z.string().optional().describe('Country (default: US)'),
|
|
762
|
+
label: z.string().optional().describe('Optional label'),
|
|
763
|
+
}),
|
|
764
|
+
z.object({
|
|
765
|
+
name: z.string().describe('Full name'),
|
|
766
|
+
label: z.string().optional().describe('Optional label (e.g. Legal, Preferred)'),
|
|
767
|
+
}),
|
|
768
|
+
z.object({
|
|
769
|
+
date: z.string().describe('Date of birth, YYYY-MM-DD'),
|
|
770
|
+
label: z.string().optional().describe('Optional label'),
|
|
771
|
+
}),
|
|
772
|
+
]).describe('Data fields — shape depends on type'),
|
|
773
|
+
}, async ({ type, data }) => {
|
|
774
|
+
const d = data;
|
|
775
|
+
const label = d.label ?? '';
|
|
776
|
+
const vaultKey = await getVaultKey();
|
|
777
|
+
if (vaultKey) {
|
|
778
|
+
// Shape the payload to exactly what iOS decryptPersonalInfo expects per
|
|
779
|
+
// type, then seal with the vault key → an iOS-readable VAULT SealedBlob.
|
|
780
|
+
// (The legacy server-side RSA path produces blobs iOS cannot decrypt.)
|
|
781
|
+
let payload;
|
|
782
|
+
switch (type) {
|
|
783
|
+
case 'email':
|
|
784
|
+
payload = { email: d.email, label };
|
|
785
|
+
break;
|
|
786
|
+
case 'phone':
|
|
787
|
+
payload = { phone: d.phone, label };
|
|
788
|
+
break;
|
|
789
|
+
case 'name':
|
|
790
|
+
payload = { name: d.name, label };
|
|
791
|
+
break;
|
|
792
|
+
case 'birthday':
|
|
793
|
+
payload = { date: d.date, label };
|
|
794
|
+
break;
|
|
795
|
+
case 'address':
|
|
796
|
+
payload = { street: d.street, city: d.city, state: d.state, zip: d.zip, label };
|
|
797
|
+
break;
|
|
798
|
+
// Never store the full PAN/CVV — keep only the last four, like the app.
|
|
799
|
+
case 'card':
|
|
800
|
+
payload = { lastFour: (d.number || '').replace(/\D/g, '').slice(-4), cardType: 'Card', expiration: d.expiration, label };
|
|
801
|
+
break;
|
|
802
|
+
default: payload = { ...d, label };
|
|
803
|
+
}
|
|
804
|
+
const encrypted_data = sealVault(payload, vaultKey);
|
|
805
|
+
return ok(await call('/personal-info', 'POST', { type, encrypted_data }));
|
|
806
|
+
}
|
|
807
|
+
// No vault grant — fall back to the legacy server-side RSA path (re-pair to seal).
|
|
808
|
+
return ok(await call('/personal-info', 'POST', { type, data }));
|
|
809
|
+
});
|
|
810
|
+
server.tool('personal_info_delete', 'Delete a personal info entry by ID and type', {
|
|
811
|
+
id: z.string().uuid().describe('The personal info entry ID'),
|
|
812
|
+
type: z.enum(['email', 'phone', 'card', 'address', 'name', 'birthday']).describe('Type of personal info'),
|
|
813
|
+
}, async ({ id, type }) => ok(await call(`/personal-info?id=${id}&type=${type}`, 'DELETE')));
|
|
814
|
+
// ── Webhooks ─────────────────────────────────────────────────────────────────
|
|
815
|
+
server.tool('webhook_register', 'Register a webhook to receive events instead of polling. Payload is metadata only (no E2EE content). Signature sent in X-Decoy-Signature header.', {
|
|
816
|
+
url: z.string().url().describe('HTTPS URL to deliver events to'),
|
|
817
|
+
events: z.array(z.enum([
|
|
818
|
+
'message.received', 'message.sent', 'message.delivered', 'message.bounced',
|
|
819
|
+
'alert.triggered', 'alert.dismissed',
|
|
820
|
+
'decoy.burned', 'decoy.disabled', 'decoy.enabled', 'decoy.created', 'decoy.paused',
|
|
821
|
+
])).min(1)
|
|
822
|
+
.describe('Event types to subscribe to'),
|
|
823
|
+
secret: z.string().min(16)
|
|
824
|
+
.describe('Secret for HMAC-SHA256 signature verification (min 16 chars)'),
|
|
825
|
+
}, async ({ url, events, secret }) => ok(await call('/webhooks', 'POST', { url, events, secret })));
|
|
826
|
+
server.tool('webhook_list', 'List webhook registrations with optional filtering.', {
|
|
827
|
+
active: z.boolean().optional()
|
|
828
|
+
.describe('Filter by active status'),
|
|
829
|
+
event: z.string().optional()
|
|
830
|
+
.describe('Filter to webhooks subscribed to this event (e.g. message.received)'),
|
|
831
|
+
sort_by: z.enum(['created_at', 'last_triggered_at', 'failure_count']).default('created_at')
|
|
832
|
+
.describe('Sort column (default: created_at)'),
|
|
833
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
834
|
+
.describe('Sort direction (default: desc)'),
|
|
835
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
836
|
+
.describe('Max results (default: 20, max: 100)'),
|
|
837
|
+
cursor: z.string().optional()
|
|
838
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
839
|
+
}, async ({ active, event, sort_by, order, limit, cursor }) => {
|
|
840
|
+
const params = new URLSearchParams({ sort_by, order, limit: String(limit) });
|
|
841
|
+
if (active !== undefined)
|
|
842
|
+
params.set('active', String(active));
|
|
843
|
+
if (event)
|
|
844
|
+
params.set('event', event);
|
|
845
|
+
if (cursor)
|
|
846
|
+
params.set('cursor', cursor);
|
|
847
|
+
return ok(await call(`/webhooks?${params}`));
|
|
848
|
+
});
|
|
849
|
+
server.tool('webhook_delete', 'Remove a webhook registration by ID', { webhook_id: z.string().uuid().describe('The webhook ID to delete') }, async ({ webhook_id }) => ok(await call(`/webhooks?id=${webhook_id}`, 'DELETE')));
|
|
850
|
+
// ── Profile ─────────────────────────────────────────────────────────────────
|
|
851
|
+
server.tool('profile_stats', 'Get protection score and aggregate stats across all identities', {}, async () => {
|
|
852
|
+
const data = await call('/profile');
|
|
853
|
+
const active = data.stats?.active_decoys ?? 0;
|
|
854
|
+
const protection_score = Math.min(100, Math.round((active / Math.max(active + 1, 5)) * 100));
|
|
855
|
+
return ok({ stats: data.stats, protection_score });
|
|
856
|
+
});
|
|
857
|
+
// ── Approvals ────────────────────────────────────────────────────────────────
|
|
858
|
+
server.tool('request_approval', 'Request user approval via push notification before taking a sensitive action. ' +
|
|
859
|
+
'Sends a push to the user\'s iPhone with Approve/Deny buttons. ' +
|
|
860
|
+
'Returns ephemeral_token on approval (valid 60s). Throws on denial or expiry. ' +
|
|
861
|
+
'Use this before decoy_burn (hard-delete), decoy_burn_batch, forwarding changes, rule creation, or webhook registration. ' +
|
|
862
|
+
'decoy_disable / decoy_enable are reversible and do not require approval.', {
|
|
863
|
+
action: z.enum(['decoy.burn', 'decoy.burn_batch', 'forwarding.change', 'rule.create', 'webhook.register'])
|
|
864
|
+
.describe('The sensitive action requiring approval'),
|
|
865
|
+
description: z.string().max(200)
|
|
866
|
+
.describe('Human-readable description shown verbatim in the push notification (max 200 chars). E.g. "Burn bright.stone42@decoys.me — detected in breach alert"'),
|
|
867
|
+
metadata: z.record(z.unknown()).optional()
|
|
868
|
+
.describe('Optional context passed to the approval request: { decoy_id?, decoy_email?, count? }'),
|
|
869
|
+
}, async ({ action, description, metadata }) => {
|
|
870
|
+
// Create the approval request
|
|
871
|
+
const req = await call('/approvals', 'POST', { action, description, ...(metadata && { metadata }) });
|
|
872
|
+
// Poll for up to 5 minutes (150 × 2s)
|
|
873
|
+
for (let i = 0; i < 150; i++) {
|
|
874
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
875
|
+
const status = await call(`/approvals?id=${req.id}`);
|
|
876
|
+
if (status.status === 'approved') {
|
|
877
|
+
return ok({ approved: true, ephemeral_token: status.ephemeral_token });
|
|
878
|
+
}
|
|
879
|
+
if (status.status === 'denied') {
|
|
880
|
+
throw new Error('User denied the request');
|
|
881
|
+
}
|
|
882
|
+
if (status.status === 'expired') {
|
|
883
|
+
throw new Error('Approval request expired without a response');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
throw new Error('Approval timed out after 5 minutes');
|
|
887
|
+
});
|
|
888
|
+
// ── Accounts ─────────────────────────────────────────────────────────────────
|
|
889
|
+
// Accounts represent the user's relationship with a service.
|
|
890
|
+
// A decoy email is a privacy protection layer on top — not the account itself.
|
|
891
|
+
// This mirrors password-manager semantics: track the service, protect the identity.
|
|
892
|
+
server.tool('account_list', 'List accounts with filtering, sorting, and pagination. Each account represents a service relationship.', {
|
|
893
|
+
status: z.enum(['active', 'inactive', 'all']).default('active')
|
|
894
|
+
.describe('Filter by account status (default: active)'),
|
|
895
|
+
category: z.string().optional()
|
|
896
|
+
.describe('Filter by category: shopping, social, streaming, finance, travel, food, health, productivity, other'),
|
|
897
|
+
has_decoy: z.boolean().optional()
|
|
898
|
+
.describe('Filter to accounts with (true) or without (false) a linked decoy email'),
|
|
899
|
+
search: z.string().optional()
|
|
900
|
+
.describe('Search service_name, username, or agent_label'),
|
|
901
|
+
sort_by: z.enum(['created_at', 'updated_at', 'service_name']).default('created_at')
|
|
902
|
+
.describe('Sort column (default: created_at)'),
|
|
903
|
+
order: z.enum(['asc', 'desc']).default('desc')
|
|
904
|
+
.describe('Sort direction (default: desc)'),
|
|
905
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
906
|
+
.describe('Max results (default: 20, max: 100)'),
|
|
907
|
+
cursor: z.string().optional()
|
|
908
|
+
.describe('Opaque cursor from previous page\'s pagination.next_cursor'),
|
|
909
|
+
created_after: z.string().optional()
|
|
910
|
+
.describe('ISO8601 — only accounts created after this date'),
|
|
911
|
+
created_before: z.string().optional()
|
|
912
|
+
.describe('ISO8601 — only accounts created before this date'),
|
|
913
|
+
}, async ({ status, category, has_decoy, search, sort_by, order, limit, created_after, created_before }) => {
|
|
914
|
+
// SealedBlob (Phase 3b): only non-content filters run server-side; content
|
|
915
|
+
// search / category / name-sort run HERE after decrypting each blob locally.
|
|
916
|
+
const serverParams = new URLSearchParams({ status });
|
|
917
|
+
if (has_decoy !== undefined)
|
|
918
|
+
serverParams.set('has_decoy', String(has_decoy));
|
|
919
|
+
if (created_after)
|
|
920
|
+
serverParams.set('created_after', created_after);
|
|
921
|
+
if (created_before)
|
|
922
|
+
serverParams.set('created_before', created_before);
|
|
923
|
+
const raw = await fetchAllAccounts(serverParams);
|
|
924
|
+
let items = await Promise.all(raw.map(decryptAccount));
|
|
925
|
+
if (search) {
|
|
926
|
+
const q = search.toLowerCase();
|
|
927
|
+
items = items.filter(a => [a.service_name, a.username, a.notes, a.agent_label]
|
|
928
|
+
.some(v => typeof v === 'string' && v.toLowerCase().includes(q)));
|
|
929
|
+
}
|
|
930
|
+
if (category)
|
|
931
|
+
items = items.filter(a => a.category === category);
|
|
932
|
+
const dir = order === 'asc' ? 1 : -1;
|
|
933
|
+
items.sort((a, b) => dir * String(a[sort_by] ?? '').localeCompare(String(b[sort_by] ?? '')));
|
|
934
|
+
const total = items.length;
|
|
935
|
+
const capped = raw.length >= 500;
|
|
936
|
+
return ok({
|
|
937
|
+
accounts: items.slice(0, limit),
|
|
938
|
+
count: Math.min(items.length, limit),
|
|
939
|
+
total,
|
|
940
|
+
...(capped && { note: 'Fetched accounts capped at 500; narrow filters for completeness.' }),
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
server.tool('account_get', 'Get a single account by ID, including its linked decoy email hash if protected.', { account_id: z.string().uuid().describe('The account ID') }, async ({ account_id }) => ok(await decryptAccount(await call(`/accounts/${account_id}`))));
|
|
944
|
+
server.tool('account_create', 'Create an account record for a service. Optionally link an existing decoy email by providing decoy_id. ' +
|
|
945
|
+
'To auto-create and attach a new decoy email, use account_protect after creating the account.', {
|
|
946
|
+
service_name: z.string().min(1).max(200).describe('Name of the service (e.g. "Nike", "GitHub"). Required, 1-200 chars.'),
|
|
947
|
+
service_url: z.string().url().max(2000).optional().describe('URL of the service (must include https://)'),
|
|
948
|
+
login_url: z.string().url().max(2000).optional().describe('Login page URL (must include https://)'),
|
|
949
|
+
username: z.string().max(200).optional()
|
|
950
|
+
.describe('Login identifier for this account — typically the email used to sign up. Not a password.'),
|
|
951
|
+
category: z.enum(['shopping', 'social', 'streaming', 'finance', 'travel', 'food', 'health', 'productivity', 'other']).optional()
|
|
952
|
+
.describe('Account category for organization'),
|
|
953
|
+
notes: z.string().max(2000).optional().describe('Free-text notes about this account (max 2000 chars)'),
|
|
954
|
+
tags: z.array(z.string().min(1).max(50)).max(20).optional()
|
|
955
|
+
.describe('Tags for organization (max 20 tags, each max 50 chars)'),
|
|
956
|
+
two_factor_enabled: z.boolean().optional()
|
|
957
|
+
.describe('Whether 2FA is enabled on this account'),
|
|
958
|
+
agent_label: z.string().max(200).optional()
|
|
959
|
+
.describe('Stable plaintext tag for agent references (e.g. "nike-account")'),
|
|
960
|
+
decoy_id: z.string().uuid().optional()
|
|
961
|
+
.describe('Link an existing decoy email to this account at creation time'),
|
|
962
|
+
structured: STRUCTURED_INPUT_SCHEMA.optional()
|
|
963
|
+
.describe('Multi-part typed fields. Each: { kind: "address"|"credit-card", label?, values }. ' +
|
|
964
|
+
'values keys = CXF member names — address: streetAddress, apt, city, territory, postalCode, country; ' +
|
|
965
|
+
'credit-card: number, fullName, expiryDate (YYYY-MM), verificationNumber.'),
|
|
966
|
+
}, async ({ service_name, service_url, login_url, username, category, notes, tags, two_factor_enabled, agent_label, decoy_id, structured }) => {
|
|
967
|
+
// SealedBlob (Phase 3b): seal sensitive metadata locally with the vault key
|
|
968
|
+
// so the server only ever sees ciphertext. agent_label/decoy_id/2FA stay top-level.
|
|
969
|
+
const vk = await getVaultKey();
|
|
970
|
+
const normStructured = normalizeStructured(structured);
|
|
971
|
+
const body = {
|
|
972
|
+
...(two_factor_enabled !== undefined && { two_factor_enabled }),
|
|
973
|
+
...(agent_label && { agent_label }),
|
|
974
|
+
...(decoy_id && { decoy_id }),
|
|
975
|
+
};
|
|
976
|
+
if (vk) {
|
|
977
|
+
body.encrypted_data = sealVault({ service_name, service_url: service_url ?? null, login_url: login_url ?? null,
|
|
978
|
+
username: username ?? null, category: category ?? null, notes: notes ?? null, tags: tags ?? null,
|
|
979
|
+
...(normStructured && { structured: normStructured }) }, vk);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
// No grant — fall back to plaintext (server-visible). Re-pair to seal.
|
|
983
|
+
Object.assign(body, { service_name,
|
|
984
|
+
...(service_url && { service_url }), ...(login_url && { login_url }), ...(username && { username }),
|
|
985
|
+
...(category && { category }), ...(notes && { notes }), ...(tags && { tags }) });
|
|
986
|
+
}
|
|
987
|
+
return ok(await decryptAccount(await call('/accounts', 'POST', body)));
|
|
988
|
+
});
|
|
989
|
+
server.tool('account_update', 'Update account metadata. Only provided fields are changed; omitted fields are left unchanged.', {
|
|
990
|
+
account_id: z.string().uuid().describe('The account ID to update'),
|
|
991
|
+
service_name: z.string().min(1).max(200).optional().describe('Updated service name (1-200 chars)'),
|
|
992
|
+
service_url: z.string().url().max(2000).optional().describe('Updated service URL (must include https://)'),
|
|
993
|
+
login_url: z.string().url().max(2000).optional().describe('Updated login page URL (must include https://)'),
|
|
994
|
+
username: z.string().max(200).optional().describe('Updated login identifier (max 200 chars)'),
|
|
995
|
+
category: z.enum(['shopping', 'social', 'streaming', 'finance', 'travel', 'food', 'health', 'productivity', 'other']).optional()
|
|
996
|
+
.describe('Updated category'),
|
|
997
|
+
notes: z.string().max(2000).optional().describe('Updated notes (max 2000 chars)'),
|
|
998
|
+
tags: z.array(z.string().min(1).max(50)).max(20).optional()
|
|
999
|
+
.describe('Updated tags (max 20 tags, each max 50 chars)'),
|
|
1000
|
+
two_factor_enabled: z.boolean().optional()
|
|
1001
|
+
.describe('Whether 2FA is enabled on this account'),
|
|
1002
|
+
agent_label: z.string().max(200).optional().describe('Updated agent label (max 200 chars)'),
|
|
1003
|
+
status: z.enum(['active', 'inactive']).optional().describe('Updated account status'),
|
|
1004
|
+
structured: STRUCTURED_INPUT_SCHEMA.optional()
|
|
1005
|
+
.describe('Replace the multi-part typed fields (address/credit-card). Omit to leave unchanged; ' +
|
|
1006
|
+
'pass [] to clear. Same shape as account_create.structured.'),
|
|
1007
|
+
}, async ({ account_id, ...fields }) => {
|
|
1008
|
+
// SealedBlob (Phase 3b): metadata is one sealed blob, so an update is a
|
|
1009
|
+
// read-modify-write — decrypt current, merge provided fields, re-seal.
|
|
1010
|
+
const { status, agent_label, two_factor_enabled, ...metaFields } = fields;
|
|
1011
|
+
// Normalize provided structured (stamp ids) so it round-trips into the blob.
|
|
1012
|
+
// `[]` is a deliberate clear; undefined means "leave current" (pick handles it).
|
|
1013
|
+
if (metaFields.structured !== undefined) {
|
|
1014
|
+
metaFields.structured = normalizeStructured(metaFields.structured) ?? [];
|
|
1015
|
+
}
|
|
1016
|
+
const body = {
|
|
1017
|
+
...(status !== undefined && { status }),
|
|
1018
|
+
...(agent_label !== undefined && { agent_label }),
|
|
1019
|
+
...(two_factor_enabled !== undefined && { two_factor_enabled }),
|
|
1020
|
+
};
|
|
1021
|
+
const vk = await getVaultKey();
|
|
1022
|
+
if (vk && Object.keys(metaFields).length > 0) {
|
|
1023
|
+
// Full decrypt (incl. password) as the re-seal source so a metadata-only
|
|
1024
|
+
// update can't strip the password; not returned to the agent.
|
|
1025
|
+
const current = await decryptAccountFull(await call(`/accounts/${account_id}`));
|
|
1026
|
+
const pick = (k) => (metaFields[k] !== undefined ? metaFields[k] : (current[k] ?? null));
|
|
1027
|
+
// One record = one sealed blob: re-seal ALL fields, incl. the password
|
|
1028
|
+
// (folded into the blob like a decoy). Omitting `password` here would
|
|
1029
|
+
// strip an account's password on any metadata update — the exact
|
|
1030
|
+
// bespoke-scheme fragmentation we must never reintroduce.
|
|
1031
|
+
body.encrypted_data = sealVault({
|
|
1032
|
+
service_name: pick('service_name'), service_url: pick('service_url'), login_url: pick('login_url'),
|
|
1033
|
+
username: pick('username'), category: pick('category'), notes: pick('notes'), tags: pick('tags'),
|
|
1034
|
+
password: pick('password'), customFields: pick('customFields'),
|
|
1035
|
+
// Preserve multi-part typed fields (address, credit-card). Omitting this
|
|
1036
|
+
// dropped the user's address/card on ANY agent metadata update — the same
|
|
1037
|
+
// bespoke-scheme data loss we must never reintroduce (cf. password above).
|
|
1038
|
+
structured: pick('structured'),
|
|
1039
|
+
}, vk);
|
|
1040
|
+
// Keep the client-maintained flag accurate: the re-seal preserves the
|
|
1041
|
+
// password, so has_password must reflect whether one is present. (No
|
|
1042
|
+
// password_changed — a metadata update doesn't change the password.)
|
|
1043
|
+
body.has_password = !!pick('password');
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
Object.assign(body, metaFields); // no grant or no metadata change → passthrough
|
|
1047
|
+
}
|
|
1048
|
+
return ok(await decryptAccount(await call(`/accounts/${account_id}`, 'PATCH', body)));
|
|
1049
|
+
});
|
|
1050
|
+
server.tool('account_protect', 'Attach a decoy email to an account as a privacy protection layer. ' +
|
|
1051
|
+
'If decoy_id is provided, links that existing decoy. ' +
|
|
1052
|
+
'If decoy_id is omitted, auto-creates a new decoy email for the service and links it. ' +
|
|
1053
|
+
'Returns the decoy_id and decoy_email (plaintext, only returned at creation time).', {
|
|
1054
|
+
account_id: z.string().uuid().describe('The account ID to protect'),
|
|
1055
|
+
decoy_id: z.string().uuid().optional()
|
|
1056
|
+
.describe('Existing decoy ID to link (optional — omit to auto-create a new decoy email)'),
|
|
1057
|
+
}, async ({ account_id, decoy_id }) => ok(await call(`/accounts/${account_id}/protect`, 'POST', decoy_id ? { decoy_id } : {})));
|
|
1058
|
+
server.tool('account_burn_decoy', 'Permanently delete (burn) the decoy email attached to an account. IRREVERSIBLE. ' +
|
|
1059
|
+
'By default (replace=true), auto-creates and links a fresh decoy email immediately after burning. ' +
|
|
1060
|
+
'Set replace=false to just burn and leave the account unprotected. ' +
|
|
1061
|
+
'Returns the burned decoy ID and, if replaced, the new decoy email (plaintext).', {
|
|
1062
|
+
account_id: z.string().uuid().describe('The account ID whose decoy email should be burned (hard-deleted)'),
|
|
1063
|
+
replace: z.boolean().default(true)
|
|
1064
|
+
.describe('Auto-create and link a new decoy email after burning (default: true)'),
|
|
1065
|
+
}, async ({ account_id, replace }) => ok(await call(`/accounts/${account_id}/burn-decoy`, 'POST', { replace })));
|
|
1066
|
+
// ── Credentials (E2EE password manager) ────────────────────────────────────
|
|
1067
|
+
//
|
|
1068
|
+
// Passwords generated here exist only in MCP server memory until account_set_credential
|
|
1069
|
+
// is called. The MCP server fetches the user's RSA public key, encrypts locally with
|
|
1070
|
+
// RSA-OAEP + AES-256-GCM, and sends only the ciphertext to the API. Plaintext never
|
|
1071
|
+
// travels over any network connection.
|
|
1072
|
+
server.tool('password_generate', 'Generate a strong random password in MCP server memory. ' +
|
|
1073
|
+
'The password is returned to the agent context and is NEVER stored or sent anywhere automatically. ' +
|
|
1074
|
+
'Pass it to account_set_credential to store it E2EE in the user\'s vault.', {
|
|
1075
|
+
length: z.number().int().min(8).max(128).default(20)
|
|
1076
|
+
.describe('Password length (default: 20)'),
|
|
1077
|
+
symbols: z.boolean().default(true)
|
|
1078
|
+
.describe('Include symbols like !@#$%^&* (default: true)'),
|
|
1079
|
+
memorable: z.boolean().default(false)
|
|
1080
|
+
.describe('Generate a memorable word-based passphrase instead of random chars (default: false)'),
|
|
1081
|
+
}, async ({ length, symbols, memorable }) => {
|
|
1082
|
+
let password;
|
|
1083
|
+
if (memorable) {
|
|
1084
|
+
// Word-based passphrase: AdjectiveNounNumber (e.g. "correct-horse-42")
|
|
1085
|
+
const words = ['correct', 'horse', 'battery', 'staple', 'purple', 'monkey',
|
|
1086
|
+
'swift', 'cloud', 'river', 'stone', 'frost', 'ember',
|
|
1087
|
+
'lunar', 'solar', 'ocean', 'forest', 'amber', 'cedar'];
|
|
1088
|
+
const w1 = words[Math.floor(Math.random() * words.length)];
|
|
1089
|
+
const w2 = words[Math.floor(Math.random() * words.length)];
|
|
1090
|
+
const w3 = words[Math.floor(Math.random() * words.length)];
|
|
1091
|
+
const num = Math.floor(Math.random() * 9000) + 1000;
|
|
1092
|
+
password = `${w1}-${w2}-${w3}-${num}`;
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
const alpha = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
1096
|
+
const sym = '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
|
1097
|
+
const charset = symbols ? alpha + sym : alpha;
|
|
1098
|
+
const bytes = new Uint8Array(length);
|
|
1099
|
+
crypto.getRandomValues(bytes);
|
|
1100
|
+
password = Array.from(bytes).map(b => charset[b % charset.length]).join('');
|
|
1101
|
+
}
|
|
1102
|
+
return ok({ password, length: password.length, has_symbols: symbols });
|
|
1103
|
+
});
|
|
1104
|
+
server.tool('account_set_credential', 'Store a password for an account using end-to-end encryption. ' +
|
|
1105
|
+
'The MCP server fetches the user\'s RSA public key, encrypts the password locally ' +
|
|
1106
|
+
'(RSA-OAEP + AES-256-GCM), and sends only the encrypted blob to the API. ' +
|
|
1107
|
+
'Plaintext never leaves this process over any network connection. ' +
|
|
1108
|
+
'After storage, the user\'s iPhone receives a silent push and syncs the credential ' +
|
|
1109
|
+
'to AutoFill and the Safari extension automatically.', {
|
|
1110
|
+
account_id: z.string().uuid().describe('The account ID to store the credential for'),
|
|
1111
|
+
password: z.string().min(1).describe('The plaintext password to encrypt and store'),
|
|
1112
|
+
}, async ({ account_id, password }) => {
|
|
1113
|
+
const vaultKey = await getVaultKey();
|
|
1114
|
+
if (vaultKey) {
|
|
1115
|
+
// One blob (SealedBlob): fold the password into the SAME encrypted_data as
|
|
1116
|
+
// the rest of the account (read-modify-write), exactly like the iOS app —
|
|
1117
|
+
// retiring the bespoke encrypted_password column write. The server can't
|
|
1118
|
+
// read the blob, so we set the client-maintained has_password flag.
|
|
1119
|
+
const current = await decryptAccountFull(await call(`/accounts/${account_id}`));
|
|
1120
|
+
const keep = (k) => current[k] ?? null;
|
|
1121
|
+
const encrypted_data = sealVault({
|
|
1122
|
+
service_name: keep('service_name'), service_url: keep('service_url'), login_url: keep('login_url'),
|
|
1123
|
+
username: keep('username'), category: keep('category'), notes: keep('notes'), tags: keep('tags'),
|
|
1124
|
+
password,
|
|
1125
|
+
customFields: keep('customFields'), structured: keep('structured'),
|
|
1126
|
+
}, vaultKey);
|
|
1127
|
+
const result = await call(`/accounts/${account_id}`, 'PATCH', { encrypted_data, has_password: true, password_changed: true });
|
|
1128
|
+
// decryptAccount returns the SAFE projection — never echoes the password back.
|
|
1129
|
+
return ok(await decryptAccount(result));
|
|
1130
|
+
}
|
|
1131
|
+
// No vault-key grant — fall back to the legacy RSA-OAEP envelope + the
|
|
1132
|
+
// dedicated credential endpoint (encrypted_password). Re-pair to seal into
|
|
1133
|
+
// the vault blob.
|
|
1134
|
+
const keyData = await call('/accounts/public-key');
|
|
1135
|
+
const publicKeyDer = Buffer.from(keyData.public_key, 'base64');
|
|
1136
|
+
const pub = createPublicKey({ key: publicKeyDer, format: 'der', type: 'spki' });
|
|
1137
|
+
const aesKey = randomBytes(32);
|
|
1138
|
+
const iv = randomBytes(12);
|
|
1139
|
+
const cipher = createCipheriv('aes-256-gcm', aesKey, iv);
|
|
1140
|
+
const ciphertext = Buffer.concat([cipher.update(password, 'utf-8'), cipher.final(), cipher.getAuthTag()]);
|
|
1141
|
+
const encryptedAesKey = publicEncrypt({ key: pub, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, aesKey);
|
|
1142
|
+
const keyLenBuf = Buffer.alloc(2);
|
|
1143
|
+
keyLenBuf.writeUInt16BE(encryptedAesKey.length, 0);
|
|
1144
|
+
const encrypted_password = Buffer.concat([keyLenBuf, encryptedAesKey, iv, ciphertext]).toString('base64');
|
|
1145
|
+
const result = await call(`/accounts/${account_id}/credential`, 'POST', { encrypted_password });
|
|
1146
|
+
return ok(result);
|
|
1147
|
+
});
|
|
1148
|
+
server.tool('account_credential_status', 'Check whether a stored credential exists for an account, and when it was last updated. ' +
|
|
1149
|
+
'Returns has_password (boolean) and password_last_changed (ISO 8601 or null). ' +
|
|
1150
|
+
'Never returns the encrypted blob or any decrypted value.', {
|
|
1151
|
+
account_id: z.string().uuid().describe('The account ID to check'),
|
|
1152
|
+
}, async ({ account_id }) => ok(await call(`/accounts/${account_id}/credential/status`)));
|
|
1153
|
+
server.tool('message_read', '[Deprecated — prefer message_unlock] Request decrypted content for one or more messages via the E2EE content bridge. ' +
|
|
1154
|
+
'Requires inbox:content (legacy) or message.unlock (canonical) scope AND agent_public_key registered during pairing. ' +
|
|
1155
|
+
'\n\nFlow: creates a content request → sends APNs push to user\'s iPhone → user approves ' +
|
|
1156
|
+
'→ iOS app decrypts with user private key, re-encrypts for this agent → poll returns ' +
|
|
1157
|
+
'agent_encrypted_content (base64) per message → decrypt with your agent private key. ' +
|
|
1158
|
+
'\n\nThe server never sees plaintext. Two ciphertexts per message at most. ' +
|
|
1159
|
+
'\n\nDecryption: RSA-OAEP-SHA256 for AES key, AES-256-GCM for content (same format as ' +
|
|
1160
|
+
'existing account_set_credential). ' +
|
|
1161
|
+
'\n\nReturns: items[].{ message_id, from, subject, text, html } on approval, or throws on deny/expire. subject/from decrypted on-device — server never sees them.', {
|
|
1162
|
+
message_ids: z.array(z.string().uuid())
|
|
1163
|
+
.min(1).max(20)
|
|
1164
|
+
.describe('Array of 1–20 message UUIDs to decrypt (from inbox_list or inbox_get)'),
|
|
1165
|
+
reason: z.string().max(200).optional()
|
|
1166
|
+
.describe('Optional plain-text reason shown to user in the approval notification. E.g. "Summarize your inbox"'),
|
|
1167
|
+
poll_timeout_seconds: z.number().int().min(10).max(300).default(300).optional()
|
|
1168
|
+
.describe('How long to poll for approval (default 300s / 5 minutes)'),
|
|
1169
|
+
}, async ({ message_ids, reason, poll_timeout_seconds = 300 }) => {
|
|
1170
|
+
if (!privateKeyPem) {
|
|
1171
|
+
throw new Error('message_read requires the MCP server to have an agent keypair. ' +
|
|
1172
|
+
'Delete ~/.decoy/mcp-token and restart the server to re-pair with a keypair.');
|
|
1173
|
+
}
|
|
1174
|
+
// Create the content request
|
|
1175
|
+
const req = await call('/content', 'POST', { message_ids, reason });
|
|
1176
|
+
const maxAttempts = Math.ceil(poll_timeout_seconds / 2);
|
|
1177
|
+
// Poll until ready, denied, or expired
|
|
1178
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1179
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1180
|
+
const result = await call(`/content/${req.id}`);
|
|
1181
|
+
if (result.status === 'ready') {
|
|
1182
|
+
if (!result.items || result.items.length === 0) {
|
|
1183
|
+
throw new Error('Content request approved but no items returned (already consumed or no messages decrypted)');
|
|
1184
|
+
}
|
|
1185
|
+
// Decrypt each item and parse the structured content from the iOS content bridge.
|
|
1186
|
+
// The bridge now sends { from, subject, text, html } — same fields as any email API.
|
|
1187
|
+
const decrypted = result.items.map(item => {
|
|
1188
|
+
try {
|
|
1189
|
+
const raw = decryptAgentContent(item.agent_encrypted_content, privateKeyPem);
|
|
1190
|
+
let parsed;
|
|
1191
|
+
try {
|
|
1192
|
+
parsed = JSON.parse(raw);
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
// Fallback for old-format blobs that contain raw text
|
|
1196
|
+
parsed = { text: raw };
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
message_id: item.message_id,
|
|
1200
|
+
from: parsed.from ?? null,
|
|
1201
|
+
subject: parsed.subject ?? null,
|
|
1202
|
+
text: parsed.text ?? null,
|
|
1203
|
+
html: parsed.html ?? null,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return {
|
|
1208
|
+
message_id: item.message_id,
|
|
1209
|
+
from: null, subject: null, text: '[decryption failed]', html: null,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
return ok(decrypted);
|
|
1214
|
+
}
|
|
1215
|
+
if (result.status === 'denied') {
|
|
1216
|
+
throw new Error('User denied the content access request');
|
|
1217
|
+
}
|
|
1218
|
+
if (result.status === 'expired') {
|
|
1219
|
+
throw new Error('Content access request expired before user responded');
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
throw new Error(`Content access request timed out after ${poll_timeout_seconds}s waiting for user approval`);
|
|
1223
|
+
});
|
|
1224
|
+
// ── Unified unlock bridge (messages / accounts / personal info) ─────────────
|
|
1225
|
+
// The polymorphic /api/agent/unlock endpoint replaces message_read and adds
|
|
1226
|
+
// credential + personal-info reads, with one approval UX, one wire format,
|
|
1227
|
+
// and one consumed-once envelope per kind.
|
|
1228
|
+
async function runUnlock(kind, target, reason, pollTimeoutSeconds) {
|
|
1229
|
+
if (!privateKeyPem) {
|
|
1230
|
+
throw new Error('unlock tools require the MCP server to have an agent keypair. ' +
|
|
1231
|
+
'Delete ~/.decoy/mcp-token and restart the server to re-pair with a keypair.');
|
|
1232
|
+
}
|
|
1233
|
+
const req = await call('/unlock', 'POST', { target: { kind, ...target }, reason });
|
|
1234
|
+
const maxAttempts = Math.ceil(pollTimeoutSeconds / 2);
|
|
1235
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1236
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1237
|
+
const result = await call(`/unlock/${req.id}`);
|
|
1238
|
+
if (result.status === 'ready') {
|
|
1239
|
+
if (!result.items || result.items.length === 0) {
|
|
1240
|
+
throw new Error('Unlock approved but no items returned (already consumed)');
|
|
1241
|
+
}
|
|
1242
|
+
return result.items.map((item) => {
|
|
1243
|
+
const raw = decryptAgentContent(item.sealed_envelope, privateKeyPem);
|
|
1244
|
+
let parsed;
|
|
1245
|
+
try {
|
|
1246
|
+
parsed = JSON.parse(raw);
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
parsed = { value: raw };
|
|
1250
|
+
}
|
|
1251
|
+
return { target_id: item.target_id, data: parsed };
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
if (result.status === 'denied') {
|
|
1255
|
+
throw new Error('User denied the unlock request');
|
|
1256
|
+
}
|
|
1257
|
+
if (result.status === 'expired') {
|
|
1258
|
+
throw new Error('Unlock request expired before user responded');
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
throw new Error(`Unlock request timed out after ${pollTimeoutSeconds}s waiting for user approval`);
|
|
1262
|
+
}
|
|
1263
|
+
server.tool('account_unlock', 'Unlock a stored credential via the E2EE bridge. Pass either { domain } ' +
|
|
1264
|
+
'(e.g. "foreup.com") to find the account by hostname or { account_id } for ' +
|
|
1265
|
+
'a direct lookup. Fires an APNs push; user approves on iPhone; sealed ' +
|
|
1266
|
+
'envelope is decrypted in this MCP process and the plaintext credential ' +
|
|
1267
|
+
'returned. Requires account.unlock scope AND agent_public_key registered ' +
|
|
1268
|
+
'during pairing. The credential shape is { username?, password, … } as ' +
|
|
1269
|
+
'encoded by iOS — exact fields depend on the stored account.', {
|
|
1270
|
+
domain: z.string().optional()
|
|
1271
|
+
.describe('Hostname to resolve (e.g. "foreup.com"). Use this OR account_id.'),
|
|
1272
|
+
account_id: z.string().uuid().optional()
|
|
1273
|
+
.describe('Direct account UUID. Use this OR domain.'),
|
|
1274
|
+
reason: z.string().max(200).optional()
|
|
1275
|
+
.describe('Plain-text rationale shown to the user in the approval sheet'),
|
|
1276
|
+
poll_timeout_seconds: z.number().int().min(10).max(300).default(300).optional(),
|
|
1277
|
+
}, async ({ domain, account_id, reason, poll_timeout_seconds = 300 }) => {
|
|
1278
|
+
if (!domain && !account_id)
|
|
1279
|
+
throw new Error('Provide either domain or account_id');
|
|
1280
|
+
if (domain && account_id)
|
|
1281
|
+
throw new Error('Provide either domain or account_id, not both');
|
|
1282
|
+
const items = await runUnlock('account', domain ? { domain } : { id: account_id }, reason, poll_timeout_seconds);
|
|
1283
|
+
return ok(items[0]?.data ?? null);
|
|
1284
|
+
});
|
|
1285
|
+
server.tool('message_unlock', 'Unlock decrypted content for one or more messages via the E2EE bridge. ' +
|
|
1286
|
+
'Preferred over message_read for new code — same E2EE guarantees, unified ' +
|
|
1287
|
+
'flow with account_unlock and personal_unlock. Requires message.unlock ' +
|
|
1288
|
+
'scope (legacy inbox:content also satisfies). Returns items[] with ' +
|
|
1289
|
+
'{ target_id, data: { from, subject, text, html } } per message.', {
|
|
1290
|
+
message_ids: z.array(z.string().uuid()).min(1).max(20)
|
|
1291
|
+
.describe('1–20 message UUIDs to decrypt'),
|
|
1292
|
+
reason: z.string().max(200).optional(),
|
|
1293
|
+
poll_timeout_seconds: z.number().int().min(10).max(300).default(300).optional(),
|
|
1294
|
+
}, async ({ message_ids, reason, poll_timeout_seconds = 300 }) => {
|
|
1295
|
+
const items = await runUnlock('message', { ids: message_ids }, reason, poll_timeout_seconds);
|
|
1296
|
+
return ok(items);
|
|
1297
|
+
});
|
|
1298
|
+
server.tool('personal_unlock', 'Unlock a personal-info field (email, phone, card, address) via the E2EE ' +
|
|
1299
|
+
'bridge. Pass the personal_info_id from personal_info_list. Returns the ' +
|
|
1300
|
+
'decrypted JSON payload as encoded by iOS — shape depends on the field ' +
|
|
1301
|
+
'type. Requires personal.unlock scope AND agent_public_key registered.', {
|
|
1302
|
+
personal_info_id: z.string().uuid().describe('UUID from personal_info_list'),
|
|
1303
|
+
reason: z.string().max(200).optional(),
|
|
1304
|
+
poll_timeout_seconds: z.number().int().min(10).max(300).default(300).optional(),
|
|
1305
|
+
}, async ({ personal_info_id, reason, poll_timeout_seconds = 300 }) => {
|
|
1306
|
+
const items = await runUnlock('personal', { id: personal_info_id }, reason, poll_timeout_seconds);
|
|
1307
|
+
return ok(items[0]?.data ?? null);
|
|
1308
|
+
});
|
|
1309
|
+
// ── Personal alias (subdomain) management ──────────────────────────────────
|
|
1310
|
+
server.tool('subdomain_status', 'Get the user\'s personal alias (subdomain) status — whether claimed, enabled, and disabled alias count.', {}, async () => ok(await call('/subdomain')));
|
|
1311
|
+
server.tool('subdomain_claim', 'Claim a personal alias subdomain (e.g. "yourname" gives you *@yourname.decoys.me). ' +
|
|
1312
|
+
'This is permanent and cannot be changed. The subdomain must be 3-32 chars, ' +
|
|
1313
|
+
'lowercase alphanumeric + hyphens, no leading/trailing hyphens.', {
|
|
1314
|
+
subdomain: z.string().min(3).max(32)
|
|
1315
|
+
.describe('The subdomain to claim (3-32 chars, lowercase alphanumeric + hyphens)'),
|
|
1316
|
+
}, async ({ subdomain }) => ok(await call('/subdomain', 'PUT', { subdomain })));
|
|
1317
|
+
server.tool('subdomain_toggle', 'Enable or disable the personal alias catch-all. When disabled, emails to *@yourname.decoys.me are not received.', {
|
|
1318
|
+
enabled: z.boolean()
|
|
1319
|
+
.describe('true to enable, false to disable'),
|
|
1320
|
+
}, async ({ enabled }) => ok(await call('/subdomain?action=toggle', 'PUT', { enabled })));
|
|
1321
|
+
server.tool('alias_list_disabled', 'List all disabled (blocked) aliases for the personal subdomain. ' +
|
|
1322
|
+
'Disabled aliases no longer receive emails; re-enable them with alias_enable.', {}, async () => ok(await call('/subdomain?action=disabled')));
|
|
1323
|
+
server.tool('alias_disable', 'Disable (block) a personal alias so it no longer receives emails. Reversible — use alias_enable to restore. ' +
|
|
1324
|
+
'For example, disable "netflix" to block netflix@yourname.decoys.me.', {
|
|
1325
|
+
alias: z.string().min(1).max(64)
|
|
1326
|
+
.describe('The alias local part to disable (e.g. "netflix")'),
|
|
1327
|
+
}, async ({ alias }) => ok(await call('/subdomain?action=disable', 'POST', { alias })));
|
|
1328
|
+
server.tool('alias_enable', 'Restore a previously disabled alias so it receives emails again.', {
|
|
1329
|
+
alias: z.string().min(1).max(64)
|
|
1330
|
+
.describe('The alias local part to restore (e.g. "netflix")'),
|
|
1331
|
+
}, async ({ alias }) => ok(await call('/subdomain?action=disable', 'DELETE', { alias })));
|
|
1332
|
+
server.tool('alias_stats', 'Get first-seen dates for personal aliases (when each alias first received an email). ' +
|
|
1333
|
+
'Useful for tracking when a service started sending to a specific alias.', {
|
|
1334
|
+
aliases: z.array(z.string().min(1).max(64)).min(1).max(100)
|
|
1335
|
+
.describe('List of alias names to check (max 100)'),
|
|
1336
|
+
}, async ({ aliases }) => ok(await call('/subdomain?action=alias-stats', 'POST', { aliases })));
|
|
1337
|
+
return server;
|
|
1338
|
+
}
|
|
1339
|
+
// ── Express server ────────────────────────────────────────────────────────────
|
|
1340
|
+
const app = express();
|
|
1341
|
+
app.use(express.json());
|
|
1342
|
+
const transports = new Map();
|
|
1343
|
+
// Resolved at startup — set before MCP routes become active
|
|
1344
|
+
let globalToken = '';
|
|
1345
|
+
let globalKeyPair = null;
|
|
1346
|
+
app.post('/mcp', async (req, res) => {
|
|
1347
|
+
// Per-request Authorization header overrides the global token (advanced/multi-user setups)
|
|
1348
|
+
const authHeader = req.headers.authorization ?? '';
|
|
1349
|
+
const requestToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
1350
|
+
const agentToken = requestToken || globalToken;
|
|
1351
|
+
if (!agentToken) {
|
|
1352
|
+
res.status(503).json({ error: 'Server is not yet paired — restart the MCP server to trigger browser-based pairing on ' + CONNECT_FRONTEND_URL });
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const existingId = req.headers['mcp-session-id'];
|
|
1356
|
+
let transport = existingId ? transports.get(existingId) : undefined;
|
|
1357
|
+
if (!transport || isInitializeRequest(req.body)) {
|
|
1358
|
+
const sessionId = randomUUID();
|
|
1359
|
+
transport = new StreamableHTTPServerTransport({
|
|
1360
|
+
sessionIdGenerator: () => sessionId,
|
|
1361
|
+
onsessioninitialized: (id) => { transports.set(id, transport); },
|
|
1362
|
+
});
|
|
1363
|
+
transport.onclose = () => transports.delete(existingId ?? sessionId);
|
|
1364
|
+
await buildMcpServer(agentToken, globalKeyPair?.privateKeyPem).connect(transport);
|
|
1365
|
+
}
|
|
1366
|
+
await transport.handleRequest(req, res, req.body);
|
|
1367
|
+
});
|
|
1368
|
+
app.get('/mcp', async (req, res) => {
|
|
1369
|
+
const transport = transports.get(req.headers['mcp-session-id']);
|
|
1370
|
+
if (!transport) {
|
|
1371
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
await transport.handleRequest(req, res);
|
|
1375
|
+
});
|
|
1376
|
+
app.delete('/mcp', async (req, res) => {
|
|
1377
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
1378
|
+
const transport = transports.get(sessionId);
|
|
1379
|
+
if (!transport) {
|
|
1380
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
await transport.handleRequest(req, res);
|
|
1384
|
+
transports.delete(sessionId);
|
|
1385
|
+
});
|
|
1386
|
+
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '2.2.0', mode: DEV_MODE ? 'dev' : 'prod', paired: !!globalToken }));
|
|
1387
|
+
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
1388
|
+
async function main() {
|
|
1389
|
+
// Start the HTTP server (serves /mcp + /health; pairing UI lives on decoys.me)
|
|
1390
|
+
await new Promise(resolve => app.listen(PORT, resolve));
|
|
1391
|
+
console.error(`Decoy MCP Server v2.2 on :${PORT} (${DEV_MODE ? 'dev' : 'prod'})`);
|
|
1392
|
+
try {
|
|
1393
|
+
globalKeyPair = loadOrGenerateKeyPair();
|
|
1394
|
+
console.error('[Decoy] Agent keypair ready (public key registered during pairing)');
|
|
1395
|
+
globalToken = await resolveToken(globalKeyPair);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
console.error('\n❌ Setup failed:', err.message);
|
|
1399
|
+
process.exit(1);
|
|
1400
|
+
}
|
|
1401
|
+
console.error(`Agent API: ${AGENT_API_URL}`);
|
|
1402
|
+
console.error('Ready — connect Claude Desktop to http://localhost:' + PORT + '/mcp');
|
|
1403
|
+
}
|
|
1404
|
+
// Only boot the HTTP server when run as the entrypoint (`node dist/index.js`).
|
|
1405
|
+
// Importing this module (e.g. from tests) must NOT start listening or pair.
|
|
1406
|
+
const isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1407
|
+
if (isEntrypoint) {
|
|
1408
|
+
main();
|
|
1409
|
+
}
|