I had three MCP servers sitting in private repos — one for App Store Connect, one for Google Play Console, one wrapping Recraft's image API. All working, all tested, none of them usable by anyone but me. So I spent an afternoon getting them onto npm and the official MCP Registry.
I expected the hard part to be the code. It wasn't. The code was already done. The hard part was everything downstream of npm publish — and none of it is documented anywhere in one place, so here's the writeup I wish I'd had.
npm's 2FA wall has a hole in it, and it's not the one you think
First npm publish attempt:
npm error code E403
npm error 403 Forbidden - Two-factor authentication or granular access token with bypass 2FA enabled is required to publish packages.
Fine, I have 2FA. Except my account only has a security key registered — no TOTP app, because npm quietly dropped authenticator-app 2FA from the UI at some point and I hadn't noticed. npm publish wants a typed six-digit code (--otp=<code>). A WebAuthn security key doesn't produce one. There's no button for "just tap your key" in the CLI flow. So the account has 2FA, and there is no way to satisfy it from a terminal.
The npm docs don't spell this out. What actually works: a Granular Access Token with "Bypass two-factor authentication (2FA)" checked at creation. It's unchecked by default — I generated one, published, still got EOTP, and only realized on the second token that I'd never ticked the box. Bypass tokens also need write access scoped to the specific package(s) you're publishing; scope one to package A and try to publish package B with it, and you get a plain 403 that looks identical to the 2FA error, which cost me another ten minutes of confused debugging.
One more thing worth knowing if you're doing this after early 2027: npm's own dashboard now carries a banner warning that bypass-2FA tokens for direct publishing are being phased out. The documented replacement is trusted publishing (OIDC from CI), not a personal token. If you're setting this up for the first time today, you might as well build it against CI from the start.
The MCP Registry is not a GitHub pull request
I assumed listing a server meant opening a PR against modelcontextprotocol/servers, the way "awesome-list"-style ecosystems usually work. Wrong. That repo now hosts only the small set of reference servers the steering group maintains — the README says so directly, and the merged-PR history confirms it: nothing but doc fixes and CI tweaks get in from outsiders.
The actual registry is a separate service at registry.modelcontextprotocol.io, with its own CLI (mcp-publisher). The flow, once you know it:
- Add an
mcpNamefield topackage.json—io.github.<user>/<name>. This is the ownership proof the registry checks against the published npm package. If the currently live npm version doesn't have this field, validation fails, even if your local source does. You have to publish a version that includes it first. -
mcp-publisher initgenerates aserver.json— fill in the real env vars, and make surepackages[0].identifiermatches your actual npm package name (more on why that matters below). -
mcp-publisher login github— device code flow, same UX asgh auth login. -
mcp-publisher publish.
The one operational quirk: the login session expires faster than I expected — I hit Invalid or expired Registry JWT token twice in one afternoon, both times after getting pulled into an unrelated npm rabbit hole for 20+ minutes in between. Re-running login fixes it instantly; nothing else needs to be redone.
I didn't own the npm name I'd been building against
This one stung a little. My App Store Connect server's README had an npm install badge pointing at asc-mcp-server — unscoped — for months. Turns out that name belongs to someone else's npm account entirely, unrelated to my project. I only found out because the registry publish step kept 403ing with "you do not have permission," which sent me to check npm owner ls asc-mcp-server. One name, not mine.
In hindsight there was already a tell I'd walked past: the previously published version under that name had a completely different internal file layout than my actual source tree. I'd assumed it was just an old build artifact of my own. It wasn't — someone else's package, coincidentally similar in purpose, sitting on the exact name I was linking to in my own docs.
There's no real recourse short of npm's name-dispute process, which is slow and not guaranteed. The fix that actually ships today: move to a scoped package, @you/name. It's always available under your own username, npm publish just needs --access public since scoped packages default to private. Cost: about ten minutes to update package.json, server.json, and the README badge. If you're about to publish something for the first time, checking npm view <name> before you write a single line of your README costs nothing and saves this entire detour.
The security bug that only showed up because I tried to make the server more discoverable
This is the one I'm most glad happened before launch instead of after.
My App Store Connect server supports two transports: stdio (the default — runs locally, one set of credentials per machine) and an HTTP mode with a full OAuth 2.1 layer, meant for connecting to it remotely. I wanted to list it on Smithery, which currently only accepts servers reachable over a public HTTPS URL — so the HTTP mode was the obvious path.
Then I actually read the OAuth provider code before flipping it on publicly. The comment at the top of the file said exactly what it does, plainly: /authorize auto-approves any client that registers itself, no login screen, no consent step — because the whole thing was designed around "the operator already trusts the network, since it's their own VPS." Dynamic Client Registration means literally anyone who finds the URL can self-register, complete the OAuth flow with zero friction, and get a valid bearer token. From there, they'd have full API access — using my App Store Connect credentials configured on that server, not their own. Delete a version, submit something for review, reply to a real customer's App Store review, as me.
None of that is a bug in the traditional sense. It's a deliberate, reasonable trust model for "I'm the only one who will ever know this URL exists." It just becomes a real vulnerability the second you do the thing I was about to do: put the URL on a public directory with "10,000+ users" written on the landing page.
I skipped the Smithery listing for that server. The lesson generalizes past MCP: any tool you build assuming private-URL-as-security stops being safe the moment "let's make this more discoverable" enters the conversation. Worth a second look at why an auth flow is shaped the way it is before you widen its blast radius, not just whether it currently works.
Where things ended up
All three are live now:
-
gpc-mcp-server— Google Play Console -
@muhammetali/asc-mcp-server— App Store Connect (scoped, for the reason above) -
recraft-mcp-server— Recraft AI image generation
All three are also registered on the official MCP Registry and listed on Glama.
If you're about to publish an MCP server for the first time, the short version: check the npm name is actually yours before you build docs around it, get a 2FA-bypass token with the box checked before you're mid-npm publish, use mcp-publisher instead of looking for a PR template, and read your own auth code once more before you make anything easier to find.
Top comments (0)