← 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);