Skip to content

Commit bc5f2da

Browse files
ralyodioclaude
andcommitted
feat(mcp): known-server catalog, starting with Porkbun
`moshcode mcp add porkbun` now expands to the official npx invocation instead of requiring the package scope to be remembered. `moshcode mcp catalog` lists what is known. The catalog is a convenience and never a gate: expansion only happens when no target was given, so an explicit `-- node ./my-fork.js` always wins, and an unrecognised bare name still errors rather than guessing. Credentials are named, not registered. The entry records that Porkbun needs PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY and reports which are missing, but they are deliberately kept out of the registered spec -- an API key copied into five engines' config files is five places to leak it from and five to rotate. On provenance, since this one gets DNS-write credentials: Porkbun's own API documentation links it as the official MCP server, but the source lives on an individual's GitHub account rather than a Porkbun org. Its documentation tools work with no keys, which is a reasonable way to evaluate it first. Both facts are in the entry rather than left for someone to rediscover. Verified the server actually runs: an MCP initialize handshake returns serverInfo porkbun-mcp 0.12.0. parseMcp keeps its existing return shape -- `catalog` is attached only on a hit, because the shape is asserted with a strict deepEqual in tests/integrations-parse-mcp.test.mjs and an always-present null would break that contract for no reason. Full suite: 365 tests, 271 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0429c49 commit bc5f2da

4 files changed

Lines changed: 190 additions & 2 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ it reaches ttyd.
155155
when you do it.
156156
- The gateway's signing secret is per-process, so restarting it logs everyone out.
157157

158+
## Known MCP servers
159+
160+
Some MCP servers are worth remembering by name rather than by npx invocation:
161+
162+
```sh
163+
moshcode mcp catalog # what we know how to run
164+
moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server
165+
```
166+
167+
That registers it across every engine that supports MCP (claude, gemini, codex,
168+
opencode, privacycode) in one go.
169+
170+
The catalog is a convenience, never a gate — an explicit command always wins, so
171+
`moshcode mcp add porkbun -- node ./my-fork.js` runs your fork.
172+
173+
**Credentials are named, not registered.** `porkbun` needs `PORKBUN_API_KEY` and
174+
`PORKBUN_SECRET_API_KEY`; moshcode prints which are missing rather than copying
175+
them into five engines' config files, which would be five places to leak them
176+
from and five to rotate. Porkbun's API access is off by default and enabled
177+
per-domain — and its documentation tools work with no keys at all, which is a
178+
sensible way to try the server before trusting it with DNS writes.
179+
158180
## Upgrade everything
159181

160182
```sh

src/integrations.mjs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
SKILL_ENGINES, planSkillInstall, runSkillInstall, skillName,
1010
} from "./skills.mjs";
11+
import { catalogList, resolveCatalog } from "./mcp-catalog.mjs";
1112
import { acid, ash, bone, ok, err, info } from "./ui.mjs";
1213

1314
function splitKV(pair) {
@@ -32,7 +33,8 @@ function flagValue(rest, index, flag) {
3233
export function parseMcp(tokens) {
3334
const verb = tokens[0];
3435
if (!verb || verb === "list") return { list: true };
35-
if (verb !== "install" && verb !== "add") return { error: `unknown mcp verb "${verb}" — try install, add, or list` };
36+
if (verb === "catalog") return { showCatalog: true };
37+
if (verb !== "install" && verb !== "add") return { error: `unknown mcp verb "${verb}" — try install, add, catalog, or list` };
3638

3739
const rest = tokens.slice(1);
3840
let name, transport, cmdParts = null;
@@ -68,6 +70,19 @@ export function parseMcp(tokens) {
6870
if (cmdParts) { target = cmdParts[0]; args = cmdParts.slice(1); }
6971
else { target = positional[0]; args = positional.slice(1); }
7072

73+
// A bare known name is enough: `mcp add porkbun` fills the command in from
74+
// the catalog. Only when nothing else was given — an explicit target always
75+
// wins, so the catalog can never override what was actually typed.
76+
let catalog = null;
77+
if (!target) {
78+
catalog = resolveCatalog(name) || resolveCatalog(positional[0]);
79+
if (catalog) {
80+
name = name || catalog.key;
81+
target = catalog.target;
82+
args = catalog.args;
83+
}
84+
}
85+
7186
if (verb === "install" && !name) {
7287
if (target && isRemoteTarget(target)) name = deriveName(target);
7388
else return { error: "a stdio command server needs an explicit --name" };
@@ -83,12 +98,21 @@ export function parseMcp(tokens) {
8398
if (headers.some((header) => headerName(header) === "")) {
8499
return { error: "mcp --header requires a non-empty header name" };
85100
}
86-
return { spec: { name, target, args, transport, env, headers } };
101+
return {
102+
spec: { name, target, args, transport, env, headers },
103+
...(catalog ? { catalog } : {}),
104+
};
87105
}
88106

89107
const DOT = { installed: acid("●"), missing: ash("○") };
90108
function line(key, statusText) { return ` ${bone(key.padEnd(9))} ${statusText}`; }
91109

110+
/** Print the known-server catalog. */
111+
export function printMcpCatalog() {
112+
console.log(bone(" known mcp servers") + ash(" — register one with ") + acid("/mcp add <name>"));
113+
console.log(catalogList());
114+
}
115+
92116
/** Print the MCP support matrix + install status. */
93117
export function printMcpTargets() {
94118
console.log(bone(" mcp") + ash(" — register a server everywhere with ") + acid("/mcp install <url>"));
@@ -127,12 +151,21 @@ function summarize(results) {
127151
export async function mcpCommand(tokens) {
128152
const parsed = parseMcp(tokens);
129153
if (parsed.list) { printMcpTargets(); return; }
154+
if (parsed.showCatalog) { printMcpCatalog(); return; }
130155
if (parsed.error) { console.log(err(parsed.error)); return; }
131156

132157
const { spec } = parsed;
133158
console.log(info(`registering ${bone(spec.name)}${ash(spec.target)} across MCP engines…`));
134159
const results = await runMcpAdd(planMcpAdd(spec));
135160
summarize(results);
161+
// Credentials are named, never registered: an API key copied into five
162+
// engines' config files is five places to leak it from and five to rotate.
163+
const missing = (parsed.catalog?.env || []).filter((k) => !process.env[k]);
164+
if (missing.length) {
165+
console.log(ash(` note: ${spec.name} needs ${missing.join(" and ")} in the environment.`));
166+
if (parsed.catalog?.note) console.log(ash(` ${parsed.catalog.note}`));
167+
if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`));
168+
}
136169
if (spec.headers.length || /^https?:/i.test(spec.target)) {
137170
console.log(ash(" note: OAuth/HTTP servers may still need per-engine auth (e.g. `opencode mcp auth`, `codex mcp login`)."));
138171
}

src/mcp-catalog.mjs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Known MCP servers, so a name is enough: `moshcode mcp add porkbun` instead of
2+
// remembering an npx invocation and its package scope.
3+
//
4+
// This is a convenience layer, not a gate — `mcp add <name> -- <cmd> …` still
5+
// takes anything. An entry here only means "we know the canonical way to run
6+
// this one".
7+
//
8+
// `env` lists the variables the server needs to do real work. They are NOT
9+
// baked into the registration: an API key belongs in the environment (or a
10+
// secrets manager), not copied into five engines' config files. They are
11+
// printed as a reminder instead.
12+
13+
export const MCP_CATALOG = {
14+
porkbun: {
15+
target: "npx",
16+
args: ["-y", "@porkbunllc/mcp-server"],
17+
desc: "Porkbun — domains, DNS records, SSL, email forwarding",
18+
// Linked as the official MCP server from Porkbun's own API documentation,
19+
// though the source lives on an individual's account rather than a Porkbun
20+
// org — worth knowing before handing it DNS-write credentials.
21+
docs: "https://porkbun.com/api/json/v3/documentation",
22+
env: ["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY"],
23+
// The doc tools work with no credentials at all, so it is worth trying
24+
// before deciding whether to trust it with keys.
25+
note: "API access is off by default and must be enabled per-domain; the docs tools work without keys",
26+
},
27+
};
28+
29+
/** Resolve a catalog name to a spec fragment, or null. Own properties only. */
30+
export function resolveCatalog(token) {
31+
if (!token) return null;
32+
const key = String(token).trim().toLowerCase();
33+
// MCP_CATALOG is a plain object literal, so `constructor` and friends would
34+
// otherwise resolve to something off Object.prototype with no target.
35+
if (!Object.hasOwn(MCP_CATALOG, key)) return null;
36+
const entry = MCP_CATALOG[key];
37+
return { key, ...entry, args: [...(entry.args || [])] };
38+
}
39+
40+
/** Names, for help text and error messages. */
41+
export function catalogNames() {
42+
return Object.keys(MCP_CATALOG);
43+
}
44+
45+
/** One line per known server, for `mcp catalog`. */
46+
export function catalogList() {
47+
return Object.entries(MCP_CATALOG)
48+
.map(([key, e]) => ` ${key.padEnd(10)} ${e.desc}`)
49+
.join("\n");
50+
}

test/mcp-catalog.test.mjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// `mcp add porkbun` should be enough — but the catalog must never be able to
2+
// override a command the user actually typed, and it must never quietly bake an
3+
// API key into five engines' config files.
4+
import assert from "node:assert/strict";
5+
import test from "node:test";
6+
7+
import { MCP_CATALOG, catalogList, catalogNames, resolveCatalog } from "../src/mcp-catalog.mjs";
8+
import { parseMcp } from "../src/integrations.mjs";
9+
10+
test("porkbun resolves to the official npx invocation", () => {
11+
const e = resolveCatalog("porkbun");
12+
assert.equal(e.key, "porkbun");
13+
assert.equal(e.target, "npx");
14+
assert.deepEqual(e.args, ["-y", "@porkbunllc/mcp-server"]);
15+
assert.deepEqual(e.env, ["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY"]);
16+
});
17+
18+
test("catalog lookup is case-insensitive and ignores Object.prototype", () => {
19+
assert.equal(resolveCatalog("PORKBUN").key, "porkbun");
20+
assert.equal(resolveCatalog(" porkbun ").key, "porkbun");
21+
// A plain object literal: these are truthy but are not servers, and would
22+
// otherwise be handed downstream with no target.
23+
assert.equal(resolveCatalog("constructor"), null);
24+
assert.equal(resolveCatalog("__proto__"), null);
25+
assert.equal(resolveCatalog("toString"), null);
26+
assert.equal(resolveCatalog(""), null);
27+
assert.equal(resolveCatalog(undefined), null);
28+
});
29+
30+
test("resolveCatalog copies args so a caller cannot mutate the catalog", () => {
31+
const first = resolveCatalog("porkbun");
32+
first.args.push("--rogue");
33+
assert.deepEqual(resolveCatalog("porkbun").args, ["-y", "@porkbunllc/mcp-server"]);
34+
assert.deepEqual(MCP_CATALOG.porkbun.args, ["-y", "@porkbunllc/mcp-server"]);
35+
});
36+
37+
test("`mcp add porkbun` expands to the full spec", () => {
38+
const { spec, catalog, error } = parseMcp(["add", "porkbun"]);
39+
assert.equal(error, undefined);
40+
assert.equal(spec.name, "porkbun");
41+
assert.equal(spec.target, "npx");
42+
assert.deepEqual(spec.args, ["-y", "@porkbunllc/mcp-server"]);
43+
assert.equal(catalog.key, "porkbun");
44+
});
45+
46+
test("an explicit command always beats the catalog", () => {
47+
// Someone running a fork or a local build must get exactly what they typed.
48+
const { spec } = parseMcp(["add", "porkbun", "--", "node", "./my-fork.js"]);
49+
assert.equal(spec.name, "porkbun");
50+
assert.equal(spec.target, "node");
51+
assert.deepEqual(spec.args, ["./my-fork.js"]);
52+
});
53+
54+
test("an unknown bare name still errors rather than guessing", () => {
55+
const { error } = parseMcp(["add", "not-a-known-server"]);
56+
assert.match(error, /missing server URL or command/);
57+
});
58+
59+
test("catalog expansion does not disturb ordinary registrations", () => {
60+
const { spec, catalog } = parseMcp(["add", "sentry", "https://mcp.sentry.dev/mcp"]);
61+
assert.equal(spec.name, "sentry");
62+
assert.equal(spec.target, "https://mcp.sentry.dev/mcp");
63+
assert.equal(catalog, undefined, "a normal add is not a catalog hit");
64+
});
65+
66+
test("credentials are never written into the spec", () => {
67+
// The env the server *needs* is documented on the catalog entry; the spec
68+
// that gets registered with each engine must stay empty of it.
69+
const { spec, catalog } = parseMcp(["add", "porkbun"]);
70+
assert.deepEqual(spec.env, [], "an API key must not be baked into engine config");
71+
assert.ok(catalog.env.includes("PORKBUN_API_KEY"));
72+
});
73+
74+
test("`mcp catalog` is its own verb, and unknown verbs mention it", () => {
75+
assert.deepEqual(parseMcp(["catalog"]), { showCatalog: true });
76+
assert.match(parseMcp(["bogus"]).error, /install, add, catalog, or list/);
77+
});
78+
79+
test("the catalog listing names every entry", () => {
80+
const listing = catalogList();
81+
for (const name of catalogNames()) assert.match(listing, new RegExp(name));
82+
assert.match(listing, /Porkbun/);
83+
});

0 commit comments

Comments
 (0)