run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
228 lines • 10.6 kB
JavaScript
/**
* `docs` — the discovery surface for `run`.
*
* Answers from two files that ship inside the `run402-mcp` package, copied
* there by `npm run build`: the SDK reference (`sdk/llms-sdk.txt`, generated
* from `docs-site/src/content/docs/sdk/**`) and the `run` primer
* (`docs-site/src/content/docs/mcp/run.md`). The text a host reads is therefore
* the SDK the snippet runs against, never a newer or older copy from the
* network; this tool never fetches anything.
*
* `topic` is `index` (the default: the primer, the `r` namespace table, and the
* list of topics), `sdk` (the whole reference), or one section of it: a
* namespace such as `assets` or `project.apply`, or a section slug such as
* `local-state`. `search` returns the sections whose heading or body contains
* every word, capped. Every answer goes through the result store, so a long
* one is a window plus a `ref` that `expand_result` pages.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { storeResult } from "../result-store.js";
/** Lines shown inline; the index fits inside one window. */
export const DOCS_WINDOW_LINES = 200;
/** Sections a search returns at most. */
export const DOCS_SEARCH_MAX_SECTIONS = 8;
export const docsSchema = {
topic: z
.string()
.optional()
.describe("`index` (default: the run primer, the r namespace table, the topics), `sdk` (the whole SDK reference), or one section: a namespace such as `projects`, `assets`, `project.apply`, `rooms`, or a section slug from the index."),
search: z
.string()
.optional()
.describe(`Words to look for; returns up to ${DOCS_SEARCH_MAX_SECTIONS} SDK reference sections whose heading or body contain all of them.`),
};
const here = dirname(fileURLToPath(import.meta.url));
function readPackaged(name, sourcePath) {
// Published package: dist/tools/docs.js reads dist/docs/<name>. In the
// repository (tests, `tsx`), fall back to the source the build copies.
const packaged = join(here, "..", "docs", name);
const path = existsSync(packaged) ? packaged : join(here, "..", "..", ...sourcePath);
return readFileSync(path, "utf8");
}
let corpus = null;
function load() {
if (corpus)
return corpus;
const reference = readPackaged("llms-sdk.txt", ["sdk", "llms-sdk.txt"]).replace(/\n+$/, "").split("\n");
const primerRaw = readPackaged("run.md", ["docs-site", "src", "content", "docs", "mcp", "run.md"]);
const primer = primerRaw.replace(/^---\n[\s\S]*?\n---\n+/, "").replace(/\n+$/, "").split("\n");
corpus = { reference, primer, sections: parseSections(reference) };
return corpus;
}
/** Split the reference at `##` / `###` headings outside code fences. */
function parseSections(lines) {
const heads = [];
let fenced = false;
lines.forEach((line, index) => {
if (/^\s*(```|~~~)/.test(line))
fenced = !fenced;
if (fenced)
return;
const m = /^(##|###) (.+)$/.exec(line);
if (m)
heads.push({ index, level: m[1] === "##" ? 2 : 3, heading: m[2].trim() });
});
let inNamespaces = false;
return heads.map((h, i) => {
if (h.level === 2)
inNamespaces = /^Namespaces\b/.test(h.heading);
const nextSame = heads.slice(i + 1).find((n) => n.level <= h.level);
const nextAny = heads[i + 1];
const keys = topicKeys(h.heading);
const namedForR = /`r\./.test(h.heading);
return {
heading: h.heading,
level: h.level,
keys,
lines: lines.slice(h.index, nextSame ? nextSame.index : lines.length),
leaf: lines.slice(h.index, nextAny ? nextAny.index : lines.length),
namespace: (h.level === 3 && inNamespaces) || (h.level === 2 && namedForR),
};
});
}
/** `### \`r.project(id).apply\`` → ["project.apply", "r-project-id-apply"]; every `r.x` a heading names is a key. */
function topicKeys(heading) {
const keys = [];
for (const m of heading.matchAll(/`r\.([A-Za-z][\w]*(?:\([^)`]*\))?(?:\.[A-Za-z][\w]*(?:\([^)`]*\))?)*)`?/g)) {
keys.push(m[1].replace(/\([^)]*\)/g, ""));
}
const short = slug(heading.replace(/\s*\(.*$/, "").replace(/\s+—.*$/, ""));
if (short)
keys.push(short);
keys.push(slug(heading));
return [...new Set(keys)];
}
function slug(text) {
return text
.toLowerCase()
.replace(/`/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** One line per namespace: its topic key and the method names its signature block lists. */
function namespaceTable(sections) {
const rows = ["| topic | what `r` offers there |", "|---|---|"];
for (const s of sections.filter((x) => x.namespace)) {
const key = s.keys[0];
rows.push(`| \`${key}\` | ${summarize(s)} |`);
}
return rows;
}
const JS_KEYWORDS = new Set(["if", "for", "while", "switch", "return", "await", "const", "let", "function", "catch", "new", "typeof"]);
function summarize(s) {
const methods = [];
let fenced = false;
let sawFence = false;
for (const line of s.lines.slice(1)) {
if (/^\s*```/.test(line)) {
if (fenced)
break;
fenced = true;
sawFence = true;
continue;
}
if (!fenced)
continue;
const m = /^\s*(?:r\.[\w.]+\.)?([a-zA-Z]\w*)\s*[(<]/.exec(line);
if (m && !JS_KEYWORDS.has(m[1]) && !methods.includes(m[1]))
methods.push(m[1]);
}
if (methods.length > 0) {
const shown = methods.slice(0, 8).join(", ");
return methods.length > 8 ? `${shown}, … (${methods.length} methods)` : shown;
}
const prose = s.lines.slice(1).find((l) => l.trim() && !/^\s*(```|>|\||-|#)/.test(l));
if (prose && !sawFence)
return truncate(prose.trim(), 110);
return truncate(prose?.trim() ?? s.heading, 110);
}
function truncate(text, max) {
const flat = text.replace(/\|/g, "\\|");
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function indexLines(c) {
const topics = c.sections.filter((s) => s.level === 2).map((s) => `\`${s.keys[0]}\``);
return [
"# run402-mcp docs: index",
"",
"Eight tools: `up` and `deploy` (the first deploy), `status`, `whoami`, `doctor`, `docs` (this), `run`, and `expand_result`. Every other operation is a `run` snippet against `r`, the Node SDK client.",
"",
...c.primer,
"",
"## `r` namespaces",
"",
"Call `docs` with the topic for the full section. Node-only local state (`r.wallets` selection, `r.orgs` context, `r.doctor()`, `r.init()`, `r.status()`, `r.diagnostics.probeOrigin()`) is topic `local-state`.",
"",
...namespaceTable(c.sections),
"",
"## Other topics",
"",
`\`sdk\` (the whole reference), ${topics.join(", ")}.`,
"",
"`docs({ search: \"words\" })` finds sections by content.",
];
}
function findSection(c, topic) {
const wanted = topic.trim().replace(/^r\./, "").toLowerCase();
const ordered = [...c.sections.filter((s) => s.namespace), ...c.sections.filter((s) => !s.namespace)];
return (ordered.find((s) => s.keys.some((k) => k.toLowerCase() === wanted)) ??
ordered.find((s) => s.keys.some((k) => k.toLowerCase() === slug(wanted))) ??
null);
}
function searchLines(c, query) {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const hits = c.sections.filter((s) => {
const text = s.leaf.join("\n").toLowerCase();
return words.every((w) => text.includes(w));
});
const lines = [];
for (const s of hits.slice(0, DOCS_SEARCH_MAX_SECTIONS)) {
if (lines.length)
lines.push("");
lines.push(`(topic \`${s.keys[0]}\`)`, ...s.leaf);
}
return { lines, matched: hits.length };
}
function respond(kind, header, lines, error) {
const view = storeResult(kind, lines, { shown: DOCS_WINDOW_LINES });
const where = view.shown < view.total
? `lines 1–${view.shown} of ${view.total}; expand_result with ref ${view.ref} and offset ${view.shown} pages the rest.`
: `${view.total} lines, all shown (ref ${view.ref}).`;
const text = [`${header} — ${where}`, "", ...view.items].join("\n");
const page = { kind, ref: view.ref, shown: view.shown, total: view.total, lines: view.items };
if (error)
return { content: [{ type: "text", text }], structuredContent: { status: "error", ...page, error }, isError: true };
return { content: [{ type: "text", text }], structuredContent: { status: "ok", ...page } };
}
export async function handleDocs(args) {
const c = load();
if (args.search !== undefined && args.search.trim()) {
const { lines, matched } = searchLines(c, args.search);
if (matched === 0) {
return respond("docs", `docs search "${args.search}": no section matches`, ["No SDK reference section contains all of those words. Try fewer words, or `docs` with no arguments for the index."]);
}
const capped = matched > DOCS_SEARCH_MAX_SECTIONS ? ` (showing the first ${DOCS_SEARCH_MAX_SECTIONS} of ${matched} matching sections; add words to narrow)` : ` (${matched} matching section${matched === 1 ? "" : "s"})`;
return respond("docs", `docs search "${args.search}"${capped}`, lines);
}
const topic = (args.topic ?? "index").trim() || "index";
if (topic === "index")
return respond("docs", "docs: index", indexLines(c));
if (topic === "sdk")
return respond("docs", "docs: the whole SDK reference", c.reference);
if (topic === "run")
return respond("docs", "docs: run", c.primer);
const section = findSection(c, topic);
if (!section) {
const topics = c.sections.map((s) => s.keys[0]);
return respond("docs", `docs: unknown topic "${topic}"`, [`No topic "${topic}". Topics: index, sdk, run, ${topics.join(", ")}.`, "", "Or search: docs({ search: \"words\" })."], {
code: "DOCS_TOPIC_NOT_FOUND",
message: `No topic "${topic}".`,
next_actions: [{ type: "call_tool", tool: "docs", why: "Call docs with no arguments for the topic list, or pass search." }],
});
}
return respond("docs", `docs: ${section.keys[0]}`, section.lines);
}
//# sourceMappingURL=docs.js.map