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/, a bin field in package.json, a shebang line in the entry point, and files restricting 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 pkg or nexe compile 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 the npx installation 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:

text
my-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:

text
Step                          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

  1. Missing shebang line — Without #!/usr/bin/env node at 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.
  2. Publishing node_modules — If your files field is misconfigured, the entire node_modules directory gets published, creating a massive package. Always use npm pack --dry-run to verify.
  3. 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

  1. Use prepublishOnly for build safety — The prepublishOnly script runs automatically before npm publish, ensuring the compiled output is always fresh. Never publish without building.
  2. 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.
  3. 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 bin field, files restriction, shebang entry point, and prepublishOnly build 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

typescript
#!/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);
✓ Completed