Introduction
Building an MCP server is half the battle. Getting it into users' hands requires proper packaging, distribution, and configuration standards. This lesson covers npm package structure for MCP servers, Docker image distribution for containerized deployments, binary distribution for environments without Node.js, and the testing workflow you should follow before publishing.
Key Concepts
- npm Package Structure: MCP servers follow a specific package structure: compiled JavaScript in
dist/, abinfield inpackage.json, a shebang line in the entry point, andfilesrestricting what gets published. - Docker Distribution: For teams that deploy MCP servers as services (especially with HTTP transport), Docker images provide reproducible, isolated environments. The Dockerfile should use multi-stage builds to keep images small.
- Binary Distribution: Tools like
pkgornexecompile Node.js applications into standalone binaries. This eliminates the Node.js runtime dependency, making distribution simpler for non-Node.js teams. - Configuration File Standards: MCP servers should support configuration through environment variables (primary), configuration files (optional), and command-line arguments (optional). Environment variables are the standard because they integrate with all MCP host configuration formats.
- Pre-Publish Testing: Before publishing, test your server locally with the MCP Inspector, verify the npm package contents with
npm pack --dry-run, and test thenpxinstallation flow from scratch.
Real World Context
Your team builds an MCP server for querying the company's data warehouse. The data engineering team uses Docker. The frontend team uses Claude Desktop with npx. The DevOps team wants a standalone binary for CI/CD pipelines. By supporting all three distribution methods, you serve all teams without asking anyone to change their toolchain.
Deep Dive
npm Package Structure
A well-structured MCP server npm package looks like this:
textmy-mcp-server/ ├── src/ │ ├── index.ts # Entry point with shebang │ ├── tools/ # Tool implementations │ └── utils/ # Shared utilities ├── dist/ # Compiled output (in .gitignore) │ └── index.js # Entry point with shebang preserved ├── package.json # With bin, files, keywords ├── tsconfig.json # TypeScript configuration └── README.md # Installation and configuration docs
The key fields in package.json:
json{ "name": "mcp-server-datawarehouse", "version": "2.0.0", "description": "MCP server for data warehouse queries", "type": "module", "bin": { "mcp-server-datawarehouse": "./dist/index.js" }, "files": ["dist"], "keywords": ["mcp", "mcp-server", "data-warehouse"], "scripts": { "build": "tsc", "prepublishOnly": "npm run build" }, "engines": { "node": ">=18.0.0" } }
The prepublishOnly script ensures the project is compiled before publishing. The files field ensures only dist/ is included in the published package.
Docker Image Distribution
For HTTP-transported MCP servers deployed as services, use a multi-stage Dockerfile:
dockerfile# Build stage FROM node:20-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile COPY tsconfig.json ./ COPY src/ ./src/ RUN pnpm run build # Production stage FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package.json ./ EXPOSE 3001 CMD ["node", "dist/index.js"]
The multi-stage build keeps the final image small by excluding TypeScript source, dev dependencies, and build tools. Tag and push to your container registry for deployment.
Binary Distribution
For environments without Node.js, compile to a standalone binary:
bash# Using pkg to create standalone binaries npx pkg dist/index.js \ --targets node20-linux-x64,node20-macos-x64,node20-win-x64 \ --output mcp-server-datawarehouse
This produces platform-specific binaries that include the Node.js runtime. Users run the binary directly without installing Node.js or npm. The trade-off is larger file size (typically 50-80MB) for zero-dependency distribution.
Configuration Standard
Support configuration through environment variables with validation at startup:
typescript#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // Validate required configuration const requiredEnvVars = ["DW_CONNECTION_STRING"]; const missing = requiredEnvVars.filter(v => !process.env[v]); if (missing.length > 0) { console.error( `Missing required environment variables: ${missing.join(", ")}\n` + `Set them in your MCP host configuration under the "env" key.` ); process.exit(1); } const config = { connectionString: process.env.DW_CONNECTION_STRING!, timeoutMs: parseInt(process.env.DW_TIMEOUT_MS || "30000"), maxRows: parseInt(process.env.DW_MAX_ROWS || "1000") }; const server = new McpServer({ name: "dw-server", version: "2.0.0" }); // ... register tools using config ... const transport = new StdioServerTransport(); await server.connect(transport);
The startup validation catches misconfiguration immediately. The error message tells users exactly what they need to set and where to set it.
Pre-Publish Testing Workflow
Before publishing, follow this checklist:
textStep Command ────────────────────────────────────────────────────────── 1. Build npm run build 2. Test with Inspector npx @modelcontextprotocol/inspector node ./dist/index.js 3. Verify package contents npm pack --dry-run 4. Test npx flow npx ./my-mcp-server-1.0.0.tgz 5. Test in Claude Desktop Configure and verify tool discovery 6. Publish npm publish
Step 3 is especially important: it shows exactly what files will be included in the published package. If you see src/ files or node_modules/, your files field is misconfigured.
Common Pitfalls
- Missing shebang line — Without
#!/usr/bin/env nodeat the top of your entry point, Unix systems do not know to execute the file with Node.js. Users get "permission denied" or "exec format error" messages. - Publishing node_modules — If your
filesfield is misconfigured, the entirenode_modulesdirectory gets published, creating a massive package. Always usenpm pack --dry-runto verify. - Hardcoded configuration — Embedding API keys, database URLs, or file paths in source code makes your server impossible to configure for other users. Always use environment variables.
Best Practices
- Use prepublishOnly for build safety — The
prepublishOnlyscript runs automatically beforenpm publish, ensuring the compiled output is always fresh. Never publish without building. - Test the full installation flow — Create a fresh directory, install your package with
npx, and verify it starts correctly. This catches issues with missing dependencies, broken bin links, and incorrect file paths. - Provide multiple distribution channels — Offer npm for Node.js users, Docker for containerized environments, and binaries for zero-dependency deployments. Meet users where they are.
Summary
- Structure npm packages with
binfield,filesrestriction, shebang entry point, andprepublishOnlybuild script. - Use multi-stage Docker builds for HTTP-transported servers deployed as services.
- Compile standalone binaries with pkg or nexe for environments without Node.js.
- Validate required environment variables at startup with clear error messages.
- Follow a pre-publish testing workflow: build, inspect, verify package contents, test npx flow, and test with a real MCP host.
Code Examples
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// Validate configuration at startup
const required = ["API_KEY"];
const missing = required.filter(v => !process.env[v]);
if (missing.length > 0) {
console.error(`Missing env vars: ${missing.join(", ")}`);
process.exit(1);
}
const server = new McpServer({ name: "my-server", version: "1.0.0" });
const transport = new StdioServerTransport();
await server.connect(transport);