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:
-
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. -
All-or-nothing trust. Nobody wants to grant an agent access to "the everything server."
hash-checksum-mcpis obviously harmless; a monolith is not. -
All-or-nothing installs.
uvx password-generator-mcpstarts 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)
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
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"
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
]
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"
)
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
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:
-
pythonpathand src layouts. Fifteen servers use flat layouts, one usessrc/. Listing them individually inpythonpathwas the only reliable fix â pytest does not care about your cleverness. -
Python version drift. The
mcpSDK'suuid7helper only exists on newer Pythons. A small fallback keeps servers installable on 3.10+, which is whatrequires-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)