Skip to main content
Glama

FastMCP Polyrepo Template

CI Audit

Production-grade FastMCP template built around dependency injection and zero global state. A composition root wires clients, services, and loggers so tests inject dependencies instead of monkeypatching; Cursor rules keep AI edits copying this architecture. Layered import DAG, Testcontainers, Prometheus, and Kubernetes probes included.

Table of Contents

Related MCP server: MCP Template

Highlights

  • Dependency injection, zero global state ๐Ÿงฉ: Every component takes dependencies in __init__; LifecycleManager (the composition root) constructs the clients, services, and loggers and wires them โ€” main.py only builds AppConfig and that manager. No module-level config, logger, or client, so tests inject dependencies instead of monkeypatching or process-wide setup.

  • Three-layer import DAG, enforced ๐Ÿงญ: The protocol surface (http_routes / mcp_primitives / middlewares / prometheus), services, and clients stay loosely coupled so you can test a service without FastMCP and a client without a registrator. Packages import downward only; lint-imports fails cycles and FastMCP leaking into services or clients.

  • Sample vertical slice ๐Ÿšฉ: UserNotesService plus its tool, resource, and prompt, Prometheus series, and matching integration and e2e tests. Copy that pattern for the next feature.

  • Nested Pydantic config โš™๏ธ: AppConfig composes resource-specific models (redis, logger, prometheus, โ€ฆ). Invalid config fails at startup, not on the first request.

  • Structured JSON logging ๐Ÿ“ก: Every line carries prod-grade fields (app, instance, env, app.version, correlation_id). Each component gets a named child logger with its own context, so you can filter easily โ€” with grep, or with observability tools such as Groundcover, Grafana, and Datadog.

  • Unit, integration, and e2e from day 1 ๐Ÿงช: pytest is wired for all three: in-process units, Testcontainers against real Redis, and the real app through LifecycleManager. The notes slice is covered at each layer.

  • Cursor rules that keep the conventions ๐Ÿค–: Checked-in .cursor/rules encode layers, logging, tests, and metrics so AI-assisted edits copy this architecture instead of inventing a new one.

  • Production-shaped ops ๐Ÿšข: Non-root multi-stage image, Kubernetes liveness and readiness probes (GET /livez, GET /readyz), Prometheus scrape (GET /metrics), and GitHub Actions CI. HTTP-aware Prometheus series live in prometheus/; the notes counters live in metrics/.

  • Supply-chain hygiene ๐Ÿ”’: SHA-pinned Actions, weekly Dependabot for the uv lockfile and GitHub Actions, and a separate uv audit workflow on push/PR plus a weekly schedule so a CVE published while main is idle still fails the Audit badge.

Why this template

Unlike opinionated application frameworks such as NestJS, Spring, and .NET, FastMCP does not prescribe a strict architecture. Without widely shared project conventions, tightly coupled components, module-level globals, and related anti-patterns are common in Python codebases, in part because package docs favor short examples that use global state for brevity. The risk is sharper in AI-assisted development: each existing pattern becomes the baseline the next change copies. This template is a starting architecture that follows best practices from the first commit, so you can spend the remaining effort on the product.

The guiding constraint: nothing important is reachable through a module-level global. No global config object, no global logger, no import-time singletons. Every component declares what it needs in its __init__, and a single composition root (LifecycleManager) wires those dependencies together. That is what makes the code testable without monkeypatching and without import-order surprises.

This template does not ship a database. For an MCP server, a database is not the usual resource: a tool is more likely to call a service client than to query a database itself. Redis is here only so the notes sample has a store.

Architecture

The application is a three-layer stack. Imports point downward only.

                         main.py
                            |
                       lifecycle/          composition root: builds the graph
                            |
   . . . . . . . . . . . . .|. . . . . . . . . . . . . . . . . . . .
                            v
   Protocol surface   http_routes/  mcp_primitives/  middlewares/  prometheus/
                                                custom HTTP routes, MCP primitives,
                                                ASGI middleware, scrape endpoint
                                                siblings: none of the four imports another
   . . . . . . . . . . . . .|. . . . . . . . . . . . . . . . . . . .
                            v
                       state/                  typed AppState + accessors
   . . . . . . . . . . . . .|. . . . . . . . . . . . . . . . . . . .
                            v
   Service layer       services/               business logic
   . . . . . . . . . . . . .|. . . . . . . . . . . . . . . . . . . .
                            v
   Data access layer   clients/                redis, external APIs

   Imports point downward only. A client never imports a service, and never
   imports FastMCP.

   Cross-cutting, importable from any layer, importing none of them:
       models/     config/     logger/     metrics/     distribution/

A registrator calls a service; a service calls a client. A Redis client that imports FastMCP is the canonical violation: it creates a cycle between the HTTP and data access layers.

lifecycle/ is the one package allowed to import every layer โ€” that is what a composition root is for. models/, config/, logger/, metrics/ and distribution/ sit below everything so they cannot import back up.

The import graph must stay a DAG. Python tolerates cycles; this codebase does not. Adding a new top-level package fails the contract until it is placed in the hierarchy, so every insertion is forced to declare where it sits. uv run lint-imports enforces both rules from pyproject.toml.

Avoiding hidden cyclic dependencies

A cycle is not a style nit. Python will import A while B is still half-initialized, so construction order becomes whoever got imported first instead of a decision the composition root made. Tests then cannot build one dependency without dragging in the other, which is how suites slide into monkeypatching and process-wide setup. The stack is a DAG so LifecycleManager can start resources in topological order and tear them down in reverse; a cycle turns that into a race of who exists before whom.

Import Linter only sees directory-sized layers: services/ must not import http_routes/. Two modules in the same package โ€” orders_service.py importing billing_service.py and back โ€” still compile, still pass uv run lint-imports, and are still a cycle. Same-layer packages listed with | (http_routes | mcp_primitives | middlewares | prometheus) are the sibling set the linter does catch. Everything else at one level is the developer's job.

Layout

Path

Contents

src/app/config/

Pydantic settings and immutable protocol constants

src/app/lifecycle/

The composition root: LifecycleManager, startup and shutdown

src/app/state/

Typed AppState and the accessors handlers call

src/app/logger/

LogManager, the factory for injectable structured child loggers

src/app/metrics/

Prometheus collectors injected into services; not HTTP-aware

src/app/distribution/

Installed dist metadata for the app import (app.version)

src/app/models/

Shared Pydantic models used by services and registrators

src/app/clients/

Data access clients for Redis and external APIs

src/app/services/

Business services; the layer registrators and middleware depend on

src/app/http_routes/

Custom HTTP registrators (register_k8s_probes)

src/app/mcp_primitives/

MCP registrators. The shipped example is register_user_notes_primitives

src/app/middlewares/

ASGI middleware wrapping the HTTP surface

src/app/prometheus/

HTTP instrumentation: scrape endpoint and HTTP-aware series

src/app/main.py

Process entry point

compose.yaml

Local Redis only

Dockerfile

Production image; the default command starts the server

Each src/app/ directory has its own README explaining what belongs there.

Configuration

Configuration is a composition of resource-specific Pydantic models under a single AppConfig (see src/app/config/README.md). Environment variables follow the nested model path, joined by __: AppConfig.redis.HOST comes from REDIS__HOST. .env.example documents every supported variable.

Validation is strict and fails at startup rather than at first use: ports are range-checked, passwords are SecretStr, and cross-cutting rules (such as forbidding debug logging in production) are enforced by model validators.

Logging

Every record is one JSON object, including uvicorn startup lines, so the same keys can be filtered in grep or in Grafana, Datadog, or Groundcover. There is no process-wide structlog.configure(); LogManager stamps identity on each record and hands out a named child, which is what makes a test inject a logger instead of patching a global.

{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-09-22T05:30:41.991882Z", "msg": "Started server process [31796]"}

That line has no correlation_id: it is process startup, not a request. The keys on it:

  • context โ€” the component (get_child_logger name, a route's name=, or a bridge such as uvicorn)

  • env โ€” local / dev / staging / prod

  • app โ€” fixed APP_NAME, not an env var

  • instance โ€” hostname of this process

  • app.version โ€” installed distribution version, injected by the composition root

  • level, ts (UTC, Z suffix), msg

An HTTP line also carries correlation_id, bound by CorrelationIdMiddleware so the route, service, and client share it.

LogManager receives a LoggerConfig and hands out children through get_child_logger(name). Components take a logger in __init__ and never call a global one.

Uncaught asyncio, Starlette Exception, and sys.excepthook paths, plus the uvicorn.error, uvicorn.access, and fastmcp stdlib loggers, are rewritten onto those children. context values and LOGGER__ENABLE_UVICORN_ACCESS_LOGS are documented in src/app/logger/README.md. A start-to-stop process-lifetime transcript is under Running locally.

Demonstrative Feature - User Notes

UserNotesService is the sample, registered as the user-notes.add_note tool, the user-notes://notes resource, and the user-notes.get_prompt prompt. Those primitives are registered in src/app/mcp_primitives/register_user_notes_primitives.py. Each feature gets its own register_<feature> function. The function takes that feature's dependencies as arguments, and LifecycleManager injects them.

Logging

A UserNotes call also carries tenant_id and user_id. get_user_details is a Depends factory and an async context manager: FastMCP enters it before the primitive body and exits it after the body returns, and the body binds those two ids with structlog context variables. Most primitives do not log. The service logger picks up the same ids because it runs inside that call. Do not add middleware for X-Tenant-ID or X-User-ID, and do not pass those ids on each info().

{"context": "user-notes.add_note", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "api-1", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T00:00:00.000000Z", "msg": "user_notes.add_note", "correlation_id": "6f1d3c2a-9b4e-4c11-8a2f-0e7b1d4a9c33", "tenant_id": "tenant-a", "user_id": "user-1"}

Those two fields are present for the whole primitive call, including the service log. A unit or integration test that calls UserNotesService directly never enters get_user_details, so the ids are absent there.

Running locally

Requirements

  • Python 3.14 (pinned in .python-version; uv can install it)

  • uv for dependency management

  • Docker Desktop (Compose v2) for local Redis

Setup

uv sync
uv run pre-commit install
cp .env.example .env

uv sync installs the dev tools. uv run pre-commit install registers the Git hook once per clone so commits run the checks in Linting and type checking.

.env.example is a working local configuration for the Compose stack, not a list of placeholders. Copy it before docker compose up so Compose interpolation and the app share one file. .env is git-ignored.

Compose and serve

Compose is local-only: it runs Redis on loopback. The HTTP process stays on the host so .env can keep 127.0.0.1 instead of Docker DNS names.

From the repository root:

docker compose up -d --wait
uv run app

The MCP endpoint is POST http://127.0.0.1:8080/mcp (SERVER__MCP_PATH). Kubernetes probes are unprefixed GET /livez and GET /readyz. Prometheus scrapes unprefixed GET /metrics. The server is stateless streamable HTTP: no session id, and one request may stream progress unless SERVER__ENABLE_SSE_STREAM_RESPONSE is false.

{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.000000Z", "msg": "lifecycle.app.creating"}
{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.000375Z", "msg": "lifecycle.app_state.building"}
{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.003880Z", "msg": "lifecycle.features.registering"}
{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.073136Z", "msg": "lifecycle.middlewares.registering"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.271118Z", "msg": "Started server process [20496]"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.271438Z", "msg": "Waiting for application startup."}
{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.290013Z", "msg": "lifecycle.resources.initializing"}
{"context": "RedisClient", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.290311Z", "msg": "redis.starting"}
{"context": "RedisClient", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.296947Z", "msg": "redis.started"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.298540Z", "msg": "Application startup complete."}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:00.299524Z", "msg": "Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.500831Z", "msg": "Shutting down"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.609072Z", "msg": "Waiting for application shutdown."}
{"context": "LifecycleManager", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.610610Z", "msg": "lifecycle.resources.tearing_down"}
{"context": "RedisClient", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.611250Z", "msg": "redis.stopping"}
{"context": "RedisClient", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.612774Z", "msg": "redis.stopped"}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.613958Z", "msg": "Application shutdown complete."}
{"context": "uvicorn", "env": "local", "app": "fastmcp-polyrepo-template", "instance": "MacBook-Pro.local", "app.version": "1.0.0", "level": "info", "ts": "2026-10-03T09:24:43.614787Z", "msg": "Finished server process [20496]"}

Stop the dependency stack with docker compose down.

Hosts in .env are 127.0.0.1, not localhost, so Windows does not stall on IPv6 while Docker Desktop publishes IPv4 only. Inside a container network, point REDIS__HOST at the service name redis.

One process per container

Uvicorn can take a live ASGI app (what main.py passes) or an import string such as app.main:app. The string exists so a new process can import a fresh app; you cannot hand a live in-memory graph to a child. --reload (restart on file change) and --workers N (N processes behind one master) both need that string. --reload is a laptop convenience and stays out of the entry point.

--workers is the classic one-VM pattern (Gunicorn-style workers on a single box). This README's production shape is Kubernetes: one app process per container, N Pods for throughput. Liveness and readiness probes, SIGTERM, memory limits, and HPA all assume that. The same parallelism is several small Pods (about 1โ€“2 cores each). The scheduler, rolling deploys, and "this replica is sick" then match one process. Eight workers inside one container look like one container to the kubelet: CPU and memory requests become a guess, and a kill of the container kills every worker.

Container image

The Dockerfile is a production-style image (multi-stage, non-root, frozen lockfile, no .env). The virtualenv is owned by root; the runtime user cannot rewrite it. It is not used by the default local loop.

docker build -t fastmcp-polyrepo-template .

The default command starts the server:

docker run --rm -p 8080:8080 --env-file .env fastmcp-polyrepo-template

Production deploy

This repository does not ship Helm or Kubernetes YAML.

Testing

uv run pytest

Three trees, one invocation. Layout under each tree mirrors src/app/. Docker Desktop must be running for integration and e2e; uv run pytest tests/unit does not start containers.

uv run pytest tests/unit
uv run pytest tests/integration
uv run pytest tests/e2e
  • Unit (tests/unit/): In-process. Redis is an injected double (AsyncMock), so nothing binds a socket.

  • Integration (tests/integration/): A service against throwaway Redis via Testcontainers. The directory, README, and redis_client fixture are in place; test modules arrive with the next Redis-backed use case.

  • End-to-end (tests/e2e/): HTTP through LifecycleManager against the same throwaway Redis. Probe tests are the coverage that ships today.

Linting and type checking

uv run ruff format .
uv run ruff check .
uv run pyright
uv run lint-imports

Ruff both formats and lints: 100-column lines, double quotes, and a broad rule set that includes Google-style docstring checks, import sorting, and a ban on print. Formatting is the formatter's job alone โ€” the quote and comma lint rules are deliberately left off because they fight it.

Pyright runs in strict mode over src and tests.

Import Linter enforces the one-directional stack: an upward import, a cycle, or a new top-level package that has not been placed in the hierarchy all fail the contract. So does a service or client that imports FastMCP or Starlette.

All three are configured in pyproject.toml, and every rule exemption there carries a comment explaining itself.

The same four commands run on git commit via pre-commit. Format in the hook is ruff format --check ., so an unformatted tree fails the commit instead of rewriting it. After uv sync, install the hook once:

uv run pre-commit install

CI

.github/workflows/ci.yml runs on pushes and pull requests to main: ruff, pyright, import-linter, the full pytest suite (unit, integration, and e2e โ€” GitHub-hosted Ubuntu has Docker, so Testcontainers can start), and docker build with no push. There is no deploy workflow: where an image goes is a product decision, not a template one.

.github/workflows/audit.yml runs uv audit --locked on those same events and weekly, so a CVE published while main is idle still fails the Audit badge. Dependabot (.github/dependabot.yml) opens weekly PRs for the uv lockfile and for GitHub Actions; outdated packages without a known CVE do not fail Audit.

Authentication

This template does not ship authentication. The scheme is a product choice (bearer token, mTLS, or none). Many deployments never authenticate in-process: an API gateway or mesh already did. Shipping one scheme would become the pattern every later change โ€” including AI-assisted ones โ€” copies.

X-Tenant-ID and X-User-ID name the caller for logs and Redis keys. They are not authentication.

Identity is request-scoped. It is a method argument, not a constructor dependency on a service. The shipped notes service takes UserDetails from Depends(get_user_details). A later bearer-token check builds that object, or the fields the method already takes. A service that imports Request or takes a credential in __init__ is the bug this layout exists to prevent.

Resolve identity in the handler, not in ASGI middleware. Kubernetes probes and GET /metrics stay callable because they never perform that check. Liveness also omits application state. Readiness checks Redis and does not require a caller identity.

This process is stateless streamable HTTP. A local stdio server can rely on the machine it runs on. This template cannot. A remote MCP server does not authenticate with a session id or a cookie. Each request carries its own credential.

Verify a bearer token in a service the composition root constructed. Pin the signing algorithms. Check audience and issuer. Do not log the token. A token issued for this server must not be accepted for another resource (resource indicators, RFC 8707), and its scopes must match the tool being called.

See src/app/state/README.md and src/app/middlewares/README.md. A uniform 401 before routing, if you add one, is registered as described in the middleware README.

Related MCP Connectors

Related MCP Servers

  • F
    license
    B
    quality
    D
    maintenance
    A production-ready MCP server template that enables developers to quickly build and deploy MCP servers with dynamic tool/resource loading, YAML-based prompts, and seamless OpenShift deployment. Supports both local development with hot-reload and production HTTP deployment with optional JWT authentication.
    1
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    A production-ready Python template for building MCP servers with enterprise features including registry integration, configuration management, structured logging, and extensible patterns for tools, resources, and prompts.
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides production-grade starter templates for MCP servers with permission boundaries, integration tests, and eval contracts, enabling rapid development of secure and testable MCP servers.
    Apache 2.0
  • A
    license
    Not graded
    quality
    D
    maintenance
    Production-ready MCP server starter with authentication, observability, and a plugin system for building and deploying MCP servers quickly.
    MIT