Publish to npm, bin field, files allowlist, npx -y
Make your server installable via 'claude mcp add foo -s user -- npx -y your-package'. The four package.json fields that decide whether it works for users.
Publish to npm, bin field, files allowlist, npx -y
npx -y your-package is the lowest-friction install path for an MCP server. User runs claude mcp add foo -- npx -y your-package, npm fetches the latest version, runs your binary, done. For that to work, four package.json fields need to be exactly right. Get any of them wrong and users hit confusing errors.
Schritt 1: The four fields that matter
{
"name": "your-mcp-server",
"version": "1.0.0",
"description": "What your MCP server does in one sentence.",
"type": "module",
"main": "dist/server.js",
"bin": {
"your-mcp-server": "dist/server.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"keywords": [
"mcp",
"model-context-protocol",
"claude",
"claude-code",
"claude-desktop",
"cursor",
"your-domain"
]
}
Each field's job:
bin, npm symlinks your binary intonode_modules/.bin/.npx -y your-packagethen finds it. Without this,npxfails withcommand not found.files, allowlist of what gets included in the published tarball. Without this, npm includes everything not matched by.npmignore, your.git,node_modules,.env,tests/all get published. Allowlist > denylist.type: "module", tells Node to use ESM. Required for top-levelawaitand modern imports. Without this, yourimportstatements fail at runtime.engines.node, npm warns users on incompatible versions. MCP SDK needs Node 18+; some features want 20+.
Schritt 2: The shebang (don't forget)
Your dist/server.js must start with:
#!/usr/bin/env node
Without it, npx runs your file via the wrong shell and errors with unexpected token import. With it, npx knows to use Node.
If your build is tsc, the shebang must be in your source src/server.ts. TypeScript preserves it through compilation:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
// ...
After build, verify:
head -1 dist/server.js
# → #!/usr/bin/env node
Schritt 3: Make the binary executable
chmod +x dist/server.js
tsc doesn't preserve the executable bit. Add this to your build step:
"scripts": {
"build": "tsc && chmod +x dist/server.js"
}
npm publish includes the executable bit in the tarball, so users get it executable when they install.
Schritt 4: Pre-publish dry-run
npm pack --dry-run
Output shows exactly what will be published:
npm notice 📦 [email protected]
npm notice Tarball Contents
npm notice 1.2kB README.md
npm notice 543B LICENSE
npm notice 12kB dist/server.js
npm notice 4kB dist/lib/logger.js
npm notice ...
npm notice Tarball Details
npm notice name: your-mcp-server
npm notice version: 1.0.0
npm notice filename: your-mcp-server-1.0.0.tgz
npm notice package size: 18.3 kB
npm notice unpacked size: 92.1 kB
npm notice total files: 23
What to check:
- No
.env,.git,tests/,node_modulesin the file list. If they show up, yourfilesallowlist is wrong. dist/server.jsis included.- Total size < 1MB for a typical MCP server. If it's bigger, you probably accidentally included
node_modulesor build artifacts.
Schritt 5: Publish
First publish:
npm login
npm publish --access public # only needed for scoped packages (@your-org/foo)
For scoped packages, --access public is required on first publish (default is private).
Subsequent publishes:
npm version patch # 1.0.0 → 1.0.1
npm publish
npm version patch bumps the version, creates a git tag, and commits. Push the tag separately:
git push origin main --tags
Schritt 6: Confirm the install works
After publish, test the install path your users will use:
# Fresh terminal, no project context
npx -y your-mcp-server@latest --version
# → 1.0.0
# In Claude Code:
claude mcp add my-test -s user -- npx -y your-mcp-server
claude mcp list
# → my-test ✓ stdio npx -y your-mcp-server
Open Claude Code, send a message, the server should start and respond.
If it doesn't work in the fresh-install case, your real users won't be able to install it either.
Schritt 7: Verify
Run academy_validate_step. The validator checks npm view your-package version returns a version (i.e., the package exists on npm registry).
For the package quality, also run:
npm view your-mcp-server
# Confirms metadata: description, keywords, dependencies, repository link
If repository isn't set, npm shows an unhelpful default. Add to package.json:
"repository": {
"type": "git",
"url": "git+https://github.com/your-org/your-mcp-server.git"
}
Common traps
- Missing shebang,
npxerrors withunexpected token import. Add#!/usr/bin/env nodeat the top ofsrc/server.ts. - Missing
chmod +x,npxerrors withpermission denied. Addchmod +x dist/server.jsto build. binpath doesn't matchfilesallowlist,npxerrors withcannot find module dist/server.js. The path inbinmust point at a file included infiles.type: "module"missing, runtimeimporterrors. Always include for ESM projects.@your-org/your-serverpublished as private by accident,npm publishwithout--access publicdefaults to private for scoped packages. Use--access publicon first publish.- Forgetting to bump version,
npm publisherrors withcannot publish over existing version.npm version patchbumps + tags + commits in one step. - Including dev dependencies in
dependencies, bloats install size, slowsnpx -y. Move todevDependencies. - Including
dist/in git, pollutes diffs. Adddist/to.gitignoreand rely onnpm publishto include it viafiles.
What good looks like
npm publish from a clean checkout finishes in 30 seconds. npx -y your-mcp-server@latest --version from a fresh terminal prints the right version. claude mcp add my-test -s user -- npx -y your-mcp-server adds the server, claude mcp list shows it, sending a message in Claude triggers a tool call. Users can copy-paste your README install snippet without reading more than 2 lines.
That's the bar. Anything more friction than npx -y your-package and you'll lose half your potential installs to "I'll set this up later", which means never.
cat package.json 2>/dev/null | python3 -c "import json,sys; p=json.load(sys.stdin); print(\"name:\", p.get(\"name\")); print(\"version:\", p.get(\"version\")); print(\"private:\", p.get(\"private\"))" 2>/dev/null || echo "no package.json"