Manifest-driven MCP server scaffolder and runtime hub for tool integrations.
io.github.jaimenbell/mcp-factory — Model Context Protocol (MCP) server
The io.github.jaimenbell/mcp-factory server is a manifest-driven MCP server scaffolder and runtime hub for tool integrations. It is described with the scope “Manifest-driven MCP server scaffolder and runtime hub for tool integrations,” and is tagged under topics including mcp and model-context-protocol.
🛠️ Key Features
Manifest-driven MCP server scaffolding
Runtime hub for tool integrations
Package published to PyPI (via project page reference)
🚀 Use Cases
Generate MCP server scaffolding based on manifests
Manage and run tool integrations through an MCP runtime hub
⚡ Developer Benefits
Developer-oriented tooling focused on MCP (mcp, model-context-protocol)
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.
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:
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
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:
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)
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 descriptionsWhatthisbotdoesandwhentouseit.runtime:# REQUIREDtype:python# python | node | binarycommand:"C:\\Python314\\python.exe"# full path to interpreterscript:"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 identifiertitle:"Tool Name"# Optional — human display name (see below)description:># REQUIRED — used by Claude for routingWhatthistooldoes.annotations:# Optional — the four MCP behaviour hintsreadOnlyHint:true# tool does not modify anythingdestructiveHint:false# tool does not perform destructive updatesidempotentHint:true# repeat calls have no additional effectopenWorldHint:false# tool does not touch an open external worldargs:# Optional list of arguments-name:arg_name# REQUIREDtype:string# string | number | boolean | object | arrayrequired:true# default: truedescription:"..."# shown in Claude's tool schemaenv_required:# env var names that must be set at runtime-MY_API_KEYenv:# static env vars injected into claude.json entryMY_API_KEY:""# leave value empty — fill in ~/.claude.json manuallytags: [trading, health] # for documentation / future routingpriority:high# high | medium | low
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.
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
Write mcp.yaml at your bot repo root (or in examples/)
Run the factory:
bash
python hub_server.py --manifest path/to/mcp.yaml
Review ~/.claude.json.factory-test — confirm the entry looks correct
Copy the entry into ~/.claude.json under mcpServers
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.
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:
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. For production MCP integrations, custom servers, or agent-reliability work, see jaimenbell.dev.
Building your own MCP server? The MCP Starter Kit has templates, a build playbook, and packaging war-stories from shipping this one.