io.github.jaimenbell/mcp-factory
Manifest-driven MCP server scaffolder and runtime hub for tool integrations.
Open source Open in the app JSON README (API)
About
Manifest-driven MCP server scaffolder and runtime hub for tool integrations.
Details
- Kind
- MCP servers
- Topic
- Developer tools
- Publisher
- jaimenbell
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.1.3
- Last push
- 2026-08-30T00:29:27Z
- Repository state
- ativo
- Language
- Python
- License
- MIT
- Added
- 2026-08-29 04:00:12
- Updated
- 2026-08-29 04:00:12
- Origin id
io.github.jaimenbell/mcp-factory
README
# MCP Factory
[](https://github.com/jaimenbell/MCP-Factory/actions/workflows/ci.yml)   [](https://pypi.org/project/jaimenbell-mcp-factory/) [](https://registry.modelcontextprotocol.io/)
> *The test count is verifiable below (`python -m pytest tests/` → **348 passed, 8 skipped**) and enforced in CI by `scripts/check_readme_counts.py`, which fails the build if this README's counts drift from the live suite.*
## 60-Second Quickstart
**From PyPI (registry users):**
```bash
pip install jaimenbell-mcp-factory
mcp-factory-hub --serve
# equivalent: python -m mcp_factory --serve
```
**From a git checkout (contributors):** see the `python hub_server.py ...` examples throughout this README — `hub_server.py` at the repo root is a backward-compat wrapper around the same `mcp_factory.cli` module the console script runs, so behavior is identical either way.
**The manifest-driven engine behind the MCP Integration Sprint.** Write one `mcp.yaml` for a bot repo and the factory generates the server stub and the `~/.claude.json` entry; run the hub and it serves every bot's tools through a single MCP endpoint.
The SDK wrapper is the easy part. What makes an MCP server safe to put in front of a real internal tool — **scoped auth/env, fail-soft error handling, validated manifests, a collision-safe registry, and a real test suite** — is the engineering this engine is built around. That same production layer is hand-built per engagement; the factory scaffolds it, it doesn't fake it.
### Browse before you reply
This repo is public **so you can verify the discipline instead of taking my word for it.** Every claim below maps to a file you can open:
| Claim | Where it lives | What to look for |
|---|---|---|
| **Validated, env-scoped manifests** | [`mcp_factory/manifest.py`](mcp_factory/manifest.py) | strict `from_dict` validation (raises on missing/invalid fields); the `env_required` / `env` model that scopes which secrets a server may see |
| **Fail-soft subprocess proxying** | [`mcp_factory/runtime/subprocess_adapter.py`](mcp_factory/runtime/subprocess_adapter.py) | typed `SubprocessError`, lazy start, JSON-RPC error surfacing, `timeout`/`OSError`-guarded teardown + `atexit` cleanup — a dead bot returns a clean error, it doesn't crash the hub |
| **Collision-safe, manifest-driven registry** | [`mcp_factory/runtime/registry.py`](mcp_factory/runtime/registry.py) · [`registry.json`](registry.json) | `CollisionError` on duplicate `<bot>.<tool>` names; the registry is built from manifests, not hand-maintained |
| **Tested on a clean checkout** | [`tests/`](tests/) | **348 passed, 8 skipped, 0 failed** (Python 3.12); the 8 skips are real integration tests or lint checks that no-op when the thing they need is absent |
> **Honesty rails:** `348` is the real, reproducible count on a clean checkout — the same number the public CI run produces and gates on. mcp-factory generates the *scaffold* and runs the hub — it does not "generate the production server" or carry any client/CI claims. The hardened production layer (per-tool auth boundaries, the full failure set, two-axis version-pinning) is built per engagement on top of this engine. That applies to both Python scaffold styles below — see "Two Python styles" for exactly what the fastmcp variant does and doesn't add on top of that baseline.
## Quick Start
### Factory mode (generate config from one manifest)
```bash
# Reference an existing MCP server (no code generated — just the config entry)
python hub_server.py --manifest examples/fleet_health.yaml
# Scaffold a new MCP server from scratch
python hub_server.py --manifest my_bot/mcp.yaml --output-dir my_bot/
# Dry run — preview without writing
python hub_server.py --manifest my_bot/mcp.yaml --dry-run
# Self-verify: compare factory output to live ~/.claude.json entry
python hub_server.py --manifest examples/fleet_health.yaml --verify ~/.claude.json
```
Output always goes to `~/.claude.json.factory-test` by default — **never** to the live `~/.claude.json`. Copy entries manually after review.
### Scan mode (batch-register all bots)
```bash
# Dry-run diff: show what would change in ~/.claude.json
python hub_server.py --scan C:\path\to\projects
# Apply: write ~/.claude.json after backing it up
python hub_server.py --scan C:\path\to\projects --apply
# Force-update entries already registered
python hub_server.py --scan C:\path\to\projects --apply --force
```
`--scan` discovers all `projects/*/mcp.yaml` files, validates each, and diffs them against the current `~/.claude.json`. Default root is `C:\path\to\projects`. With `--apply`, a timestamped backup is created at `~/.claude.json.scan-backup-<timestamp>` before writing.
**Skip logic:** manifests whose name already exists in `~/.claude.json` are skipped unless `--force` is passed. This prevents accidentally overwriting manually-crafted entries.
### Serve mode (runtime hub — single MCP for all bots)
```bash
# Run the hub as a live MCP server (stdio transport)
python hub_server.py --serve
# Serve with multiple scan roots (repeatable)
python hub_server.py --serve \
--scan-root C:\path\to\projects \
--scan-root C:\path\to\Claude
# Register+serve in one step (scan --apply then start hub)
python hub_server.py --register \
--scan-root C:\path\to\projects \
--scan-root C:\path\to\Claude
```
The hub scans all `mcp.yaml` manifests under each `--scan-root` at startup, then exposes every bot's tools under the `<bot>.<tool>` namespace (e.g., `fleet-health.fleet_status`, `my-bot.run_scan`). Tools are proxied to per-bot subprocess MCP servers with lazy startup.
**Hub meta-tool:** `_hub.list_bots` returns the registered bots and their subprocess status.
Hub is pre-registered in `~/.claude.json` as `mcp-factory-hub` (see `scripts/register_hub.py`).
### Node.js template
Factory generates Node.js stubs when `runtime.type: node` is set in `mcp.yaml`:
```yaml
runtime:
type: node
command: "node"
output: "path/to/server.js"
```
Generated stubs use `@modelcontextprotocol/sdk` with stdio transport and zod for argument validation. See `examples/node_example.yaml` for a working demo.
### Two Python styles: raw SDK vs. FastMCP
For `runtime.type: python`, the factory can scaffold either of two styles from the exact same manifest:
```yaml
runtime:
type: python
command: "python"
style: raw # default — official `mcp` SDK, hand-rolled list_tools/call_tool
# style: fastmcp # FastMCP v3 (PrefectHQ/fastmcp), decorator-based tool registration
```
Both styles read the same `tools:` / `env_required:` fields and produce a server that speaks the same stdio JSON-RPC wire protocol — the runtime hub's `SubprocessAdapter` proxies either one without any adapter changes (see `tests/test_fastmcp_template.py::TestFastmcpServeSmoke` for a live generate-and-call test).
| | `style: raw` (`python_server.py.j2`) | `style: fastmcp` (`python_fastmcp.j2`) |
|---|---|---|
| SDK | official `mcp` package, `mcp.server.Server` | `fastmcp` (pinned exact `fastmcp==3.4.2` — 4.0.0b1 is a beta that breaks sampling/roots, do not float) |
| Tool registration | manual `@server.list_tools()` / `@server.call_tool()` dispatch | one `@mcp.tool(...)`-decorated function per tool |
| Arg schema | hand-built JSON Schema dict per arg | `Annotated[type, Field(description=...)]` on real Python parameters — FastMCP derives the JSON Schema, including required/optional, from the signature |
| Tool body | `# TODO: implement` stub | same stub, wrapped in `try/except Exception` — a runtime error in a filled-in implementation returns a structured `{"status": "error", ...}` instead of crashing the process |
| `env_required` | not enforced at scaffold level | rendered into a `_check_required_env()` startup check that warns to stderr if a declared var is missing — a presence check, not credential validation |
**Gaps, stated honestly:** neither style implements per-tool authorization, rate limiting, or the "full failure set" the hub-level `subprocess_adapter.py` gives you for free (typed errors, lazy start, `atexit` cleanup) — that's still a per-engagement build on top of either scaffold. The fastmcp template's fail-soft wrapper and env-presence check are new, real code (read `mcp_factory/templates/python_fastmcp.j2`), not a marketing claim about auth — they were added because FastMCP's decorator model made them cheap to include cleanly; they have not (yet) been backported to the raw template, which is why the two styles differ slightly in what ships out of the box. If your engagement needs FastMCP-specific features beyond this (resources, prompts, HTTP/SSE transport, middleware-based auth), the generated file is a normal FastMCP app — extend it directly.
See `examples/fastmcp_example.yaml` for a working demo manifest.
## mcp.yaml Schema
```yaml
name: my-bot # REQUIRED — unique MCP server name (key in claude.json)
description: > # REQUIRED — shown in Claude's tool descriptions
What this bot does and when to use it.
runtime: # REQUIRED
type: python # python | node | binary
command: "C:\\Python314\\python.exe" # full path to interpreter
script: "path/to/server.py" # existing server (skips scaffold generation)
output: "path/to/out.py" # where to write generated scaffold (omit = auto)
style: raw # python only: raw (default) | fastmcp — see "Two Python styles"
tools: # REQUIRED — list of MCP tools to expose
- name: tool_name # REQUIRED — programmatic identifier
title: "Tool Name" # Optional — human display name (see below)
description: > # REQUIRED — used by Claude for routing
What this tool does.
annotations: # Optional — the four MCP behaviour hints
readOnlyHint: true # tool does not modify anything
destructiveHint: false # tool does not perform destructive updates
idempotentHint: true # repeat calls have no additional effect
openWorldHint: false # tool does not touch an open external world
args: # Optional list of arguments
- name: arg_name # REQUIRED
type: string # string | number | boolean | object | array
required: true # default: true
description: "..." # shown in Claude's tool schema
env_required: # env var names that must be set at runtime
- MY_API_KEY
env: # static env vars injected into claude.json entry
MY_API_KEY: "" # leave value empty — fill in ~/.claude.json manually
tags: [trading, health] # for documentation / future routing
priority: high # high | medium | low
```
### Key rules
- `runtime.script` + existing file → factory references it, skips scaffold
- `runtime.script` + missing file → validation error (use `runtime.output` for new scaffolds)
- `runtime.output` → explicit path for generated stub (absolute recommended)
- Neither `script` nor `output` → error at config-write step
### `title` and `annotations`
Both are optional and both are worth declaring.
`title` is the human display name, which lets `name` stay a programmatic
identifier. Display precedence is `title` → `annotations.title` → `name`; the
factory exposes only the top-level `title` so one manifest cannot declare two
competing display names.
`annotations` matters more than it looks. The four hints have **pessimistic
defaults** — `destructiveHint` and `openWorldHint` both default to `true`,
`readOnlyHint` to `false` — so a tool that declares nothing is treated by a
careful client as the most dangerous thing it could be. Declaring
`readOnlyHint: true` on a read-only tool is how you opt out of that. Only the
four hint names above are accepted, each must be a real boolean, and an unknown
key is a hard error rather than a silent drop (the typo `readonlyHint` would
otherwise leave the tool effectively unannotated on the wire).
Annotations are **hints, not a security boundary** — the spec is explicit that a
client should never make tool-use decisions based on annotations from an
untrusted server. They inform a client's UX; they do not enforce anything.
Both fields are emitted by the two Python templates. The Node template does not
render them yet — the repo vendors no `node_modules`, so an altered call shape
could not be executed in test, and shipping unverified generated code is worse
than shipping the current shape. Declaring them in a node manifest is still
valid and forward-compatible.
## Generation-time tool-design lint
Manifest validation is a *security* gate: it fails closed on anything that could
inject code into a generated server. It says nothing about whether the resulting
tools are any good. A manifest can be perfectly valid and still produce a server
that makes an agent measurably worse.
Every run of factory mode therefore also runs a **tool-design lint** (Step 2),
covering 15 rules across three scopes:
| Scope | Checks |
|---|---|
| manifest | tool-count budget, names colliding once case/separators are ignored, bare generic names that collide across servers |
| tool | empty / thin / filler descriptions, descriptions that only restate the name, non-snake_case and over-long names, missing annotations, unbounded listing tools, annotations that contradict the name |
| arg | undescribed parameters, unqualified parameter names, structured (`object`/`array`) parameters with no description and therefore no schema shape |
It is **advisory by default** — findings print, generation proceeds, exit 0.
`--lint-strict` makes error-severity findings fatal: it reports them, writes no
scaffold, and exits `3`.
```bash
mcp-factory-hub --manifest examples/fastmcp_example.yaml --lint-strict
```
Three deliberate properties:
- **It reports `INDETERMINATE`, not a clean bill**, when it has no discriminating
power — a manifest whose `runtime.script` exists (the factory references that
hand-written file rather than generating one, so the tool list may not match
the real server), or a runtime with no template. `--lint-strict` never blocks
on an indeterminate result: the absence of a verdict must not be converted
into one.
- **There is no tool-name charset rule**, even though SEP-986 defines one. The
manifest's own identifier validation is already stricter and rejects violations
at parse time, so such a rule could never fire on any input reaching the lint —
and a check that cannot fail is not a check. Only the length half of SEP-986 is
unenforced upstream, so only the length half is a rule.
- **Every rule ships a positive control**: a test proving it fires on a known-bad
tool definition and stays silent on a known-good one, plus a suite-level test
asserting a well-formed manifest produces zero findings. The examples in
`examples/` are themselves held to the lint by `tests/test_examples_lint_clean.py`.
## How to Add a New MCP
1. Write `mcp.yaml` at your bot repo root (or in `examples/`)
2. Run the factory:
```bash
python hub_server.py --manifest path/to/mcp.yaml
```
3. Review `~/.claude.json.factory-test` — confirm the entry looks correct
4. Copy the entry into `~/.claude.json` under `mcpServers`
5. Restart Claude Code
If the bot has no existing server, the factory generates a stub at `generated/<name>_server.py`. Fill in the `# TODO: implement` sections and set `runtime.script` to the stub path for future runs.
## Runtime Hub Architecture
```
hub_server.py --serve
└── mcp_factory/runtime/
├── hub.py async MCP server (lists + routes all tools)
├── registry.py maps <bot>.<tool> → manifest + adapter
└── subprocess_adapter.py spawns per-bot MCP server, proxies JSON-RPC
```
**Subprocess lifecycle:**
- Adapters start lazily on first tool call (no upfront spawn)
- Keep-alive for the hub session (one process per bot)
- `_hub.list_bots()` reports status: `idle` (not yet started) or `running`
- All adapters stopped via `atexit` on hub exit; `stop()` kills if needed after 5 s
**Tool naming:** `<bot-name>.<tool-name>` — hyphens preserved, dots as separator.
Example: `fleet-health.fleet_status`, `my-bot.get_alerts`.
## Day 4 — workflow_runner.py
Standalone CLI harness for research workflows, independent of `hub_server.py`.
```bash
# Discover and list all SKILL.md workflows
python -m mcp_factory.workflow_runner --list
# Run a specific workflow
python -m mcp_factory.workflow_runner --run my-skill
# Validate all discovered SKILL.md files
python -m mcp_factory.workflow_runner --validate
# Write/update registry.json from discovered skills
python -m mcp_factory.workflow_runner --write-registry
# Check for drift between discovered skills and registry.json
python -m mcp_factory.workflow_runner --check
# Control cache behavior
python -m mcp_factory.workflow_runner --run my-skill --cache-policy force-refresh
python -m mcp_factory.workflow_runner --run my-skill --cache-policy read-only
```
### How it works
`workflow_runner.py` scans `~/research` by default (override with `--scan-root`) for `SKILL.md` files containing YAML frontmatter. Each `SKILL.md` defines a named workflow with metadata:
```yaml
---
name: my-skill
description: What this workflow does
output_path_template: "~/vault/output/{date}/{name}.md"
---
Prompt body passed to claude -p subprocess...
```
- **Discover:** `git ls-files` to enumerate tracked `SKILL.md` files under each scan root
- **Validate:** checks required frontmatter fields (`name`, `description`)
- **Cache:** SHA-based cache keyed on prompt content; `auto` (default) skips re-run if output unchanged, `force-refresh` always re-runs, `read-only` never writes
- **Run:** invokes `claude -p <prompt>` as a subprocess, streams output
- **Write output:** expands `output_path_template`, writes result to vault
- **Registry:** `--write-registry` persists discovered skills to `registry.json`; `--check` detects drift between filesystem and registry without writing
## Directory Layout
```
mcp-factory/
├── hub_server.py # CLI entry point (factory / scan / serve)
├── mcp_factory/
│ ├── manifest.py # Manifest dataclass + YAML loader + validation
│ ├── generator.py # Python MCP server stub scaffolder
│ ├── config.py # claude.json entry builder + comparator
│ ├── scan.py # --scan mode: manifest discovery + diff/apply
│ ├── workflow_runner.py # Day 4: standalone CLI harness for SKILL.md workflows
│ ├── templates/ # packaged as data so `pip install` ships them too
│ │ ├── python_server.py.j2 # Jinja2 template — raw mcp SDK stubs (style: raw, default)
│ │ ├── python_fastmcp.j2 # Jinja2 template — FastMCP v2 stubs (style: fastmcp)
│ │ └── node_server.js.j2 # Jinja2 template for generated Node.js stubs
│ └── runtime/
│ ├── subprocess_adapter.py # subprocess MCP client (JSON-RPC proxy)
│ ├── registry.py # tool registry with collision detection
│ └── hub.py # async hub MCP server
├── tests/
│ ├── fixtures/
│ │ ├── fleet_health.yaml # Day 1 self-verification fixture
│ │ ├── minimal.yaml # Minimal valid manifest
│ │ └── mock_mcp_server.py # Stdlib-only mock MCP server for adapter tests
│ ├── test_manifest.py
│ ├── test_generator.py
│ ├── test_subprocess_adapter.py
│ ├── test_registry.py
│ ├── test_scan.py
│ ├── test_hub_cli.py
│ ├── test_mcp_pkg.py
│ ├── test_node_template.py
│ ├── test_python_template.py
│ ├── test_fastmcp_template.py # style: fastmcp generation + import + serve-smoke tests
│ ├── test_register_flag.py
│ ├── test_registration.py
│ ├── test_smoke_hub.py
│ ├── test_watcher.py
│ ├── test_workflow_runner.py # Day 4: workflow_runner unit + integration tests
│ └── test_integration_fleet_health.py # live integration tests (skipped if server absent)
├── examples/
│ ├── fleet_health.yaml # Example manifest referencing an existing server
│ ├── node_example.yaml # Example manifest for the node template
│ └── fastmcp_example.yaml # Example manifest for the fastmcp template
└── pyproject.toml
```
## Self-Verification
The `examples/fleet_health.yaml` manifest references an example server. Running:
```bash
python hub_server.py --manifest examples/fleet_health.yaml --verify ~/.claude.json
```
confirms the factory produces a matching `~/.claude.json` entry.
## Running Tests
```bash
python -m pytest tests/ -v
```
On a clean checkout (Python 3.12), with `pip install -e .[dev]`: **348 passed, 8 skipped, 0 failed** — the same numbers the public CI run produces and gates on.
The 8 skipped tests skip automatically when the resource or condition they need is absent:
- `test_integration_fleet_health.py` (5 tests) requires a fleet-health `server.py` on disk (`FLEET_HEALTH_SERVER_PATH`).
- `test_node_template.py` (1 test) requires `node` and `@modelcontextprotocol/sdk` (`node_modules/`) to be present.
- `test_examples_lint_clean.py` (2 tests) skips `examples/fleet_health.yaml`, which references an existing hand-written server — the tool-design lint correctly reports `INDETERMINATE` there rather than judging code it cannot see, and a skip is the honest way to record that.
`test_smoke_hub.py` (4 tests) no longer needs a live bot fleet to run for real: the hub's demo-manifest fallback (see below) gives it something to discover even against an empty scan root, so these run unconditionally on a clean checkout now.
(On the maintainer's fleet machine, where the fleet-health server and live bots exist, the remaining skipped integration tests run for real and the passed count is higher — but this README claims only what a clean checkout and public CI reproduce.)
The fastmcp-style template tests (`test_fastmcp_template.py`) are not in this skip list — `fastmcp` is installed as a `[dev]` extra, so they run for real on a standard dev setup.
## Commercial support
Maintained by [Jaimen Bell](https://jaimenbell.dev). For production MCP integrations, custom servers, or agent-reliability work, see [jaimenbell.dev](https://jaimenbell.dev).
Building your own MCP server? The [MCP Starter Kit](https://jaimenbell.gumroad.com/l/adnojp) has templates, a build playbook, and packaging war-stories from shipping this one.
<!-- MCP registry ownership marker -->
mcp-name: io.github.jaimenbell/mcp-factory