← back to Cncp Mcp

index.js

719 lines

#!/usr/bin/env node
/**
 * cncp-mcp — Model Context Protocol wrapper around CNCP data stores.
 *
 * CNCP is Steve's local-only "Curated Network Control Panel" at
 * http://localhost:3333. The HTTP layer is a UI viewer; the truth lives
 * in JSON files under ~/cncp-starter/. This MCP reads those files
 * directly so it works whether or not the CNCP node process is running.
 *
 * Backing files (CNCP_DIR defaults to ~/cncp-starter):
 *   cncp-config.json         — config + domains[] portfolio
 *   cncp-projects.json       — flat dict { slug -> {3 fields} }
 *   cncp-wins.json           — list of recent wins
 *   cncp-lessons.json        — list of lessons-learned entries
 *   cncp-parking-lot.json    — list of ideas/links to revisit
 *   cncp-session-recaps.json — list of session-recap pointers
 *   cncp-agents.json         — dict of DW agents
 *
 * Tool surface (12 tools):
 *   read   : cncp_search, cncp_list_domains, cncp_get_domain,
 *            cncp_list_projects, cncp_get_project, cncp_list_wins,
 *            cncp_list_lessons, cncp_list_parking_lot,
 *            cncp_list_session_recaps, cncp_list_agents
 *   write  : cncp_add_parking_lot, cncp_add_win
 *
 * Writes go through CNCP's same JSON files — atomic write-then-rename
 * with a .bak.<ts> backup of the prior file. CNCP picks up changes on
 * its next read; no restart needed.
 */

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 { promises as fs } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

// ─── config ──────────────────────────────────────────────────────────────

const CNCP_DIR =
  process.env.CNCP_DIR || join(homedir(), "cncp-starter");

const FILES = {
  config: "cncp-config.json",
  projects: "cncp-projects.json",
  wins: "cncp-wins.json",
  lessons: "cncp-lessons.json",
  parkingLot: "cncp-parking-lot.json",
  sessionRecaps: "cncp-session-recaps.json",
  agents: "cncp-agents.json",
};

// Hard cap on response size to keep agent context budget reasonable.
const CHARACTER_LIMIT = 25000;

// ─── helpers ─────────────────────────────────────────────────────────────

async function loadJson(file) {
  const path = join(CNCP_DIR, file);
  try {
    const raw = await fs.readFile(path, "utf-8");
    return JSON.parse(raw);
  } catch (e) {
    if (e.code === "ENOENT") {
      throw new Error(
        `CNCP file not found: ${path}. Set CNCP_DIR env var if your CNCP install is elsewhere.`
      );
    }
    throw new Error(
      `Failed to load ${path}: ${e.message}. The file may be corrupted; check ${path}.bak.* for a recoverable copy.`
    );
  }
}

async function saveJson(file, data) {
  const path = join(CNCP_DIR, file);
  // Backup current file before overwriting (timestamped).
  try {
    const current = await fs.readFile(path, "utf-8");
    const ts = Date.now();
    await fs.writeFile(`${path}.bak.${ts}`, current, "utf-8");
  } catch (e) {
    if (e.code !== "ENOENT") throw e;
    // First write — no backup needed.
  }
  // Atomic write: tmp + rename.
  const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
  await fs.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
  await fs.rename(tmp, path);
}

function truncate(text, limit = CHARACTER_LIMIT) {
  if (text.length <= limit) return text;
  return (
    text.slice(0, limit) +
    `\n\n…[truncated ${text.length - limit} chars — refine your filters or pagination to narrow the result]`
  );
}

function asText(data) {
  return truncate(JSON.stringify(data, null, 2));
}

function matchesQuery(obj, q) {
  if (!q) return true;
  const needle = q.toLowerCase();
  const haystack = JSON.stringify(obj).toLowerCase();
  return haystack.includes(needle);
}

function daysUntil(dateStr) {
  if (!dateStr) return null;
  const t = new Date(dateStr).getTime();
  if (Number.isNaN(t)) return null;
  return Math.round((t - Date.now()) / 86400000);
}

// ─── tool definitions ────────────────────────────────────────────────────

const TOOLS = [
  {
    name: "cncp_search",
    description:
      "Full-text search across every CNCP store (domains, projects, wins, lessons, parking-lot, session recaps, DW agents). Returns up to `limit` matches per store, with the store name attached. Use this when you don't know which store to look in, or want a cross-cutting view (e.g., 'find anything mentioning ventura' or 'every entry tagged shopify').",
    inputSchema: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "Case-insensitive substring; matches anywhere in the JSON (keys + values).",
        },
        stores: {
          type: "array",
          items: {
            enum: [
              "domains",
              "projects",
              "wins",
              "lessons",
              "parking-lot",
              "session-recaps",
              "agents",
            ],
          },
          description: "Restrict to specific stores. Omit to search all.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 100,
          default: 10,
          description: "Max hits per store (default 10).",
        },
      },
      required: ["query"],
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_domains",
    description:
      "List Steve's domain portfolio from cncp-config.json `domains[]`. Each domain has fields like name, registrar (`reg`), expires (YYYY-MM-DD), status (active/expired/parked/transferring/etc.), ip, ns (nameservers), suggestion, suggestionReason, dnsHost, note. Filter by status, registrar, or expiry window. Returns up to `limit` rows sorted by expiry date ascending (soonest-expiring first) by default.",
    inputSchema: {
      type: "object",
      properties: {
        status: {
          type: "string",
          description: "Exact match on the `status` field, e.g. 'active', 'expired', 'parked'.",
        },
        registrar: {
          type: "string",
          description: "Exact match on the `reg` field, e.g. 'GoDaddy', 'Cloudflare', 'Namecheap', 'Porkbun'.",
        },
        expiringWithinDays: {
          type: "integer",
          minimum: 1,
          description: "Only return domains whose `expires` falls within N days from today.",
        },
        nameContains: {
          type: "string",
          description: "Substring match on the domain name (e.g. 'wallpaper' to find every *wallpaper*.com).",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 500,
          default: 50,
        },
        sortBy: {
          enum: ["expires", "name", "registrar"],
          default: "expires",
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_get_domain",
    description:
      "Fetch one domain entry from cncp-config.json `domains[]` by exact name match. Returns null if not found. Use `cncp_list_domains` with `nameContains` first if unsure of exact spelling.",
    inputSchema: {
      type: "object",
      properties: {
        name: {
          type: "string",
          description: "Exact domain name, e.g. 'designerwallcoverings.com'.",
        },
      },
      required: ["name"],
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_projects",
    description:
      "List projects tracked in cncp-projects.json. Stored as a flat dict keyed by project slug; this tool returns an array of {slug, ...fields}. Use `nameContains` to filter, or `limit` to paginate. Each project has 3 fields (typically including `path`, `port`, and a status/notes field). For full project source, use Read tool against the project's actual directory.",
    inputSchema: {
      type: "object",
      properties: {
        nameContains: {
          type: "string",
          description: "Substring match on the project slug, e.g. 'wallpaper' or '1900s'.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 500,
          default: 50,
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_get_project",
    description:
      "Fetch one project from cncp-projects.json by exact slug match. Returns null if not found.",
    inputSchema: {
      type: "object",
      properties: {
        slug: {
          type: "string",
          description: "Exact project slug, e.g. '1800swallpaper' or 'NationalPaperHangers'.",
        },
      },
      required: ["slug"],
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_wins",
    description:
      "List recent 'wins' from cncp-wins.json. Each win has {id, date (YYYY-MM-DD), rank, project, title, summary, ...}. Returns most recent first. Filter by project name or date floor.",
    inputSchema: {
      type: "object",
      properties: {
        project: {
          type: "string",
          description: "Filter to wins whose `project` field equals this value.",
        },
        sinceDate: {
          type: "string",
          description: "ISO date (YYYY-MM-DD). Only return wins on or after this date.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 200,
          default: 20,
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_lessons",
    description:
      "List lessons-learned entries from cncp-lessons.json. Empty list is normal — Steve adds these sparingly when something non-obvious bit him. Filter by project name or text substring.",
    inputSchema: {
      type: "object",
      properties: {
        project: {
          type: "string",
          description: "Filter to lessons whose `project` field equals this value.",
        },
        contains: {
          type: "string",
          description: "Substring match on the lesson body.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 200,
          default: 50,
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_parking_lot",
    description:
      "List 'parking lot' entries from cncp-parking-lot.json — ideas, links, and TODOs Steve dropped to revisit later. Each entry has {id, url, note, saved (epoch ms), discussed (bool)}. Filter by `discussed` to see only outstanding items.",
    inputSchema: {
      type: "object",
      properties: {
        discussed: {
          type: "boolean",
          description: "true → only items already discussed; false → only pending.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 200,
          default: 50,
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_session_recaps",
    description:
      "List session-recap pointers from cncp-session-recaps.json. Each entry has {file, name, path, summary} pointing at a markdown file under ~/.claude/projects/-Users-stevestudio2/memory/. Returns most recent first. Use the `path` to read the full recap with the Read tool.",
    inputSchema: {
      type: "object",
      properties: {
        contains: {
          type: "string",
          description: "Substring match on summary or filename.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 100,
          default: 20,
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_list_agents",
    description:
      "List DW agents tracked in cncp-agents.json. Stored as dict keyed by agent name; this tool returns an array of {name, ...fields}. Each agent typically has fields about its purpose and current state.",
    inputSchema: {
      type: "object",
      properties: {
        nameContains: {
          type: "string",
          description: "Substring match on the agent name.",
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: "cncp_add_parking_lot",
    description:
      "Append a new entry to cncp-parking-lot.json. Use this to capture an idea, link, or future-TODO that surfaced mid-conversation but isn't actionable now. The id is auto-generated from the note (slugified) if not provided. `saved` is set to now. `discussed` defaults to false. Backs up the prior file as cncp-parking-lot.json.bak.<ts> before writing.",
    inputSchema: {
      type: "object",
      properties: {
        note: {
          type: "string",
          description: "The idea or TODO. Plain text; max ~500 chars.",
        },
        url: {
          type: "string",
          description: "Optional URL associated with the note.",
        },
        id: {
          type: "string",
          description: "Optional explicit id. If omitted, a slug is derived from `note`.",
        },
      },
      required: ["note"],
      additionalProperties: false,
    },
  },
  {
    name: "cncp_add_win",
    description:
      "Append a new entry to cncp-wins.json. Use this when something concretely shipped or got measurably better today. Required: project, title. Optional: summary, rank (1-5, defaults to 3). The id is auto-generated as `win-<date>-<project>-<seq>`. `date` defaults to today (YYYY-MM-DD). Backs up the prior file before writing.",
    inputSchema: {
      type: "object",
      properties: {
        project: {
          type: "string",
          description: "Project slug or short name (e.g. 'bubbesblock', 'NPH').",
        },
        title: {
          type: "string",
          description: "One-line headline of the win, plain English.",
        },
        summary: {
          type: "string",
          description: "Optional 2-3 sentence detail, plain prose.",
        },
        rank: {
          type: "integer",
          minimum: 1,
          maximum: 5,
          default: 3,
          description: "Importance 1 (minor) - 5 (huge). Default 3.",
        },
        date: {
          type: "string",
          description: "ISO date YYYY-MM-DD. Defaults to today.",
        },
      },
      required: ["project", "title"],
      additionalProperties: false,
    },
  },
];

// ─── tool implementations ────────────────────────────────────────────────

async function searchAll({ query, stores, limit = 10 }) {
  const want = new Set(
    stores && stores.length
      ? stores
      : ["domains", "projects", "wins", "lessons", "parking-lot", "session-recaps", "agents"]
  );
  const out = {};

  if (want.has("domains")) {
    const cfg = await loadJson(FILES.config);
    out.domains = (cfg.domains || [])
      .filter((d) => matchesQuery(d, query))
      .slice(0, limit);
  }
  if (want.has("projects")) {
    const projs = await loadJson(FILES.projects);
    const arr = Object.entries(projs).map(([slug, v]) => ({ slug, ...v }));
    out.projects = arr.filter((p) => matchesQuery(p, query)).slice(0, limit);
  }
  if (want.has("wins")) {
    const wins = await loadJson(FILES.wins);
    out.wins = wins.filter((w) => matchesQuery(w, query)).slice(0, limit);
  }
  if (want.has("lessons")) {
    const lessons = await loadJson(FILES.lessons);
    out.lessons = lessons.filter((l) => matchesQuery(l, query)).slice(0, limit);
  }
  if (want.has("parking-lot")) {
    const items = await loadJson(FILES.parkingLot);
    out["parking-lot"] = items.filter((i) => matchesQuery(i, query)).slice(0, limit);
  }
  if (want.has("session-recaps")) {
    const recaps = await loadJson(FILES.sessionRecaps);
    out["session-recaps"] = recaps.filter((r) => matchesQuery(r, query)).slice(0, limit);
  }
  if (want.has("agents")) {
    const ags = await loadJson(FILES.agents);
    const arr = Object.entries(ags).map(([name, v]) => ({ name, ...v }));
    out.agents = arr.filter((a) => matchesQuery(a, query)).slice(0, limit);
  }
  return out;
}

async function listDomains({
  status,
  registrar,
  expiringWithinDays,
  nameContains,
  limit = 50,
  sortBy = "expires",
}) {
  const cfg = await loadJson(FILES.config);
  let domains = cfg.domains || [];

  if (status) domains = domains.filter((d) => d.status === status);
  if (registrar) domains = domains.filter((d) => d.reg === registrar);
  if (nameContains) {
    const nc = nameContains.toLowerCase();
    domains = domains.filter((d) => (d.name || "").toLowerCase().includes(nc));
  }
  if (expiringWithinDays != null) {
    domains = domains.filter((d) => {
      const days = daysUntil(d.expires);
      return days != null && days >= 0 && days <= expiringWithinDays;
    });
  }

  if (sortBy === "expires") {
    domains.sort((a, b) => (a.expires || "").localeCompare(b.expires || ""));
  } else if (sortBy === "name") {
    domains.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
  } else if (sortBy === "registrar") {
    domains.sort((a, b) => (a.reg || "").localeCompare(b.reg || ""));
  }

  return {
    total: domains.length,
    returned: Math.min(domains.length, limit),
    domains: domains.slice(0, limit),
  };
}

async function getDomain({ name }) {
  const cfg = await loadJson(FILES.config);
  const found = (cfg.domains || []).find((d) => d.name === name);
  return found || null;
}

async function listProjects({ nameContains, limit = 50 }) {
  const projs = await loadJson(FILES.projects);
  let arr = Object.entries(projs).map(([slug, v]) => ({ slug, ...v }));
  if (nameContains) {
    const nc = nameContains.toLowerCase();
    arr = arr.filter((p) => p.slug.toLowerCase().includes(nc));
  }
  arr.sort((a, b) => a.slug.localeCompare(b.slug));
  return {
    total: arr.length,
    returned: Math.min(arr.length, limit),
    projects: arr.slice(0, limit),
  };
}

async function getProject({ slug }) {
  const projs = await loadJson(FILES.projects);
  const v = projs[slug];
  return v ? { slug, ...v } : null;
}

async function listWins({ project, sinceDate, limit = 20 }) {
  let wins = await loadJson(FILES.wins);
  if (project) wins = wins.filter((w) => w.project === project);
  if (sinceDate) wins = wins.filter((w) => (w.date || "") >= sinceDate);
  wins.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
  return {
    total: wins.length,
    returned: Math.min(wins.length, limit),
    wins: wins.slice(0, limit),
  };
}

async function listLessons({ project, contains, limit = 50 }) {
  let lessons = await loadJson(FILES.lessons);
  if (project) lessons = lessons.filter((l) => l.project === project);
  if (contains) {
    const c = contains.toLowerCase();
    lessons = lessons.filter((l) => JSON.stringify(l).toLowerCase().includes(c));
  }
  return {
    total: lessons.length,
    returned: Math.min(lessons.length, limit),
    lessons: lessons.slice(0, limit),
  };
}

async function listParkingLot({ discussed, limit = 50 }) {
  let items = await loadJson(FILES.parkingLot);
  if (discussed != null) items = items.filter((i) => Boolean(i.discussed) === discussed);
  items.sort((a, b) => (b.saved || 0) - (a.saved || 0));
  return {
    total: items.length,
    returned: Math.min(items.length, limit),
    items: items.slice(0, limit),
  };
}

async function listSessionRecaps({ contains, limit = 20 }) {
  let recaps = await loadJson(FILES.sessionRecaps);
  if (contains) {
    const c = contains.toLowerCase();
    recaps = recaps.filter((r) =>
      ((r.summary || "") + " " + (r.name || "") + " " + (r.file || ""))
        .toLowerCase()
        .includes(c)
    );
  }
  // session-recaps.json is already most-recent-first per CNCP convention,
  // but sort by name (which contains the date) defensively.
  recaps.sort((a, b) => (b.name || "").localeCompare(a.name || ""));
  return {
    total: recaps.length,
    returned: Math.min(recaps.length, limit),
    recaps: recaps.slice(0, limit),
  };
}

async function listAgents({ nameContains }) {
  const ags = await loadJson(FILES.agents);
  let arr = Object.entries(ags).map(([name, v]) => ({ name, ...v }));
  if (nameContains) {
    const nc = nameContains.toLowerCase();
    arr = arr.filter((a) => a.name.toLowerCase().includes(nc));
  }
  arr.sort((a, b) => a.name.localeCompare(b.name));
  return { total: arr.length, agents: arr };
}

function slugify(s) {
  return s
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 60);
}

async function addParkingLot({ note, url, id }) {
  if (!note || typeof note !== "string") {
    throw new Error("`note` is required (string).");
  }
  const items = await loadJson(FILES.parkingLot);
  const finalId = id || slugify(note) || `pl-${Date.now()}`;
  if (items.some((i) => i.id === finalId)) {
    throw new Error(
      `Parking-lot id '${finalId}' already exists. Pass a unique \`id\` or vary the note.`
    );
  }
  const entry = {
    id: finalId,
    url: url || "",
    note,
    saved: Date.now(),
    discussed: false,
  };
  items.push(entry);
  await saveJson(FILES.parkingLot, items);
  return { added: entry, total: items.length };
}

async function addWin({ project, title, summary, rank = 3, date }) {
  if (!project || !title) {
    throw new Error("`project` and `title` are both required.");
  }
  const wins = await loadJson(FILES.wins);
  const today =
    date || new Date().toISOString().slice(0, 10); // YYYY-MM-DD
  const sameDay = wins.filter(
    (w) => w.date === today && w.project === project
  );
  const seq = sameDay.length + 1;
  const entry = {
    id: `win-${today}-${slugify(project)}-${seq}`,
    date: today,
    rank,
    project,
    title,
    summary: summary || "",
  };
  wins.push(entry);
  await saveJson(FILES.wins, wins);
  return { added: entry, total: wins.length };
}

// ─── server wiring ───────────────────────────────────────────────────────

const server = new Server(
  { name: "cncp", 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 "cncp_search":
        data = await searchAll(args);
        break;
      case "cncp_list_domains":
        data = await listDomains(args);
        break;
      case "cncp_get_domain":
        data = await getDomain(args);
        break;
      case "cncp_list_projects":
        data = await listProjects(args);
        break;
      case "cncp_get_project":
        data = await getProject(args);
        break;
      case "cncp_list_wins":
        data = await listWins(args);
        break;
      case "cncp_list_lessons":
        data = await listLessons(args);
        break;
      case "cncp_list_parking_lot":
        data = await listParkingLot(args);
        break;
      case "cncp_list_session_recaps":
        data = await listSessionRecaps(args);
        break;
      case "cncp_list_agents":
        data = await listAgents(args);
        break;
      case "cncp_add_parking_lot":
        data = await addParkingLot(args);
        break;
      case "cncp_add_win":
        data = await addWin(args);
        break;
      default:
        throw new Error(
          `Unknown tool: ${name}. Available: ${TOOLS.map((t) => t.name).join(", ")}`
        );
    }
    return { content: [{ type: "text", text: asText(data) }] };
  } catch (e) {
    return { isError: true, content: [{ type: "text", text: e.message }] };
  }
});

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