AkurAI Build
Menu

Public repository / 41b5d1110a34166099a7737c821dcaf20a45fb28

AkurAI-Mail / tools/akurai-mail-mcp.ts

Latest change ebbbadc91746d843b58df8c2619808a58602876c - Harden mail CLI against transient IMAP failures by Ólafur Búi Ólafsson

#!/usr/bin/env bun
/**
 * AkurAI-Mail MCP Server — maintained, inspectable interface for mail operations.
 *
 * Permission tiers:
 *   "read"  — any agent
 *   "draft" — write/mutation (flag, archive, move, draft-create/update)
 *   "send"  — requires idempotencyKey + approvalProof (string token proving human approval)
 *
 * Every call is audited. Credentials and full message bodies are NEVER stored in audit.
 */
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..");
const SERVER_CLI = join(REPO_ROOT, "scripts/akurai-mail-server");
const INDEX_CLI = join(REPO_ROOT, "tools/akurai-mail-index.ts");

// ── Permission scope (least-privilege gate) ─────────────────────────────────
// AKURAI_MAIL_MCP_SCOPE caps the maximum tier this server exposes, so a
// read-only agent can be deployed that cannot mutate:
//   "read"  — read-only (no draft/mutation, no send)
//   "draft" — read + draft/flag/archive/move, no external send
//   "send"  — full surface (send still needs idempotencyKey + approvalProof)
// Defaults to "send" for backward compatibility.
type PermissionTier = "read" | "draft" | "send";
const PERMISSION_RANK: Record<PermissionTier, number> = { read: 0, draft: 1, send: 2 };
const SCOPE: PermissionTier = ((): PermissionTier => {
  const raw = (process.env.AKURAI_MAIL_MCP_SCOPE ?? "send").toLowerCase();
  return raw === "read" || raw === "draft" || raw === "send" ? raw : "send";
})();

// ── CLI response types ─────────────────────────────────────────────────────
interface CliResult {
  ok: boolean;
  error?: string;
  raw?: string;
  [key: string]: unknown;
}

interface MessageResult {
  ok: boolean;
  message?: {
    id?: string;
    folder?: string;
    from?: string;
    to?: string;
    cc?: string;
    subject?: string;
    date?: string;
    bodyText?: string;
    bodyHtml?: string;
    attachments?: AttachmentMeta[];
    [key: string]: unknown;
  };
}

interface AttachmentMeta {
  index: number;
  filename: string;
  contentType: string;
  size: number;
}


interface DraftResult {
  ok: boolean;
  draft?: string;
}

// ── Audit log ───────────────────────────────────────────────────────────────
const AUDIT_LOG = join(REPO_ROOT, ".akurai-mail-mcp-audit.jsonl");

type AuditEntry = {
  at: string;
  kind: "read" | "draft" | "send";
  email: string;
  tool: string;
  detail: Record<string, unknown>;
};

async function appendAudit(kind: AuditEntry["kind"], email: string, tool: string, detail: Record<string, unknown>) {
  const entry: AuditEntry = {
    at: new Date().toISOString(),
    kind,
    email,
    tool,
    detail: redactForAudit(detail),
  };
  try {
    const existing = await Bun.file(AUDIT_LOG).text().catch(() => "");
    await Bun.write(AUDIT_LOG, existing + JSON.stringify(entry) + "\n");
  } catch {
    // audit failure must not halt the operation
  }
}

function redactForAudit(d: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys: Record<string, true> = { password: true, secret: true, token: true, approvalProof: true, body: true, bodyHtml: true, bodyText: true, data: true };
  const r: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(d)) {
    if (sensitiveKeys[k]) {
      r[k] = typeof v === "string" && v.length > 4 ? v.slice(0, 4) + "…" : "[REDACTED]";
    } else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
      r[k] = redactForAudit(v as Record<string, unknown>);
    } else {
      r[k] = v;
    }
  }
  return r;
}

// ── CLI invocation helpers ──────────────────────────────────────────────────

async function runServer(args: string[]): Promise<CliResult> {
  const proc = Bun.spawnSync([SERVER_CLI, ...args], {
    cwd: REPO_ROOT,
    env: { ...process.env },
  });
  if (proc.exitCode !== 0) {
    const stderr = proc.stderr.toString().trim();
    throw new Error(stderr || `server CLI exited ${proc.exitCode}`);
  }
  const stdout = proc.stdout.toString().trim();
  let result: CliResult;
  try {
    result = JSON.parse(stdout) as CliResult;
  } catch {
    result = { ok: true, raw: stdout };
  }
  return result;
}

async function runIndex(args: string[]): Promise<CliResult> {
  const proc = Bun.spawnSync(["bun", "run", INDEX_CLI, ...args], {
    cwd: join(__dirname),
    env: { ...process.env },
  });
  if (proc.exitCode !== 0) {
    const stderr = proc.stderr.toString().trim();
    throw new Error(stderr || `index CLI exited ${proc.exitCode}`);
  }
  const stdout = proc.stdout.toString().trim();
  let result: CliResult;
  try {
    result = JSON.parse(stdout) as CliResult;
  } catch {
    result = { ok: true, raw: stdout };
  }
  return result;
}

// ── Idempotency store (in-memory) ───────────────────────────────────────────
const _SetCtor: SetConstructor = Set;
const sentIds = new _SetCtor<string>();
function checkIdempotency(key: string): boolean {
  if (sentIds.has(key)) return false;
  sentIds.add(key);
  return true;
}

// ── Approval proof validation ───────────────────────────────────────────────
function validateApprovalProof(proof: string): boolean {
  if (!proof || proof.length < 8) return false;
  return /^(ALLOW|[0-9a-fA-F]{8,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(proof);
}

// ── Recipient validation ────────────────────────────────────────────────────
function validateEmailList(raw: string): string[] {
  if (!raw || !raw.trim()) return [];
  const addresses = raw.split(/[,;]/).map(s => s.trim()).filter(Boolean);
  for (const addr of addresses) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addr)) {
      throw new Error(`Invalid email address: ${addr}`);
    }
  }
  return addresses;
}

// ── MCP Protocol types ─────────────────────────────────────────────────────
type JsonRpcMessage = {
  jsonrpc: "2.0";
  id?: number | string | null;
  method?: string;
  params?: Record<string, unknown>;
  result?: unknown;
  error?: { code: number; message: string; data?: unknown };
};

type ToolMeta = {
  permission: "read" | "draft" | "send";
  description: string;
};

type ToolDefinition = {
  name: string;
  meta: ToolMeta;
  inputSchema: {
    type: "object";
    properties: Record<string, unknown>;
    required: string[];
    additionalProperties: boolean;
  };
};

// ── Tool definitions ────────────────────────────────────────────────────────

const emailProp = { type: "string" as const, description: "Mailbox email address (e.g. user@olibuijr.com)" };
const messageIdProp = { type: "string" as const, description: "Base64-encoded message ID from a list/read response" };
const folderProp = {
  type: "string" as const,
  description: "Folder name: Inbox, Sent, Drafts, Archive, Spam, Trash",
  enum: ["Inbox", "Sent", "Drafts", "Archive", "Spam", "Trash"] as string[],
};

function toolDefinitions(): ToolDefinition[] {
  const readTools: ToolDefinition[] = [
    {
      name: "mail_accounts",
      meta: { permission: "read", description: "List all configured mail accounts" },
      inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
    },
    {
      name: "mail_folder_list",
      meta: { permission: "read", description: "List folders and unread/total counts for a mailbox" },
      inputSchema: { type: "object", properties: { email: emailProp }, required: ["email"], additionalProperties: false },
    },
    {
      name: "mail_message_list",
      meta: { permission: "read", description: "List messages in a folder with cursor-based pagination" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, folder: { ...folderProp, default: "Inbox" }, query: { type: "string", default: "" }, limit: { type: "number", default: 40 }, cursor: { type: "string", default: "" } },
        required: ["email"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_message_read",
      meta: { permission: "read", description: "Read a specific message with body text, HTML, and attachments" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, id: messageIdProp, markRead: { type: "boolean", default: false } },
        required: ["email", "id"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_attachment_list",
      meta: { permission: "read", description: "List attachment metadata for a message" },
      inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp }, required: ["email", "id"], additionalProperties: false },
    },
    {
      name: "mail_attachment_download",
      meta: { permission: "read", description: "Download attachment data (base64-encoded)" },
      inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp, index: { type: "number" } }, required: ["email", "id", "index"], additionalProperties: false },
    },
    {
      name: "mail_thread_search",
      meta: { permission: "read", description: "Semantic search across mail using the indexed search engine" },
      inputSchema: {
        type: "object",
        properties: { query: { type: "string" }, account: { type: "string" }, limit: { type: "number", default: 10 } },
        required: ["query"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_signals",
      meta: { permission: "read", description: "Compute conversation signals (reply-needed, follow-up, priority)" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, folder: { ...folderProp, default: "Inbox" } },
        required: ["email"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_audit_log",
      meta: { permission: "read", description: "Read the recent MCP audit log (no credentials or bodies)" },
      inputSchema: { type: "object", properties: { limit: { type: "number", default: 20 } }, required: [], additionalProperties: false },
    },
  ];

  const draftTools: ToolDefinition[] = [
    {
      name: "mail_draft_create",
      meta: { permission: "draft", description: "Create a new draft in the Drafts folder" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, to: { type: "string" }, subject: { type: "string" }, body: { type: "string" }, cc: { type: "string" } },
        required: ["email"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_reply_prepare",
      meta: { permission: "draft", description: "Prepare a reply draft with proper threading headers" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true } },
        required: ["email", "messageId", "body"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_reply_all_prepare",
      meta: { permission: "draft", description: "Prepare a reply-all draft with all original recipients" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true } },
        required: ["email", "messageId", "body"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_flag",
      meta: { permission: "draft", description: "Star (flag) or unstar a message" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, id: messageIdProp, flagged: { type: "boolean" } },
        required: ["email", "id", "flagged"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_archive",
      meta: { permission: "draft", description: "Move a message to Archive folder" },
      inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp }, required: ["email", "id"], additionalProperties: false },
    },
    {
      name: "mail_move",
      meta: { permission: "draft", description: "Move a message to a target folder" },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, id: messageIdProp, target: folderProp },
        required: ["email", "id", "target"],
        additionalProperties: false,
      },
    },
  ];

  const sendTools: ToolDefinition[] = [
    {
      name: "mail_send",
      meta: {
        permission: "send",
        description: "Send a new email. Requires idempotencyKey + approvalProof for external send gate.",
      },
      inputSchema: {
        type: "object",
        properties: {
          email: emailProp,
          to: { type: "string" },
          cc: { type: "string" },
          bcc: { type: "string" },
          subject: { type: "string" },
          body: { type: "string" },
          signature: { type: "string" },
          idempotencyKey: { type: "string" },
          approvalProof: { type: "string" },
        },
        required: ["email", "to", "subject", "body", "idempotencyKey", "approvalProof"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_reply",
      meta: {
        permission: "send",
        description: "Send a reply to an existing message. Requires idempotencyKey + approvalProof.",
      },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true }, idempotencyKey: { type: "string" }, approvalProof: { type: "string" } },
        required: ["email", "messageId", "body", "idempotencyKey", "approvalProof"],
        additionalProperties: false,
      },
    },
    {
      name: "mail_reply_all",
      meta: {
        permission: "send",
        description: "Send a reply-all. Requires idempotencyKey + approvalProof.",
      },
      inputSchema: {
        type: "object",
        properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true }, idempotencyKey: { type: "string" }, approvalProof: { type: "string" } },
        required: ["email", "messageId", "body", "idempotencyKey", "approvalProof"],
        additionalProperties: false,
      },
    },
  ];

  return [...readTools, ...draftTools, ...sendTools];
}

// ── Tool handler ────────────────────────────────────────────────────────────

async function handleToolCall(name: string, rawArgs: Record<string, unknown>): Promise<unknown> {
  const defs = toolDefinitions();
  const def = defs.find(t => t.name === name);
  if (!def) throw new Error(`Unknown tool: ${name}`);

  const permission = def.meta.permission;

  // Least-privilege scope gate: block tiers above the configured scope.
  if (PERMISSION_RANK[permission] > PERMISSION_RANK[SCOPE]) {
    throw new Error(`Tool "${name}" requires "${permission}" scope but server runs in "${SCOPE}" scope`);
  }

  // Permission enforcement for send
  if (permission === "send") {
    const idempotencyKey = String(rawArgs.idempotencyKey ?? "");
    const approvalProof = String(rawArgs.approvalProof ?? "");

    if (!idempotencyKey) throw new Error("idempotencyKey is required for send operations");
    if (!checkIdempotency(idempotencyKey)) {
      return { ok: true, deduplicated: true, message: "Message already sent (idempotency key consumed)" };
    }
    if (!validateApprovalProof(approvalProof)) {
      throw new Error("Invalid approvalProof: must be ALLOW or a valid token (≥8 hex chars or UUID) from the session");
    }
  }

  switch (name) {
    // ── Read tools ──
    case "mail_accounts": {
      const result = await runServer(["list-users"]);
      await appendAudit("read", "system", name, {});
      const accounts = (result as Record<string, unknown>).accounts ?? result;
      return { ok: true, accounts: Array.isArray(accounts) ? accounts : [] };
    }

    case "mail_folder_list": {
      const email = String(rawArgs.email);
      const state = await runServer(["webmail-state", email, "--folder", "Inbox", "--limit", "1"]);
      await appendAudit("read", email, name, { email });
      const folders = (state as Record<string, unknown>).folders;
      return { ok: true, folders: Array.isArray(folders) ? folders : [] };
    }

    case "mail_message_list": {
      const email = String(rawArgs.email);
      const folder = String(rawArgs.folder ?? "Inbox");
      const query = String(rawArgs.query ?? "");
      const limit = Math.min(Number(rawArgs.limit) || 40, 100);
      const cursor = String(rawArgs.cursor ?? "");
      const result = await runServer(["webmail-state", email, "--folder", folder, "--query", query, "--limit", String(limit), "--cursor", cursor]);
      await appendAudit("read", email, name, { email, folder, limit });
      return result;
    }

    case "mail_message_read": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const markRead = Boolean(rawArgs.markRead);
      const cliArgs = ["webmail-read", email, id];
      if (markRead) cliArgs.push("--mark-read");
      const result = await runServer(cliArgs);
      await appendAudit("read", email, name, { email, id, markRead });
      return result;
    }

    case "mail_attachment_list": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const result = await runServer(["webmail-read", email, id]) as unknown as MessageResult;
      const attachments = result.message?.attachments ?? [];
      await appendAudit("read", email, name, { email, id, attachmentCount: attachments.length });
      return { ok: true, attachments };
    }

    case "mail_attachment_download": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const index = Number(rawArgs.index);
      const result = await runServer(["webmail-attachment", email, id, String(index)]);
      await appendAudit("read", email, name, { email, id, index });
      return result;
    }

    case "mail_thread_search": {
      const query = String(rawArgs.query);
      const account = rawArgs.account ? String(rawArgs.account) : "";
      const limit = Math.min(Number(rawArgs.limit) || 10, 50);
      const indexArgs = ["search", query, "--json"];
      if (account) indexArgs.push("--account", account);
      if (limit) indexArgs.push("--limit", String(limit));
      const result = await runIndex(indexArgs);
      await appendAudit("read", account || "all", name, { query, limit });
      return result;
    }

    case "mail_signals": {
      const email = String(rawArgs.email);
      const folder = String(rawArgs.folder ?? "Inbox");
      const result = await runServer(["webmail-signals", email, "--folder", folder]);
      await appendAudit("read", email, name, { email, folder });
      return result;
    }

    case "mail_audit_log": {
      const limit = Math.min(Number(rawArgs.limit) || 20, 100);
      try {
        const file = Bun.file(AUDIT_LOG);
        const text = await file.text().catch(() => "");
        if (!text.trim()) return { ok: true, entries: [] };
        const lines = text.trim().split("\n").filter(Boolean);
        const entries = lines.slice(-limit).map(l => JSON.parse(l) as AuditEntry);
        return { ok: true, entries };
      } catch {
        return { ok: true, entries: [] };
      }
    }

    // ── Draft tools ──
    case "mail_draft_create": {
      const email = String(rawArgs.email);
      const payload = JSON.stringify({
        from: email,
        to: String(rawArgs.to ?? ""),
        cc: String(rawArgs.cc ?? ""),
        subject: String(rawArgs.subject ?? ""),
        body: String(rawArgs.body ?? ""),
      });
      const result = await runServer(["webmail-draft", email, payload]);
      await appendAudit("draft", email, name, { email, to: rawArgs.to, subject: rawArgs.subject });
      return result;
    }

    case "mail_reply_prepare": {
      const email = String(rawArgs.email);
      const messageId = String(rawArgs.messageId);
      const body = String(rawArgs.body);
      const includeOriginal = Boolean(rawArgs.includeOriginal);

      const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
      const msg = original.message ?? {};
      const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;
      const to = msg.from || "";

      let fullBody = body;
      if (includeOriginal && msg.bodyText) {
        fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
      }

      const payload = JSON.stringify({ from: email, to, subject, body: fullBody });
      const result = await runServer(["webmail-draft", email, payload]) as unknown as DraftResult;
      await appendAudit("draft", email, name, { email, messageId, subject, to });
      return { ok: true, draft: result.draft, preparedReply: { to, subject, body: fullBody } };
    }

    case "mail_reply_all_prepare": {
      const email = String(rawArgs.email);
      const messageId = String(rawArgs.messageId);
      const body = String(rawArgs.body);
      const includeOriginal = Boolean(rawArgs.includeOriginal);

      const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
      const msg = original.message ?? {};
      const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;

      const selfEmail = email.toLowerCase();
      const from = msg.from || "";
      const originalTo = (msg.to || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
      const originalCc = (msg.cc || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);

      const replyTo = [from, ...originalTo].filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
      const replyCc = originalCc.filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");

      let fullBody = body;
      if (includeOriginal && msg.bodyText) {
        fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
      }

      const payload = JSON.stringify({ from: email, to: replyTo, cc: replyCc, subject, body: fullBody });
      const result = await runServer(["webmail-draft", email, payload]) as unknown as DraftResult;
      await appendAudit("draft", email, name, { email, messageId, subject, to: replyTo, cc: replyCc });
      return { ok: true, draft: result.draft, preparedReply: { to: replyTo, cc: replyCc, subject, body: fullBody } };
    }

    case "mail_flag": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const flagged = Boolean(rawArgs.flagged);
      const result = await runServer(["webmail-action", email, id, flagged ? "star" : "unstar"]);
      await appendAudit("draft", email, name, { email, id, flagged });
      return result;
    }

    case "mail_archive": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const result = await runServer(["webmail-action", email, id, "archive"]);
      await appendAudit("draft", email, name, { email, id });
      return result;
    }

    case "mail_move": {
      const email = String(rawArgs.email);
      const id = String(rawArgs.id);
      const target = String(rawArgs.target);
      const result = await runServer(["webmail-action", email, id, "move", "--target", target]);
      await appendAudit("draft", email, name, { email, id, target });
      return result;
    }

    // ── Send tools ──
    case "mail_send": {
      const email = String(rawArgs.email);
      const to = String(rawArgs.to);
      const cc = String(rawArgs.cc ?? "");
      const bcc = String(rawArgs.bcc ?? "");
      const subject = String(rawArgs.subject);
      const body = String(rawArgs.body);
      const signature = String(rawArgs.signature ?? "");

      validateEmailList(to);
      if (cc) validateEmailList(cc);
      if (bcc) validateEmailList(bcc);

      const payload = JSON.stringify({ from: email, to, cc, bcc, subject, body, signature });
      const result = await runServer(["webmail-send", email, payload]);
      await appendAudit("send", email, name, { email, to, cc, bccCount: bcc ? bcc.split(/[,;]/).length : 0, subject });
      return result;
    }

    case "mail_reply":
    case "mail_reply_all": {
      const email = String(rawArgs.email);
      const messageId = String(rawArgs.messageId);
      const body = String(rawArgs.body);
      const includeOriginal = Boolean(rawArgs.includeOriginal);

      const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
      const msg = original.message ?? {};
      const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;

      let fullBody = body;
      if (includeOriginal && msg.bodyText) {
        fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
      }

      const selfEmail = email.toLowerCase();
      const from = msg.from || "";
      const originalTo = (msg.to || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
      const originalCc = (msg.cc || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);

      let toAddr: string;
      let ccAddr: string;

      if (name === "mail_reply_all") {
        toAddr = [from, ...originalTo].filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
        ccAddr = originalCc.filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
      } else {
        toAddr = from;
        ccAddr = "";
      }

      validateEmailList(toAddr);
      if (ccAddr) validateEmailList(ccAddr);

      const payload = JSON.stringify({ from: email, to: toAddr, cc: ccAddr, subject, body: fullBody });
      const result = await runServer(["webmail-send", email, payload]);
      await appendAudit("send", email, name, { email, messageId, to: toAddr, cc: ccAddr, subject });
      return result;
    }

    default:
      throw new Error(`Unhandled tool: ${name}`);
  }
}

// ── Protocol I/O ───────────────────────────────────────────────────────────

const reader = Bun.stdin.stream();
const decoder = new TextDecoder();
let buffer = "";

async function sendMessage(msg: JsonRpcMessage) {
  const json = JSON.stringify(msg);
  const header = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n`;
  await Bun.write(Bun.stdout, header + json);
}

async function handleRequest(msg: JsonRpcMessage): Promise<void> {
  const method = msg.method ?? "";
  const id = msg.id;
  const params = (msg.params ?? {}) as Record<string, unknown>;

  try {
    switch (method) {
      case "initialize": {
        await sendMessage({
          jsonrpc: "2.0",
          id,
          result: {
            protocolVersion: "2025-03-26",
            capabilities: { tools: {}, resources: {} },
            serverInfo: { name: "akurai-mail-mcp", version: "0.4.3" },
          },
        });
        // Send initialized notification
        await sendMessage({
          jsonrpc: "2.0",
          method: "notifications/initialized",
        } as unknown as JsonRpcMessage);
        break;
      }

      case "tools/list": {
        await sendMessage({
          jsonrpc: "2.0",
          id,
          result: { tools: toolDefinitions().filter(t => PERMISSION_RANK[t.meta.permission] <= PERMISSION_RANK[SCOPE]) },
        });
        break;
      }

      case "tools/call": {
        const rawParams = params as { name?: string; arguments?: Record<string, unknown> };
        const toolName = String(rawParams.name ?? "");
        const toolArgs = rawParams.arguments ?? {};
        const result = await handleToolCall(toolName, toolArgs);
        await sendMessage({
          jsonrpc: "2.0",
          id,
          result: {
            content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
          },
        });
        break;
      }

      case "notifications/initialized":
        // Ignore — client acknowledging server init
        break;

      case "shutdown":
        await sendMessage({ jsonrpc: "2.0", id, result: null });
        process.exit(0);

      default: {
        if (id != null) {
          await sendMessage({
            jsonrpc: "2.0",
            id,
            error: { code: -32601, message: `Method not found: ${method}` },
          });
        }
      }
    }
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : String(err);
    if (id != null) {
      await sendMessage({
        jsonrpc: "2.0",
        id,
        error: { code: -32603, message },
      });
    }
  }
}

// ── Main loop ──────────────────────────────────────────────────────────────

for await (const chunk of reader) {
  buffer += decoder.decode(chunk, { stream: true });
  while (true) {
    const headerEnd = buffer.indexOf("\r\n\r\n");
    if (headerEnd === -1) break;
    const match = buffer.slice(0, headerEnd).match(/Content-Length:\s*(\d+)/i);
    if (!match) { buffer = buffer.slice(headerEnd + 4); continue; }
    const length = parseInt(match[1], 10);
    const bodyStart = headerEnd + 4;
    if (buffer.length < bodyStart + length) break;
    const body = buffer.slice(bodyStart, bodyStart + length);
    buffer = buffer.slice(bodyStart + length);

    try {
      const msg = JSON.parse(body) as JsonRpcMessage;
      handleRequest(msg); // fire-and-forget
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      await sendMessage({
        jsonrpc: "2.0",
        id: null,
        error: { code: -32700, message: `Parse error: ${message}` },
      });
    }
  }
}