Guide · Release Engineering
Publishing an MCP server to npm
Most MCP servers are deployed as running services, but many are distributed as npm packages — installed by users who run them locally via npx, configured in Claude Desktop or Cursor, and updated with npm update. The package structure for an MCP server differs from a library: you need a bin entry for the CLI entry point, the exports field to expose the programmatic API for testing, and a versioning strategy tied to your tool schema changes rather than your internal API surface.
TL;DR
Set "type": "module", define exports with a . key for the programmatic API and a ./server key for the CLI entrypoint, and add a bin field pointing to your compiled CLI script. Version semantically based on tool schema changes: patch for bug fixes that don't change tool inputs/outputs, minor for new tools (additive), major for removing a tool or changing a tool's inputSchema in a breaking way. Automate npm publish via GitHub Actions on every tagged release, using --provenance for npm attestation.
Why publish to npm vs just deploying a server?
Deployed MCP servers run at a URL and require the client to make network requests. npm-published MCP servers run as local processes via stdio transport — users install and run them locally, which means:
- No authentication surface — the server runs in the user's process, so API keys come from environment variables rather than HTTP headers
- No network latency — tool calls go through a local pipe, not an HTTP round trip
- No hosting cost — you distribute the server, users run it
- Versioning via npm — users get updates with
npm updateand pin versions in package.json
The trade-off: users must have Node.js installed, and you can't push server-side fixes instantly. This model suits developer tools (filesystem access, code analysis, database explorers) rather than services that need central data.
package.json structure
{
"name": "@yourscope/mcp-server-documents",
"version": "1.2.0",
"description": "MCP server for document search and retrieval",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./server": {
"import": "./dist/cli.js",
"types": "./dist/cli.d.ts"
}
},
"bin": {
"mcp-server-documents": "./dist/cli.js"
},
"files": [
"dist/",
"README.md"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build && npm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"publishConfig": {
"access": "public"
}
}
Key fields explained:
"type": "module"— ESM output so.jsfiles are treated as ES modules. Required for the MCP SDK which is ESM-only.exports— The.key exports the programmatic server factory (used in integration tests). The./serverkey exports the CLI entrypoint.bin— Makes the package executable as a command. Users can runnpx @yourscope/mcp-server-documentsor configure it in Claude Desktop ascommand: "npx", args: ["-y", "@yourscope/mcp-server-documents"].files— Only include the compileddist/output and README, notsrc/, tests, or configuration files. Keeps the package small.
CLI entry point (cli.ts)
The CLI entry point must start with a shebang, accept environment variables for configuration, and connect to stdio transport. It should not import anything that writes to stdout before the transport is connected — stdout is the MCP protocol channel, and anything written to it before the server connects corrupts the JSON-RPC stream.
#!/usr/bin/env node
// cli.ts — the bin entry point
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer } from './index.js'; // Programmatic factory
// Environment variable configuration — no stdout writes before this line
const config = {
apiKey: process.env.DOCUMENTS_API_KEY ?? '',
baseUrl: process.env.DOCUMENTS_BASE_URL ?? 'https://api.example.com',
debug: process.env.MCP_DEBUG === 'true',
};
if (!config.apiKey) {
// Write to stderr, NOT stdout — stdout is the MCP protocol channel
process.stderr.write('Error: DOCUMENTS_API_KEY environment variable is required\n');
process.exit(1);
}
const server = createServer(config);
const transport = new StdioServerTransport();
await server.connect(transport);
// Server is now running — block until the client disconnects
The separation between index.ts (programmatic factory, exported via exports["."]\) and cli.ts (bin entry, reads env vars, connects transport) is important. Tests import from index.ts directly to create the server without starting a transport. The CLI is the runtime shell around it.
Versioning rules for MCP servers
Standard semver applies, but the "API surface" for an MCP server is its tool schema, not its programmatic TypeScript API. Users (and their AI clients) depend on tool names, inputSchema shapes, and output formats. Version against those:
| Change | Semver bump | Example |
|---|---|---|
| Bug fix in tool handler — same inputs and outputs | patch (1.2.0 → 1.2.1) | Fix get_document returning null when record exists |
| New tool added (no existing tool changed) | minor (1.2.0 → 1.3.0) | Add delete_document tool |
| New optional parameter added to existing tool | minor (1.2.0 → 1.3.0) | Add optional format parameter to get_document |
| Tool renamed | major (1.2.0 → 2.0.0) | Rename get_document to fetch_document |
| Tool removed | major (1.2.0 → 2.0.0) | Remove deprecated search_documents tool |
| Required parameter added to existing tool | major (1.2.0 → 2.0.0) | Add required tenant_id to get_document |
| Required parameter removed from existing tool | major (1.2.0 → 2.0.0) | Remove api_version from all tools |
| Output schema changed in breaking way | major (1.2.0 → 2.0.0) | get_document now returns JSON instead of plain text |
Use snapshot tests to catch unintentional schema changes — a snapshot of client.listTools() output will fail if any tool name, description, or inputSchema changes between builds, prompting you to explicitly bump the version.
Automated publish via GitHub Actions
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for npm provenance attestation
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Publish to npm with provenance
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The --provenance flag generates an attestation linking the npm package to the specific GitHub Actions run and commit that produced it. npm displays this attestation on the package page as "Provenance verified", giving users confidence the package was built from the published source.
Create a tag to trigger the publish:
# Bump version in package.json, commit, then tag
npm version minor -m "Add delete_document tool (v%s)"
git push && git push --tags
Prerelease tags for beta testing
Before a major version release with breaking tool schema changes, publish a prerelease under the beta tag so early adopters can test without affecting users on the stable channel:
# Publish v2.0.0-beta.1 — only installed by users who explicitly request it
npm version 2.0.0-beta.1
npm publish --tag beta --provenance
# Users opt into the beta explicitly:
# npm install @yourscope/mcp-server-documents@beta
# After validation period, promote to latest:
npm dist-tag add @yourscope/mcp-server-documents@2.0.0-beta.1 latest
Beta installs don't appear in npm update for users on the stable channel. This gives you a validation window where real users can test breaking schema changes before you promote to stable.
Dry-run verification before publish
Always run npm pack --dry-run locally before pushing a tag. It shows exactly which files would be included in the published package and the compressed size:
npm pack --dry-run
# Output:
# npm notice === Tarball Contents ===
# npm notice 1.2kB dist/cli.js
# npm notice 8.3kB dist/index.js
# npm notice 4.1kB dist/index.d.ts
# npm notice 1.8kB README.md
# npm notice === Tarball Details ===
# npm notice name: @yourscope/mcp-server-documents
# npm notice version: 1.3.0
# npm notice package size: 4.2 kB
# npm notice unpacked size: 15.4 kB
Verify that src/, *.test.ts, and .env files are NOT listed. If they appear, update your .npmignore or files field in package.json. Shipping source or test files increases package size and potentially exposes internal structure.
Claude Desktop configuration for published packages
Users who install your published MCP server configure it in Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"documents": {
"command": "npx",
"args": ["-y", "@yourscope/mcp-server-documents"],
"env": {
"DOCUMENTS_API_KEY": "sk-...",
"DOCUMENTS_BASE_URL": "https://api.example.com"
}
}
}
}
The -y flag tells npx to install the latest version without prompting. Users can pin a version with "@yourscope/mcp-server-documents@1.3.0" to avoid automatic major updates.
Note: For stdio-transport packages like this, AliveMCP cannot monitor the server directly (stdio transport is local, not network-accessible). AliveMCP applies when users connect your package to an HTTP endpoint. If your package also exposes an HTTP mode (SSE or Streamable HTTP), document that and instruct users to add an AliveMCP monitor for the HTTP endpoint.
Related pages
FAQ
Should I use CommonJS or ESM output for a published MCP server?
Use ESM ("type": "module" in package.json). The @modelcontextprotocol/sdk package is ESM-only and cannot be imported in CommonJS output without dynamic imports. If you need to support both, use a dual-build setup with separate dist-cjs/ and dist-esm/ directories and the exports field with "require" and "import" conditions, but this adds complexity for little benefit since Node.js 20+ and all current AI clients support ESM natively.
How do I handle breaking changes when users have the package pinned?
Publish the breaking change as a new major version. Users who have pinned to v1.x will not receive the update automatically. Document the migration in CHANGELOG.md and the README — specifically which tool names or input parameters changed. For significant breaking changes, consider supporting both old and new behavior during a deprecation window (e.g., accept both document_id and the deprecated doc_id parameter for one minor version before removing the old parameter in a major version).
What should go in the files field vs .npmignore?
Prefer files in package.json over .npmignore. The files field is an explicit allowlist — only the listed paths are included. .npmignore is a blocklist, which means forgetting to exclude something ships it. Use files: ["dist/", "README.md"] and verify with npm pack --dry-run. The .npmignore pattern is error-prone because it's easy to add a new directory to the repo and forget to exclude it from the package.
Can I publish to a private npm registry instead of the public registry?
Yes. Set "publishConfig": { "registry": "https://npm.pkg.github.com" } for GitHub Packages, or your organization's Verdaccio/Artifactory URL for private registries. The publish workflow is the same — set the registry-url in the actions/setup-node step and provide the appropriate auth token via NODE_AUTH_TOKEN. Note that --provenance requires the public npmjs.com registry.
How do I add an AliveMCP monitor when my server uses stdio transport?
Stdio-transport MCP servers run locally and are not network-accessible, so AliveMCP cannot probe them directly. If you want external monitoring, add an optional HTTP mode to your server: accept a --transport=http flag that starts the server on a port instead of stdio. Users who deploy the server behind a network (self-hosted or as a service) can then add an AliveMCP monitor for that HTTP endpoint. For purely local stdio servers, integration tests are the appropriate quality gate.