Skip to main content
Glama
README.md
# mcphost

<!-- agent-quickstart:start -->
Ship an MCP tool, not a deployment project.

mcphost lets an agent create the tool it needs, mid-task, without a human
in the loop: sign up with one unauthenticated tool call, publish with the
next, and the new tool is live immediately — no restart, no deploy, no
review queue.

**Measured** (panel run `0.26.3-20260908T085001Z`, 21 sessions): median
time from signup to a tenant's first successful `host.tool_publish` is
**30.7s**; median time from signup to a successful call on that tenant's
own tool is **42.4s**.
<!-- cite: docs/benchmarks/measure-0.26.3-20260908T085001Z.md -->

## Quickstart for agents

1. Connect to the endpoint and call `tools/list` with no credentials. The
   only tool offered is `signup`.
2. Call `signup(name)`. The response contains `tenant`, `key` (a bearer
   token, shown once), `namespace`, and `endpoint`. Signup is rate-limited
   to 5 per IP per hour. Recommended: `signup(name, handoff: true)` returns
   a short-lived, single-use `handoff_token` instead of `key`; call
   `host.redeem(handoff_token)` once to get the key, so a transcript of
   this exchange carries a dead credential. `host.key_rotate` invalidates
   the current key and issues a new one in one call, any time you suspect
   it leaked.
3. Reconnect with `Authorization: Bearer <key>`. The `host.*` control
   plane is now available.
4. Publish a tool: `host.tool_publish(name, kind, spec)`. Three ways: wrap
   an API you already use (`http` — url and method required, `args_schema`
   inferred if omitted), submit code (`python` — source required,
   `args_schema`/`requirements` inferred if omitted), or test the pipes
   (`echo` — returns its arguments; spec is a JSON Schema). Dry-run first
   with `host.spec_test(kind, spec, invocations)` — up to 5 example calls
   through the same sandbox a real call uses, no tool row written until
   you're green.
5. Call your tool. Two equivalent ways over the same streamable-HTTP
   connection: as `<namespace>.<tool_name>` (its own entry in
   `tools/list`), or `host.tool_call(name, args)` (same dispatch path,
   useful when your client doesn't refresh `tools/list` between publish
   and call). `host.tool_test(name, args)` dry-runs an already-published
   tool by name instead of a raw spec.
6. Check the plan and quota before you rely on volume:
   `billing.plans()` — the plan catalog, works anonymously.
   `billing.status()` — this tenant's plan and usage against each quota.
7. Inspect and manage: `host.tool_list()`, `host.tool_logs(name)`,
   `host.tool_remove(name)`, `host.usage(window)`,
   `host.secret_set`/`host.secret_list()` (secrets stored AES-256-GCM
   encrypted). A `python` spec's plain, non-secret configuration lives in a
   separate `env` map (up to 16 entries / 4 KiB total, names matching
   `^[A-Z][A-Z0-9_]{0,63}$`) — shown verbatim in `host.tool_test`, unlike
   `secrets`, which stay redacted there.

<!-- cite: docs/benchmarks/measure-0.26.3-20260908T085001Z.md -->
<!-- agent-quickstart:end -->

`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.

> The machine-readable summary lives at [`/llms.txt`](https://mcphost.dev/llms.txt)
> on the production endpoint — generated from the same source as the
> quickstart above (`docs/agent-quickstart.md`, `scripts/gen-agent-docs.sh`).

Built from `PRD-mcphost-endpoint.md` (vision: `visions/mcp-host.md`).

## Recent

- **v0.56.0** — agent consent: `contact_policy: contacts` now has a
  middle setting between open and closed — a stranger may send one
  `host.agent.contact_request` and nothing else until the recipient calls
  `host.agent.contact_accept`; `host.agent.mute`/`unmute` keep a sender's
  messages arriving without waking the agent, and `host.msg.send(urgent=true)`
  bypasses mute (never block, never a `closed` policy) under the per-plan
  `urgent_per_day` cap.

- **v0.11.0** — `host.tool_publish` reports every simultaneously-invalid
  field at once (`data.errors`, each with its own `field`/`expected`/
  `example`) instead of one rejection per attempt; each kind's example
  spec/blurb and the new "Kinds" section below both render from
  `docs/kinds/*.md`, checked to match by
  `tests/publishfirsttry_ac06_docs_shared_source.rs`.
- **v0.4.0** — `args_schema` (and, for `python`, `requirements`) is now
  optional on the `python` and `http` kinds: when absent, the host derives it
  deterministically and offline from the source/templates the tenant already
  wrote (`src/kinds/infer.rs`). An explicit `args_schema` is used unchanged.
- **v0.1.2** — `synthorg consume --preflight` now has a real integration
  test (AC12); the `Kind` conformance suite moved to
  `tests/ac17_kind_conformance.rs`; `host.registry_publish` + `GET
  /.well-known/mcp/<namespace>/server.json` are implemented behind the
  `--registry-url` flag (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`):

1. 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")`.
2. That tenant can then call `host.registry_publish()` (no arguments): it
   POSTs a `server.json` document (`name`/`description`/`version`/`remotes:
   [{type: "streamable-http", url}]`) to `<registry-url>/v0/publish`, and
   the same document becomes servable, unauthenticated, at
   `GET /.well-known/mcp/<namespace>/server.json`.
3. `host.registry_publish` refuses with a distinct, machine-readable error
   in `data.error_code`: `registry_disabled` (flag off),
   `namespace_unverified` (step 1 not done for this tenant), or
   `registry_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.

<!-- kinds:start -->
### `echo`

spec.schema is any JSON Schema; a call echoes back the arguments it was given, validated against it.

Example spec:

```json
{
  "schema": {
    "properties": {
      "msg": {
        "type": "string"
      }
    },
    "required": [
      "msg"
    ],
    "type": "object"
  }
}
```

Example call arguments:

```json
{
  "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:

```json
{
  "method": "GET",
  "url": "https://api.example.com/items/{{id}}"
}
```

Example call arguments:

```json
{
  "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:

```json
{
  "source": "def main(args):\n    return {\"doubled\": args[\"n\"] * 2}\n"
}
```

Example call arguments:

```json
{
  "n": 3
}
```

### `wasm`

component is a base64-encoded WebAssembly component (component-model, not a core module) exporting `call: func(args: string) -> result<string, string>`; args_schema is optional (defaults to accepting any object).

Example spec:

```json
{
  "component": "AGFzbQEAAAAA"
}
```

Example call arguments:

```json
{
  "msg": "hi"
}
```
<!-- kinds:end -->

### 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.

## Call limits

PRD-mcphost-call-limits-honest: every limit here is the one the code
enforces -- `tests/limits_ac06_quickstart_docs_match_constants.rs` checks
this section and `www/llms.txt`'s "Limits and pricing" section against the
same constants `host.quickstart`'s `limits` object reads.

- **Call timeout**: 30 s by default, or your own `timeout_s` up to 60 s max
  -- a python spec that declares `timeout_s` gets exactly that deadline
  (bounded by the 60 s host maximum), not a shorter one applied silently
  underneath it. `call_timeout` names the deadline that actually applied.
- **Output size**: tool output at most 1 MiB. Over the cap returns
  `tool_output_too_large` naming `limit_bytes` and the `actual_bytes`
  produced, never a bare `tool_output_invalid` parse failure.
- **Request body**: at most 1 MiB (HTTP 413 over that -- see
  `tests/ac16_request_body_too_large.rs`; the "2 MiB" in that AC's own
  description is the oversized test payload used to *prove* the 1 MiB cap,
  not the cap itself).
- **Concurrency**: 20 concurrent calls host-wide; per tenant, 4 per tenant on the free plan
  (10 on pro). A refusal past your own tenant's cap is `capacity` with
  `scope: "tenant"` and a `retry_after_ms`; past the host-wide cap it's
  `scope: "host"`.
- **Sandbox process cap**: a python tool's sandbox allows at most 64 live
  processes; a fork past that fails with the structured
  `tool_process_limit`, not a silent hang or an opaque OS error.

## 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 id `billing.checkout`
  attaches alongside the base price.
- `MCPHOST_STRIPE_METER_EVENT_NAME` -- defaults to `mcphost_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.

## Synthetic tenants (`admin.*_synthetic`)

PRD-mcphost-synthetic-flag: a tenant a test harness creates carries a
free-form `synthetic` label (e.g. `synthorg:<run_id>`) from signup onward,
set by the harness sending `x-mcphost-synthetic: <label>` on its `signup`
call -- no behavior change, metadata only. `/healthz`'s `tenants_real` /
`tenants_synthetic` split, and `admin.tenants`' `synthetic` filter
(`true`/`false`/`all`, default `all`), read this column so
`synthorg candidates --measure` can exclude panel traffic from "real
tenant" evidence.

**Backfilling the existing census** (every tenant predates this column, so
all load with `synthetic: null` until tagged): use `admin.tenants_set_synthetic`,
previewed with `dry_run: true` before the `dry_run: false` that applies it.
The recipe this host's own census used:

```
admin.tenants_set_synthetic(name_like: 'joe-%',  label: 'operator',                    dry_run: true)
admin.tenants_set_synthetic(name_like: 'joe-%',  label: 'operator',                    dry_run: false)
admin.tenants_set_synthetic(name_like: '%',      label: 'synthorg:backfill-20260906',  dry_run: true)
admin.tenants_set_synthetic(name_like: '%',      label: 'synthorg:backfill-20260906',  dry_run: false)
```

Run the `joe-*` pass first -- the second call's broader `%` pattern would
otherwise overwrite those rows' label too, since a tenant re-tagged by a
later call simply gets the later label (there is no "already labeled, skip"
guard by design: retagging is how a label ever gets corrected). A single
tenant can be corrected at any time with `admin.tenant_set_synthetic(tenant,
label)` (`label: null` clears it).

## 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 <!-- cite: .github/workflows/ci.yml -->. Which SUITE BINARIES go
where is derived, not hand-listed: `scripts/ci-test-partition.sh core|sandbox`
classifies every `tests/*.rs` FILE by whether it touches the sandbox-execution
surface (PRD-mcphost-test-suite-consolidation moved the unit cargo links from
"one binary per file" to a handful of `tests/suite_<core|sandbox>_NN.rs`
binaries — see "Adding a test" below — so the partition is now file→suite,
not file→binary), and `check` proves the split is total and disjoint at both
levels. Both jobs then fail on any capability-skip in their log, so a file
filed into the wrong half turns CI red rather than passing vacuously.

### Adding a test

`tests/*.rs` stopped being cargo's unit of test-binary discovery
(PRD-mcphost-test-suite-consolidation, 2026-09-12): `autotests = false` in
`Cargo.toml`, plus a handful of generated `tests/suite_<core|sandbox>_NN.rs`
files that `#[path]`-include the real files, keep `target/debug/deps` from
holding one ~280 MB binary per test file. Every test keeps its own file, its
own name, and its AC pairing — only which BINARY it links into changed.

To add a test: drop `tests/<name>.rs` in as always (same naming convention:
`<prefix>_ac<N>_<description>.rs`, `mod common;` if it needs the shared
harness), then run `scripts/gen-test-suites.sh` to fold it into a suite (or
just let CI tell you — `scripts/gen-test-suites.sh --check`, wired into
`ci-test-partition.sh check`, fails naming the exact file if you forget). The
generator buckets by filename prefix, splits sandbox-needing files from
core-only ones first (so no suite ever mixes the two — see above), and
rewrites a lone top-level `mod common;`/`mod ci_sandbox_support;` line in your
new file to `use crate::common;`/`use crate::ci_sandbox_support;` (those
compile once per suite now, not once per file) — no other line changes.
Never hand-edit a `tests/suite_*.rs` file; it is fully regenerated.

Running a single test by name now takes one extra flag: `cargo test --test
suite_core_01 my_test_file:: -- --nocapture` (`cargo nextest run -E
'test(my_test_file::)'` works too, and needs no suite name at all). `cargo
test --test my_test_file` alone no longer resolves — that file isn't its own
cargo target anymore.

| 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 suite_core_01 ac11_load_smoke:: -- --ignored --nocapture`). Measured on the build box: **p95 = 34.20ms, 0 errors, RSS = 37.3MiB** <!-- cite: docs/benchmarks/ac11-load-smoke.txt --> |
| 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) | A request body over the 1 MiB cap (proven with a 2MiB body) is 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`](https://github.com/j0yen/mcp-core) — the reusable stdio
  JSON-RPC 2.0 MCP-server core (`Tool` trait + `serve_stdio`) other wintermute
  MCP servers build on. Not reused here: `mcphost` is a streamable-HTTP
  server (`rmcp`), not a stdio server, and its tool surface is dynamic
  (per-tenant, DB-backed) rather than the static `Tool` trait `mcp-core`
  wraps. 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`.