An installable MCP server needs the same release discipline as any Python
package: lint and test every change, build the artifact, and publish on a tagged
release. This tutorial wires that pipeline with GitHub Actions and uv —
ruff for linting and formatting, pytest for tests, uv build for the
wheel/sdist, and PyPI trusted publishing (OIDC, no stored token) on a version
tag. It is the CI/CD companion to Publish a FastMCP Server to PyPI and Run It
Anywhere with uvx.
The whole pipeline runs locally with one make target before it ever reaches
GitHub, so you develop against the exact steps CI runs.
What you will build
- An installable FastMCP package (
mcp-ci-demo) with a--checksmoke command. ruff,pytest, anduv buildwired through aMakefilewhosecitarget mirrors the pipeline.- A GitHub Actions workflow with three jobs: a test matrix (Python 3.12 and 3.13), a build job that smoke-tests the wheel, and a tag-gated publish job using trusted publishing.
- Dependency caching, least-privilege permissions, and run cancellation.
Prerequisites
- uv 0.11+ (
brew install uv). Validated on uv 0.11.26,ruff0.15.22, Python 3.12.9, FastMCP 3.4.4. - A GitHub repository to hold the project (the workflow runs there).
- For publishing: a PyPI account and a configured trusted publisher (Step 8). You do not need either to build and validate the pipeline locally.
- macOS commands are shown; the pipeline runs on
ubuntu-latest.
Step 1: Scaffold the package
Use uv’s packaged layout so the project has a src/ module and a console script,
the same shape you publish to PyPI.
Create the files
mkdir -p ci-cd-mcp-server-macos
cd ci-cd-mcp-server-macos
touch .gitignore
uv init --package --name mcp-ci-demo .
uv add "fastmcp>=3.4.4"
uv add --dev "pytest>=9" "pytest-asyncio>=1.4" "ruff>=0.15"
Add the code: .gitignore
.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
dist/
.DS_Store
*.log
Add the code: pyproject.toml
[project]
name = "mcp-ci-demo"
version = "0.1.0"
description = "A FastMCP server with a lint/test/build/publish GitHub Actions pipeline"
readme = "README.md"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[project.scripts]
mcp-ci-demo = "mcp_ci_demo:main"
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9",
"pytest-asyncio>=1.4",
"ruff>=0.15",
]
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
# A small, opinionated rule set: pyflakes (F), pycodestyle (E/W), isort (I),
# flake8-bugbear (B), and pyupgrade (UP). Enough to catch real problems in CI
# without bikeshedding.
select = ["E", "W", "F", "I", "B", "UP"]
Detailed breakdown
uv init --packagecreates thesrc/mcp_ci_demo/layout, the[project.scripts]console script (mcp-ci-demo), and theuv_buildbackend — everything needed foruv buildto produce a wheel.dist/is in.gitignore. Build artifacts are regenerated by CI and byuv build; they never belong in git.ruffis a dev dependency, pinned in the lockfile, so CI runs the same linter version you do — no drift between your machine and the runner. The[tool.ruff.lint] selectlist is committed config, soruff checkbehaves identically everywhere.
Step 2: Write the server
Create the file
touch src/mcp_ci_demo/server.py
Add the code: src/mcp_ci_demo/server.py
"""A small, installable FastMCP server.
Nothing here is unusual for a FastMCP server — this project is about the
*pipeline* that lints, tests, builds, and publishes it. Transport is chosen at
runtime so the same entry point works as a stdio subprocess or over HTTP.
"""
import os
from fastmcp import FastMCP
mcp = FastMCP("mcp-ci-demo")
@mcp.tool
def greet(name: str) -> str:
"""Return a greeting for name."""
return f"Hello, {name}! Served by mcp-ci-demo."
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
def run() -> None:
"""Start the server on the transport named by MCP_TRANSPORT (default stdio)."""
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio: launched as a subprocess by a client
Detailed breakdown
- Two tools (
greet,add) are enough to have something to test. The focus is the pipeline, not the server surface. runpicks the transport fromMCP_TRANSPORT. The defaultstdiopath is what a client launches; the HTTP branch is there so the same published command serves remotely if needed. CI never starts the server — it uses the--checkpath from Step 3.
Step 3: The package entry point
The console script needs a main. Add a --check flag that imports the package
and lists its tools without starting the server — a fast way for CI to prove the
built artifact is importable and wired correctly.
Create the file
uv init --package created src/mcp_ci_demo/__init__.py. Replace its contents.
Add the code: src/mcp_ci_demo/__init__.py
"""Package entry point.
`main` is the target of the `mcp-ci-demo` console script declared in
pyproject.toml, so the installed command (and `uvx mcp-ci-demo` once published)
calls it. A `--check` flag verifies the package resolves, imports, and registers
its tools without starting the server — the CI pipeline runs it as a fast smoke
test of the built artifact.
"""
import asyncio
import sys
from .server import mcp, run
def _check() -> None:
"""Print server identity and tools, then exit — verifies an install."""
tools = asyncio.run(mcp.list_tools())
names = sorted(t.name for t in tools)
print(f"mcp-ci-demo OK — tools: {names}")
def main() -> None:
if "--check" in sys.argv[1:]:
_check()
return
run()
Detailed breakdown
mainis the[project.scripts]target. After a build,mcp-ci-demo(installed or viauvx) calls it. With no flag it starts the server; with--checkit runs_checkand exits 0._checkis the artifact smoke test. It asserts nothing, but it imports the package, builds the server, and lists tools — so a broken import or a bad entry point fails immediately. The build job runs it against the freshly built wheel, proving the published form works, not just the source tree.
Step 4: Tests
Create the files
touch pytest.ini tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_server.py
"""Drive the server in-memory so the package is proven before it is built."""
from fastmcp import Client
from mcp_ci_demo.server import mcp
async def test_tools_are_registered():
async with Client(mcp) as client:
names = sorted(t.name for t in await client.list_tools())
assert names == ["add", "greet"]
async def test_greet():
async with Client(mcp) as client:
result = await client.call_tool("greet", {"name": "Ada"})
assert result.data == "Hello, Ada! Served by mcp-ci-demo."
async def test_add():
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 40, "b": 2})
assert result.data == 42
Detailed breakdown
- The tests import the installed package (
mcp_ci_demo.server), not a loose file. Because the package is installed into the environment byuv sync, thesrc/layout resolves without apythonpathhack. Client(mcp)drives the server in-memory, so the suite is fast and deterministic — no subprocess, no network.test_addpins40 + 2 = 42.
Step 5: The Makefile
Wrap every pipeline step, and add a ci target that runs them in the same order
GitHub will.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help sync lint format test build check ci clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-8s %s\n", $$1, $$2}'
sync: ## Install dependencies from the lockfile
uv sync --locked
lint: ## Lint with ruff
uv run ruff check .
format: ## Check formatting with ruff
uv run ruff format --check .
test: ## Run the test suite
uv run pytest -q
build: ## Build the sdist and wheel into dist/
uv build
check: build ## Smoke-test the built wheel with uvx
uvx --from dist/*.whl mcp-ci-demo --check
ci: lint format test build check ## Run the whole pipeline locally (mirrors CI)
@echo "local CI passed"
clean: ## Remove build artifacts and caches
rm -rf dist .pytest_cache .ruff_cache __pycache__ src/*/__pycache__ tests/__pycache__
Detailed breakdown
cichainslint format test build check, the exact steps the workflow runs. Runningmake cilocally reproduces a CI run before you push — the fastest way to avoid a red build.checkdepends onbuildand installs the freshly built wheel withuvx --from dist/*.whl, so it exercises the published artifact, not the source tree..DEFAULT_GOAL := helpprints the target list on baremake. Recipes are tab-indented.
Step 6: Run the pipeline locally
Before touching GitHub, run the whole thing:
make ci
uv run ruff check .
All checks passed!
uv run ruff format --check .
3 files already formatted
uv run pytest -q
... [100%]
3 passed in 0.28s
uv build
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/mcp_ci_demo-0.1.0.tar.gz
Successfully built dist/mcp_ci_demo-0.1.0-py3-none-any.whl
uvx --from dist/*.whl mcp-ci-demo --check
Installed 67 packages in 35ms
mcp-ci-demo OK — tools: ['add', 'greet']
local CI passed
Green locally means the same steps will pass on the runner. That is the point of
mirroring the pipeline in a make target: CI holds no surprises.
Step 7: The GitHub Actions workflow
This is the deliverable. Three jobs: test (a version matrix), build (which
depends on test and smoke-tests the wheel), and publish (which depends on
build and runs only on a version tag).
Create the file
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Add the code: .github/workflows/ci.yml
name: CI
# Run on every push to main and every PR, plus on version tags (which also
# publish). Tag pushes match `v*`, e.g. `v0.1.0`.
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Least privilege by default; the publish job widens this for OIDC below.
permissions:
contents: read
# Cancel superseded runs on the same ref to save minutes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Lint & test (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true # cache the uv download cache across runs
- name: Install dependencies
run: uv sync --locked --python ${{ matrix.python-version }}
- name: Lint
run: uv run ruff check .
- name: Format check
run: uv run ruff format --check .
- name: Test
run: uv run pytest -q
build:
name: Build & smoke-test the wheel
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Build sdist and wheel
run: uv build
- name: Smoke-test the built wheel
run: uvx --from dist/*.whl mcp-ci-demo --check
- name: Upload the distribution
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: build
# Only on a version tag, never on a branch push or PR.
if: startsWith(github.ref, 'refs/tags/v')
environment: pypi
permissions:
id-token: write # mint a short-lived PyPI token via OIDC (trusted publishing)
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build sdist and wheel
run: uv build
- name: Publish
run: uv publish --trusted-publishing always
Detailed breakdown
- Triggers: the workflow runs on pushes to
main, on pull requests, and on tags matchingv*. A tag push runstestandbuildand thenpublish, because a tag both validates and releases. permissions: contents: readat the top is least privilege: the whole workflow can only read the repo. Thepublishjob re-declaresid-token: write, the only extra scope it needs, for OIDC.concurrencycancels superseded runs on the same ref, so pushing twice in a row does not burn a full run on the stale commit.astral-sh/setup-uvwithenable-cache: trueinstalls uv and caches its download cache keyed on the lockfile, so dependency installs are fast on repeat runs.uv sync --lockedinstalls the exact pinned versions and fails if the lockfile is out of date — reproducible builds, not “latest that resolves.”- The
testmatrix runs Python 3.12 and 3.13 withfail-fast: false, so one version failing still reports the other.uv sync --python ${{ ... }}selects the interpreter per matrix leg. buildneedstest— it never runs unless every matrix leg is green — then builds, smoke-tests the wheel withuvx --from dist/*.whl mcp-ci-demo --check, and uploadsdist/as an artifact you can download from the run.publishneedsbuild, is gated byif: startsWith(github.ref, 'refs/tags/v'), and targets thepypienvironment. It usesuv publish --trusted-publishing always, which exchanges the job’s OIDC token for a short-lived PyPI token — there is noPYPI_TOKENsecret to store or leak.
Step 8: Publish on a tag with trusted publishing
The publish job needs a one-time setup on PyPI, then every release is just a
tag.
Register the project on PyPI (or use a pending publisher for a new name): on the project’s Publishing settings, add a GitHub trusted publisher with your repository, the workflow filename (
ci.yml), and the environment name (pypi).Create the
pypienvironment in the GitHub repo settings (Settings → Environments). Optionally require a reviewer, so a human approves each release.Tag and push:
git tag v0.1.0 git push origin v0.1.0
The tag push triggers the workflow; test and build run, and on success
publish exchanges its OIDC token for a PyPI token and uploads the wheel and
sdist. Once published, anyone can run the server with uvx mcp-ci-demo.
Bump version in pyproject.toml for each release — PyPI refuses to overwrite an
existing version. Because publishing is irreversible, treat it as a deliberate,
authorized action: this tutorial validates everything up to the tag and leaves the
actual upload to you.
Validate the workflow before pushing
A YAML typo or an invalid expression should not cost a round-trip to GitHub. Lint
the workflow locally with actionlint:
brew install actionlint # or download the single binary
actionlint .github/workflows/ci.yml
No output and exit code 0 means the workflow is well-formed — job dependencies resolve, expressions parse, and step keys are valid. This catches the mistakes that otherwise fail only after a push.
Troubleshooting
uv sync --lockedfails in CI with “lockfile out of date.” You changedpyproject.tomlwithout updatinguv.lock. Runuv lock(oruv sync) locally and commit the lockfile.- The publish job is skipped. It runs only on a
v*tag. A push tomainrunstestandbuildbut notpublish— that is by design. uv publishfails with an OIDC/permity error. Confirm the job hasid-token: write, thepypienvironment exists, and the PyPI trusted publisher matches the repo, workflow filename, and environment exactly.ruff format --checkfails CI butruff checkpasses. They are different:checkis lint rules,format --checkis formatting. Runuv run ruff format .to fix formatting, then commit.- A matrix leg fails only on 3.13. Some dependency lacks a 3.13 wheel or a
behavior changed. Reproduce locally with
uv run --python 3.13 pytest.
Recap
- The pipeline is lint → format-check → test → build → smoke-test → publish,
each step a
uv/ruff/pytestcommand that also runs locally viamake ci. - The workflow uses
astral-sh/setup-uvwith caching, a Python matrix, least-privilege permissions, and run cancellation — the pieces that keep CI fast, reproducible, and safe. - Publishing is tag-gated and uses trusted publishing (OIDC), so there is no long-lived PyPI token in the repo.
- Validate the workflow with
actionlintbefore pushing, and reproduce a CI run withmake ci.
Next improvements
- Add a lint/type job with
mypyortyalongsiderufffor static typing. - Publish to TestPyPI first from a pre-release tag to rehearse the release end to end (see Publish a FastMCP Server to PyPI and Run It Anywhere with uvx).
- Attach the artifacts to a GitHub Release with
softprops/action-gh-releaseso each tag ships downloadable wheels. - Add a Docker image job that builds and pushes a container next to the wheel,
for clients that run the server via
docker run.