Menu
Public repository / e0753e49bfea7ef672aabf8df9a3a27b8bdd9dc7
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}` },
});
}
}
}