#!/usr/bin/env node
/**
 * rmfmail-mcp — Model Context Protocol server over the running rmfmail
 * daemon, so an AI (Claude Code, Claude Desktop, anything MCP-speaking) can
 * process mail on the user's behalf.
 *
 *   claude mcp add rmfmail -- rmfmail-mcp
 *
 * Transport: MCP stdio (newline-delimited JSON-RPC 2.0). Hand-rolled — the
 * protocol subset needed (initialize / tools/list / tools/call) is ~100
 * lines and not worth an SDK dependency.
 *
 * Backend: the daemon's /rpc endpoint, discovered via ~/.rmfmail/agent.json
 * (written each boot, removed on shutdown; a dead pid means stale). Scope:
 * the TRIAGE token by default — reads plus flag/mark-read. Set
 * RMFMAIL_MCP_FULL=1 to use the full token, which unlocks move_message and
 * create_draft. There is deliberately NO send tool at either scope: AI
 * drafts, the human sends (standing policy).
 */
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
const CONFIG_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
const AGENT_JSON = path.join(CONFIG_DIR, "agent.json");
const FULL = process.env.RMFMAIL_MCP_FULL === "1";
function loadAgentInfo() {
    let raw;
    try {
        raw = fs.readFileSync(AGENT_JSON, "utf-8");
    }
    catch {
        throw new Error(`rmfmail daemon not running (no ${AGENT_JSON}) — start it with: rmfmail`);
    }
    const info = JSON.parse(raw);
    try {
        process.kill(info.pid, 0);
    }
    catch {
        throw new Error(`agent.json is stale (pid ${info.pid} not running) — start rmfmail`);
    }
    return info;
}
let rpcSeq = 0;
async function rpc(action, params = {}) {
    const info = loadAgentInfo();
    const token = FULL ? info.tokens.full : info.tokens.triage;
    const r = await fetch(`${info.rpcUrl}?t=${token}`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ _action: action, _cbid: String(++rpcSeq), ...params }),
    });
    if (!r.ok)
        throw new Error(`daemon rpc HTTP ${r.status}`);
    const res = await r.json();
    if (res.error)
        throw new Error(res.error);
    return res.result;
}
// ── Tools ──
/** Trim a message object down to what an LLM needs — full envelopes carry
 *  parsed MIME internals that waste context. */
function slimMessage(m, includeBody) {
    const one = (x) => x?.name ? `${x.name} <${x.address}>` : (x?.address || x || "");
    const addr = (a) => Array.isArray(a) ? a.map(one).join(", ") : (a && typeof a === "object" ? one(a) : a);
    const out = {
        accountId: m.accountId, uid: m.uid, folderId: m.folderId,
        date: m.date, from: addr(m.from), to: addr(m.to), cc: addr(m.cc) || undefined,
        subject: m.subject, seen: m.seen, flagged: m.flagged,
        snippet: m.preview || undefined,
    };
    if (includeBody) {
        out.bodyText = m.bodyText || undefined;
        // Fall back to a crude strip when only HTML exists.
        if (!out.bodyText && m.bodyHtml)
            out.bodyText = String(m.bodyHtml).replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 20000);
        if (m.bodyError)
            out.bodyError = m.bodyError;
        if (Array.isArray(m.attachments) && m.attachments.length) {
            out.attachments = m.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size }));
        }
    }
    return out;
}
const str = (description) => ({ type: "string", description });
const num = (description) => ({ type: "number", description });
const bool = (description) => ({ type: "boolean", description });
const obj = (props, required) => ({ type: "object", properties: props, required });
const TOOLS = {
    list_accounts: {
        description: "List configured mail accounts (id, name, email).",
        inputSchema: obj({}, []),
        run: async () => (await rpc("getAccounts")).map((a) => ({ id: a.id, name: a.name, email: a.email })),
    },
    list_folders: {
        description: "List folders for an account, with unread/total counts.",
        inputSchema: obj({ accountId: str("Account id from list_accounts") }, ["accountId"]),
        run: async (a) => (await rpc("getFolders", { accountId: a.accountId })).map((f) => ({ id: f.id, path: f.path, specialUse: f.specialUse || undefined, unread: f.unreadCount, total: f.totalCount })),
    },
    list_messages: {
        description: "List messages in a folder, newest first (paged, 50/page). Returns envelopes with snippets, no bodies.",
        inputSchema: obj({ accountId: str("Account id"), folderId: num("Folder id from list_folders"), page: num("1-based page, default 1") }, ["accountId", "folderId"]),
        run: async (a) => {
            const r = await rpc("getMessages", { accountId: a.accountId, folderId: a.folderId, page: a.page || 1, pageSize: 50 });
            return { total: r.total, messages: (r.items || []).map((m) => slimMessage({ ...m, accountId: a.accountId, folderId: a.folderId }, false)) };
        },
    },
    search_messages: {
        description: "Full-text search across all local mail (FTS). Query supports plain words; results span accounts/folders.",
        inputSchema: obj({ query: str("Search text"), includeTrashSpam: bool("Also search Trash/Spam (default false)") }, ["query"]),
        run: async (a) => {
            const r = await rpc("searchMessages", { query: (a.query || "").trim(), page: 1, pageSize: 50, scope: "all", accountId: "", folderId: 0, includeTrashSpam: !!a.includeTrashSpam });
            return { total: r.total, messages: (r.items || []).map((m) => slimMessage(m, false)) };
        },
    },
    get_message: {
        description: "Fetch one message including its text body and attachment list.",
        inputSchema: obj({ accountId: str("Account id"), uid: num("Message uid"), folderId: num("Folder id the uid lives in") }, ["accountId", "uid", "folderId"]),
        run: async (a) => slimMessage(await rpc("getMessage", { accountId: a.accountId, uid: a.uid, folderId: a.folderId, allowRemote: false }), true),
    },
    flag_message: {
        description: "Set or clear flags on a message. flags is the COMPLETE new flag list, e.g. [\"\\\\Seen\", \"\\\\Flagged\"] or [\"\\\\Seen\"] to unflag.",
        inputSchema: obj({ accountId: str("Account id"), uid: num("Message uid"), folderId: num("Folder id"), flags: { type: "array", items: { type: "string" }, description: "Complete flag list" } }, ["accountId", "uid", "folderId", "flags"]),
        run: async (a) => rpc("updateFlags", { accountId: a.accountId, uid: a.uid, folderId: a.folderId, flags: a.flags }),
    },
    move_message: {
        description: "Move a message to another folder (same account). FULL scope only.",
        inputSchema: obj({ accountId: str("Account id"), uid: num("Message uid"), folderId: num("Source folder id"), targetFolderId: num("Destination folder id") }, ["accountId", "uid", "folderId", "targetFolderId"]),
        fullOnly: true,
        run: async (a) => rpc("moveMessages", { accountId: a.accountId, uids: [a.uid], folderIds: [a.folderId], targetFolderId: a.targetFolderId }),
    },
    create_draft: {
        description: "Create a draft (NOT sent — drafts are for the human to review and send). FULL scope only.",
        inputSchema: obj({ accountId: str("Account id to draft from"), to: str("To recipients, comma-separated"), cc: str("Cc recipients (optional)"), subject: str("Subject"), bodyText: str("Plain-text body") }, ["accountId", "to", "subject", "bodyText"]),
        fullOnly: true,
        run: async (a) => rpc("saveDraft", { accountId: a.accountId, subject: a.subject, bodyHtml: String(a.bodyText).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/\n/g, "<br>"), bodyText: a.bodyText, to: a.to, cc: a.cc || "" }),
    },
};
// ── MCP stdio plumbing (JSON-RPC 2.0, one message per line) ──
const out = (msg) => { process.stdout.write(JSON.stringify(msg) + "\n"); };
function toolList() {
    return Object.entries(TOOLS)
        .filter(([, t]) => FULL || !t.fullOnly)
        .map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
}
const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on("line", (line) => { void handle(line); });
async function handle(line) {
    if (!line.trim())
        return;
    let req;
    try {
        req = JSON.parse(line);
    }
    catch {
        return;
    }
    const { id, method, params } = req;
    const reply = (result) => out({ jsonrpc: "2.0", id, result });
    const fail = (code, message) => out({ jsonrpc: "2.0", id, error: { code, message } });
    try {
        switch (method) {
            case "initialize":
                reply({
                    protocolVersion: params?.protocolVersion || "2024-11-05",
                    capabilities: { tools: {} },
                    serverInfo: { name: "rmfmail", version: "1.0.0" },
                    instructions: `Email triage over the local rmfmail daemon (scope: ${FULL ? "full — includes move + draft creation" : "triage — reads and flags only"}). There is no send tool: draft, never send.`,
                });
                return;
            case "notifications/initialized":
            case "notifications/cancelled":
                return; // notifications get no response
            case "tools/list":
                reply({ tools: toolList() });
                return;
            case "tools/call": {
                const tool = TOOLS[params?.name];
                if (!tool || (tool.fullOnly && !FULL)) {
                    fail(-32602, `unknown tool: ${params?.name}`);
                    return;
                }
                try {
                    const result = await tool.run(params?.arguments || {});
                    reply({ content: [{ type: "text", text: JSON.stringify(result, null, 1) }] });
                }
                catch (e) {
                    reply({ content: [{ type: "text", text: `Error: ${e?.message || e}` }], isError: true });
                }
                return;
            }
            case "ping":
                reply({});
                return;
            default:
                if (id !== undefined)
                    fail(-32601, `method not found: ${method}`);
        }
    }
    catch (e) {
        if (id !== undefined)
            fail(-32603, e?.message || String(e));
    }
}
//# sourceMappingURL=mcp-server.js.map