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:

VariableRequiredWhat it’s for
KITEBASE_API_TOKENyesThe user’s API token
KITEBASE_URLnoTheir 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).

HOST CONFIG "command": "uvx", "args": ["kitebase-mcp@0.1.0"], "env": { "KITEBASE_API_TOKEN": "kb_..." } starts THE SERVER'S ENVIRONMENT a few basics: PATH HOME USER SHELL ... plus the env block: KITEBASE_API_TOKEN and nothing else from your terminal MAIN() load_settings() checks the token and the URL before serving anything YOUR TERMINAL export KITEBASE_API_TOKEN=kb_... never arrives The host wasn't started from this shell, so it never saw the export. TOKEN SET stderr, into the host log: kitebase-mcp 0.1.0 starting, Kitebase at https://app... then mcp.run() serves stdio TOKEN MISSING stderr, into the host log: KITEBASE_API_TOKEN is not set. Create a ... exit 1, no JSON on stdout
The env block is the only way settings reach the server.

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.

1. YOUR PROJECT pyproject.toml src/kitebase_mcp/ server.py config.py data/kitebase.json uv build 2. THE WHEEL (A ZIP FILE) kitebase_mcp-0.1.0-py3-none-any.whl kitebase_mcp/server.py, config.py kitebase_mcp/data/kitebase.json dist-info/entry_points.txt: kitebase-mcp = kitebase_mcp.server:main uv publish 3. PYPI kitebase-mcp 0.1.0 downloads the wheel and mcp 4. HOST CONFIG "command": "uvx", "args": ["kitebase-mcp@0.1.0"], "env": { "KITEBASE_API_TOKEN": "..." } runs 5. UVX installs 29 packages into a cached environment first launch only, then reused starts 6. YOUR SERVER kitebase-mcp server.main() reads env, then mcp.run() The pin decides the version. A new release reaches a user only when their config changes: uvx keeps using the cached environment.
From a folder on your machine to a process a host starts.

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_ticket becomes fetch_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.0 pins a version. Users move when they change the pin. Default to this for anything a team relies on.
  • kitebase-mcp@latest checks PyPI on every launch, so updates arrive on their own, broken ones included.
  • Plain kitebase-mcp takes 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.0 ships broken, the fix is 0.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 --index option.
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 -->
YOUR RELEASE 0.1.0 dist/*.whl, *.tar.gz the code, with the README inside 1. uv publish server.json name, version, where to get it, env vars 2. mcp-publisher publish PYPI: THE CODE kitebase-mcp 0.1.0 description = your README: <!-- mcp-name: io.github.you/kitebase --> MCP REGISTRY: METADATA io.github.you/kitebase pypi: kitebase-mcp 0.1.0 runtimeHint: uvx KITEBASE_API_TOKEN, secret checks the name matches MARKETPLACES pull the registry, list it for hosts A USER'S HOST uvx kitebase-mcp@0.1.0 + their own token fetch Publish to PyPI first: the registry only stores metadata, and it looks the package up on PyPI to check the mcp-name line before it accepts server.json.
Code goes to PyPI, metadata goes to the registry, and the registry checks one against the other.

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:

  1. Delete KITEBASE_API_TOKEN from ENV_BLOCK in client.py, run export KITEBASE_API_TOKEN=abc, then python client.py. Both launches fail: the server only gets the env block.
  2. Install uv, run uv build and unzip -l dist/*.whl, then python 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.
  3. Set version = "0.2.0" in pyproject.toml and run kitebase-mcp --version. Still 0.1.0. Run pip install -r requirements.txt again and both --version and 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 and uvx kitebase-mcp finds no command. Check entry_points.txt in 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-mcp in the README. Users get the newest release on their first launch, then never update. Pin, or use @latest on 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 command uvx runs. Startup code goes in a main() 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 --from before 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-name line 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

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.


Auto-marks when you reach the end. Click to toggle.