Name the thing instead of calling it a door

One word stood for several: the MCP server, one address that accepts requests,
the single way in, and the boundary a credential is validated at. Each site now
names its own sense — endpoint, entry point, the MCP server, the edge, address,
relay — or, where none of those fit, takes the word the surrounding code already
uses.

Prose and comments only, 1:1 line for line. Identifiers keep their names: CSS
classes, DOM ids, struct fields, enum variants, config values and the doc
comments that must open with the identifier they document.
This commit is contained in:
2026-08-23 13:49:39 -07:00
parent 9c0d8b259a
commit 0b132a8881
9 changed files with 43 additions and 43 deletions
+10 -10
View File
@@ -18,7 +18,7 @@ MEASURED at `hanzoai/[email protected]`: the hand-merged master carried 2093
operations against cloud's 2333, sharing 1908 — so it described 185 operations
nothing serves under that name and missed 425 that are served. Of the 185, 36 are
refuted outright (real 404, nonsense-sibling control 404), 44 are unfalsifiable
behind a prefix auth gate, 66 sit under a `{wildcardN}` relay door, and **39 are
behind a prefix auth gate, 66 sit under a `{wildcardN}` relay route, and **39 are
live and undescribed** — every one a hanzoai/cloud defect, itemised in LLM.md.
The projection is six codegen rules and nothing else. It exists because
@@ -250,7 +250,7 @@ cross-language method rename, not cosmetics. It fell out of the resync rather
than being chosen, and on review it stands: a route-derived id is a total
function of the immutable `/v1` path, while a hand-authored id is owned by a
spec that no longer describes the route and vanishes when that spec is deleted —
the same break again, later, silently. It is also the name the MCP door already
the same break again, later, silently. It is also the name the MCP server already
uses. The break is once, now, and cannot recur for these operations.
### `mcp_Request.id` is a string — a scalar `oneOf` does not survive codegen
@@ -261,21 +261,21 @@ carrier class for a scalar `oneOf` (Kotlin `class McpRequestId()` with no
members) that serializes to `{}` — not a legal id anywhere. Java and C++ produce
the same shape, so it blocked three languages on one declaration.
Verified on the live door before changing it, because the fix is only safe if
Verified on the live MCP server before changing it, because the fix is only safe if
the wire agrees: `id: "1"`, `id: 1` and `id: "abc-123"` are all accepted and all
echoed back with the TYPE they were sent; omitting `id` returns `null`. A string
is therefore always legal, and a client generated from this document always
reads back the string it sent.
Declared `type: string` on both `Request` and `Response`, with the polymorphism
recorded in the prose rather than in a shape — no second door, no alias, one
recorded in the prose rather than in a shape — no second type, no alias, one
declaration. The generated field is now `kotlin.String?`, `private String id`,
`Id *string`; the `McpRequestId`/`McpResponseId` carriers are gone from every
client.
### Declare the MCP door — `POST /v1/mcp`, the one address an MCP client speaks
### Declare the MCP endpoint — `POST /v1/mcp`, the one address an MCP client speaks
The fleet's JSON-RPC door has been answering all along and was in no spec:
The fleet's JSON-RPC endpoint has been answering all along and was in no spec:
`POST /v1/mcp` returns 200 with 796 tools, unauthenticated. It read as missing
because the probe was a GET — the route is POST-only, so GET is 404 — and
because the document held only `/v1/mcp/servers`, the registry of external
@@ -284,14 +284,14 @@ operation (`mcp_rpc`) with the JSON-RPC 2.0 request and response typed:
`initialize`, `tools/list`, `tools/call`, a `Tool` with its `inputSchema`, and
both failure channels that live INSIDE a 200 — `error` (`-32601` for an unknown
method) and `result.isError` (a tool that ran and refused). Verified against the
live door, not guessed: `serverInfo`, `protocolVersion: 2025-06-18`, and the
live MCP server, not guessed: `serverInfo`, `protocolVersion: 2025-06-18`, and the
`content[]` shape are all copied from its own answers.
The `tools` flow moves onto it, and the probe rule that hid it is corrected
everywhere it was written: **probe the method the document declares, not GET.**
A POST-only route and an absent route are indistinguishable from a GET.
What the door exposes is recorded because it cannot be derived: 796 tools
What the MCP server exposes is recorded because it cannot be derived: 796 tools
against 2479 operations, so about two thirds of the surface is not a tool, and
the split is the binary's decision. The naming rule is mechanical — a tool name
is its operationId minus the leading `<service>_` — holding for 795 of 796. The
@@ -301,7 +301,7 @@ one identifier in every generator, while its tool is still `get_v1_pricing_polic
A tag whose key is a service NAME now belongs to that service's domain rather
than to whichever spec used it first: `MCP` was tagged by four specs, the
earliest alphabetically was `automations`, and the fleet's MCP door was being
earliest alphabetically was `automations`, and the fleet's MCP endpoint was being
filed under Streams.
### `hanzo.yaml` is THE published document — cloud's woven spec merged, and it wins
@@ -350,7 +350,7 @@ served, so every one was re-resolved against the merged document AND probed.
`money` and `agent` move to cloud's ids (`billing_*` and
`cloud_AgentsController.*` no longer exist — the binary's document owns those
routes now). `store` moves from the KV value plane to the store itself, and
`tools` from the automations MCP door to `/v1/tools`: both of the originals
`tools` from the automations MCP endpoint to `/v1/tools`: both of the originals
reply 404 to GET and 405 to PUT/POST/DELETE, which is what a GET-only wildcard
answers when nothing is routed there. `hello` and `chat` were already right —
`ai_getAccount` returns 200 with the owner and name to print.
+10 -10
View File
@@ -60,15 +60,15 @@ Each was probed at `api.hanzo.ai` with **its own method** (a POST-only route
answers 404 to GET, so a GET sweep cannot tell "absent" from "wrong verb"), and
each probe carries a CONTROL: the same address with `-zzq9` appended to its last
literal segment. 119 are addresses no live route pattern covers; 66 sit under a
`{wildcardN}` relay door, which by construction answers identically for a real
`{wildcardN}` relay route, which by construction answers identically for a real
path and an invented one and therefore decides nothing.
| verdict | n | reading |
|---|---:|---|
| **ABSENT** — real 404, control 404 | **36** | refuted. Nothing serves this. |
| **UNFALSIFIABLE** — real ≠ 404, control identical | **44** | a gate or a door answered, not a route |
| **UNFALSIFIABLE** — real ≠ 404, control identical | **44** | a gate or a relay answered, not a route |
| **SERVED** — real ≠ 404, control 404 | **39** | live, and cloud's document lacks it |
| absorbed by a `{wildcardN}` door | 66 | unfalsifiable by construction |
| absorbed by a `{wildcardN}` relay | 66 | unfalsifiable by construction |
**A 404 on both sides IS a refutation, and it is the only way absence can ever be
shown.** The control exists to stop a 401/403/200 being read as existence — a
@@ -136,7 +136,7 @@ served as `DeleteSession`, and the suffix went out verbatim in the tool catalogu
of three client distributions, naming a tool `POST /v1/mcp` answers to no such
name. So the rename now records what it renamed: `x-id` is the id hanzo-inc/cloud
emitted, written only where the two differ, and `tools.py`'s `wire()` is the one
place a tool takes its name. Measured against the live door: 1299/1323 before,
place a tool takes its name. Measured against the live MCP server: 1299/1323 before,
1300/1323 after. The collision itself belongs upstream — two operations whose ids
differ only in case are one name to every generator there is.
@@ -310,7 +310,7 @@ separate functions here (`refuted()` fetches; the caller decides).
make a document GENERATABLE; a doc page needs none of them, because it reads
paths, tags, summaries and schemas, every one of which cloud emits itself. So the
projection bought the reference nothing and cost it currency — its pin sat 19
cloud releases back, rendering four relay-door products with twelve operations
cloud releases back, rendering four relay products with twelve operations
each where the document has one, one, one and two. A consumer belongs here only
while it needs what `publish.py` adds. Codegen does; prose does not.
@@ -350,7 +350,7 @@ Nothing below is fixable in this repo, and every item is measured at
`wecom-bot`, `traffic`, `provider-flags`, `docs`, `health`, `feedback`,
`documents`) that are that seam's hand-declared shrapnel rather than products.
- **`POST /v1/mcp`** — 200 with the tool list, control `/v1/mcp-zzq9` 404, in no
document. The fleet's one MCP door is undeclared, which cost `flows.yaml` its
document. The fleet's one MCP endpoint is undeclared, which cost `flows.yaml` its
`tools` example. Note it answers 404 to GET, which is why a GET-only sweep has
twice concluded it does not exist.
- **`GET /v1/{world,evals,referrals}/health`** — 200, controls 404, undescribed.
@@ -360,12 +360,12 @@ Nothing below is fixable in this repo, and every item is measured at
registers `/v1/s3/{name}` (provisioning) and `/v1/s3/buckets/…` (storage);
`manifest/apps.go:83` already notes those two owners share one prefix.
### 2. Relay doors that can only ever emit `{wildcardN}` — 66 addresses
### 2. Relay routes that can only ever emit `{wildcardN}` — 66 addresses
`bot` (32), `dns` (16), `tasks` (8), `collections` (5), `kms` (2), and one each
of `download`, `exec`, `files`. Each is a single `app.All("/v1/<x>/*")`
registration, so there is no per-operation site and the document can only show
the door. The fix is projecting the mounted registry, exactly as for `apps/ai`;
the relay. The fix is projecting the mounted registry, exactly as for `apps/ai`;
until then these addresses are unknowable from outside, and the honest document
says so rather than guessing a list.
@@ -532,7 +532,7 @@ Every SDK also renders `flows.yaml` into its `examples/` — the same six journe
from the same operationIds, in every language. An SDK does not choose its own
examples any more than it chooses its own methods. Two of the six moved in this
change, both because a hand-authored operationId vanished with its hand-authored
spec: `hello` was `bot_authMe` (`/v1/bot/auth/me`, now behind a relay door) and
spec: `hello` was `bot_authMe` (`/v1/bot/auth/me`, now behind a relay route) and
is `get_v1_keys`; `tools` was `mcp_rpc` (`POST /v1/mcp`, undeclared) and is
`get_v1_tools`. Both replacements were probed with controls — see `flows.yaml`.
@@ -542,7 +542,7 @@ The `<svc>_` prefix `merge.py` applied is gone. It existed to keep 52 authored
specs from colliding, and with one document there is exactly **one** collision in
2284 operations. `cloud_get_v1_billing_balance` is now `get_v1_billing_balance`;
`gateway_createChatCompletion` is `post_v1_chat_completions`. A prefix invented
here was a second naming authority, and the bare id is the one the MCP door
here was a second naming authority, and the bare id is the one the MCP server
already uses — a tool name is its operationId. Pin against the document, not
against a remembered method name.
+1 -1
View File
@@ -104,7 +104,7 @@ move upstream into cloud's emitter; see [LLM.md](LLM.md).
the API's own version is `/v1`. Three things, three places.
- operationIds are the document's own (`get_billing_balance`) — no `<svc>_`
prefix, because a prefix invented here is a second naming authority and the
bare id is what the MCP door already uses as a tool name.
bare id is what the MCP server already uses as a tool name.
- No `deprecated: true`. No `/api/` prefix. No dates in filenames.
- Security: every operation uses cloud's `bearer` scheme. No service has its own login.
- Errors: 4xx client, 5xx server, JSON body with `status` and `msg`.
+4 -4
View File
@@ -46,7 +46,7 @@ domains:
role: "models · agents · evaluation — the mind of the cloud"
# The bare-/v1 inference surface (chat, completions, embeddings, messages,
# rerank, models, images, audio, videos, responses) is Hanzo AI's OpenAI- and
# Anthropic-compatible door: there the PATH is the compatibility contract,
# Anthropic-compatible surface: there the PATH is the compatibility contract,
# because every vendor SDK hard-codes it. It is served by cloud like
# everything else — measured with nonsense-sibling controls: GET /v1/models
# 200 while /v1/models-zzq9 404s, POST /v1/chat/completions 401 while its
@@ -55,7 +55,7 @@ domains:
# The long tail here (query, query_multiple, install-patch, dev-bridge,
# wecom-bot, traffic, docs, health, feedback, documents) is SHRAPNEL from one
# seam, not ten products: apps/ai relays a whole embedded router through one
# door and hand-declares the operations behind it, so its declarations tag
# endpoint and hand-declares the operations behind it, so its declarations tag
# themselves. Those names disappear from this file the release apps/ai
# projects the mounted registry instead — `provider-flags` already has.
tags: [ai, router, agent, agents, ml, finetune, evals, benchmark,
@@ -87,8 +87,8 @@ domains:
# that share the carrier with it. It files here rather than under
# Applications because a call is a message reaching a person over somebody
# else's network, not a surface of its own.
# `git-webhook` is the door the forge pushes through, not a thing anybody
# opens: one POST that a push fires, which starts a build. Its family is
# `git-webhook` is the endpoint the forge pushes to, not something a person
# calls: one POST that a push fires, which starts a build. Its family is
# `webhooks` and `event` — inbound async — rather than `git`, which is the
# surface a person uses.
tags: [tasks, pubsub, mq, notify, tel, automations, auto, flow,
+5 -5
View File
@@ -26,7 +26,7 @@
# live handler says "not found" too.
# • PROBE THE METHOD THE DOCUMENT DECLARES, not GET. A POST-only route also
# 404s on GET: POST /v1/mcp answers 200 with 796 tools while GET /v1/mcp is
# 404, and reading that 404 as absence is how the fleet's one MCP door
# 404, and reading that 404 as absence is how the fleet's one MCP endpoint
# stayed undeclared. One verb is never a liveness probe.
#
# And a 200 is not proof either — see `hello`: /v1/ai/account answers 200 with
@@ -64,7 +64,7 @@ flows:
Verified with a nonsense-sibling control: GET /v1/keys answers 403
{"code":"forbidden","error":"sign in to manage API keys"} with NO key and
with a bogus one, while GET /v1/keys-zzq9 answers 404 — so the 403 is this
route refusing, not a wildcard door. With a real key it returns the
route refusing, not a wildcard relay. With a real key it returns the
caller's own keys. An SDK's hello must be the call that says no.
@@ -154,8 +154,8 @@ flows:
activated. Assert the count is greater than zero and print three names.
This flow named `mcp_rpc` (POST /v1/mcp, the JSON-RPC door) until the
document became the only authority on what exists. That door IS live —
This flow named `mcp_rpc` (POST /v1/mcp, the JSON-RPC endpoint) until the
document became the only authority on what exists. That endpoint IS live —
POST /v1/mcp answers 200 with the tool list while POST /v1/mcp-zzq9
answers 404, so the address is real and the control refutes nothing — but
cloud's emission does not declare it, so it has no operationId and no
@@ -167,4 +167,4 @@ flows:
Note for whoever writes that declaration: JSON-RPC reports failure INSIDE a
200, so a client reads `error` before `result` and `result.isError` before
`result.content`. The same door with method=tools/call runs one.
`result.content`. The same endpoint with method=tools/call runs one.
+3 -3
View File
@@ -248,8 +248,8 @@ def project(doc, groups):
`deleteSession` vs `DeleteSession`). The later one by path order takes a
suffix, which is what a generator does for the duplicates it can see —
and it keeps the id cloud emitted as `x-id`, because an operationId is
ALSO the wire name of an MCP tool. The suffix exists for Go; the door
does not know it. Whoever needs the name a caller can use reads `x-id`
ALSO the wire name of an MCP tool. The suffix exists for Go; the MCP
server does not know it. Whoever needs the name a caller can use reads `x-id`
first. The collision itself belongs upstream: two operations whose ids
differ only in case are one name to every generator on earth.
@@ -304,7 +304,7 @@ def project(doc, groups):
# a fact about GENERATORS and the id is also the WIRE NAME: it is
# what `POST /v1/mcp` answers to. Renaming it silently put
# `DeleteSession_2` in the MCP catalogue of three client
# distributions, and the door knows no such tool — a suffix
# distributions, and the MCP server knows no such tool — a suffix
# invented here for Go's benefit is not a thing anything serves.
# Present only where the two differ, so its meaning is its
# presence. `tools.py` names the tool by this.
+2 -2
View File
@@ -730,7 +730,7 @@ sdks:
# an interpolated value: the gemspec read `hanzo-inc/cloud&#39;s`, and
# rubygems renders a gem description verbatim, so that is what the registry
# page would have shown.
gemDescription: The official Ruby library for the Hanzo API — generated from the OpenAPI document that hanzo-inc/cloud emits.
gemDescription: The official Ruby library for the Hanzo API — generated from the API OpenAPI document.
gemHomepage: https://github.com/hanzo-ruby/sdk
gemLicense: Apache-2.0
gemAuthor: Hanzo
@@ -867,7 +867,7 @@ sdks:
# generator also offers pubspec.yaml, analysis_options.yaml, README.md,
# .travis.yml and git_push.sh at the root; naming lib/ leaves them where
# the fleet `drop` list would have had to grow two Dart-shaped entries.
# The repo owns its manifest, its lint config and its front door.
# The repo owns its manifest, its lint config and its README.
take: { lib: lib }
# TWO mustache overrides, and without them there is no Dart client at all.
#
+2 -2
View File
@@ -10,7 +10,7 @@ output (dist/ is generated, never committed):
exception. This is the whole contract. A generator that synthesises names
from paths is a THIRD naming rule, and a third rule is the disease this file
exists to prevent — as is publishing `publish.py` rule 6's codegen suffix,
which the door does not answer to;
which the MCP server does not answer to;
• one name, one tool — a duplicate id makes the second tool unreachable;
• callable verbs only — OPTIONS/TRACE are transport machinery, never tools;
• self-contained schemas — every `$ref` a tool emits resolves inside that
@@ -54,7 +54,7 @@ def test_name_is_the_operation_id():
def test_no_codegen_suffix_becomes_a_tool_name():
"""Rule 6's suffix is a fact about Go, and the door does not answer to it.
"""Rule 6's suffix is a fact about Go, and the MCP server does not answer to it.
`publish.py` renames an operationId when two collide under the identity a
generator uses, so `DELETE /v1/o11y/sessions` is published as
+6 -6
View File
@@ -7,10 +7,10 @@
ONE source, and the source is the DOCUMENT. A tool name is an operation id:
not derived from the path, not looked up in a table, not invented here. The
fleet's own door already answers that way — every tool `POST /v1/mcp` lists is
fleet's own MCP server already answers that way — every tool `POST /v1/mcp` lists is
verbatim an id hanzo-inc/cloud emitted — so a catalogue built any other way would
name tools the API cannot be told to run. Which is measurable, and was measured
against the door: the id, not the published `operationId`. `publish.py` rule 6
against the MCP server: the id, not the published `operationId`. `publish.py` rule 6
suffixes an id when two collide under a generator's identity, and that suffix is
a fact about Go rather than about the API — see `wire()`, which is the only
place a tool gets its name.
@@ -34,12 +34,12 @@ The projection is total and mechanical:
machinery, not something an agent calls;
• `inputSchema` is the operation's parameters and request body as one flat
JSON Schema object, component `$ref`s carried along into `$defs` — the same
shape the live door serves, so a tool generated here and a tool listed
shape the live MCP server serves, so a tool generated here and a tool listed
there are callable the same way.
WHICH operations a given deployment will actually answer for is not a fact
about the document — it is a fact about what that deployment mounts and what
the caller's key reaches. The door answers that at runtime. This file answers
the caller's key reaches. The MCP server answers that at runtime. This file answers
the other question, the only one a static artifact can: what the contract says
exists. Filtering here would bake one deployment's answer into every client.
@@ -61,7 +61,7 @@ OUT = os.path.join(ROOT, "dist", "tools.json")
SCHEMA_ID = "hanzo.mcp-tools/v1"
# The verbs an agent can be told to call. OPTIONS/TRACE are transport
# machinery and are never tools; the live door omits them too.
# machinery and are never tools; the live MCP server omits them too.
VERBS = ("get", "post", "put", "patch", "delete")
REFS = "#/components/schemas/"
@@ -74,7 +74,7 @@ def wire(op: dict):
the identity a GENERATOR uses (punctuation stripped, case folded), so that
Go does not declare one request type twice. That suffix is a fact about
codegen: `DELETE /v1/o11y/sessions` is `DeleteSession_2` in the published
document and `DeleteSession` on the wire, and the door answers to exactly
document and `DeleteSession` on the wire, and the MCP server answers to exactly
one of those. Publishing the other named a tool in three client
distributions that nothing can be told to run. Where the projection had to
change the name it records the original as `x-id`, so this is the whole