# Phase 11: PyPI Packaging - Research

**Researched:** 2026-03-24
**Domain:** Python packaging — pyproject.toml, hatchling build backend, PyPI publish via uv
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Add `[project.scripts]` to `pyproject.toml`: `vmcluster-mcp = "vmcluster_mcp.__main__:main"`
- **D-02:** Wrap the existing `mcp.run()` call in `__main__.py` with a `main()` function:
  ```python
  def main():
      mcp.run()

  if __name__ == "__main__":
      main()
  ```
- **D-03:** `uvx vmcluster-mcp` must also work — satisfied automatically by console script entry point once on PyPI.
- **D-04:** Bump version to `"1.0.0"` in `pyproject.toml`.
- **D-05:** Static version string (no dynamic versioning). Manual bump on each release.
- **D-06:** Add `readme = "README.md"` to `[project]`.
- **D-07:** Add `license = "MIT"` to `[project]` and create a `LICENSE` file with MIT license text.
- **D-08:** Add Python classifiers:
  - `"Programming Language :: Python :: 3"`
  - `"Programming Language :: Python :: 3.11"`
  - `"License :: OSI Approved :: MIT License"`
  - `"Operating System :: POSIX :: Linux"`
  - `"Topic :: Software Development :: Testing"`
  - `"Topic :: System :: Systems Administration"`
- **D-09:** Add `keywords` array: `["mcp", "libvirt", "qemu", "kvm", "vm", "cluster", "testing"]`
- **D-10:** Add author info: name and optional email in `[project]`.
- **D-11:** Keep `libvirt-python` as a hard dependency.
- **D-12:** Document in README that system-level libvirt dev headers are required before `pip install vmcluster-mcp`.
- **D-13:** Run `uv build` to produce both sdist and wheel.
- **D-14:** Check wheel contents with `unzip -l dist/*.whl` to confirm all `vmcluster_mcp/` subpackages are included.
- **D-15:** Test install from wheel locally with `pip install dist/*.whl` and verify the entry point works.
- **D-16:** Manual publish via `uv publish`. No CI/CD for this phase.
- **D-17:** Publish to the real PyPI (not TestPyPI). Package name `vmcluster-mcp` must be available.

### Claude's Discretion

- Exact author name/email format
- Whether to add `project.urls` (Homepage, Repository, Bug Tracker) — standard practice, add if clean
- Exact license year and holder in LICENSE file
- Whether to add `python_requires` version bounds (already have `>=3.11`)

### Deferred Ideas (OUT OF SCOPE)

- **Automated CI/CD publishing** (GitHub Actions on tag push, OIDC trusted publishing) — INST-07, explicitly deferred from v1.1 scope
- **TestPyPI staging** — acceptable for manual validation but not required by success criteria
- **Version pinning / dynamic versioning** (hatch-vcs, setuptools-scm) — defer until CI/CD publishing is implemented
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| INST-01 | User can install vmcluster-mcp from PyPI with a single command (`pip install vmcluster-mcp` or `uvx vmcluster-mcp`) | pyproject.toml metadata completeness, console script entry point, hatchling build, `uv publish` flow |
</phase_requirements>

---

## Summary

Phase 11 is a pure packaging and metadata hygiene phase — no changes to server logic. The project already uses hatchling as its build backend and has a working `__main__.py` entry point. The four concrete changes needed are: (1) add a `main()` wrapper function to `__main__.py`, (2) add `[project.scripts]` to `pyproject.toml`, (3) complete `pyproject.toml` metadata (version, readme, license, classifiers, keywords, author, urls), (4) create a `LICENSE` file. Once those changes are in place, `uv build` produces sdist and wheel, and `uv publish` pushes to PyPI.

The package name `vmcluster-mcp` is confirmed available on PyPI (HTTP 404 from `https://pypi.org/pypi/vmcluster-mcp/json`). PyPI no longer accepts username/password authentication — a PyPI API token is required for `uv publish`. uv (v0.10.12) is installed and both `uv build` and `uv publish` subcommands are confirmed available on this machine.

The primary runtime complication is that `libvirt-python` is a C extension requiring system-level development headers (`libvirt-dev`, `pkg-config`, `gcc`). The wheel for vmcluster-mcp itself will be a pure-Python wheel, but downstream users installing via `pip install vmcluster-mcp` will need the C build toolchain for `libvirt-python` unless pre-built wheels for their platform are available. This is a known, pre-existing concern documented in D-12.

**Primary recommendation:** Make the four targeted file changes, build with `uv build`, inspect the wheel with `unzip -l`, test the entry point locally, then publish with `uv publish --token $UV_PUBLISH_TOKEN`.

---

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| hatchling | 1.29.0 (PyPI) | Build backend | Already in use; Python Packaging Authority recommended |
| uv | 0.10.12 (installed) | Build + publish orchestration | Replaces `python -m build` + twine; single tool for both operations |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| twine | latest | PyPI upload fallback | Only if `uv publish` encounters issues; twine is NOT installed on this machine |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `uv build` + `uv publish` | `python -m build` + `twine upload` | build+twine works but requires two separate installs; uv is already present and handles both |
| Static version in pyproject.toml | hatch-vcs dynamic versioning | hatch-vcs deferred to INST-07 CI/CD phase per D-05 |

**Build command:**
```bash
uv build
# produces dist/vmcluster_mcp-1.0.0.tar.gz and dist/vmcluster_mcp-1.0.0-py3-none-any.whl
```

**Publish command:**
```bash
uv publish --token $UV_PUBLISH_TOKEN
# or: UV_PUBLISH_TOKEN=<token> uv publish
```

---

## Architecture Patterns

### pyproject.toml Complete Structure

The current `pyproject.toml` is missing: `readme`, `license`, `classifiers`, `keywords`, `authors`, `[project.urls]`, and `[project.scripts]`. The complete target structure is:

```toml
[project]
name = "vmcluster-mcp"
version = "1.0.0"
description = "MCP server for autonomous multi-VM cluster orchestration on libvirt/QEMU"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
authors = [
    {name = "...", email = "..."},
]
keywords = ["mcp", "libvirt", "qemu", "kvm", "vm", "cluster", "testing"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.11",
    "License :: OSI Approved :: MIT License",
    "Operating System :: POSIX :: Linux",
    "Topic :: Software Development :: Testing",
    "Topic :: System :: Systems Administration",
]
dependencies = [
    "mcp[cli]>=1.26.0",
    "pydantic>=2.12.5",
    "asyncssh>=2.22.0",
    "libvirt-python>=12.1.0",
    "PyYAML>=6.0.3",
]

[project.scripts]
vmcluster-mcp = "vmcluster_mcp.__main__:main"

[project.urls]
Homepage = "https://github.com/hornc/vmcluster-mcp"
Repository = "https://github.com/hornc/vmcluster-mcp"
Issues = "https://github.com/hornc/vmcluster-mcp/issues"
```

### Pattern 1: Console Script Entry Point

**What:** `[project.scripts]` maps a terminal command name to a Python callable. After install, pip/uv places an executable in the environment's `bin/` directory that calls the specified function.

**When to use:** Any package that needs to be invocable as a CLI tool or MCP stdio server.

**Example:**
```toml
# Source: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
[project.scripts]
vmcluster-mcp = "vmcluster_mcp.__main__:main"
```

The callable must be a top-level function:
```python
# vmcluster_mcp/__main__.py
from .server import mcp

def main():
    mcp.run()

if __name__ == "__main__":
    main()
```

### Pattern 2: uvx Tool Invocation

**What:** `uvx <package>` runs a console script entry point from a package in an ephemeral environment without requiring a prior `pip install`. It uses the package's `[project.scripts]` entry.

**When to use:** Success criterion — `uvx vmcluster-mcp` must work post-publish.

**How it works:** uvx resolves the package from PyPI, installs it in a temporary env, and executes the console script. No extra configuration beyond `[project.scripts]` is required.

### Pattern 3: hatchling Auto-Includes Python Packages

**What:** Hatchling automatically discovers and includes all Python packages (directories with `__init__.py`) under the project root. No explicit `[tool.hatch.build.targets.wheel]` configuration is needed for pure Python packages.

**When to use:** This project has no non-Python data files, so hatchling's defaults are correct. The `vmcluster_mcp/` directory and all sub-packages (`topology/`, `cluster/`, `exec/`, `snapshot/`, `artifact/`, `observe/`, `net/`) will be included automatically.

### Anti-Patterns to Avoid

- **Missing `main()` wrapper:** If `[project.scripts]` points to a module-level statement rather than a callable function, install will succeed but the console script will fail with `AttributeError`. Always wrap in a function.
- **Publishing with `uv build --sources` (the default):** The `uv build` docs recommend running `uv build --no-sources` to test that the package builds without `tool.uv.sources` overrides, ensuring PyPI compatibility. However, this project has no `tool.uv.sources`, so standard `uv build` is fine.
- **Assuming username/password auth still works on PyPI:** PyPI deprecated password auth. Always use an API token.
- **Wheel name mismatch:** Hatchling uses the normalized package name for the wheel filename (`vmcluster_mcp`, not `vmcluster-mcp`). This is correct per PEP 427 — do not manually rename.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Build sdist + wheel | Custom build scripts | `uv build` | Handles PEP 517/518 isolation, correct metadata embedding, reproducible artifacts |
| PyPI upload | `curl` to upload API | `uv publish` | Handles multi-file upload, retry on failure, digest verification |
| Console script shim | Manually write scripts in bin/ | `[project.scripts]` in pyproject.toml | pip/uv generates platform-correct shims at install time |
| License file | Inline license in README | `LICENSE` file at repo root | PyPI expects a separate file; `license-files = ["LICENSE"]` is the standard pattern |

**Key insight:** The entire build and publish workflow is two commands: `uv build` then `uv publish`. The complexity is in getting the metadata right in `pyproject.toml`, not in the tooling.

---

## Common Pitfalls

### Pitfall 1: libvirt-python Requires System Headers on Install

**What goes wrong:** Users run `pip install vmcluster-mcp` and get `Failed building wheel for libvirt-python` with errors about missing `libvirt/libvirt.h` or `pkg-config` not finding `libvirt`.

**Why it happens:** `libvirt-python` is a C extension package. When PyPI does not have a pre-built wheel matching the user's exact platform/Python version, pip falls back to building from source — which requires `libvirt-dev` headers, `pkg-config`, and `gcc` to be present.

**How to avoid:** README already covers this in the Prerequisites section. D-12 confirms it must be documented. This is not a packaging defect — it is inherent to the dependency. No change to `pyproject.toml` dependencies is needed (D-11 locks `libvirt-python` as hard dep).

**Warning signs:** User reports `error: command 'gcc' failed` or `Package 'libvirt', required by 'virtual:world', not found` during `pip install vmcluster-mcp`.

### Pitfall 2: Package Name Normalization

**What goes wrong:** The PyPI project name is `vmcluster-mcp` (hyphenated) but the Python import is `vmcluster_mcp` (underscored). Confusion arises when manually specifying paths or checking wheel contents.

**Why it happens:** PEP 503 normalizes package names: hyphens and underscores are interchangeable. The wheel filename will contain `vmcluster_mcp` (underscored). `pip install vmcluster-mcp` and `pip install vmcluster_mcp` both work after publish.

**How to avoid:** No action needed — hatchling handles this correctly. Just be aware of the distinction when checking `unzip -l dist/*.whl`.

### Pitfall 3: Missing `[project.scripts]` Causes uvx Failure

**What goes wrong:** Publishing without `[project.scripts]` means `uvx vmcluster-mcp` fails with "No such entry point" or silently does nothing.

**Why it happens:** `uvx` runs the console script entry point. If none is defined, there is nothing to execute.

**How to avoid:** Verify `[project.scripts]` is present and points to a real callable before building. After local wheel install, run `vmcluster-mcp --help` or any invocation to confirm the shim was created.

### Pitfall 4: PyPI API Token Scope

**What goes wrong:** `uv publish` fails with 403 Forbidden even with a valid token.

**Why it happens:** PyPI tokens can be scoped to a single project. If the token was created before the project exists on PyPI (first publish), it cannot be project-scoped yet — it must be an account-wide token for the first upload.

**How to avoid:** For the first publish, use an account-wide API token. After the project exists on PyPI, a project-scoped token can be created for subsequent publishes.

### Pitfall 5: `uv build` Produces Both sdist and wheel by Default

**What goes wrong:** Running `uv publish` uploads both sdist and wheel. If `dist/` contains stale artifacts from a previous version, they get uploaded too.

**Why it happens:** `uv publish` defaults to `dist/*` — it uploads everything in the dist directory.

**How to avoid:** Clean `dist/` before building: `rm -rf dist/ && uv build`. Or specify exact files: `uv publish dist/vmcluster_mcp-1.0.0-py3-none-any.whl dist/vmcluster_mcp-1.0.0.tar.gz`.

---

## Code Examples

### Complete Minimal pyproject.toml Changes

```toml
# Source: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
[project]
name = "vmcluster-mcp"
version = "1.0.0"                          # bumped from 0.1.0 per D-04
description = "MCP server for autonomous multi-VM cluster orchestration on libvirt/QEMU"
readme = "README.md"                        # D-06
license = "MIT"                             # D-07 (PEP 639 SPDX format)
requires-python = ">=3.11"
authors = [
    {name = "Your Name", email = "you@example.com"},   # D-10
]
keywords = ["mcp", "libvirt", "qemu", "kvm", "vm", "cluster", "testing"]   # D-09
classifiers = [                             # D-08
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.11",
    "License :: OSI Approved :: MIT License",
    "Operating System :: POSIX :: Linux",
    "Topic :: Software Development :: Testing",
    "Topic :: System :: Systems Administration",
]
dependencies = [
    "mcp[cli]>=1.26.0",
    "pydantic>=2.12.5",
    "asyncssh>=2.22.0",
    "libvirt-python>=12.1.0",
    "PyYAML>=6.0.3",
]

[project.scripts]                           # D-01
vmcluster-mcp = "vmcluster_mcp.__main__:main"

[project.urls]                              # Claude's discretion — recommended
Homepage = "https://github.com/hornc/vmcluster-mcp"
Repository = "https://github.com/hornc/vmcluster-mcp"
Issues = "https://github.com/hornc/vmcluster-mcp/issues"
```

### Updated `__main__.py`

```python
# Source: D-02 from CONTEXT.md + https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
"""Entry point for python -m vmcluster_mcp."""

from .server import mcp


def main():
    mcp.run()


if __name__ == "__main__":
    main()
```

### Build and Verify Workflow

```bash
# 1. Clean and build
rm -rf dist/
uv build

# 2. Inspect wheel contents (D-14)
unzip -l dist/vmcluster_mcp-1.0.0-py3-none-any.whl | grep vmcluster_mcp/

# 3. Test local install (D-15)
pip install dist/vmcluster_mcp-1.0.0-py3-none-any.whl
vmcluster-mcp --help        # or any no-op invocation to confirm entry point

# 4. Publish (D-16, D-17)
uv publish --token $UV_PUBLISH_TOKEN
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `python setup.py bdist_wheel` | `uv build` | PEP 517 (2015), uv matured 2024 | No setup.py needed; hatchling handles all |
| `twine upload` | `uv publish` | uv 0.4+ (2024) | Single tool for build + publish |
| `setup.cfg` / `setup.py` for metadata | `pyproject.toml` `[project]` table | PEP 621 (2020), widely adopted 2022+ | Single standardized metadata file |
| `license = {file = "LICENSE"}` | `license = "MIT"` (SPDX string) | PEP 639 (2024) | Simpler syntax; PyPI accepts both |
| username/password for PyPI | API token only | 2023 | PyPI deprecated password auth |

**Deprecated/outdated:**
- `setup.py`: Do not create — hatchling renders it unnecessary
- `MANIFEST.in`: Not needed for hatchling; it uses its own include/exclude config
- `twine check` pre-upload validation: `uv publish --dry-run` is the modern equivalent

---

## Open Questions

1. **Author name and email for pyproject.toml**
   - What we know: D-10 says to add author info; the repo owner is "hornc" (from git remote)
   - What's unclear: Full name and whether to include email
   - Recommendation: Claude's discretion per CONTEXT.md — use git config name/email if available, or a reasonable placeholder

2. **license-files field**
   - What we know: PEP 639 says `license = "MIT"` (SPDX string) is the modern form; `license-files` is optional but tells build tools which files to include in distributions
   - What's unclear: Whether hatchling 1.29 requires `license-files = ["LICENSE"]` explicitly or auto-discovers it
   - Recommendation: Add `license-files = ["LICENSE*"]` as a defensive measure — hatchling will auto-include `LICENSE` files but being explicit avoids surprises

3. **README install instructions update**
   - What we know: README currently shows `uv` install from local repo; the PyPI publish path should be added
   - What's unclear: Whether the planner should include a README update task
   - Recommendation: Add a task to update the Installation section of README.md to show `pip install vmcluster-mcp` as the primary install path

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| uv | `uv build`, `uv publish` | Yes | 0.10.12 | — |
| python3 | Test runner, venv | Yes | 3.12.3 (system), 3.11 (venv) | — |
| pytest | Test validation | Yes | in .venv | — |
| twine | PyPI upload fallback | No | — | `uv publish` is the primary tool; no fallback needed |
| PyPI API token | `uv publish` authentication | Unknown | — | Cannot publish without one; user must create it at pypi.org |

**Missing dependencies with no fallback:**
- **PyPI API token**: Required for `uv publish`. Must be created manually by the user at https://pypi.org/manage/account/token/ before the publish step. The plan should include a manual step noting this prerequisite.

**Missing dependencies with fallback:**
- None.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | pytest 9.0.2 |
| Config file | `pyproject.toml` `[tool.pytest.ini_options]` |
| Quick run command | `.venv/bin/pytest tests/test_server.py -q` |
| Full suite command | `.venv/bin/pytest tests/ -q` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| INST-01 | `vmcluster-mcp` entry point callable `main()` exists in `__main__` | unit | `.venv/bin/pytest tests/test_entrypoint.py -x` | No — Wave 0 |
| INST-01 | `[project.scripts]` references a real importable callable | smoke | `.venv/bin/pytest tests/test_entrypoint.py -x` | No — Wave 0 |
| INST-01 | Wheel contains all `vmcluster_mcp/` subpackages | smoke (manual) | `unzip -l dist/*.whl \| grep vmcluster_mcp/` | No — manual step |

**Note on `main()` unit test:** The test can import `vmcluster_mcp.__main__` and assert `main` is a callable, without actually calling `mcp.run()` (which would require libvirt). This is lightweight and automatable.

### Sampling Rate

- **Per task commit:** `.venv/bin/pytest tests/test_server.py tests/test_entrypoint.py -q` (existing + new)
- **Per wave merge:** `.venv/bin/pytest tests/ -q`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tests/test_entrypoint.py` — covers INST-01: asserts `main` is callable in `vmcluster_mcp.__main__`, verifies `[project.scripts]` key exists in pyproject.toml

*(Existing test infrastructure covers all other tests — only the new entry point needs a new test file.)*

---

## Sources

### Primary (HIGH confidence)

- [Python Packaging User Guide — Writing pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) — classifiers, project.scripts, project.urls, readme/license fields
- [uv — Building and publishing a package](https://docs.astral.sh/uv/guides/package/) — `uv build --no-sources` recommendation, `uv publish` token auth, retry-safe upload
- PyPI JSON API: `https://pypi.org/pypi/vmcluster-mcp/json` returned 404 — package name is available (verified 2026-03-24)
- `uv build --help` / `uv publish --help` — confirmed both subcommands available in uv 0.10.12 on this machine

### Secondary (MEDIUM confidence)

- [hatchling · PyPI](https://pypi.org/project/hatchling/) — current version 1.29.0 verified via PyPI JSON API
- [setuptools Entry Points docs](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) — console_scripts pattern confirmed consistent with `[project.scripts]`
- [uv Tools documentation](https://docs.astral.sh/uv/concepts/tools/) — uvx uses console script entry points; no additional config needed

### Tertiary (LOW confidence)

- WebSearch: libvirt-python build failures — C extension requires system headers; consistent across multiple community reports (2024)

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — uv is installed and both subcommands verified; hatchling already in use
- Architecture: HIGH — pyproject.toml patterns verified against official Python Packaging User Guide
- Pitfalls: HIGH (token scoping, entry point callable requirement, dist/ cleanup) / MEDIUM (libvirt-python system headers — well-documented community issue)

**Research date:** 2026-03-24
**Valid until:** 2026-09-24 (packaging tooling is stable; uv version may update but API is backward-compatible)
