MCP Project Playbook
June 28, 2026 · View on GitHub
The reusable howto for shipping a high-quality, discoverable MCP server — server code, packaging, in-repo docs, web docs, communication, and growth. Distilled from building
notebooklm-mcpandrtfm. Apply it to every new MCP underroomi-fields.
This document is opinionated on purpose. Each rule exists because skipping it cost a release or a bug. Where a rule has a scar, the scar is named.
0. Day-1 checklist (do these in order)
Before writing a single feature, get the skeleton right:
- npm package published with
name,version,mcpName,bin,files -
server.jsoncreated + published to the Official MCP Registry viamcp-publisher -
.claude-plugin/plugin.jsonwith the version-pinnednpxarg (see §2.3) - Web docs site (Docusaurus on GitHub Pages) with SEO metadata + JSON-LD
-
README.mdkeyword-rich, with a one-line install block at the top -
CHANGELOG.mdstarted,Keep a Changelogformat - CI green: lint + typecheck + prettier + tests + version-sync gate
- Listed in the
roomi-fields/claude-pluginsmarketplace - Submitted to the manual-action directories (see §6)
Design the tool surface as a namespace.action dot-notation tree from the start (§1.8) — it's MCP best practice, registries score it, and renaming later forces a major version bump.
Channels that did not move the needle (measured via GitHub Traffic): LinkedIn, X/Twitter. Do not over-invest there for a developer tool. The engine is MCP directories + Google SEO.
1. MCP server — code quality & structure
1.1 Repo layout
Keep a predictable shape. notebooklm-mcp uses:
src/
index.ts # entrypoint + the tool dispatch switch
tools/index.ts # ToolHandlers class + buildToolDefinitions()
config.ts # all env/config in one place
errors.ts # typed errors, no bare throws
<domain folders>/ # accounts/, auth/, content/, session/, startup/, ...
utils/ # shared helpers
schemas/ # JSON Schemas you publish/version
docs/ # maintainer-facing docs (architecture, registries, ...)
deployment/docs/ # user-facing docs (numbered: 01-INSTALL.md, ...)
website/ # Docusaurus site
scripts/ # build/release/ops scripts
.github/workflows/ # ci.yml, release.yml, deploy-docs.yml
Keep the repo root clean. Temp scripts, debug PNGs, tmp-*.ps1, *.tar.gz build artifacts do not belong in version control — .gitignore them or delete them. A cluttered root is the first thing a potential user (or auto-indexer) sees.
1.2 The 3-place tool wiring rule
An MCP tool is only reachable if it exists in three places. Miss one → silent "Unknown tool":
- A definition in
buildToolDefinitions() - A method on the
ToolHandlersclass - A
casein the dispatchswitchinsrc/index.ts
After any tool change, audit alignment — e.g. comm the three sets of names. Structural alignment is necessary but not sufficient.
1.3 Audit before exposing a handler
Scar: in 1.7.4 two orphaned handlers (
create_notebook,delete_notebooks_from_nblm) were wired up as MCP tools without reading their bodies. Both were broken. Cost two follow-up releases.
Before exposing any previously-unused handler as a tool: read its implementation end to end — selectors, waitForURL, post-action verification — or run it live. Wiring it up is not the same as it working.
1.4 Integration discipline (scraping / external APIs)
For anything that scrapes a UI or depends on an external service you don't control:
- Never return a hardcoded placeholder as a fallback. If a scrape can't extract the real value, return
''/nulland let the caller see the failure. A lie likename: 'Notebook'is worse than an empty string — it hides the breakage.Scar:
list_notebooks_from_nblmreturned 21 notebooks all named"Notebook"for five releases because the selector broke silently behind a placeholder. - Prefer stable selector patterns. ID-pattern selectors (
[id^="project-"][id$="-title"]) survive UI rewrites; tag/aria-pattern selectors don't. - Detect errors from specific containers, never by scanning whole-page text — real content triggers false positives.
- Poll for a state change, don't sleep a fixed delay.
1.5 Honest errors, honest status
- Typed errors in
errors.ts, not barethrow new Error(string). - A tool that half-worked must say so. Don't report success on a timeout, don't swallow a failure into a generic message.
- If a verification step (URL check, count check) is the ground truth, trust it over cached/derived state.
1.6 Test before you ship — non-negotiable
Scar: shipped
add_sourcewith a false-negative timeout because the count-detection was gated behind a staleif (dialogVisible)branch. Caught only by attaching the MCP to a live session and actually running it.
A tool is "done" when it has been run, not when it compiles. For browser/integration tools that means attaching the MCP to a real client session and exercising it. "Tests should pass" is not a test result.
1.7 CI gates (the ci.yml baseline)
Every MCP repo runs, on every push + PR, across Node 18/20/22:
eslinttsc --noEmitprettier --check- tests with coverage
type-coverage --at-least 95
The release workflow adds a version-sync gate (§2.4). If any of these is red, nothing ships.
1.8 Tool contract: dot-notation names, annotations, output schema
Three things make the tool surface "evaluable at a glance" — by agents and by registry scorers:
- Names are a
namespace.actiondot-notation tree (notebook.ask,source.add,session.list), not a flatsnake_caselist. Keep namespaces balanced — avoid single-tool namespaces and avoid going past two levels. - Every tool has
annotations— accuratereadOnlyHint/destructiveHint/idempotentHint/openWorldHint/title. From a central table, not scattered. - Every tool has an
outputSchema— and the dispatch returnsstructuredContentmatching it, not just text. If all handlers share a result envelope ({success, data?, error?}), it's one shared schema.
Scar: these were retrofitted in v2.0.0 to lift a registry quality score from 60 → 98. Doing them on day 1 is free; retrofitting is a major version bump.
Renaming tools compatibly (legacy flat → dot-notation tree):
- One source of truth for the mapping — a tiny
tool-names.tswithLEGACY_TO_CANONICAL/CANONICAL_TO_LEGACYand no heavy imports, so both the server and the HTTP proxy can share it. tools/listadvertises only the canonical names. The dispatch layer normalises any accepted name (canonical or legacy) back to the internal name before routing — the switch and handlers don't change.- Legacy names keep working as aliases — existing scripts, IDE configs and batch jobs don't break. That backward-compatibility is what makes the major bump non-disruptive.
2. Packaging & distribution plumbing
2.1 npm package
package.json essentials:
name— scoped (@roomi-fields/<thing>)mcpName—io.github.roomi-fields/<thing>. Required for the Official MCP Registry and for auto-indexers to pick you up. Missing this = invisible.bin— the executable(s)files— explicit allowlist. Shipdist,README.md,LICENSE, docs. Do not shipscripts/archive/, test logs, dev junk.engines.node— declare the floor (>=18)- Build must
chmod 755thebinentrypoints — npm tarballs otherwise preserve644and the CLI fails with "Permission denied" on some installs.
2.2 Official MCP Registry
npm publish does not propagate here. After a notable release, publish manually:
./mcp-publisher login github # GitHub device flow
./mcp-publisher publish # reads server.json + package.json mcpName
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.roomi-fields/<thing>"
server.json holds the registry metadata; keep it in the repo root.
2.3 Claude Code plugin manifest — the npx pin gotcha
.claude-plugin/plugin.json declares the mcpServers block. The npx arg must carry the version pin:
"args": ["-y", "@roomi-fields/<thing>@1.7.9"]
Scar: without the
@<version>pin,/plugin marketplace updatedoes not upgrade the running server — npx reuses the_npx/<hash>/cache. Users sat on 1.7.2 for two releases thinking they'd updated.
The pin is load-bearing, not cosmetic. It must be bumped on every release (§2.4 automates this).
2.4 Version sync — one source, many mirrors
The version number is duplicated across several files and will drift if synced by hand:
package.json— source of truth.claude-plugin/plugin.json—versionand the npx pin inmcpServers.argswebsite/docusaurus.config.ts—softwareVersionin the JSON-LDREADME.md— hero / latest-release mentions
Rules:
- A
scripts/sync-version.mjspropagates frompackage.jsonto all mirrors. Run it after every bump. - CI runs it in
--checkmode as a release gate — drift fails the publish. - Use regex replace on the target field, never
JSON.parse+JSON.stringify. Prettier andJSON.stringifydisagree on array layout, producing phantom drift on every run.Scar: the parse+stringify approach caused a CI failure on 1.7.1, forcing a 1.7.2 just for the fix.
2.5 The marketplace aggregator
roomi-fields/claude-plugins is a pure aggregator — marketplace.json only, no plugin code. Each plugin is source: github pointing at its own repo, so the upstream plugin.json stays the single source of truth. New versions are picked up by /plugin marketplace update roomi-fields + /reload-plugins with no commit in the aggregator.
Note: there is no
/plugin update <name>command. The flow is marketplace update then reload-plugins. Don't document the wrong command.
description / keywords / homepage in marketplace.json are shown in the Discover tab before the plugin is cloned — keep them accurate, they don't have to match upstream byte-for-byte.
2.6 Glama (and other auto-builders)
Glama auto-discovers and rebuilds a Docker image using its own Dockerfile (Debian + Node 24 + pnpm), not yours.
- Their builder is flaky — ECONNRESET while pulling the base image happens. Just retry from the admin UI.
- pnpm/npm gap: an
overridesblock inpackage.jsonis npm-only. pnpm readspnpm.overrides. If you pin a transitive dep for a security fix, mirror it underpnpm.overridesor the auto-built image stays vulnerable.
2.7 Smithery (stdio servers — publish a bundle, by hand)
Smithery is a high-traffic registry, worth being on. For a stdio server the publish flow is fiddly and effectively undocumented — what actually works:
- Publishing is CLI-only.
smithery.ai/newonly accepts a hosted HTTPS URL or a GitHub repo. A stdio server ships as an MCPB bundle:npx @smithery/cli mcp publish <bundle>.mcpb -n <namespace>/<name>. The CLI needs Node ≥ 20 (globalThis.File). Get the namespace fromsmithery auth whoami— don't assume it matches your GitHub org. - The bundle manifest must declare a
user_configblock or publish fails with the opaque400 "No values to set". One optional field (e.g. a data dir mapped to an env var) is enough. - Smithery never runs a stdio server, so it never reads
tools/list— the registrytoolsfield staysnulland the whole 40-point "Capability" score is zero. You must ship the full tool definitions inside the bundle manifest:name,description,inputSchema,outputSchema,annotationsper tool.Scar: the MCPB manifest schema only allows
{name, description}intools(additionalProperties: false), sonpx @anthropic-ai/mcpb packstrips everything else — but Smithery's own parser requires the full objects for scoring. Resolution: hand-zip the bundle (zip -X b.mcpb manifest.json icon.png), don'tmcpb pack. - Server metadata is separate from the bundle. Description / homepage / icon do not come from the manifest —
PATCH /servers/{ns}%2F{name}onregistry.smithery.aiwith aBearertoken fromsmithery auth token --policy '{"namespaces":"<ns>"}'. - Don't trust the read endpoints to verify.
registry.smithery.aiGETs are heavily cached and lag minutes behind a successful write — they show emptydescription/toolslong after the data is live. Verify on the server's score page. - The score (100 total): Metadata 35, Config UX 25, Capability 40. Capability is only reachable by shipping real schemas — add
outputSchema+annotationsto the actual server code (§1.8), don't fake them in the manifest. The last ~2 points ("Naming" — a "navigable tree") run on an opaque heuristic; dot-notation captures most of it, the remainder is a guess — don't chase it with another rename.
This corrects the earlier "hosted installers can't run local tools → ~0/40, won't-fix" note: the hosted install button indeed stays dead for a local/stateful server, but the quality score is fully reachable (60 → 98 in practice) by shipping the schemas in the bundle yourself.
3. In-repo documentation
3.1 README anatomy
The README is the #1 viewed page (measured) and what crawlers index. Structure:
- One-line value prop + badges
- Install block in the first screen — the marketplace one-liner first, npm second
- Keyword-rich prose — write the verbs and nouns people actually type into Google (e.g. "rest api", "n8n", "batch", "citations"), not internal jargon
- Feature list, quick examples
- Latest releases (synced — §2.4)
- Links to the web docs
3.2 CHANGELOG discipline
Keep a Changelogformat,## [x.y.z] - DATEheadings.- The release workflow extracts the section between headings into the GitHub Release notes — so write it for humans.
- Every user-visible change gets a line. Security fixes get a line that names the advisory.
3.3 docs/ vs deployment/docs/
docs/— maintainer-facing: architecture, registry tracking, migration studies. Not shipped to users' eyes.deployment/docs/— user-facing, numbered (01-INSTALL.md…). These are the source for the web docs (§4.2).CONTRIBUTING.mdat root.
4. Web documentation (Docusaurus + GitHub Pages)
A real docs site — not just a README — is a measurable SEO driver (Google was the #3 referrer for notebooklm-mcp).
4.1 Setup & deploy
- Docusaurus under
website/. deploy-docs.ymlbuilds on push tomainand deploys to thegh-pagesbranch.onBrokenLinks: 'warn'(notthrow) so a stray link doesn't block a deploy — but check the warnings.
4.2 Doc sync
website/scripts/sync-docs.mjs copies deployment/docs/* into website/docs/*. Single source of truth lives in deployment/docs/; the website is a mirror. Don't hand-edit website/docs/.
4.3 SEO — the part that actually compounds
docusaurus.config.ts→headTagswith a JSON-LDSoftwareApplicationblock (name, description, OS, downloadUrl,softwareVersion, license, author, freeoffers).themeConfig.metadata→keywords+description+robots: index, follow+ Open Graph + Twitter card.sitemappreset enabled.- Keywords = real search terms across the ecosystem (clients, integrations, use cases), not feature names.
4.4 MDX gotcha
Scar: MDX 3 (Docusaurus 3) parses
<https://...>autolinks as JSX and fails the build. URLs ending in/>happen to survive; anything else breaks.
In any markdown that gets synced into the site, use [text](url) — never bare <URL> autolinks.
4.5 Published schemas
If the project defines a data contract (e.g. nblm-answer-v1.json), it lives in multiple synced places (repo schemas/, canonical host, embedded in a doc, referenced in code). List them explicitly somewhere and treat breaking changes as a v2 — never mutate v1 in place.
5. Communication & positioning
5.1 Positioning beats promotion
notebooklm-mcp grew faster than a higher-starred competitor not from louder marketing but from a wider positioning: "REST API + MCP" addresses automation users (n8n / Zapier / Make / scripts), not just one IDE's plugin users. Decide the broadest honest framing of what the tool is, and write everything — README, docs, keywords — to that framing.
5.2 What channels work (evidence-based)
Measured from GitHub Traffic over a 14-day window:
| Channel | Verdict |
|---|---|
| MCP directories (mcpservers.org, cursor.directory, …) | Primary driver. Auto-indexed once you're discoverable. |
| Google organic | Compounds. Fed by the docs site + keywords. |
| Official MCP Registry | Real, modest. |
| ChatGPT / LLM recommendations | Small but high-intent. |
| LinkedIn / X | Negligible (~2 visits each). Don't over-invest. |
The takeaway: spend effort on discoverability (registries, SEO, directory submissions), not on social posts.
6. Growth & visibility
6.1 Directory submissions
Most directories auto-index once you're on npm/PyPI + the MCP Registry. Some claim to take manual submissions — but verify the mechanism actually works before spending any effort on it.
Scar: the March distribution plan for
rtfmlisted five "high-value manual" channels. A 2026-05-14 live audit found exactly one was actually actionable. mcp.so is a comment in a megathread issue, no triage, no SLA. Cline Marketplace had 500+ untriaged[Server Submission]issues.wong2/awesome-mcp-serversno longer takes PRs (redirects tomcpservers.org/submit).appcypher/awesome-mcp-servershas the PR feature disabled (gh api repos/.../pulls→ 404) despite its CONTRIBUTING.md still saying "make an individual pull request". Onlypunkpeye/awesome-mcp-serverswas a real PR channel.
Tiers, honestly:
- Auto-indexed — this is the engine. Glama, mcpservers.org, PulseMCP, LobeHub. Fed by your GitHub repo + the MCP Registry; they re-scrape on activity. This is where listings actually come from — verify you appear, keep the source metadata fresh.
- Manual, occasionally real.
punkpeye/awesome-mcp-servers(genuine PR review). Cursor Directory (no PR path — Supabase-backed, interactive GitHub/Google sign-in atcursor.directory/plugins/new, auto-detects the repo's.mcp.json). Before drafting an entry, confirm the channel even accepts submissions:gh api repos/<owner>/<repo>/pullsmust not 404, and check actual PR/issue throughput — not the CONTRIBUTING.md, which lies. - Dead — don't re-chase. Issue-megathread channels (mcp.so), untriaged-backlog marketplaces (Cline), repos with PRs disabled (appcypher).
The single highest-leverage action is keeping the GitHub repo description + topics accurate — every auto-indexer recopies it verbatim. A stale description (rtfm shipped "10 parsers" when it was 15) poisons every downstream directory at once; one gh repo edit fixes them all on the next re-scrape.
Track the verified state per repo in docs/DISTRIBUTION.md (or MCP_DIRECTORIES.md) — status, not aspiration. Re-audit before acting; March's plan was 80% stale by May.
Anthropic plugin directory (Claude Code) — separate from the MCP registries
This is a different channel from everything above: it lists Claude Code plugins, not MCP servers. Two repos, very different rules:
claude-plugins-official— curated by Anthropic, no application process, inclusion at their discretion. Can't be chased.claude-plugins-community(installs as@claude-community) — open submissions. This is the real target.
How it actually works (verified 2026-06-28):
- Submit via in-app form, never a PR. PRs against the repo are auto-closed; the catalog syncs nightly from Anthropic's internal pipeline. Individual authors:
platform.claude.com/plugins/submit(Console). Team/Enterprise orgs with directory access:claude.ai/admin-settings/directory/submissions/plugins/new. - One submission = one plugin repo (each needs
.claude-plugin/plugin.json). My 5 plugins live in separate repos, so each is submitted individually. Theroomi-fieldsmarketplace.json stays an independent parallel channel — it does not migrate. - Gate =
claude plugin validate+ automated safety screening. The pipeline runs the samevalidateyou can run locally. Runclaude plugin validate <repo> --stricton every plugin before submitting. - Approved plugins are pinned to a commit SHA; CI bumps the pin as you push. Delay between approval and appearing in
marketplace.json(nightly sync). Check the community catalog for your name.
Scar (2026-06-28): strict-validate caught real blockers before submission.
paper-trailfailed plain validate — two commanddescription:values had unescaped:(colon-space) → broken YAML frontmatter, commands loading with empty metadata. Fix: quote the value.rtfmtripped a strict warning for a dev-contextCLAUDE.mdat the plugin root (dead in plugin context); moved it to.claude/CLAUDE.md(still auto-loaded as project memory, validator stops flagging it). Lesson:--stricton day 1 is free; a rejected submission is not.
Safety screening is a product decision, not a bug.
paper-trailname-dropped Sci-Hub / Anna's Archive in its public pitch (functionality is opt-in, default-off). Softened the public-facing surfaces only (plugin.json description, README tagline, MARKETPLACE_ENTRY) to neutral "optional extended sources, disabled by default" — kept the code, the opt-in env var, and full DISCLAIMER/LEGAL disclosure intact. Softening the description ≠ evading the scan (it reads the code too); the goal is accurate, non-promotional framing, not concealment.
Hosted-install directories (Glama / Smithery) and local tools
Glama's "Install Server" button and Smithery's hosted runner both want to build and run your server in their infra. That only works for stateless servers. A tool that needs local files (rtfm indexes the user's project) or interactive auth (notebooklm-mcp needs a browser + Google login) cannot do anything useful in a hosted container — the hosted install button stays dead. This part is won't-fix, not a bug: never build a Dockerfile or chase a hosted "release" to satisfy it for a local tool.
But the listing and its quality score are a different thing, and they are worth it — they carry real SEO weight. For Smithery specifically, a stdio server still gets a full, high-scoring page if you publish the bundle correctly and ship the tool schemas inside it yourself (§2.7) — notebooklm-mcp went 60 → 98 that way. Presence + a good score badge ≠ a working hosted install; pursue the former, skip the latter.
6.2 Monitoring cadence
- Weekly: GitHub Insights → Traffic (referrers = ground truth for what's working). Note: the API needs push access — you only see your own repos.
- Weekly: npm downloads (npm-stat.com) — the only objective usage metric, comparable across projects.
- Monthly: star history trend.
- Don't read clone counts as adoption — CI runners inflate them.
7. Release runbook
1. Edit package.json version
2. npm run version:sync # propagate to all mirrors
3. Update CHANGELOG.md # new ## [x.y.z] section
4. Update README latest-release # (version:sync may cover this)
5. npm run build && test locally
6. git commit + push
7. git tag vX.Y.Z && git push --tags
│
└─► release.yml: version:check → build → npm publish → GitHub Release
8. (notable releases) ./mcp-publisher publish # Official MCP Registry
9. Verify: npx -y @roomi-fields/<thing>@X.Y.Z from a clean cwd boots the new version
10. If a security fix touched transitive deps: confirm pnpm.overrides mirror exists (§2.6)
8. Anti-patterns — the short list
Every one of these shipped a bug or a bad release at least once:
- ❌ Wiring a tool in only 2 of the 3 required places → silent "Unknown tool"
- ❌ Exposing a handler without reading/running it first
- ❌ Hardcoded placeholder as a scrape fallback → silent data corruption
- ❌ Tag/aria selectors instead of ID-pattern selectors → breaks on every UI rewrite
- ❌ Reporting success on a timeout / swallowing failures
- ❌ "Tests should pass" without running them
- ❌
npxarg without the@<version>pin → updates silently don't update - ❌
JSON.parse+JSON.stringifyto rewrite a prettier-controlled file → phantom CI drift - ❌
npmoverrideswithout apnpm.overridesmirror → auto-built images stay vulnerable - ❌ Bare
<URL>autolinks in synced markdown → MDX build failure - ❌ Mutating a published schema in place instead of bumping to v2
- ❌ Dev junk (tmp scripts, debug PNGs, build tarballs) committed to the repo root
- ❌ Documenting
/plugin update— it doesn't exist - ❌ Over-investing in social posts for a developer tool
- ❌ Drafting a directory submission before verifying the channel accepts one —
gh api repos/X/pulls→ 404 means PRs are off, whatever CONTRIBUTING.md claims - ❌
gh pr create/OWNER:BRANCHshorthand when the fork name ≠ the parent name → resolves to the wrong fork (or another fork in your account), fails with a misleading permission error. Use the GraphQLcreatePullRequestmutation with explicitheadRepositoryId/repositoryId - ❌ Letting the MCP Registry drift — it never auto-syncs from npm/PyPI;
rtfmsat 5 versions behind for ~2 months. Re-publish on every notable release (§2.2) - ❌ Building a Dockerfile or chasing a Glama/Smithery hosted-install "release" for a local or stateful server — it structurally can't work (§6.1). (The Smithery score, though, is reachable — §2.7.)
- ❌ Faking
outputSchema/annotationsin a distribution manifest instead of adding them to the server code — the manifest then lies about what the server exposes (§1.8) - ❌
mcpb packfor a Smithery bundle that needs full per-tool schemas — it validates against the MCPB schema and strips everything past{name, description}. Hand-zip instead (§2.7) - ❌ Trusting
registry.smithery.aiGET endpoints to verify a publish — heavily cached, minutes-stale. Check the score page (§2.7) - ❌ Chasing the opaque tail of a quality score (e.g. Smithery "Naming") with another tool rename right after a major release — churn for ~2 points on a heuristic you can't see. Stop at "excellent"
- ❌ Treating a green
npm auditas permanent — the advisory DB updates; a clean audit goes red with zero code change. Re-pin transitive deps with targetedoverrides(bit us between 1.7.9 and 2.0.0:fast-uri,hono) - ❌ Assuming a GitHub token can open PRs anywhere — an enterprise/EMU token can comment on issues but is blocked from
CreatePullRequeston external repos. There's no code workaround; open the PR from a personal account
Maintained alongside the roomi-fields/claude-plugins marketplace. When a new scar is earned, add it here.