← back to Purelymail Mcp

index.js

276 lines

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const API = "https://purelymail.com/api/v0";

function loadToken() {
  if (process.env.PURELYMAIL_API_TOKEN) return process.env.PURELYMAIL_API_TOKEN.trim();
  try {
    const txt = readFileSync(join(homedir(), "Projects/secrets-manager/.env"), "utf8");
    const m = txt.match(/^PURELYMAIL_API_TOKEN=(.+)$/m);
    if (m) return m[1].trim();
  } catch {}
  return null;
}

const TOKEN = loadToken();
if (!TOKEN) {
  console.error(
    "purelymail-mcp: no PURELYMAIL_API_TOKEN. Set the env var, or run:\n" +
      "  node ~/Projects/secrets-manager/cli.js add PURELYMAIL_API_TOKEN <value>\n" +
      "Generate one at https://purelymail.com/manage → API"
  );
  process.exit(1);
}

async function call(endpoint, body = {}) {
  const url = API + (endpoint.startsWith("/") ? endpoint : "/" + endpoint);
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Purelymail-Api-Token": TOKEN,
    },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let data;
  try { data = JSON.parse(text); } catch { data = { raw: text }; }
  if (!res.ok || (data && data.type === "error")) {
    const err = new Error(
      `Purelymail ${endpoint} → ${res.status}: ${data?.message || data?.error || text.slice(0, 200)}`
    );
    err.status = res.status;
    err.body = data;
    throw err;
  }
  return data;
}

const TOOLS = [
  {
    name: "purelymail_list_domains",
    description:
      "List every domain on the Purelymail account, with their MX/SPF/DKIM verification state. No args.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
  },
  {
    name: "purelymail_add_domain",
    description:
      "Add a new domain to the Purelymail account. Returns the verification records that need to be set on DNS.",
    inputSchema: {
      type: "object",
      properties: {
        domainName: { type: "string", description: "Apex domain, e.g. agentabrams.com" },
      },
      required: ["domainName"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_delete_domain",
    description: "Remove a domain from the Purelymail account. Destructive — confirm before calling.",
    inputSchema: {
      type: "object",
      properties: { domainName: { type: "string" } },
      required: ["domainName"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_list_routing_rules",
    description:
      "List every routing rule (alias / forwarder) for a domain. Each rule maps matchUser@domain to one or more targetAddresses.",
    inputSchema: {
      type: "object",
      properties: { domainName: { type: "string" } },
      required: ["domainName"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_add_routing_rule",
    description:
      "Create a routing rule (alias / forwarder). matchUser is the local-part to match (use '*' for catch-all, or 'info' to forward 'info@domainName'). targetAddresses is one or more destinations.",
    inputSchema: {
      type: "object",
      properties: {
        domainName: { type: "string", description: "Apex domain that owns the rule, e.g. agentabrams.com" },
        matchUser: { type: "string", description: "Local part to match. '*' = catch-all. 'info' = info@<domainName>." },
        targetAddresses: {
          type: "array",
          items: { type: "string" },
          minItems: 1,
          description: "One or more destination addresses. Plain email strings.",
        },
        prefix: { type: "boolean", description: "If true, treat matchUser as a prefix match (e.g. 'sales' matches sales+*).", default: false },
        catchAll: { type: "boolean", description: "If true, this rule catches any unmatched address on the domain.", default: false },
      },
      required: ["domainName", "matchUser", "targetAddresses"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_delete_routing_rule",
    description: "Delete a routing rule by its id. Get the id from purelymail_list_routing_rules first.",
    inputSchema: {
      type: "object",
      properties: { routingRuleId: { type: ["integer", "string"] } },
      required: ["routingRuleId"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_create_user",
    description:
      "Create a new mailbox. userName is the full address (info@domain.com). Password should be strong and stored via /secrets after creation.",
    inputSchema: {
      type: "object",
      properties: {
        userName: { type: "string", description: "Full address, e.g. info@venturablvd.agentabrams.com" },
        password: { type: "string" },
        enablePasswordReset: { type: "boolean", default: true },
        recoveryEmail: { type: "string" },
      },
      required: ["userName", "password"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_delete_user",
    description: "Delete a mailbox. Destructive.",
    inputSchema: {
      type: "object",
      properties: { userName: { type: "string" } },
      required: ["userName"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_modify_user",
    description: "Modify a user (rename, change password, change recovery email, toggle reset).",
    inputSchema: {
      type: "object",
      properties: {
        userName: { type: "string" },
        newUserName: { type: "string" },
        newPassword: { type: "string" },
        recoveryEmail: { type: "string" },
        enablePasswordReset: { type: "boolean" },
      },
      required: ["userName"],
      additionalProperties: false,
    },
  },
  {
    name: "purelymail_account_credit",
    description: "Return the current account credit balance and renewal info.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
  },
  {
    name: "purelymail_call_raw",
    description:
      "Escape hatch — call any Purelymail v0 endpoint by name with an arbitrary JSON body. Use only if a tool above doesn't fit.",
    inputSchema: {
      type: "object",
      properties: {
        endpoint: { type: "string", description: "Endpoint name without leading slash, e.g. 'listDomains'." },
        body: { type: "object", additionalProperties: true },
      },
      required: ["endpoint"],
      additionalProperties: false,
    },
  },
];

const server = new Server(
  { name: "purelymail", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args = {} } = req.params;
  try {
    let data;
    switch (name) {
      case "purelymail_list_domains":
        data = await call("listDomains");
        break;
      case "purelymail_add_domain":
        data = await call("addDomain", { domainName: args.domainName });
        break;
      case "purelymail_delete_domain":
        data = await call("deleteDomain", { domainName: args.domainName });
        break;
      case "purelymail_list_routing_rules":
        data = await call("listRoutingRules", { domainName: args.domainName });
        break;
      case "purelymail_add_routing_rule":
        data = await call("createRoutingRule", {
          domainName: args.domainName,
          matchUser: args.matchUser,
          targetAddresses: args.targetAddresses,
          prefix: args.prefix ?? false,
          catchAll: args.catchAll ?? false,
        });
        break;
      case "purelymail_delete_routing_rule":
        data = await call("deleteRoutingRule", { routingRuleId: args.routingRuleId });
        break;
      case "purelymail_create_user": {
        // Purelymail createUser expects userName = LOCAL PART + domainName separately,
        // NOT the full address (full address -> "Request could not be parsed"). Split it.
        const addr = String(args.userName);
        const at = addr.lastIndexOf("@");
        const body = {
          userName: at >= 0 ? addr.slice(0, at) : addr,
          domainName: at >= 0 ? addr.slice(at + 1) : args.domainName,
          password: args.password,
          enablePasswordReset: args.enablePasswordReset ?? true,
        };
        if (args.recoveryEmail) body.recoveryEmail = args.recoveryEmail;
        data = await call("createUser", body);
        break;
      }
      case "purelymail_delete_user":
        data = await call("deleteUser", { userName: args.userName });
        break;
      case "purelymail_modify_user": {
        const body = { userName: args.userName };
        if (args.newUserName) body.newUserName = args.newUserName;
        if (args.newPassword) body.newPassword = args.newPassword;
        if (args.recoveryEmail) body.recoveryEmail = args.recoveryEmail;
        if (args.enablePasswordReset !== undefined) body.enablePasswordReset = args.enablePasswordReset;
        data = await call("modifyUser", body);
        break;
      }
      case "purelymail_account_credit":
        data = await call("checkAccountCredit");
        break;
      case "purelymail_call_raw":
        data = await call(args.endpoint, args.body || {});
        break;
      default:
        throw new Error(`Unknown tool: ${name}`);
    }
    return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
  } catch (e) {
    const raw = e.message + (e.body ? "\n\n" + JSON.stringify(e.body, null, 2) : "");
    const safe = TOKEN ? raw.split(TOKEN).join("[REDACTED]") : raw;
    return { isError: true, content: [{ type: "text", text: safe }] };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);