← back to Cloudflare Dns Mcp

index.js

168 lines

#!/usr/bin/env node
// cloudflare-dns-mcp — owned local MCP for Cloudflare DNS record CRUD.
// Token source of truth: env CLOUDFLARE_API_TOKEN, else the secrets master
// (~/Projects/secrets-manager/.env). The token is never written into MCP config.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// ---- token loading ---------------------------------------------------------
function loadToken() {
  if (process.env.CLOUDFLARE_API_TOKEN) return process.env.CLOUDFLARE_API_TOKEN.trim();
  try {
    const envPath = join(homedir(), "Projects", "secrets-manager", ".env");
    const line = readFileSync(envPath, "utf8")
      .split("\n")
      .find((l) => l.startsWith("CLOUDFLARE_API_TOKEN="));
    if (line) return line.slice("CLOUDFLARE_API_TOKEN=".length).trim().replace(/^["']|["']$/g, "");
  } catch { /* fall through */ }
  return null;
}
const API = "https://api.cloudflare.com/client/v4";

async function cf(path, { method = "GET", body } = {}) {
  // Load per-request so a rotated token is picked up without restarting the server.
  const token = loadToken();
  if (!token) throw new Error("CLOUDFLARE_API_TOKEN not found (env or secrets master).");
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!data.success) {
    const errs = (data.errors || []).map((e) => `${e.code}: ${e.message}`).join("; ");
    throw new Error(`CF API ${res.status} — ${errs || "unknown error"}`);
  }
  return data.result;
}

// Accept a zone id (32 hex) or a zone name; resolve name → id.
async function resolveZoneId(zone) {
  if (/^[0-9a-f]{32}$/i.test(zone)) return zone;
  const zones = await cf(`/zones?name=${encodeURIComponent(zone)}&per_page=1`);
  if (!zones.length) throw new Error(`Zone not found: ${zone}`);
  return zones[0].id;
}

// ---- tool definitions ------------------------------------------------------
const TOOLS = [
  {
    name: "cf_list_zones",
    description: "List Cloudflare zones (optionally filter by exact name). Returns id, name, status.",
    inputSchema: {
      type: "object",
      properties: { name: { type: "string", description: "Exact zone name filter, e.g. designerwallcoverings.com" } },
    },
  },
  {
    name: "cf_list_dns_records",
    description: "List DNS records in a zone. zone = zone name or id. Optional type/name filters.",
    inputSchema: {
      type: "object",
      properties: {
        zone: { type: "string", description: "Zone name or zone id" },
        type: { type: "string", description: "Record type filter (A, CNAME, TXT, MX…)" },
        name: { type: "string", description: "Record name filter (exact FQDN)" },
      },
      required: ["zone"],
    },
  },
  {
    name: "cf_create_dns_record",
    description: "Create a DNS record. Returns the created record incl. its id.",
    inputSchema: {
      type: "object",
      properties: {
        zone: { type: "string", description: "Zone name or zone id" },
        type: { type: "string", description: "A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…" },
        name: { type: "string", description: "Record name (FQDN or @ for apex)" },
        content: { type: "string", description: "Record value (IP, target, text…)" },
        ttl: { type: "number", description: "TTL seconds; 1 = auto (default 1)" },
        proxied: { type: "boolean", description: "Cloudflare proxy (orange cloud); default false" },
        priority: { type: "number", description: "Priority (MX/SRV)" },
      },
      required: ["zone", "type", "name", "content"],
    },
  },
  {
    name: "cf_update_dns_record",
    description: "Update an existing DNS record by record_id. Only pass fields you want changed.",
    inputSchema: {
      type: "object",
      properties: {
        zone: { type: "string", description: "Zone name or zone id" },
        record_id: { type: "string", description: "DNS record id (from cf_list_dns_records)" },
        type: { type: "string" }, name: { type: "string" }, content: { type: "string" },
        ttl: { type: "number" }, proxied: { type: "boolean" }, priority: { type: "number" },
      },
      required: ["zone", "record_id"],
    },
  },
  {
    name: "cf_delete_dns_record",
    description: "DELETE a DNS record by record_id. Destructive — deletes the record from the live zone.",
    inputSchema: {
      type: "object",
      properties: {
        zone: { type: "string", description: "Zone name or zone id" },
        record_id: { type: "string", description: "DNS record id to delete" },
      },
      required: ["zone", "record_id"],
    },
  },
];

// ---- handlers --------------------------------------------------------------
const server = new Server(
  { name: "cloudflare-dns-mcp", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

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

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: a = {} } = req.params;
  try {
    let result;
    if (name === "cf_list_zones") {
      const q = a.name ? `?name=${encodeURIComponent(a.name)}` : "?per_page=50";
      const zones = await cf(`/zones${q}`);
      result = zones.map((z) => ({ id: z.id, name: z.name, status: z.status }));
    } else if (name === "cf_list_dns_records") {
      const zid = await resolveZoneId(a.zone);
      const params = new URLSearchParams({ per_page: "100" });
      if (a.type) params.set("type", a.type);
      if (a.name) params.set("name", a.name);
      const recs = await cf(`/zones/${zid}/dns_records?${params}`);
      result = recs.map((r) => ({ id: r.id, type: r.type, name: r.name, content: r.content, ttl: r.ttl, proxied: r.proxied }));
    } else if (name === "cf_create_dns_record") {
      const zid = await resolveZoneId(a.zone);
      const body = { type: a.type, name: a.name, content: a.content, ttl: a.ttl ?? 1, proxied: a.proxied ?? false };
      if (a.priority != null) body.priority = a.priority;
      result = await cf(`/zones/${zid}/dns_records`, { method: "POST", body });
    } else if (name === "cf_update_dns_record") {
      const zid = await resolveZoneId(a.zone);
      const body = {};
      for (const k of ["type", "name", "content", "ttl", "proxied", "priority"]) if (a[k] != null) body[k] = a[k];
      result = await cf(`/zones/${zid}/dns_records/${a.record_id}`, { method: "PATCH", body });
    } else if (name === "cf_delete_dns_record") {
      const zid = await resolveZoneId(a.zone);
      result = await cf(`/zones/${zid}/dns_records/${a.record_id}`, { method: "DELETE" });
    } else {
      throw new Error(`Unknown tool: ${name}`);
    }
    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  } catch (err) {
    return { content: [{ type: "text", text: `ERROR: ${err.message}` }], isError: true };
  }
});

await server.connect(new StdioServerTransport());