DEV Community

Marek Cziba
Marek Cziba

Posted on Originally published at linkedin.com

Building a 16-Server MCP Monorepo in 48 Hours

Sixteen MCP servers. One repository. Two days. 197 tests, 65 tools, and a CI pipeline that fails loudly the moment any single server breaks.

This is the story of how the mcp-servers monorepo came together — and why I deliberately chose many small servers over one big framework.

Why 16 small servers instead of one

The default instinct when building MCP tooling is to create a monolith: one server, a giant tool namespace, dozens of parameters. In practice that hurts in three ways:

  1. Context bloat. Every tool a server exposes is tokens an LLM pays for on every turn. A client that connects to a server advertising 65 tools burns context even if you only need sha256.
  2. All-or-nothing trust. Nobody wants to grant an agent access to "the everything server." hash-checksum-mcp is obviously harmless; a monolith is not.
  3. All-or-nothing installs. uvx password-generator-mcp starts in milliseconds. A framework with 40 dependencies does not.

So each server does exactly one job: hashing, base64, diffs, RSS reading, YouTube transcripts, SEO checks, unit conversion, and so on. The count of 16 is not a goal — it's just where "one tool family per server" ended up.

The repository layout

mcp-servers/
├── pyproject.toml          # workspace root: shared deps + ruff + pytest config
├── uv.lock                 # one lockfile for everything
├── .github/workflows/
│   ├── ci.yml              # lint + test matrix + build matrix
│   └── publish.yml         # PyPI via Trusted Publishing (OIDC)
└── servers/
    ├── hash-checksum-mcp/
    │   ├── pyproject.toml        # its own package, entry point, deps
    │   ├── hash_checksum_mcp/
    │   │   └── main.py           # the MCP server
    │   ├── tests/
    │   │   └── test_hash-checksum-mcp.py
    │   ├── Dockerfile
    │   └── README.md
    ├── regex-tester-mcp/
    ├── website-to-markdown-mcp/
    └── ... (16 total)
Enter fullscreen mode Exit fullscreen mode

The contract is simple: every directory under servers/ is an independently installable package, and the root is just glue.

The workspace contract

The root pyproject.toml never gets published. It exists to give every server the same dependencies, the same lint rules, and the same test configuration:

[project]
name = "mcp-servers-workspace"
dependencies = [
    "mcp>=2.0,<3",
    "httpx>=0.27.0",
    "beautifulsoup4>=4.12.0",
    # ... shared third-party libs
]

[dependency-groups]
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0", "ruff>=0.8.0"]

[tool.uv]
package = false   # root is not a package — only glue
Enter fullscreen mode Exit fullscreen mode

Each server declares its own pyproject.toml with a console entry point, which is what makes uvx work:

[project]
name = "hash-checksum-mcp"
dependencies = ["mcp>=2.0,<3"]

[project.scripts]
hash-checksum-mcp = "hash_checksum_mcp.main:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Enter fullscreen mode Exit fullscreen mode

Result: uvx hash-checksum-mcp runs with zero configuration, while uv sync at the repo root installs everything for development in one shot. One lockfile, sixteen packages.

Testing: 197 tests without a mega-suite

There is no central tests/ directory. Each server ships its own tests next to its code, and the root pytest config wires imports:

[tool.pytest.ini_options]
testpaths = ["servers"]
asyncio_mode = "auto"
pythonpath = [
    "servers/hash-checksum-mcp",
    "servers/website-to-markdown-mcp/src",
    # ... every server
]
Enter fullscreen mode Exit fullscreen mode

The tests are real, not smoke tests. The hash server validates against published NIST and RFC vectors:

async def test_sha256_nist_vector() -> None:
    assert await generate_hash("abc", "sha256") == (
        "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
    )
Enter fullscreen mode Exit fullscreen mode

CI then runs them as a matrix of 16 — one job per server — so a failure tells you exactly which server broke instead of burying you in one log with 400 tests.

The CI pipeline

Three jobs on every push:

lint:   uv run ruff check servers
        uv run ruff format --check servers
test:   matrix: 16 servers → uv run pytest servers/<name>
build:  matrix: 16 servers → python -m build per package
Enter fullscreen mode Exit fullscreen mode

Plus publish.yml, triggered on GitHub release, which ships selected packages to PyPI using Trusted Publishing — OIDC, no API tokens stored anywhere. It defaults to dry_run: true and takes an explicit packages input, so publishing one server never risks the other fifteen.

What actually bit me

The build was fast; the fixes were the real work:

  • pythonpath and src layouts. Fifteen servers use flat layouts, one uses src/. Listing them individually in pythonpath was the only reliable fix — pytest does not care about your cleverness.
  • Python version drift. The mcp SDK's uuid7 helper only exists on newer Pythons. A small fallback keeps servers installable on 3.10+, which is what requires-python = ">=3.10" promises.
  • asyncio_mode = "auto". Forgetting this is the classic MCP testing bug: every test fails with "async def functions are not natively supported" and you waste an hour suspecting the SDK.
  • Encoding on Windows. Writing UTF-8 markdown without a BOM and then reading it with an ANSI codepage mangles every arrow and emoji. Read files with an explicit encoding or pay for it in production.

Distribution: the part nobody writes tutorials for

Building servers is the easy half. Getting them installed is the other half:

  • PyPI — Trusted Publisher configured, releases cut from GitHub.
  • Glama — listing live: website-to-markdown-mcp.
  • MCPMarket — submitted to the free review queue.
  • Smithery — account registered; publishing requires a hosted Streamable HTTP endpoint, which is the next milestone.
  • Registry rule learned the hard way: Glama allows one listing per repository. Submitting a second server from the same repo gets rejected — monorepo or not.

What I'd do differently

Start the CI matrix from commit number one. I wrote the servers first and retrofitted the pipeline, which meant the first green run was a small celebration rather than a continuous heartbeat. Beyond that, the shape holds: small servers, one workspace, one lockfile, one matrix.

The repo is at github.com/MarekCziba/mcp-servers — MIT, uvx-installable, 65 tools waiting for an agent.

Previously: MCP vs Apify vs Custom Agents: When to Use Each

Top comments (0)