Packaging and Distributing
The Kitebase MCP server works on your laptop. Your host config points at /Users/you/kitebase-mcp/.venv/bin/python and /Users/you/kitebase-mcp/server.py, and Claude can look up tickets. Then Priya on the support team wants it. You send her instructions: clone the repo, make a virtual environment, install the requirements, change both paths in the JSON, restart. She gets No module named 'mcp', because she pasted your path to Python. A week later you fix a bug, and she only gets it if she remembers to git pull.
What Priya should get is a block she pastes once, with a version in it and a place for her own token:
"kitebase": {
"command": "uvx",
"args": ["kitebase-mcp@0.1.0"],
"env": { "KITEBASE_API_TOKEN": "her-token" }
}
What you’ll build: the Kitebase server from article 01: What Is MCP? as an installable Python package: a kitebase-mcp command, settings read from environment variables, a wheel built with uv build and run through uvx the way a user would, and the server.json you’d publish to the MCP Registry. It all runs offline, and nothing gets published.
What a package is, and what uvx does with it
A package is your code plus metadata (name, version, dependencies) in a standard format installers understand. Python packages are published to PyPI, the Python Package Index, which is where pip install downloads from. The file you upload is a wheel: a zip with a .whl extension, ready to unpack into an environment.
uv is a fast Python package manager, and it comes with uvx, which runs a command from a package without you installing it first. uvx kitebase-mcp finds kitebase-mcp on PyPI, installs it and its dependencies into a private, cached environment, and runs its command. Node’s npx does the same with npm packages.
That’s why uvx suits MCP. The host config needs only the word uvx and a package name: no paths into anyone’s home folder, no virtual environment for Priya to make, nothing clashing with her other Python projects. She needs uv installed, and that’s all. (If a host can’t find uvx, put its full path in command, from which uvx or where uvx on Windows. The official MCP docs give the same advice for uv.)
Step 1: Give the server a pyproject.toml
pyproject.toml is the standard file that describes a Python project: its name, version, dependencies, and how to build it. The packaged server looks like this:
08-packaging-and-distributing/
├── pyproject.toml
├── README.md # also becomes the description on PyPI
├── server.json # metadata for the MCP Registry
└── src/kitebase_mcp/
├── __init__.py
├── config.py # settings from environment variables
├── server.py # the tools, resource and prompt from article 01
└── data/kitebase.json # sample tickets and help articles
The code lives in src/kitebase_mcp/, a folder named after the module. This is the src layout: since the module isn’t in the project root, import kitebase_mcp only works once the package is installed, so your tests run against what users get. The whole pyproject.toml:
[project]
name = "kitebase-mcp"
version = "0.1.0"
description = "MCP server for Kitebase tickets and help-center articles."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = ["mcp>=2.2,<3"]
[project.scripts]
kitebase-mcp = "kitebase_mcp.server:main"
[build-system]
requires = ["uv_build>=0.11,<0.12"]
build-backend = "uv_build"
name is the name on PyPI and after uvx. It has a dash; the module has an underscore, because Python identifiers can’t contain dashes. dependencies says “mcp 2.2 or newer, but not 3”: version 2 of the SDK renamed FastMCP to MCPServer, and the upper bound stops the next major version from breaking every install overnight.
[build-system] names the tool that turns the folder into a wheel. uv_build is uv’s own, and it expects exactly this src/kitebase_mcp/ layout. Hatchling or setuptools work too; the [project] table is the same for all of them.
Step 2: Add the command a host will run
The line everything depends on is this one:
[project.scripts]
kitebase-mcp = "kitebase_mcp.server:main"
It declares a console script, or entry point. When the package is installed, the installer writes a small executable called kitebase-mcp into the environment’s bin folder (Scripts on Windows) that imports kitebase_mcp.server and calls main(). That’s what uvx kitebase-mcp runs. Without this table the package installs fine, and then uvx fails because there’s no command with that name.
In article 01, server.py ended with if __name__ == "__main__": mcp.run(). A console script calls a function instead of running the file, so startup moves into main():
def main() -> None:
"""Entry point for the `kitebase-mcp` command."""
parser = argparse.ArgumentParser(prog="kitebase-mcp")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.parse_args()
try:
settings = load_settings()
except ConfigError as error:
# stderr ends up in the host's log file, where the user will look.
print(f"kitebase-mcp: {error}", file=sys.stderr)
sys.exit(1)
print(f"kitebase-mcp {__version__} starting, Kitebase at {settings.url}", file=sys.stderr)
create_server(settings).run() # stdio: stdout carries the protocol from here on
The tools are article 01’s, now inside create_server(settings), which builds and returns the MCPServer. Tests call it with test settings; main() calls it with real ones.
The example’s requirements.txt is one line, -e ., which tells pip to install the package in this folder in editable mode (linked to your source, so code edits show up without reinstalling). After that, the command exists:
pip install -r requirements.txt
kitebase-mcp --version
kitebase-mcp 0.1.0
--version may print to stdout because it exits before the server starts. Once mcp.run() is going, the rule from article 03: Setting Up Your First MCP Server applies: stdout belongs to the protocol, logs go to stderr.
Step 3: Settings come from the environment
The real server needs an API token, and every user has their own. It can’t go in the code, which ships to everyone, or in args, which shows up in process listings and logs. It goes in the host config’s env block: the host starts your server with those environment variables set (named values a process gets when it starts), and your code reads os.environ. The Kitebase server reads two:
| Variable | Required | What it’s for |
|---|---|---|
KITEBASE_API_TOKEN | yes | The user’s API token |
KITEBASE_URL | no | Their Kitebase address, default https://app.kitebase.example |
config.py checks them once, at startup:
def load_settings(env: Mapping[str, str] = os.environ) -> Settings:
token = env.get("KITEBASE_API_TOKEN", "").strip()
if not token:
raise ConfigError(
"KITEBASE_API_TOKEN is not set. Create a token in Kitebase under "
'Settings > API tokens and add it to the "env" block of your host config.'
)
url = env.get("KITEBASE_URL", DEFAULT_URL).strip().rstrip("/")
if not url.startswith("https://"):
raise ConfigError(f"KITEBASE_URL must start with https://, got {url!r}.")
return Settings(api_token=token, url=url)
The sample reads bundled data instead of calling Kitebase, but it requires the token like the real one would. KITEBASE_URL shows up in results: get_ticket returns a link like https://northwind.kitebase.example/tickets/KITE-142.
Why check at startup? A server that starts without its token looks healthy, lists its tools, then fails every call with an error the model has to explain. One that exits at once with a line on stderr puts the fix in the host’s log file, where the user looks (mcp-server-kitebase.log for Claude Desktop).
The gotcha is the red arrow. You export KITEBASE_API_TOKEN=... in your terminal, test there, and it works. In the host it fails, because Claude Desktop is launched from the Dock or the Start menu and never saw your shell. The Python SDK’s client is just as strict: it passes the server only HOME, LOGNAME, PATH, SHELL, TERM and USER, plus the env block. The companion’s client.py launches the server with the env block, then with an empty one:
Connected to kitebase 0.1.0 (protocol 2026-07-28)
Tools: search_help, get_ticket
get_ticket KITE-142 -> 'Customer locked out after SSO change', link https://northwind.kitebase.example/tickets/KITE-142
Server stderr:
kitebase-mcp 0.1.0 starting, Kitebase at https://northwind.kitebase.example
Same command, "env": {}
The server closed the connection before answering.
Server stderr:
kitebase-mcp: KITEBASE_API_TOKEN is not set. Create a token in Kitebase under Settings > API tokens and add it to the "env" block of your host config.
The client only sees a closed connection; the reason is in stderr. Your users will see the same, so make that line good. And never log the token itself: logs get pasted into bug reports.
Step 4: Build the wheel and run it like a user
uv build
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/kitebase_mcp-0.1.0.tar.gz
Successfully built dist/kitebase_mcp-0.1.0-py3-none-any.whl
The .tar.gz is the source distribution (sdist), your project zipped up. The .whl is the wheel; py3-none-any means pure Python, so one file works on every OS. Unzip it to see what users get:
kitebase_mcp/__init__.py
kitebase_mcp/config.py
kitebase_mcp/data/kitebase.json
kitebase_mcp/server.py
kitebase_mcp-0.1.0.dist-info/entry_points.txt
kitebase_mcp-0.1.0.dist-info/METADATA
...
entry_points.txt holds kitebase-mcp = kitebase_mcp.server:main, which is how an installer knows to create the command. data/kitebase.json is the sample data, which the server reads through the package rather than by a path next to the source:
from importlib.resources import files
def load_data() -> dict:
return json.loads(files("kitebase_mcp").joinpath("data/kitebase.json").read_text())
That only works if the file is in the wheel. uv_build and Hatchling include files in the module folder by default. setuptools often needs package-data or a MANIFEST.in first, and without them the server installs fine and crashes on the first read with FileNotFoundError. Whatever the backend, look inside the wheel once.
Now run the wheel the way Priya would, through uvx. client.py takes any command, like a host config:
python client.py uvx --from dist/kitebase_mcp-0.1.0-py3-none-any.whl kitebase-mcp
Connected to kitebase 0.1.0 (protocol 2026-07-28)
...
Server stderr:
Installed 29 packages in 15ms
kitebase-mcp 0.1.0 starting, Kitebase at https://northwind.kitebase.example
--from says where the package comes from (a file here, PyPI by default); kitebase-mcp is the command to run from it. The new stderr line is uvx building its environment: your package, mcp, and the 27 packages mcp needs.
This is the test that catches packaging bugs: a missing data file, a forgotten dependency, a broken entry point. Running server.py from your checkout catches none of them, because the checkout has every file and your environment has every package. The companion tests build the wheel and check its contents, so a broken package fails in CI rather than on Priya’s machine.
Versioning: what counts as breaking
Semantic versioning (semver) gives versions three numbers, MAJOR.MINOR.PATCH: bump PATCH for fixes, MINOR for additions that don’t break anyone, MAJOR for anything that does.
For an MCP server, “breaking” means breaking what the model and the user rely on:
- Major: renaming or removing a tool, renaming an argument or making one required, changing a result’s shape, renaming an environment variable. Nothing crashes when
get_ticketbecomesfetch_ticket, but saved prompts and per-tool permission settings still use the old name. - Minor: a new tool, a new optional argument, a new optional variable with a default.
- Patch: a bug fix, a clearer description, faster code.
Keep the version in one place. pyproject.toml has it, and the package reads it back from its installed metadata:
from importlib.metadata import version
__version__ = version("kitebase-mcp")
--version uses it, and so does MCPServer("kitebase", version=__version__), which is where the kitebase 0.1.0 in the client output comes from. uv version --bump minor edits pyproject.toml for you. With an editable install, reinstall after a version change, because the installed metadata is what gets read.
Then choose how users get updates. uvx doesn’t check for new releases on every launch: per the uv docs, it uses the latest version on the first run and the cached one after that, unless a different version is requested.
kitebase-mcp@0.1.0pins a version. Users move when they change the pin. Default to this for anything a team relies on.kitebase-mcp@latestchecks PyPI on every launch, so updates arrive on their own, broken ones included.- Plain
kitebase-mcptakes whatever was newest on the first run and stays there. It looks like “always latest” and isn’t.
Why not just tell people to pip install it?
pip install goes into an environment the user has to create and manage, and the host config then needs the absolute path to that environment’s kitebase-mcp. That’s the path problem from the start of the article again.
uvx keeps one cached environment per tool and finds the command itself, so nothing clashes with the user’s other projects. pipx run kitebase-mcp does the same job for people who use pipx.
Publishing to PyPI
Publishing is one command, and the one step you can’t undo:
- Check the name is free on pypi.org. Names are first come, first served.
- A version is forever. PyPI never lets a file name be reused, even after you delete a release. If
0.1.0ships broken, the fix is0.1.1. - Rehearse on TestPyPI, a separate practice copy of PyPI:
uv publish --publish-url https://test.pypi.org/legacy/, then install from it with uvx’s--indexoption.
uv build
uv publish # uploads dist/*; needs a PyPI API token (--token), or trusted publishing in CI
For regular releases, set up trusted publishing: you tell PyPI which GitHub repository and workflow may publish the package, and the workflow proves who it is with a short-lived token from GitHub instead of a stored password. uv publish picks it up automatically inside that workflow.
Getting found: the MCP Registry
On PyPI, the server installs for anyone who knows its name. The MCP Registry (registry.modelcontextprotocol.io), the MCP project’s official catalog of public servers, is how everyone else finds it. It’s still in preview: its docs warn of breaking changes and data resets before general availability.
The registry stores metadata only. It points at your package on PyPI (or npm, NuGet, crates.io, a Docker image, or an MCP Bundle), and its main readers are server marketplaces that pull from its API and list servers for hosts. You describe the server in server.json (trimmed):
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.your-username/kitebase",
"description": "Look up Kitebase tickets and search the Kitebase help center.",
"version": "0.1.0",
"packages": [{
"registryType": "pypi",
"identifier": "kitebase-mcp",
"version": "0.1.0",
"runtimeHint": "uvx",
"transport": { "type": "stdio" },
"environmentVariables": [{
"name": "KITEBASE_API_TOKEN",
"description": "API token from Kitebase, under Settings > API tokens",
"isRequired": true,
"isSecret": true
}]
}]
}
It’s your README’s install instructions in a form tools can read: the package, the runner, and the variables a user must supply (isSecret tells clients to treat the value like a password). The name is a namespace you prove you own: io.github.your-username/... by logging in with that GitHub account, a name under your own domain (com.kitebase/...) by a DNS or HTTP check.
The registry also checks that the PyPI package is yours, by looking for the server’s name in the package’s PyPI description, which is your README. An HTML comment works, so readers don’t see it:
<!-- mcp-name: io.github.your-username/kitebase -->
The tool is mcp-publisher, from the registry’s GitHub releases or brew install mcp-publisher:
mcp-publisher init # writes a server.json template
mcp-publisher login github # proves you own io.github.your-username/
mcp-publisher publish # uploads server.json
Publish to PyPI first. If the registry can’t find the mcp-name line in the published package, publish fails with “Registry validation failed for package”. Every release then means a PyPI upload plus a server.json with the new version in both version fields.
MCP Bundles: one-click installs for Claude Desktop
All of that still asks users to edit JSON and install uv. For people who never open a terminal, Claude Desktop installs MCP Bundles: .mcpb files, zip archives holding a local server and a manifest.json describing it. The format began as Desktop Extensions (.dxt); the spec and the mcpb CLI live in the modelcontextprotocol/mcpb repository.
The manifest’s user_config declares settings the user fills in at install time, such as the token marked "sensitive": true, and maps them into the server’s env with ${user_config.api_token}. So the environment variables from step 3 work unchanged. For Python, a python bundle carries its dependencies in the zip (one bundle per platform once any have compiled code, as pydantic does), while the newer uv server type, from manifest version 0.4, ships pyproject.toml and lets the host install them.
I haven’t built one for this article (mcpb pack is a Node tool, and testing needs Claude Desktop), so check the repository’s MANIFEST.md before you do. My default: PyPI and a uvx config first, since every host that runs stdio servers can use it, then a bundle once non-developers want the server.
Remote servers are a different job
Everything here is for a local server that the host starts over stdio. A server Kitebase runs for all its customers is a remote one: a web service you deploy, which users add by URL (article 07: Connecting to Hosts). The registry lists those by URL too.
Try it yourself
The companion example is the packaged server, the host stand-in client.py, a server.json, and offline tests that build the wheel and check what’s in it.
Download the runnable example (zip)
cd 08-packaging-and-distributing
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
kitebase-mcp --version
python client.py
Then try these:
- Delete
KITEBASE_API_TOKENfromENV_BLOCKinclient.py, runexport KITEBASE_API_TOKEN=abc, thenpython client.py. Both launches fail: the server only gets the env block. - Install uv, run
uv buildandunzip -l dist/*.whl, thenpython client.py uvx --from dist/kitebase_mcp-0.1.0-py3-none-any.whl kitebase-mcp. That’s your package running the way a user runs it. - Set
version = "0.2.0"inpyproject.tomland runkitebase-mcp --version. Still 0.1.0. Runpip install -r requirements.txtagain and both--versionand the server info say 0.2.0.
pip install pytest && pytest -q runs the tests, offline. The wheel test needs uv and skips itself without it.
Common beginner mistakes
- No
[project.scripts]entry. The package installs anduvx kitebase-mcpfinds no command. Checkentry_points.txtin the wheel. - Testing only from the checkout. It has every file and package, so what’s missing from the wheel shows up on someone else’s machine. Test the wheel through
uvx --from. - Relying on your shell’s environment. Works in the terminal, fails in the host. Every setting goes in the env block, and the README says so.
- Checking config late. A server that starts without its token fails every call instead of once, clearly. Check in
main(). - An unpinned
uvx kitebase-mcpin the README. Users get the newest release on their first launch, then never update. Pin, or use@lateston purpose.
Questions you will face in production
“PyPI or npm?”
The server’s language decides: Python goes to PyPI and runs with uvx, TypeScript goes to npm and runs with npx. Publishing to both doubles the release work for nothing.
“Our server is internal. Does it go on PyPI?”
No. Publish to your company’s private package index and point uvx at it with --index, or install from Git with uvx --from git+https://.... The official registry doesn’t accept private servers; its docs suggest a registry of your own for those.
“How do we roll out a fix to 40 people?”
Publish 0.1.1 and update the pin in the config you hand out; a shared .mcp.json in a repository is the easiest one to change for a team. With @latest, fixes reach everyone on their next restart, and so do broken releases.
Check your understanding
Priya's host log says KITEBASE_API_TOKEN is not set, but she's sure she exported it in her terminal. What's wrong?
The host wasn’t started from her terminal, so it never saw the export, and it passes the server only a few basic variables plus the config’s env block. She should put the token in the env block and restart the host.
The wheel installs and kitebase-mcp --version works, but the first get_ticket call fails with FileNotFoundError on kitebase.json. What happened?
The data file isn’t in the wheel, most likely because the build backend didn’t include it (setuptools without package-data, say). It worked from the checkout because the file was there. unzip -l on the wheel confirms it; fix the build config and add a test for the wheel’s contents.
You rename the optional argument limit on search_help to max_results. Minor or major?
Major, and a sneaky one. The SDK ignores arguments it doesn’t know, so a call still sending limit: 10 (from a saved prompt, or a model with an old tool list) doesn’t fail: it quietly gets the default 3 results. Adding max_results alongside limit would be minor.
You publish 0.2.0. A teammate's config says "args": ["kitebase-mcp"] and they've run it since 0.1.0. Which version do they run tomorrow?
Still 0.1.0. uvx uses its cached copy after the first run unless another version is requested. They get 0.2.0 with kitebase-mcp@0.2.0 or kitebase-mcp@latest.
What to remember
- A host config should need a runner and a package name, not paths:
"command": "uvx", "args": ["kitebase-mcp@0.1.0"]. [project.scripts]creates the commanduvxruns. Startup code goes in amain()it can call.- Settings come from the env block. Check them in
main()and exit with a clear stderr line when one is missing. - Build the wheel, look inside, and run it with
uvx --frombefore publishing. Your checkout hides packaging bugs. - Renaming a tool, an argument or an env var is a major version. Pin versions in the configs you hand out.
- Code goes to PyPI; metadata goes to the MCP Registry, which checks the
mcp-nameline in your README. MCP Bundles are the one-click route for Claude Desktop.
What to study next
The server now installs with one line and can be found in the registry. Whether anyone should pay for it, and what that changes about auth and hosting, is article 09: Selling Your MCP Server. If the tools need polish before strangers use them, go back to article 06: MCP Server Best Practices first.
Further reading
- Python Packaging User Guide: Writing your pyproject.toml. Every field in
[project], including[project.scripts]. - uv: Using tools and Tools concepts. How
uvxfinds, caches and updates packages. - uv: Building and publishing a package.
uv build,uv publishand TestPyPI. - MCP Registry quickstart and package types.
mcp-publisher,server.jsonand themcp-namecheck for PyPI. - MCP Bundles (mcpb). The
.mcpbformat,manifest.jsonand themcpbCLI. - Semantic Versioning. The rules behind MAJOR.MINOR.PATCH.
Where this article comes from. This is a synthesis of the MCP specification and common packaging practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.