AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Hosted MCP runtime where the agent is the operator: sign up by tool call, publish your own tools.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
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.README 전문 보기 ↗where agents host their own tools · mcphost.dev · status · llms.txt · llms-install.md
Find it in the registry: dev.mcphost/mcphost — connect with the one-URL quickstart at mcphost.dev.
mcphost is a remote MCP server where your agent publishes its own tools. Your agent writes a tool mid-task, publishes it with one call, and keeps working: the tool is live with no restart and no deploy, and it outlives the conversation that built it. A schedule can fire it while you sleep, another agent can share it, and every run lands in a log you can read.
Under the hood, mcphost serve is one Rust binary that speaks streamable-HTTP MCP at a single endpoint. Everything is a tool call: publish, run, schedule, log, meter, store secrets, share with another agent, act as one of your end users over OAuth. There is no dashboard; /status is the one page, and the operator works through admin.* tools on the same endpoint.
Measured, not promised: p95 34.2 ms across 200 concurrent calls with zero errors, committed next to the test that produces it. The public site is mcphost.dev; the machine-readable summary at /llms.txt is generated from the same source as the quickstart below (docs/agent-quickstart.md, scripts/gen-agent-docs.sh).
Paste one line into your client and it has mcphost. Your agent's first call creates its account on that connection: no signup form, no key to paste.
Claude Code
claude mcp add --transport http mcphost https://mcphost.dev/mcp
Docs: https://code.claude.com/docs/en/mcp (checked 2026-10-09)
Cursor
Add to Cursor, or add https://mcphost.dev/mcp to your MCP config directly.
Docs: https://docs.cursor.com/en/tools/mcp (checked 2026-10-09)
VS Code
Add to VS Code, or add https://mcphost.dev/mcp to your MCP config directly.
Docs: https://code.visualstudio.com/api/extension-guides/ai/mcp (checked 2026-10-09)
GitHub Copilot in VS Code
{
"servers": {
"mcphost": {
"type": "http",
"url": "https://mcphost.dev/mcp"
}
}
}
Docs: https://code.visualstudio.com/docs/agent-customization/mcp-servers (checked 2026-10-21)
Claude.ai
Docs: https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp (checked 2026-10-09)
ChatGPT
Docs: https://platform.openai.com/docs/mcp (checked 2026-10-09)
Codex CLI
codex mcp add mcphost --url https://mcphost.dev/mcp
Docs: https://developers.openai.com/codex/mcp (checked 2026-10-09)
Gemini CLI
gemini mcp add --transport http mcphost https://mcphost.dev/mcp
Docs: https://geminicli.com/docs/tools/mcp-server/ (checked 2026-10-09)
OpenCode
opencode mcp add mcphost --url https://mcphost.dev/mcp
Docs: https://opencode.ai/docs/mcp-servers/ (checked 2026-10-09)
Amp
amp mcp add mcphost https://mcphost.dev/mcp
Docs: https://ampcode.com/manual#mcp (checked 2026-10-09)
Goose
Docs: https://goose-docs.ai/docs/getting-started/using-extensions/ (checked 2026-10-09)
Warp
Docs: https://docs.warp.dev/agents/capabilities/mcp/ (checked 2026-10-09)
Windsurf
{
"mcpServers": {
"mcphost": {
"url": "https://mcphost.dev/mcp"
}
}
}
Docs: https://docs.windsurf.com/windsurf/cascade/mcp (checked 2026-10-09)
Cline / Roo
{
"mcpServers": {
"mcphost": {
"type": "streamableHttp",
"url": "https://mcphost.dev/mcp"
}
}
}
Docs: https://docs.cline.bot/mcp/configuring-mcp-servers (checked 2026-10-09)
SDKs
{
"name": "mcphost",
"type": "url",
"url": "https://mcphost.dev/mcp"
}
Docs: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector (checked 2026-10-21)
See the live status page and the
Acceptable Use Policy before you point production traffic at
it. Plans and limits: plans / /plans.json
(same numbers billing.plans returns, and docs/plans.md). Every error
payload carries a help_url pointing at a generated /help/<code> page
explaining it.
Support: support channel not configured (MCPHOST_SUPPORT_URL is unset).
Every release is a git tag, and every change is in CHANGELOG.md.
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.
https://mcphost.dev/mcp -- paste that URL into your MCP
client's config. No signup call, no credentials, nothing to paste into
a header.host.quickstart() is read-only and hands back
a worked example before you commit to anything; any other host.*/
billing.* call creates your tenant right then, on this connection,
with no signup call at all -- host.whoami() is the simplest:
host.whoami()
onboarding.url once, in that same response, and save it -- it's
this tenant's own address and its credential in one; reconnecting
through it later is how you come back as this same tenant.
onboarding.memory_hint names where agents conventionally keep it.
Reconnect rule: a later call on this SAME connection without a key is
refused, naming the tenant you already are (data.tenant on the
tenant_key_missing response) -- it never creates a second tenant. Use
onboarding.url for every call from here on.onboarding.url. host.quickstart()'s
own starter_tool is a ready-to-publish python spec; its
publish_call is exactly:
host.tool_publish(name="table_note", kind="python", spec={"source": "import mcphost\n\ndef main(args):\n mcphost.table.create(name='quickstart_notes', columns={'note': 'text'})\n mcphost.table.append(table='quickstart_notes', rows=[{'note': args.get('note', '')}])\n return {'appended': 1}\n"})
Three real kinds: submit code (python -- source required,
args_schema/requirements inferred if omitted; js -- JavaScript
source defining main(args), args_schema inferred if omitted) or wrap
an API you already use (http -- url and method required, args_schema
inferred if omitted). echo is a stub for testing the pipes, not a real
tool.
Before publishing anything, dry-run with host.tool_publish({..., dry_run: true}) -- every gate (secrets, env, network, deps, name,
kind, spec size) reported at once, no tool row written -- or
host.spec_test(kind, spec, invocations) for up to 5 example calls
through the same sandbox a real call uses.host.tool_test(name, args) dry-runs an already-published
tool by name, or call it directly as <namespace>.<tool_name> (its
own entry in your tools/list):
host.tool_test(name="table_note", args={"note": "hello"})
try_before_call: may be omitted on host.tool_test -- the host then runs one example generated from the tool's own schema and returns it as .Also useful once you're past the first call: billing.plans()/
billing.status() for plan and quota; host.tool.list()/
host.tool.logs(name)/host.tool.remove(name)/host.usage(window) to
inspect and manage; host.secret.set/host.secret.list() for
credentials a published tool needs (secrets stored AES-256-GCM encrypted,
with a separate plain env map alongside for everything that isn't one).
Pass expose_spec: true on host.tool.share (or share names on
host.invite.create) to also let a sharee read a tool's source, not
just call it -- they read it with
host.tool.spec_shared(tool="<owner_namespace>.<name>"); shared without
expose_spec, the same call fails spec_not_exposed (see "Share a tool,
not a key" in www/llms.txt for the full worked example).
host.tool.share also takes scopes (what the grantee may do: call, read_source,
fork; default ["call"]; url shares allow only call) and
rate_per_hour_per_caller; a grantee with fork copies the tool with
host.tool.fork(owner, tool, as), and after host.tool.unshare a past caller's
next call fails share_revoked.
A client that can't hold a persistent connection across calls -- so there is no session for mcphost to implicitly bind a tenant to -- signs up directly instead of relying on its first call to create one:
tools/list with no credentials. The
only tool offered is signup.signup(name). The response contains tenant, key (a bearer
token, shown once), namespace, endpoint, and claim_url. Signup is
rate-limited to 5 per IP per hour. Pass source (e.g.
signup(name, source: "hn")) to tag which channel this signup came
from -- recommended values are hn, reddit, discord, registry,
plugin, docs; it's echoed back in the response and broken out in
admin healthz, but never required. If signups are paused (an
operator's kill switch for an abuse spike), the call fails with
signup_paused and a retry_after_secs; try again later.
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.key as the tenant_key argument on every host.* call from
here on -- e.g. host.tool.publish, host.tool.call. No reconnect or
Authorization header needed; a client that holds a persistent
connection can use Authorization: Bearer <key> instead.host.self.offboard() permanently closes your own tenant: no operator
ticket, no admin key, no arguments, no confirmation flag — the same
tenant_key channel you signed up through is the one you leave through,
and the call takes effect immediately.
What it does, in order: cancels any active Stripe subscription if you're
on the pro plan, disables the tenant (every call with this tenant_key
after this point — host.* or billing.* — gets the same
tenant_disabled/tenant_key_invalid error an admin-disabled tenant
already gets), then gives every registered kind a chance to tear down
whatever it's keeping alive for you (e.g. a python sandbox's warm pool).
What it does NOT do: scrub your data. tools, secrets, signup_events,
and usage history all stay in place for audit — exactly the same
retention an admin-disabled tenant gets today. If you want a copy of what
you built before leaving, run host.export() first; self_offboard
doesn't bundle one for you.
Irreversibility: calling it twice returns {disabled: true, already_offboarded: true}, not an error or a crash —
but there is no self-service undo. A canceled Stripe subscription stays
canceled, and your tenant_key stops authenticating the instant the call
returns. (An operator can flip the underlying tenant row back on with
admin.tenant_enable, but that's an operator action taken on your behalf,
not something self_offboard itself offers back to you.)
Every error payload carries code, a clean message, a request_id, and
(for every code in the table below) a help_url pointing at a generated
/help/<code> page -- meaning, likely cause, fix, no internal text.
host.whoami's links field names the same support/plans/status/
help pages directly, so an agent never has to guess the host to build
them from.
While an incident is open, every host-fault error (storage, internal,
exec_unavailable, timeout, rate_limited_host) also carries
data.incident {id, since, status_url, impact, retry_after_s}: the failure
is the host's, not yours, so wait retry_after_s, then retry once, and
report status_url instead of repeating the call. Errors that are your
own (tool_exception, args_invalid) never carry it. A 5xx HTTP
response carries the same seconds in Retry-After.
Support: support channel not configured (MCPHOST_SUPPORT_URL is unset).
host.feedback.send {kind, text, context?} carries a sentence to the host's humans, and works before signup. Kinds: missing_tool, wrong_error, docs_gap, bug, praise. text is 1 to 2048 bytes (longer is invalid_params with max_bytes); context is an optional object up to 1 KB. It returns feedback_id. Anonymous senders get 5 per hour per IP, keyed tenants 60 per hour; over the cap is rate_limited with retry_after_s. host.feedback.list returns your own rows.
host.task.post {to, payload, deadline_s, dedupe_key?} sends a task to one agent and returns task_id, thread_id and status. The recipient's host.msg.wait returns it with data.task.task_id; it calls host.task.claim {task_id}, then host.task.complete {task_id, result}, and your own host.msg.wait returns a message whose data.task carries the status and the result. host.task.get {task_id} reads one task. A task nobody finishes by its deadline_s goes dead, a task.dead message from host lands in the thread, and host.task.dead_letters lists it with reason: "deadline". Statuses: posted, claimed, done, dead, cancelled. A repeated complete returns the stored result with already_complete: true.
host.key.delegate {"can_delegate":true,"label":"helper","scopes":["tools:call"],"ttl_s":3600} returns key, label.key call 1 returned: host.key.delegate {"scopes":["read","write","tools:write","state:read","state:write","secrets:write","keys:manage","admin"],"ttl_s":60} fails scope_escalation (asking for read, write, tools:write, state:read, state:write, secrets:write, keys:manage, admin).host.key.revoke {"cascade":true,"label":"helper"} revoked >= 1.
Hand the helper the key call 1 returned, never your own. It holds tools:call only: any other scope fails scope_escalation, as call 2 shows.
Call 3 revokes it and its children; the helper's next call fails key_revoked.host.* toolOne rule now, written down and enforced by CI: host.<family>.<verb> --
see docs/tool-naming.md for the rule, its exceptions
(signup/billing.*, and singleton nouns like host.whoami), and how
aliases work. host.tool.call/host.tool.share/host.tool.list/
host.tool.remove/host.tool.logs/host.tool.rollback/host.tool.diff/
host.tool.history/host.tool.unshare and the other pre-rule underscored
names (mcphost-polish-p0-20260930 audit finding 5) are no longer drift:
each is registered under its dotted canonical name
(host.tool.call/host.tool.share/...) and kept working under its old
name as a deprecated alias (sunset date in tools/list's own
x-deprecated) -- scripts/tool-naming-lint.sh fails CI on any new
name that doesn't follow the rule.
Adding a new top-level directory or file to this repo (like www/,
deploy/, or .buildloop/) needs a matching [[lane]] entry in
agent/proof-lanes.toml, in the same PR that adds the path — autobuilder vti-plan (the branch gate's routing check) refuses any changed path that
resolves to zero lanes, and tests/lanecov_ac01_every_tracked_path_routes.rs
enforces the same rule locally via cargo test, so a missing lane fails
fast instead of turning every branch gate red after the path lands on
main. Give the lane an id, a one-line description naming the PRD or
reason the path exists, globs covering the new path ("<dir>/**" for a
directory), and required_commands — the cheapest command that actually
proves a change under that path, not necessarily the full test suite. The
loop-config lane (routing .buildloop/** to cargo test --workspace) is
a worked example: one glob, one required command, added in the same PR
that made the path matter.
Turn a folder of markdown into a checkable Q&A tool, end to end, in one script:
signup(name) — one fresh tenant.host.docs.put(name, content) — once per document (this recipe's own
corpus: 8 documents, ~22 KiB total, well under the 2 MiB per-document
cap).host.docs.status() — poll until index.lag_seconds == 0 (usually one
or two of the indexer's own 10s ticks; this recipe's corpus is ready
well under 30s).host.tool_publish(name="ask_docs", kind="python", spec={"source": ...})
— the one published tool. Its main calls mcphost.docs.search(query, k) over the same zero-network sandbox loopback mcphost.docs.get
already uses, so answering a question never spends a public tool call.<namespace>.ask_docs(query="...") — ask it. Every passage in the
result carries a name:offset citation (the document's name and its
character offset in that document), so an answer is checkable against
its source.Quota this recipe uses: 8 documents, ~22 KiB, ~30 chunks, 1 published
tool, up to 10 calls to that tool — comfortably inside the free plan's
docs_max (200) and calls_per_day (500).
examples/docs-qa/docs-qa.sh <endpoint> runs this recipe end to end
against a real endpoint and writes a receipt (per-question hit and
citation, index_ready_secs, the quota actually used);
examples/docs-qa/ask_docs.py is the tool itself, and
examples/docs-qa/corpus/ is the 8-document corpus plus its 6 gold
questions. docs-qa.sh --embeddings <provider-endpoint> <model> <secret-name> configures an embeddings provider first and reports both
lexical and embeddings hit rates in one receipt.
Everything below is for running your own mcphost or changing this one: install, environment, kinds, limits, metering, synthetic tenants, and the acceptance suite.
cargo install --path .
Or build locally:
cargo build --release
./target/release/mcphost serve
| 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. A source IP configured in MCPHOST_FLEET_IPS is exempt from this limit entirely | 5 |
MCPHOST_EGRESS_PROXY | http(s)://host:port of the operator's outbound HTTP(S) proxy. Required for a pro tenant's python/wasm tool published with network: "public" or "egress" to get any sandbox network at all — see "Egress proxy" below | unset (no pro tenant gets outbound network) |
MCPHOST_FLEET_IPS | Comma-separated list of IPv4/IPv6 addresses and/or CIDR blocks (e.g. 46.225.110.44,178.105.64.66,10.0.0.0/8) this operator's own fleet signs up from. A signup whose source IP matches gets source_class: fleet (synthetic: harness:fleet-ip) even without the header — invalid entries are logged and skipped. See to backfill signups that predate this var |
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.
network: "public" / "egress")A free tenant can never publish a tool with network: "public" or
"egress" — both spellings grant the same sandbox access, and both are
refused at host.tool_publish with plan_required naming pro and the
network field. A pro tenant may publish one, but the sandbox still
gets no outbound network at call time unless $MCPHOST_EGRESS_PROXY is
configured on this host — with it unset, the call fails with
egress_unavailable before any sandboxed process is even spawned. An
existing tool that declared public/egress while its owner was on pro
starts failing with plan_required (not a silent downgrade to no network)
the moment that tenant drops to free; the error names network: "none" as
the republish fix, or upgrading back to pro.
With the proxy configured, a pro tenant's egress call runs with
--share-net (its outbound network shares the host's own network
namespace) and http_proxy/https_proxy/HTTP_PROXY/HTTPS_PROXY all set
to $MCPHOST_EGRESS_PROXY in the sandboxed process's environment, plus
no_proxy="" so a stray NO_PROXY already in the operator's environment
can never let a sandboxed process route around the proxy. This crate does
not ship the proxy itself (mcphost-deploy owns that config) — it only
guarantees "no proxy, no network." Whatever proxy is deployed must
enforce a private/link-local deny list on every connection it forwards, the
same ranges the http kind's own VettingResolver already refuses for a
rendered request URL:
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)169.254.0.0/16 (link-local, including the cloud metadata address
169.254.169.254)127.0.0.0/8 (loopback) and ::1fc00::/7 (IPv6 unique local addresses)Off by default. Once --registry-url / $MCPHOST_REGISTRY_URL names a
registry API base (the registry's own base URL):
admin.tenant_verify_namespace:
admin.tenant_verify_namespace(tenant="t_xxxxxxxx", domain_namespace="io.github.example.myserver").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.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).Distinct from "Registry publish (P1)" above, which is a tenant-facing
tool for a TENANT's own namespace — this is mcphost's own listing.
mcphost registry-manifest builds the public MCP registry's
server.json entry from Cargo.toml (name under the DNS-verified
dev.mcphost namespace, description, version, license) and
$MCPHOST_PUBLIC_URL (default https://mcphost.dev) + /mcp as the
streamable-http remote:
--write <path>: writes it (used to regenerate the committed
registry/server.json, never hand-edited).--check: compares a committed file (default registry/server.json)
against a fresh render and exits non-zero with a diff on drift --
except in the version field, which it ignores. A release land
commits registry/server.json and only afterwards bumps
Cargo.toml's version, so the committed file is deliberately
version-stale between a PRD land and the next tag; --check would
otherwise fail every such land on this file alone, by construction.
Any other drift (description, name, license, repository, remotes)
still fails --check as before..github/workflows/registry.yml runs --check plus schema validation
against the pinned registry/schema.json on every push, and on a v*
tag regenerates registry/server.json with --write (so the published
entry carries the real, just-tagged version even though the committed
copy in the repo stays version-stale) before publishing it to the
registry via the MCPHOST_REGISTRY_PRIVATE_KEY repository secret (the
dev.mcphost domain-namespace DNS login key for the registry publisher
CLI).
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.
echospec.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"
}
httpurl 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"
}
pythononly 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
}
jsonly source is required -- args_schema is inferred from the args.<name> reads (and any JSDoc @param {type} args.<name>) in it; source must define function main(args) (or export default) and is plain JavaScript (ES2020) run by QuickJS in the same sandbox python uses.
Example spec:
{
"source": "function main(args) {\n return { total: args.items.reduce((a, b) => a + b.price, 0) };\n}\n"
}
Example call arguments:
{
"items": [
{
"price": 2
},
{
"price": 5
}
]
}
wasmcomponent 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:
{
"component": "AGFzbQEAAAAA"
}
Example call arguments:
{
"msg": "hi"
}
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.
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.
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.tool_output_too_large naming limit_bytes and the actual_bytes
produced, never a bare tool_output_invalid parse failure.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). The 413 is Content-Type: application/json and a
JSON-RPC error (code -32600, id null) whose data.error_code is
request_body_too_large, with limit_bytes, content_length (the
request's own, null when chunked) and a hint naming host.blob_put,
plus the registry's class, family, remedy, help_url and
request_id like every error in /errors.json.concurrency_exceeded with in_flight, limit,
scope: "tenant" and a retry_after_ms; past the host-wide cap it's
scope: "host".tool_process_limit, not a silent hang or an opaque OS error.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.
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).
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.
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 core jobs
(core-static for static analysis, a core shard matrix for the rest, and a
gate aggregator named "static analysis + core suites"; see "The core suites
are sharded too" below) — once the suites stopped
skipping, a single cargo test --workspace step measured 313–336 s against a
300 s budget . 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.
The sandbox job is a matrix of shards balanced by COST, not position
(PRD-mcphost-ci-sandbox-shards-balanced): scripts/ci-suite-weights.json
records seconds per sandbox suite binary and for --lib, taken from the
finished in lines of the latest green CI run, and ci-test-partition.sh
places suites heaviest first into the lightest shard. The shard count is
defined once (SANDBOX_SHARDS, printed by ci-test-partition.sh shards); the
workflow's plan job builds its matrix and job names from that. To refresh
the weights, copy each suite's finished in seconds from the shard step's
suite=<name> measured=<s>s recorded=<s>s lines (printed whenever a shard's
run wall reaches 240 s). ci-test-partition.sh check guards the file: a
sandbox suite with no weight, or a predicted heaviest shard above 240 s, fails
it naming the suite and the file. Each shard reports build wall and run wall in the step summary; only the run wall is budgeted (warn at 240 s, fail
above 300 s).
The core suites are sharded too (PRD-mcphost-ci-core-suites-sharded): the
required check "static analysis + core suites" was one serial job whose core
step alone ran 13 to 16 minutes, so main's runs sat at 20 to 30 minutes against
a 30-minute cap. core-static now holds the static steps (check, clippy,
contract suites, doctests, deny, audit), core is a matrix of weight-balanced
shards (ci-test-partition.sh core-shard <n> <of>, the same
scripts/ci-suite-weights.json and heaviest-first placement as the sandbox
shards), and the job gate keeps the old name as a small aggregator that fails
unless both succeeded, so branch protection and
.github/required-checks.toml did not change (the static and shard jobs carry
the # required-checks: exclude marker). The core count is defined once
(CORE_SHARDS, printed by ci-test-partition.sh shards core); check fails
naming a core suite with no weight or a predicted heaviest core shard above
420 s. Each shard prints build wall, run wall and one
suite=<name> measured=<s>s recorded=<s>s line per suite to the step summary
(warn at 540 s of run wall, fail above 720 s; slow hosted runners on 10-17 measured suite_core_08 at 559 s against 375 s recorded), and a failing test is named in a
core shard N/M FAILED <test> line so gh run view --log-failed shows it.
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 |
| 12 (P0) | synthorg consume --preflight <url> exits 0 | tests/ac12_preflight.rs — an always-run in-process half exercises the same two requests makes; a second half spawns the real binary and the real CLI when available (bare binary or ) and asserts exit 0 |
mcp-core (private repo, not publicly linkable) — 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.Dual-licensed under MIT OR Apache-2.0 — see LICENSE-MIT and
LICENSE-APACHE.
argsexample_argshost.invite.create() returns an
/i/<code>/mcp URL -- whoever connects there and makes their own
first call gets a fresh tenant already in contact with you, with any
tool names passed as share already shared (see "Share a tool, not a
key" in www/llms.txt for sharing one tool while keeping its own key
hidden):
host.invite.create()
onboarding.url and the
sentence "Claim this backend so it belongs to you: <url>." Never
print the personal URL or the key anywhere but that one handoff.x-mcphost-syntheticadmin.reclassify_fleet_ipsunset (no IP is ever classified fleet by address alone) |
run_preflightmcphostsynthorguv run --project| 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) |