Publishing Your MCP Server: Packaging and Distribution
Why "It Works On My Machine" Isn't Good Enough
You've built an MCP server. It runs beautifully from your terminal, Claude Desktop picks it up, tools fire correctly, and you've moved on to the next feature. Then a teammate tries to run it and hits a missing dependency. A user on Windows can't get the path resolution right. Someone installs an old version from a stale git clone and can't figure out why a tool you fixed last week is still broken.
This is the gap between building an MCP server and publishing one. Building is about making the protocol work — implementing tools/list, wiring up tools/call, handling stdio or HTTP transport correctly. Publishing is a different discipline entirely. It's about making sure a stranger, with no context on your code and no Slack channel to ask questions in, can install your server, configure it correctly, trust that it won't do something unsafe, and get updates without breaking their setup.
Most tutorials stop at "here's how to build an MCP server." Very few cover what happens after: how do you package it so npx or uvx can run it with zero setup? How do you version it so updates don't silently break clients? How do you write a manifest that tells an MCP host what your server needs before it ever runs? This article walks through the full packaging and distribution lifecycle — from directory layout to registry listing — using real examples in both Node.js and Python, since those are the two ecosystems where the vast majority of MCP servers live today.
Choosing Your Packaging Target
Before you package anything, decide who is going to run your server and how. There are three common distribution shapes, and each has different packaging requirements.
Local stdio servers are the most common shape. The host application (Claude Desktop, an IDE extension, a custom agent runtime) spawns your server as a child process and talks to it over stdin/stdout. These are typically distributed as npm packages (run via npx) or Python packages (run via uvx or pipx). No networking, no auth — just a binary that starts fast and speaks JSON-RPC over pipes.
Remote HTTP servers run continuously somewhere — your own infrastructure, a serverless function, a container on Fly.io or Render. Clients connect over HTTP with Server-Sent Events or the newer Streamable HTTP transport. These need real deployment packaging: Docker images, environment variable contracts, health checks.
Bundled/embedded servers ship inside a larger application, like a desktop app that includes an MCP server as an internal implementation detail. These barely need "distribution" at all since they're compiled into the parent artifact — but they still benefit from clean packaging because the same code is often reused as a standalone server later.
Most developers publishing their first MCP server should target the local stdio shape first. It has the lowest barrier to entry for users — one command, no infrastructure, no billing. This article focuses primarily on that path, with a section on containerizing for the remote case.
One more decision to make before you write any packaging config: will this server be public or internal? A server meant only for your own company's engineers can afford shortcuts — a private npm registry, a shared .env file distributed over a password manager, a Slack channel for support questions. A server meant for the open MCP ecosystem cannot. Every choice from here on, from how you validate configuration to how you write your README, should assume the least helpful possible user: someone who found your package through a registry search, has never spoken to you, and will judge whether to trust your code within about thirty seconds of reading your documentation.
Structuring the Package for npm Distribution
If you're building in TypeScript or JavaScript, the npm ecosystem is your distribution channel, and npx is your friend. Here's a directory layout that works well for an MCP server package:
my-mcp-server/
package.json
tsconfig.json
src/
index.ts
tools/
search.ts
fetch.ts
bin/
my-mcp-server.js
README.md
LICENSEThe critical piece is the bin entry in package.json, which is what makes npx my-mcp-server work at all:
{
"name": "@youraccount/my-mcp-server",
"version": "1.2.0",
"description": "MCP server for querying internal ticket data",
"type": "module",
"bin": {
"my-mcp-server": "./dist/index.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
}
}Two things matter here that beginners get wrong constantly. First, files: ["dist"] keeps your published tarball small — you don't want to ship src, test fixtures, or your .env.example file to every installer. Second, prepublishOnly guarantees you never accidentally publish stale compiled output; it forces a fresh build every time you run npm publish.
Your entry file needs a shebang line so the OS knows to run it with Node when invoked directly:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.2.0" },
{ capabilities: { tools: {} } }
);
// tool registration happens here
const transport = new StdioServerTransport();
await server.connect(transport);After npm run build, test the exact thing your users will run before you ever publish:
npm pack
npx --yes ./youraccount-my-mcp-server-1.2.0.tgzThis runs the packed tarball exactly as npm would install it from the registry, which catches path bugs and missing-file bugs that "works in my dev folder" testing never surfaces.
It's worth being deliberate about scoping your package name too. Publishing under a scope (@youraccount/my-mcp-server) instead of an unscoped name avoids the increasingly common problem of your ideal package name already being squatted, and it makes it immediately obvious to installers who maintains the code. If you're publishing on behalf of a company or open-source org, use the org's npm scope rather than a personal account, since ownership transfer later — if you move teams or leave the project — is far messier than setting it up correctly the first time.
Structuring the Package for PyPI Distribution
Python's MCP ecosystem has converged hard on uv and uvx as the preferred runner, which changes some of the packaging conventions relative to older pip-based workflows. A clean layout using pyproject.toml:
my-mcp-server/
pyproject.toml
src/
my_mcp_server/
__init__.py
server.py
tools/
__init__.py
search.py
README.md
LICENSEThe pyproject.toml needs a console script entry point so uvx my-mcp-server resolves to your code:
[project]
name = "my-mcp-server"
version = "1.2.0"
description = "MCP server for querying internal ticket data"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.0.0",
]
[project.scripts]
my-mcp-server = "my_mcp_server.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"And your server.py needs a main() function that the script entry point can call synchronously, even though the server itself is async:
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
app = Server("my-mcp-server")
# tool registration decorators go here
async def run():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
def main():
asyncio.run(run())
if __name__ == "__main__":
main()Test locally before publishing using uv build and then running the wheel directly with uvx --from ./dist/my_mcp_server-1.2.0-py3-none-any.whl my-mcp-server. This is the Python equivalent of the npm pack test above — it exercises the actual installable artifact, not your editable dev install, and it will catch missing dependencies that only showed up as "already installed globally" in your dev environment.
Pin your dependency floor carefully in pyproject.toml. It's tempting to leave dependencies unbounded, but the mcp SDK has shipped breaking changes to its server initialization API across minor versions during its early releases. Set a floor you've actually tested against, and if you're aware of an upper bound that's known to break your server, cap it explicitly rather than letting users discover the incompatibility themselves. The same logic applies on the Node side with the @modelcontextprotocol/sdk dependency in package.json — use a caret range you've verified, not a blind * or latest.
Writing the Server Manifest
Since late 2025, MCP hosts increasingly expect a manifest file alongside your server — a declarative description of what the server needs and provides, so the host can validate and configure it before ever spawning the process. This is usually called server.json or bundled as part of an MCPB (MCP Bundle) package. A minimal manifest looks like this:
{
"mcpVersion": "2025-06-18",
"name": "my-mcp-server",
"version": "1.2.0",
"description": "Query and search internal ticket data",
"runtime": "node",
"entry": {
"command": "npx",
"args": ["-y", "@youraccount/my-mcp-server"]
},
"env": {
"TICKETS_API_KEY": {
"description": "API key for the ticketing backend",
"required": true,
"secret": true
}
},
"tools": [
{ "name": "search_tickets", "description": "Search tickets by keyword or status" },
{ "name": "get_ticket", "description": "Fetch a single ticket by ID" }
]
}The manifest earns its keep in three ways. It lets a host display what environment variables a server needs before installation, so users aren't surprised by a crash on first launch. It lets registries index your server's capabilities without executing arbitrary code to introspect them. And it gives you a place to declare the minimum protocol version you support, which matters more than it sounds — MCP has moved fast, and a server built against an early draft of the spec can fail silently against a host that expects newer initialization semantics.
If you're distributing as an MCPB bundle (useful for servers with native dependencies or ones you want to ship as a single double-clickable artifact for less technical users), the manifest also needs to declare bundled files and, on desktop platforms, any binary permissions the server requires — network access, filesystem access outside its working directory, and so on. Being explicit here is not bureaucracy; it's the thing that lets a security-conscious user or an automated scanner decide whether to trust your server before running it.
Semantic Versioning and Backward Compatibility
MCP servers get updated automatically far more often than most developers expect, because npx and uvx frequently re-resolve to "latest" unless the host pins a version. That means your version number is a promise, not a formality.
Follow semver strictly:
- Patch (
1.2.0to1.2.1) for bug fixes that don't change any tool's input schema, output shape, or behavior in a way a caller would notice. - Minor (
1.2.0to1.3.0) for adding new tools or optional parameters. Existing callers must keep working unchanged. - Major (
1.2.0to2.0.0) for anything that breaks an existing tool contract: renaming a tool, changing a required parameter, altering the shape of a tool's response, or changing default behavior in a way that could surprise an existing integration.
The part people get wrong is tool schemas specifically. If an LLM has been calling your search_tickets tool with a status parameter and you silently rename it to ticket_status in a patch release, every downstream agent that learned the old schema — whether from a cached tool list, a fine-tuned prompt, or just the model's context from earlier in a session — starts failing in ways that are hard to debug because nothing "crashed," the tool call just silently returns wrong or empty results.
A practical discipline: keep a CHANGELOG.md at the repository root and update it in the same commit as any version bump, not after the fact.
## 1.3.0 - 2026-06-14
### Added
- New `get_ticket_comments` tool for fetching comment threads.
### Changed
- `search_tickets` now accepts an optional `limit` parameter (default 20).
## 1.2.1 - 2026-05-30
### Fixed
- Fixed pagination cursor not being passed through on `search_tickets`.This is also the artifact registries and MCP hosts will often surface directly to users deciding whether to update, so treat it as user-facing documentation, not internal notes.
Handling Secrets and Configuration Safely
An MCP server that talks to a real backend needs credentials, and how you handle them in your packaging directly affects whether your server is safe to install. The rule that matters most: never bake a default credential, sample API key, or hardcoded endpoint into your published package. Configuration should always flow in through environment variables that the host injects at spawn time, declared explicitly in your manifest's env block as shown earlier.
A common pattern for validating configuration at startup, so failures are loud and immediate rather than a confusing timeout later:
import os
import sys
def get_required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
print(f"Missing required environment variable: {name}", file=sys.stderr)
sys.exit(1)
return value
API_KEY = get_required_env("TICKETS_API_KEY")This fails fast with a clear message in the host's logs, instead of letting the server start successfully and then throw an opaque 401 three tool calls later. Fail-fast at startup is one of the highest-leverage things you can do for anyone installing your server, because it turns a confusing debugging session into a one-line error message.
If your server needs OAuth rather than a static API key, document the full flow in your README, including exactly which redirect URI and scopes to register, since this is the single most common source of setup friction for MCP servers wrapping third-party APIs.
It's also worth thinking about what your server logs. A surprising number of published MCP servers accidentally log full request payloads — including the credentials just validated above — to stderr for debugging, and that stderr output often ends up captured in the host application's own log files, sitting on a user's disk in plaintext long after the debugging session ended. Log tool names and outcomes, not full argument payloads, unless you've explicitly redacted anything that looks like a secret. A simple rule that catches most cases: never log a value that came from an environment variable you marked "secret": true in your manifest, even at debug level.
Containerizing for Remote Distribution and Automating Release
If you're distributing your server as a remote HTTP endpoint rather than a locally-spawned process, Docker is the standard packaging unit. A minimal, production-reasonable Dockerfile for a Node-based MCP server:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]The multi-stage build keeps the final image lean by excluding dev dependencies and build tooling from the shipped artifact. For the HTTP transport, make sure your server implements a basic health check endpoint separate from the MCP endpoint itself, since your hosting platform's load balancer needs something to poll that doesn't require a full MCP initialization handshake:
app.get("/healthz", (_req, res) => res.status(200).send("ok"));Tag your images with the same version as your package manifest, and push to a registry your users or your own infrastructure can pull from. If you're open-sourcing the server, GitHub Container Registry (ghcr.io) paired with a GitHub Actions workflow that builds and pushes on every tagged release removes the manual step entirely.
Manual publishing works fine for a first release, whether that's a Docker image or an npm package. By the third or fourth, doing it by hand invites mistakes — forgetting to bump the version, publishing from a branch with uncommitted local changes, or skipping the build step and pushing stale output. A small GitHub Actions workflow that publishes on tagged commits removes almost all of that risk:
name: Publish
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}The workflow only runs on version tags, which forces a small but valuable discipline: you can't publish without first creating a tag, and the tag itself becomes a permanent, auditable record of exactly what code shipped as which version. Pair this with branch protection on your main branch, and it becomes very difficult to accidentally publish something that wasn't reviewed. A git tag v1.3.0 && git push --tags becomes the only action needed to ship both the npm package and, with an equivalent job, the Docker image. The same pattern applies to PyPI by swapping the setup-node step for astral-sh/setup-uv and replacing npm publish with uv publish, using a PyPI API token stored the same way.
Documentation That Actually Gets Read
Your README is the first and often only thing a potential user reads before deciding whether to trust your server with their credentials and let it run arbitrary code. Structure it in the order people actually need the information, not the order that feels natural to write:
- One-line description of what the server does and what backend or API it wraps.
- Installation command, copy-pasteable, for the primary host (Claude Desktop config snippet, or the raw
npx/uvxinvocation). - Required configuration — every environment variable, where to get the values, and what happens if one is missing.
- Tool reference — every tool the server exposes, its parameters, and a realistic example of when an agent would call it.
- Security notes — what data the server can access, what it can write or modify, and any destructive operations it exposes.
- Versioning and changelog link.
For the installation snippet specifically, give people the exact JSON block for the most common host rather than making them translate:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["-y", "@youraccount/my-mcp-server"],
"env": {
"TICKETS_API_KEY": "your-api-key-here"
}
}
}
}People will copy this block verbatim, so make sure it's correct — test it yourself in a clean config before publishing, not just in your development setup where stale paths or cached versions can mask real problems.
Listing on Registries and Ongoing Maintenance
Once your package is live on npm or PyPI, the next step is discoverability. The official MCP registry (part of the Model Context Protocol project) accepts submissions of your server.json manifest, and several community directories index servers by category. Submitting to these costs little and meaningfully increases the odds someone finds your server through search rather than a direct link.
Before submitting anywhere public, run through a short pre-publish checklist:
- Does
npm packoruv buildfollowed by a clean install actually work, tested in a directory with no prior cache? - Does the server fail loudly and clearly when required environment variables are missing?
- Is there a LICENSE file, and have you actually read what it permits?
- Does the README's installation snippet match the current package name and version exactly?
- Have you removed any hardcoded test credentials, internal URLs, or debug logging that dumps request payloads?
Publishing isn't a one-time event — it's the start of a maintenance obligation. Watch your issue tracker, respond to compatibility reports when MCP hosts update their client implementations, and treat every breaking change as a major version bump even when it feels minor to you as the author. The developers who succeed at getting their MCP servers widely adopted are rarely the ones with the cleverest tool implementation — they're the ones whose packaging is boring, predictable, and never surprises anyone.
Getting comfortable with this full lifecycle — from writing your first tool handler through packaging, versioning, and shipping updates without breaking existing users — is exactly what we work through hands-on in Building & Integrating MCP Servers, where you'll take a server from a local prototype to a properly published, versioned package other developers can actually depend on.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.