mcphost
mcphost serve is a streamable-HTTP MCP server, stateless per the 2026-07-28
specification, on which an agent signs up with one unauthenticated tool call,
receives a tenant key, and then owns a namespace of tools it publishes, lists,
inspects and removes through further tool calls. There is no web page. The
operator administers tenants and reads metering through admin.* tools on
the same endpoint. Tool execution kinds (REST wrappers, code) are separate
PRDs; this one ships the endpoint, tenancy, the control plane, the Kind
trait, and a built-in echo kind so the harness can measure the bootstrap
path end to end.
For agents evaluating this host: the machine-readable summary lives at
/llms.txton the production endpoint. Signup is one unauthenticated tool call; the quickstart there is six steps.
Built from PRD-mcphost-endpoint.md (vision: visions/mcp-host.md).
Recent
- v0.11.0 —
host.tool_publishreports every simultaneously-invalid field at once (data.errors, each with its ownfield/expected/example) instead of one rejection per attempt; each kind's example spec/blurb and the new "Kinds" section below both render fromdocs/kinds/*.md, checked to match bytests/publishfirsttry_ac06_docs_shared_source.rs. - v0.4.0 —
args_schema(and, forpython,requirements) is now optional on thepythonandhttpkinds: when absent, the host derives it deterministically and offline from the source/templates the tenant already wrote (src/kinds/infer.rs). An explicitargs_schemais used unchanged. - v0.1.2 —
synthorg consume --preflightnow has a real integration test (AC12); theKindconformance suite moved totests/ac17_kind_conformance.rs;host.registry_publish+GET /.well-known/mcp/<namespace>/server.jsonare implemented behind the--registry-urlflag (AC19, see "Registry publish (P1)" below).
Install
cargo install --path .
Or build locally:
cargo build --release
./target/release/mcphost serve
Environment contract
| Variable | Meaning | Default |
|---|---|---|
MCPHOST_DATA_DIR | Directory holding mcphost.db (SQLite, WAL) | ./data |
MCPHOST_BIND | host:port to listen on | 127.0.0.1:8080 |
MCPHOST_PUBLIC_URL | URL returned by signup as the endpoint | http://<bind> |
MCPHOST_ADMIN_KEY | Bearer key that unlocks admin.* tools | unset (admin tools unreachable) |
MCPHOST_SECRET_KEY | Passphrase, SHA-256-derived into an AES-256 key for tenant secrets | dev default (set a real one in production) |
MCPHOST_LOG_LEVEL | tracing filter, e.g. info | info |
MCPHOST_REGISTRY_URL | Enables host.registry_publish (P1) and names the registry API's base URL; mcphost serve --registry-url <url> takes precedence | unset (registry-publish disabled) |
MCPHOST_SIGNUP_RATE_LIMIT_PER_HOUR | Overrides the per-source-IP signup rate limit (PRD-mcphost-signup-rate-configurable) — raise it for a many-session measure run from one IP; absent or non-integer falls back to the default. Effective value is logged once at startup | 5 |
mcphost migrate applies pending SQL migrations and exits. mcphost version
prints the version and exits. mcphost serve --registry-url <url> is the
CLI-flag form of MCPHOST_REGISTRY_URL above.
Registry publish (P1)
Off by default. Once --registry-url / $MCPHOST_REGISTRY_URL names a
registry API base (e.g. https://registry.modelcontextprotocol.io):
- The operator verifies a tenant's domain namespace by whatever method
they trust (the PRD leaves the verification METHOD itself — DNS vs
HTTP record — as an open question owned by Joe; this crate does not
implement one) and records the outcome with
admin.tenant_verify_namespace:admin.tenant_verify_namespace(tenant="t_xxxxxxxx", domain_namespace="io.github.example.myserver"). - That tenant can then call
host.registry_publish()(no arguments): it POSTs aserver.jsondocument (name/description/version/remotes: [{type: "streamable-http", url}]) to<registry-url>/v0/publish, and the same document becomes servable, unauthenticated, atGET /.well-known/mcp/<namespace>/server.json. host.registry_publishrefuses with a distinct, machine-readable error indata.error_code:registry_disabled(flag off),namespace_unverified(step 1 not done for this tenant), orregistry_rejected(the registry API answered non-2xx).
Kinds
Every registered kind's minimal example spec, below, and host.tool_publish's
on-wire description (visible from tools/list before signup) are both
rendered from the same docs/kinds/*.md files (PRD-mcphost-publish-first-try
requirement 6) -- tests/publishfirsttry_ac06_docs_shared_source.rs
regenerates this section from those files and fails CI if it's drifted from
what's checked in below. Call host.quickstart(kind) for the same example
with your own namespace already filled in.
echo
spec.schema is any JSON Schema; a call echoes back the arguments it was given, validated against it.
Example spec:
{
"schema": {
"properties": {
"msg": {
"type": "string"
}
},
"required": [
"msg"
],
"type": "object"
}
}
Example call arguments:
{
"msg": "hi"
}
http
url must be an absolute https URL; method and url are the only required fields -- args_schema is inferred from the url/header/body templates when omitted.
Example spec:
{
"method": "GET",
"url": "https://api.example.com/items/{{id}}"
}
Example call arguments:
{
"id": "123"
}
python
only source is required -- args_schema and requirements are both inferred from it (tool-infer, v0.4.0); source must define main(args).
Example spec:
{
"source": "def main(args):\n return {\"doubled\": args[\"n\"] * 2}\n"
}
Example call arguments:
{
"n": 3
}
Python spec-language notes
PRD-mcphost-python-kind-runtime (AC6): the AST-check that gates
host.tool_publish accepts assignment expressions (:=, PEP 572) in
general -- CPython has parsed them since 3.8, and mcphost's publish-time
check and the tool's own runtime both compile source with the same
CPython grammar, so there is no mcphost-added restriction to relax. The
one thing that is rejected is a restriction Python's own grammar
enforces: an assignment expression's target must be a plain name.
(obj.attr := 1) and (d[key] := 1) are both invalid Python syntax
(cannot use assignment expressions with attribute / ...with subscript) and would fail identically whether or not mcphost validated
them first -- the tool's own main(args) would refuse to even parse.
Because this is executor-level, not validator-level, there is nothing for
mcphost to loosen; the fix here is that the publish-time rejection now
names the construct and the accepted alternative in one sentence (assign
to a plain name first, then set the attribute/subscript in a separate
statement) instead of leaving CPython's bare grammar message to speak for
itself.
Metered overage (billing emit-meter)
PRD-mcphost-metered-overage: pro tenants' successful calls past the plan's
50,000 included calls/month bill themselves through Stripe's
mcphost_tool_calls meter and its graduated metered price. Set these
env vars from ~/.config/mcphost/stripe-objects.json (unset means the
same v0.14.0 behavior -- no metering, no meter_lag):
MCPHOST_STRIPE_METERED_PRICE_ID-- the metered price idbilling.checkoutattaches alongside the base price.MCPHOST_STRIPE_METER_EVENT_NAME-- defaults tomcphost_tool_calls.
Then run mcphost billing emit-meter on a timer (every five minutes is the
shipped default): it reads pro tenants' unemitted ok calls, POSTs one
Stripe meter event per tenant (chunked at 100 events/request), ledgers each
batch, and advances its own high-water mark only once every event in the
run has been accepted -- safe to rerun after a crash or a failed POST (see
src/metering.rs's doc comment for the replay/idempotency contract).
Install the shipped systemd user units (~/.config/systemd/user/,
matching this host's other mcphost-* units):
cp deploy/mcphost-emit-meter.service deploy/mcphost-emit-meter.timer \
~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now mcphost-emit-meter.timer
mcphost-emit-meter.service reads ~/.config/mcphost/emit-meter.env (via
EnvironmentFile=-, so a missing file is not an error) for
MCPHOST_DATA_DIR / MCPHOST_STRIPE_SECRET_KEY / the two vars above.
Both unit files pass systemd-analyze verify --user (AC9;
tests/metering_ac09_deploy_units_verify.rs).
/healthz's meter_lag field (present only when MCPHOST_STRIPE_METERED_PRICE_ID
is set) is the count of pro-tenant ok calls still above the high-water
mark -- watch it for emission health at a glance.
Acceptance
Every P0 acceptance criterion is paired with a real cargo test (integration
tests under tests/ spin up the server on an ephemeral port against a temp
$MCPHOST_DATA_DIR), except AC11 which is hardware-dependent and is
recorded as a smoke result below.
Sandbox suite: user namespace requirement
The python kind's sandbox suites (tests/sandboxready_*, python_ac*,
infer_ac*, warmpool_ac*, ac17_kind_conformance) spawn real bwrap/
unshare isolation and need unprivileged user namespaces
(unshare --user --map-root-user -- true must succeed) to run for real. If
your box denies that (Ubuntu's default AppArmor policy on some kernels, some
container runtimes), running cargo test fails loudly by design outside
CI, naming the fix: sysctl kernel.unprivileged_userns_clone=1 on older
kernels, or sysctl kernel.apparmor_restrict_unprivileged_userns=0 on
Ubuntu 24.04+. See sandbox::require_user_namespaces_or_ci_skip's doc
comment for the full contract, and .github/workflows/ci.yml for how the
hosted CI runner grants the same capability (PRD-mcphost-ci-sandbox-coverage)
instead of silently skipping.
CI runs these suites as their own sandbox job, in parallel with the gate
job that carries static analysis and everything else — once the suites stopped
skipping, a single cargo test --workspace step measured 313–336 s against a
300 s budget. Which targets go where is derived, not hand-listed:
scripts/ci-test-partition.sh core|sandbox classifies every tests/*.rs by
whether it touches the sandbox-execution surface, and check proves the split
is total and disjoint. Both jobs then fail on any capability-skip in their log,
so a target filed into the wrong half turns CI red rather than passing
vacuously.
| AC | Requirement | Test |
|---|---|---|
| 1 (P0) | Unauthenticated tools/list shows only signup; response carries MCP-Protocol-Version | tests/ac01_unauthenticated_lists_signup.rs |
| 2 (P0) | signup returns key/namespace/endpoint; key stored only as a hash | tests/ac02_signup_creates_hashed_tenant.rs |
| 3 (P0) | Tenant tools/list shows host.* and no other tenant's tools | tests/ac03_tenant_lists_control_plane_only.rs |
| 4 (P0) | Publish, then list, then call round-trips | tests/ac04_publish_list_and_call.rs |
| 5 (P0) | Cross-tenant isolation: B can't see or call A's tool | tests/ac05_cross_tenant_isolation.rs |
| 6 (P0) | Remove a tool: omitted from list, tool_not_found on call | tests/ac06_remove_tool.rs |
| 7 (P0) | host.usage/admin.usage report calls + p50/p95 | tests/ac07_usage_metering.rs |
| 8 (P0) | admin.tenant_disable locks out a key; tenant key is forbidden on admin.tenants | tests/ac08_admin_disable_and_forbidden.rs |
| 9 (P0) | 6th signup/hour/IP is rate_limited, no tenant created | tests/ac09_signup_rate_limit.rs |
| 10 (P0) | Unregistered kind / invalid name / oversized spec each fail distinctly, nothing written | tests/ac10_publish_validation_errors.rs |
| 11 (P0, non-functional) | 200 concurrent echo calls, p95 < 50ms, 0 errors, RSS < 100MiB | tests/ac11_load_smoke.rs (#[ignore]d — hardware-dependent; run with cargo test --release --test ac11_load_smoke -- --ignored --nocapture). Measured on the build box: p95 = 34.20ms, 0 errors, RSS = 37.3MiB |
| 12 (P0) | synthorg consume --preflight <url> exits 0 | tests/ac12_preflight.rs — an always-run in-process half exercises the same two requests run_preflight makes; a second half spawns the real mcphost binary and the real synthorg CLI when available (bare binary or uv run --project) and asserts exit 0 |
| 13 (P0) | Mismatched Mcp-Name header vs. body is recorded by body name and flagged | tests/ac13_mcp_name_mismatch_metering.rs |
| 14 (P0) | Unwritable database: storage error, /healthz db_ok: false, process stays up | tests/ac14_storage_unwritable.rs |
| 15 (P0) | A call that never completes times out at the deadline, future dropped | tests/ac15_call_timeout.rs |
| 16 (P0) | 2MiB request body rejected with HTTP 413 | tests/ac16_request_body_too_large.rs |
| 17 (P0) | Kind conformance suite passes echo, fails naming describe for a bad schema | tests/ac17_kind_conformance.rs (reusable checker at mcphost::kinds::conformance) |
| 18 (P1) | tools/list carries ttlMs/cacheScope, ttlMs: 0 within 60s of a publish | tests/ac18_tools_list_ttl.rs |
| 19 (P1) | host.registry_publish() + /.well-known/mcp/<ns>/server.json | tests/ac19_registry_publish.rs (mocks the registry API with wiremock; see "Registry publish (P1)" above — the namespace-verification METHOD stays out of scope, "verified" is an admin-set boolean) |
Related fleet work
mcp-core— the reusable stdio JSON-RPC 2.0 MCP-server core (Tooltrait +serve_stdio) other wintermute MCP servers build on. Not reused here:mcphostis a streamable-HTTP server (rmcp), not a stdio server, and its tool surface is dynamic (per-tenant, DB-backed) rather than the staticTooltraitmcp-corewraps. Cited per the PRD's technical considerations as related, not shared, code.
License
Dual-licensed under MIT OR Apache-2.0 — see LICENSE-MIT and
LICENSE-APACHE.