Say MCP, endpoint or edge instead of door in the authored docs

The generators stopped emitting the metaphor in f1178b6b85. This is the rest of
it: the hand-written pages, the site's own components, and the two configs.

"door" named five different things, so each site now names its own: the MCP
server where it is POST /v1/mcp, endpoint where it is one address that accepts
requests, entry point where it is the single way in, the edge where a credential
is validated, address where it is a location. Three sentences that were only the
metaphor now state the fact instead.

/llms.txt was publishing it to every agent that reads the site.

docs-hero's DOORS list is renamed PATHS, which is what its own doc comment
already called it — three paths ordered by how much you type. It is local to
that file and exported nowhere, so nothing outside it moves.

MCP_DOOR, cat.door, doors(), type Door and the `doors:` key in capabilities.yaml
keep their names: renaming those is a cross-file change to a tracked schema, not
a prose edit. capabilities.ts keeps the one comment quoting pubsub's package doc,
because stripping it would leave a rationale that explains nothing.

Measured: authored pages 39 -> 1 (a smart-home camera nickname, a literal door),
scripts 151 -> 45, all 45 identifiers. docs-mdx exits 0; the two script test
files fail 5/30 identically with and without this change.
This commit is contained in:
2026-08-23 13:31:24 -07:00
parent d2e1771698
commit 132aa8e8fa
48 changed files with 166 additions and 166 deletions
+1 -1
View File
@@ -421,7 +421,7 @@ to the image `name`, and whether the sha is abbreviated. Only a repo that sets
`tag-suffix` explicitly *and* ignores the sha length gets agreement, and even then
only between the last two.
`build_on_push.go`'s header states that its door and the ci door "enqueue
`build_on_push.go`'s header states that its lane and the ci lane "enqueue
identical image tags so they converge, never diverge". That holds only for a repo
that sets `tag-suffix`. For every repo that omits it — most of them, this one
included — they differ by exactly the suffix. Its own test documents the fallback
+2 -2
View File
@@ -8,7 +8,7 @@ import { Footer } from '@/components/footer';
// It sat under (home)'s HomeLayout, which has no tree and therefore no sidebar:
// a reader arriving at / saw eight domain cards and had to click one before the
// site would show them what it contains. The sidebar is the table of contents
// for ~1,600 pages, and the front door is where it is worth the most.
// for ~1,600 pages, and the landing page is where it is worth the most.
//
// / gets its own route group because (home) also wraps /blog, an editorial
// page with no place in the docs tree, so it keeps the marketing chrome — and
@@ -27,7 +27,7 @@ export default function Layout({ children }: LayoutProps<'/'>) {
on the page's own <main>. */}
{/* THE TREE IS THE POINT, so it starts open here as it does everywhere
else. It started collapsed on the argument that a reader who typed the
domain has not asked for a table of contents — but the front door is
domain has not asked for a table of contents — but the landing page is
exactly where somebody does not yet know what is here, and a collapsed
rail answers that by showing nothing. It also cost the page its
wordmark: the sidebar renders it, so collapsing the sidebar rendered no
+1 -1
View File
@@ -223,7 +223,7 @@ export default function Page() {
It also has to be here rather than in the bar: this page has no table
of contents, which is where the doc pages host it, so without this the
front door offered no way to hand anything to an agent at all. */}
the landing page offered no way to hand anything to an agent at all. */}
<div className="relative mt-6" style={{ display: 'grid', justifyItems: 'center', rowGap: 8 }}>
<AgentActions />
<p className="text-xs text-neutral-400">
+1 -1
View File
@@ -30,7 +30,7 @@ const TITLES: Record<string, string> = {
openapi: 'API reference — one page per capability, one per operation',
cli: 'CLI — the `hanzo` command line, one page per capability',
start: 'Start — six journeys, each shown as CLI, SDK, HTTP and MCP',
'mcp-tools': 'MCP — one page per tool the door exposes',
'mcp-tools': 'MCP — one page per tool MCP exposes',
projects: 'Open source — docs ported from the repositories themselves',
sdks: 'SDKs — one page per language',
architecture: 'Architecture',
+11 -11
View File
@@ -31,7 +31,7 @@ import { PROVIDER_ICONS } from '@/components/provider-icons';
* The three paths, ordered by how much you type. Each carries its real first
* step — these are commands that work, not illustrative pseudocode.
*/
const DOORS = [
const PATHS = [
{
id: 'app',
eyebrow: 'No code',
@@ -86,9 +86,9 @@ export function DocsHero({
title?: string;
description?: string;
}) {
const [active, setActive] = useState<string>(DOORS[0].id);
const [active, setActive] = useState<string>(PATHS[0].id);
const door = DOORS.find((d) => d.id === active) ?? DOORS[0];
const path = PATHS.find((d) => d.id === active) ?? PATHS[0];
return (
@@ -135,7 +135,7 @@ export function DocsHero({
panel below follows the pointer and a reader compares paths by moving
across them rather than by clicking three times. */}
<div className="mb-4 grid gap-3 sm:grid-cols-3">
{DOORS.map((d, i) => {
{PATHS.map((d, i) => {
const on = d.id === active;
return (
<button
@@ -176,7 +176,7 @@ export function DocsHero({
})}
</div>
{/* The selected path's real first step. Keyed on the door id so React
{/* The selected path's real first step. Keyed on the path id so React
remounts it and the entrance animation replays on every switch — the
movement is what tells you the panel answers the thing you just hovered. */}
<div
@@ -185,18 +185,18 @@ export function DocsHero({
>
<div className="flex items-center justify-between gap-3 border-b border-fd-border px-4 py-2.5">
<span className="text-xs font-medium text-fd-muted-foreground">
{door.lang}
{path.lang}
</span>
<Link
href={door.href}
{...(door.external ? { target: '_blank', rel: 'noreferrer' } : {})}
href={path.href}
{...(path.external ? { target: '_blank', rel: 'noreferrer' } : {})}
className="text-xs text-fd-muted-foreground underline-offset-4 transition-colors hover:text-fd-foreground hover:underline"
>
{door.cta} →
{path.cta} →
</Link>
</div>
<pre key={door.id} className="hanzo-rise overflow-x-auto px-4 py-3.5 text-[13px] leading-relaxed text-fd-foreground">
<code>{door.code}</code>
<pre key={path.id} className="hanzo-rise overflow-x-auto px-4 py-3.5 text-[13px] leading-relaxed text-fd-foreground">
<code>{path.code}</code>
</pre>
</div>
+1 -1
View File
@@ -88,7 +88,7 @@ export function DocsNavbar(props: ComponentProps<'header'>) {
'inline-grid grid-flow-col items-center gap-2.5 font-semibold',
// Hidden on desktop only while the SIDEBAR is showing it — which it is
// not when collapsed, and collapsed is exactly the landing page. Left
// unconditional the front door rendered no wordmark at all: the rail
// unconditional the landing page rendered no wordmark at all: the rail
// that owns it was off-screen and this copy was suppressed for a
// neighbour that was not drawing.
!collapsed && 'md:hidden',
+1 -1
View File
@@ -78,7 +78,7 @@ export default function CustomSearchDialog(props: SharedProps) {
return map;
}, [full]);
// ASKING IS A DIFFERENT INTENTION FROM FINDING, and until now only one of them
// had a door. The field says "search or ask", full-text search matches TERMS,
// had one. The field says "search or ask", full-text search matches TERMS,
// and a question is mostly terms that are not in any page — measured, "api
// key", "quickstart" and "kms" all return results while "how do I get an api
// key" returns none. So the reader most in need of an answer got the emptiest
+1 -1
View File
@@ -14,7 +14,7 @@ import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
* So it is not a second search any more — it is the same dialog ⌘K opens,
* wearing the shape of a field. Full text, every page, and the AI answer on the
* same surface, because "find the page" and "answer my question" are one
* intention and the reader should not have to pick a door before they know
* intention and the reader should not have to pick a mode before they know
* which one they wanted.
*
* It renders as a BUTTON, not an input. An input would take focus and swallow
+3 -3
View File
@@ -8,7 +8,7 @@ description: How a request proves who it is — the two Hanzo key types, which o
Every request to the Hanzo platform is authenticated with a bearer token. There
are two kinds of key, they are not interchangeable, and the difference is what
each one resolves to when the door reads it.
each one resolves to when the edge reads it.
## The two types
@@ -16,7 +16,7 @@ each one resolves to when the door reads it.
### What each one resolves to
A **secret key** resolves to **you**. The door looks it up and gets your user
A **secret key** resolves to **you**. The edge looks it up and gets your user
row, which makes it session-equivalent: anything you can do in the console, a
holder of your `sk-` can do over HTTP. That is why it belongs on a server and
nowhere else.
@@ -24,7 +24,7 @@ nowhere else.
A **publishable key** resolves to **your organization** and stops there — it
never yields a principal, so it cannot read anything. It identifies; it does not
authenticate. That is the property that makes it safe to ship in a browser
bundle, and it is enforced at the door rather than promised: a `pk-` presented
bundle, and it is enforced at the edge rather than promised: a `pk-` presented
as a bearer credential is refused outright, no matter which endpoint it arrives
at. One `pk-` covers events, product insights and error capture, which is why
you do not need a separate key or a separate DSN for each.
@@ -31,7 +31,7 @@ Eleven primitives generate the platform. Each is one idea, named for what it is.
| **Meter** | A count of usage, for limits and billing. | [Usage](/docs/usage), [Analytics](/docs/openapi/event), [Billing](/docs/openapi/billing) |
| **Schedule** | Deferred and recurring execution — run this later, or on a cadence. | [Tasks](/docs/openapi/tasks), [Pipelines](/docs/openapi/auto) |
| **Bus** | Asynchronous delivery between components. | [PubSub](/docs/openapi/pubsub), [Stream](/docs/openapi), [MQ](/docs/openapi/mq) |
| **Gateway** | The one front door; every capability is reached at `/v1/<name>`. | [Gateway](/docs/openapi/gateway), [Ingress](/docs/openapi/ingress) |
| **Gateway** | The one entry point; every capability is reached at `/v1/<name>`. | [Gateway](/docs/openapi/gateway), [Ingress](/docs/openapi/ingress) |
| **Store** | Durable bytes and blobs. | [Storage](/docs/openapi/s3), [KV](/docs/openapi), [Vector](/docs/openapi), [Datastore](/docs/openapi) |
| **Money** | Value movement — charges, payouts, balances, rewards. | [Commerce](/docs/openapi/commerce), [Billing](/docs/openapi/billing), [Referrals](/docs/openapi), [Marketplace](/docs/openapi) |
@@ -10,7 +10,7 @@ This section explains how Hanzo Cloud is put together — what runs today, and w
## Today
Hanzo Cloud is **one Go binary**. A single API key at `api.hanzo.ai` reaches every capability — AI inference, identity, secrets, storage, commerce, and more — because those capabilities are **native subsystems compiled into that one binary** (`cloud`, `CGO_ENABLED=0`, static), each served under `/v1/<capability>` and described by the one document the binary emits, [hanzoai/cloud](https://github.com/hanzoai/cloud)'s `openapi.yaml`. Ingress and routing are native subsystems too, so the binary is its own front door rather than sitting behind a separate gateway or ingress tier. This single-binary consolidation is the unified cloud binary (HIP-0106), already substantially shipped — and it is the same binary that powers [the network you can run yourself](/docs/network).
Hanzo Cloud is **one Go binary**. A single API key at `api.hanzo.ai` reaches every capability — AI inference, identity, secrets, storage, commerce, and more — because those capabilities are **native subsystems compiled into that one binary** (`cloud`, `CGO_ENABLED=0`, static), each served under `/v1/<capability>` and described by the one document the binary emits, [hanzoai/cloud](https://github.com/hanzoai/cloud)'s `openapi.yaml`. Ingress and routing are native subsystems too, so the binary is its own entry point rather than sitting behind a separate gateway or ingress tier. This single-binary consolidation is the unified cloud binary (HIP-0106), already substantially shipped — and it is the same binary that powers [the network you can run yourself](/docs/network).
Every capability is grouped into exactly one of **nine domains**. [The reference](/docs/openapi) counts them and lists them all:
@@ -9,7 +9,7 @@ description: The architecture direction for Hanzo Cloud — plugins as the unit
This page describes a **design direction**, not a shipped feature. The runtime it describes is specified in **HIP-0125 (Draft)**. Foundational pieces exist — the Quasar post-quantum BFT consensus engine is implemented, and the seven-voter placement ceremony has been built and tested — but they are deliberately kept **out of the live serving path**: placement does not yet decide where traffic goes, and there is no `cloud.Register` plugin runtime to deploy against. It is published so the design can be reviewed. For what runs today, see the product pages under [Platform & Services](/docs/openapi); this page is about where the platform is headed and why.
</Callout>
Hanzo Cloud is **one Go binary**. Every subsystem — IAM, KMS, ingress, billing, MPC, observability, agents, and the rest — is compiled natively into it and served under a single `/v1` surface described by the document the binary emits, [hanzoai/cloud](https://github.com/hanzoai/cloud)'s `openapi.yaml`. Routing and ingress are themselves native subsystems, so the design point is that **the one binary is the front door** — not a stack of gateway and ingress tiers layered in front of it. This is already substantially shipped: the unified binary (HIP-0106) collapsed the former per-service fleet into this one process.
Hanzo Cloud is **one Go binary**. Every subsystem — IAM, KMS, ingress, billing, MPC, observability, agents, and the rest — is compiled natively into it and served under a single `/v1` surface described by the document the binary emits, [hanzoai/cloud](https://github.com/hanzoai/cloud)'s `openapi.yaml`. Routing and ingress are themselves native subsystems, so the design point is that **the one binary is the entry point** — not a stack of gateway and ingress tiers layered in front of it. This is already substantially shipped: the unified binary (HIP-0106) collapsed the former per-service fleet into this one process.
What the plugin platform adds is the **next** step, and it is *not* a re-layering: the one binary stays one binary. Each native subsystem becomes a **placed plugin** so that nodes **specialize** — the same binary runs on every node, but each node runs a dynamic *subset* of the plugins rather than all of them — with a **small consensus control plane** deciding where each plugin's write side lives. The goal is that a subsystem author writes almost none of the hard parts — placement, failover, replication, high availability — and inherits them from the platform.
+1 -1
View File
@@ -92,7 +92,7 @@ lost, so a run can be replayed after it finishes.
calls through.
- **Compute** — a [sandbox](/docs/concepts/sandboxes) lease.
- **Knowledge** — [memory](/docs/concepts/memory).
- **Models** — [ai](/docs/openapi/ai), the inference door.
- **Models** — [ai](/docs/openapi/ai), the inference plane.
## Next
@@ -35,7 +35,7 @@ or by hand at `/v1/auto/flows/{id}/run`. Every execution is a **run** you read
back at `/v1/auto/runs`, and a run that stopped for input resumes at
`/v1/auto/runs/{id}/resume`.
The inbound door is `POST /v1/auto/hooks/{source}/{event}`. Unlike a provider's
The inbound endpoint is `POST /v1/auto/hooks/{source}/{event}`. Unlike a provider's
public webhook URL it is authenticated and org-scoped. It answers `{"matched":n}`
— **zero matches is a success**, because "nothing was listening" is a fact, not
an error. Send `X-Idempotency-Key` (or let it hash the content) and a hammer of
@@ -67,7 +67,7 @@ as a warning.
run it, read every run. Four operations — `/v1/flow/workflows`,
`/v1/flow/workflows/{workflow}`, `/v1/flow/runs`, `/v1/flow/status`.
The product itself lives outside the cloud door; what the door adds is exactly
The product itself lives outside the cloud; what the cloud adds is exactly
three things — identity, the tenant boundary, and the unified surface. It
deliberately contributes no data model of its own, which is why its surface is
four operations rather than forty.
+6 -6
View File
@@ -1,7 +1,7 @@
---
icon: Radio
title: Events
description: One ingest door that dispatches on the shape of the body, what each credential may write, and the receipt every wire gets back.
description: One ingest endpoint that dispatches on the shape of the body, what each credential may write, and the receipt every wire gets back.
---
import { Callout } from '@hanzo/docs-ui/components/callout'
@@ -9,10 +9,10 @@ import { Callout } from '@hanzo/docs-ui/components/callout'
**After this page you can send an event from a browser, a server or an existing
SDK, and read what happened to it.**
An event is a product fact — somebody did something. You send it to one door,
and you read it back through lenses.
An event is a product fact — somebody did something. You send it to one
endpoint, and you read it back through lenses.
## One door, dispatched by shape
## One endpoint, dispatched by shape
```bash
curl -X POST https://api.hanzo.ai/v1/event \
@@ -72,7 +72,7 @@ and identity fields. It cannot name the person.
`/v1/event/overview`, `/timeseries`, `/top`, `/errors` and `/insights/events`
are the lenses. `/v1/event/tag.js` is the hosted browser tag, served beside the
door so the tag and the wire cannot drift apart. `/v1/event/replay` takes
ingest endpoint so the tag and the wire cannot drift apart. `/v1/event/replay` takes
session-recorder snapshots.
Two Sentry-compatible wires — `/v1/event/{project}/envelope` and
@@ -84,7 +84,7 @@ Their `project` is the DSN's project id and is **not** a Hanzo IAM project.
Named once so you stop wondering which one to reach for:
- **[event](/docs/openapi/event)** — the product fact, admitted at the beacon
door. This page.
endpoint. This page.
- **[metrics](/docs/openapi/metrics)** — the deployment's own logs, metrics and
traces.
- **[o11y](/docs/openapi/o11y)** — the full observability plane over that store,
+1 -1
View File
@@ -11,7 +11,7 @@ import { Card, Cards } from '@hanzo/docs-base-ui/components/card';
One Model Context Protocol server, one tool surface. Add **`hanzo-mcp`** to Claude Code (or Cursor, Claude Desktop, or any MCP client) and every Hanzo capability shows up through a single connection — no per-tool wiring.
> Looking for the hosted door instead? **[Cloud MCP](/docs/mcp-tools)** is a URL with nothing to install, and its every tool is enumerated in the [tool catalogue](/docs/mcp-tools/all-tools). This page is the server that runs beside your editor.
> Looking for the hosted MCP server instead? **[Cloud MCP](/docs/mcp-tools)** is a URL with nothing to install, and its every tool is enumerated in the [tool catalogue](/docs/mcp-tools/all-tools). This page is the server that runs beside your editor.
```bash title="Add the Hanzo MCP to Claude Code"
claude mcp add hanzo -- npx -y @hanzo/mcp serve
+2 -2
View File
@@ -6,8 +6,8 @@ description: Algolia hosts search indexes and ranks documents against a query. H
Algolia stores your records, builds an index over them, and ranks them against a
query with typo tolerance and facets. `/v1/index` (17 operations) is that half,
and it speaks the Meilisearch dialect — point an existing Meilisearch client at
`https://api.hanzo.ai/v1/index` and it works unchanged, which is a door Algolia's
own SDK does not open.
`https://api.hanzo.ai/v1/index` and it works unchanged, which Algolia's own SDK
cannot do.
The structural difference is that indexing is not a queue. Algolia's `taskID`
names real background work and `waitTask` polls until the record is published.
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Datadog
description: Datadog collects metrics, traces and logs from one agent and gives each signal its own search API. Here that is /v1/o11y — 381 operations behind one query door — with /v1/event (12) for the browser and /v1/metrics (11) for an in-process store.
description: Datadog collects metrics, traces and logs from one agent and gives each signal its own search API. Here that is /v1/o11y — 381 operations behind one query endpoint — with /v1/event (12) for the browser and /v1/metrics (11) for an in-process store.
---
Datadog collects metrics, traces and logs from one agent, then gives each signal
@@ -52,7 +52,7 @@ claim inside the key rather than a header you set.
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Metrics, logs and traces in one store behind one query door, plus dashboards, alert rules, downtimes, notification channels, host and Kubernetes views | 381 |
| `/v1/o11y` | Metrics, logs and traces in one store behind one query endpoint, plus dashboards, alert rules, downtimes, notification channels, host and Kubernetes views | 381 |
| `/v1/event` | Browser tag, session replay and the Sentry wire — pageviews, uncaught errors, custom events | 12 |
| `/v1/metrics` | A write-and-query store small enough to run inside the process it measures | 11 |
+1 -1
View File
@@ -38,7 +38,7 @@ curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/recipients" \
```
You have a DRAFT document and one recipient, and step 3 answered a `token` —
minted before anything is sent, and the entire credential the signing door
minted before anything is sent, and the entire credential the signing endpoint
accepts. That is the shape difference from DocuSign in one call: the signer has
no account, only the link built from that token.
+5 -5
View File
@@ -50,7 +50,7 @@ id and no `X-Scope-OrgID` — the key carries the org.
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Dashboards, alert rules, PromQL and composite queries, log pipelines, trace waterfalls, Kubernetes inventory — one store behind all of it | 381 |
| `/v1/metrics` | The ingest doors — metric, log and trace writes, plus the reads over that bounded ring | 11 |
| `/v1/metrics` | The ingest endpoints — metric, log and trace writes, plus the reads over that bounded ring | 11 |
| `/v1/notify` | Sends the SMS and email an escalation ends in | 4 |
## Nouns
@@ -210,19 +210,19 @@ onto a panel. `POST /v1/o11y/llm/annotation` is a different noun — a human's l
one LLM trace or observation, in a review queue, not a band drawn across a chart.
**No continuous profiling, and no probes in other regions.** Pyroscope's per-process
CPU and heap profiles have no ingest door. `POST /v1/o11y/traces/{traceId}/flamegraph`
CPU and heap profiles have no ingest endpoint. `POST /v1/o11y/traces/{traceId}/flamegraph`
buckets one trace's spans by depth, which draws like a flame graph and measures
something else. Synthetic Monitoring's checks from probe locations and k6's load tests
have no equivalent either — `GET /v1/o11y/summary` and `GET /v1/o11y/status` report
Hanzo's own fleet health, which is a different fact from your endpoint's.
**Two ingest doors, both counting in nanoseconds.** `/v1/metrics/logs/write` and
**Two ingest endpoints, both counting in nanoseconds.** `/v1/metrics/logs/write` and
`/v1/metrics/traces/write` hold a bounded ring per org — 1,048,576 records and
1,048,576 spans, oldest evicted — and take the tenant from the gateway-minted
`X-Org-Id`; a series written to `/v1/metrics/write` keeps its newest 65,536 samples.
The `/v1/o11y` reads run over the collector-fed datastore, whose retention you set per
signal at `POST /v1/o11y/settings/ttl`. Choose the door deliberately. Note also that
Prometheus remote write sends milliseconds while both doors here read nanoseconds, and
signal at `POST /v1/o11y/settings/ttl`. Choose the endpoint deliberately. Note also that
Prometheus remote write sends milliseconds while both endpoints here read nanoseconds, and
a sample sent with no timestamp is stored at 0 and then falls outside any query with a
lower bound — that is the usual reason a write which answered 200 does not read back.
+1 -1
View File
@@ -57,7 +57,7 @@ what the model actually served.
| Project, sent as `session_name` on every run | The project scope the credential carries; the LLM reads are narrowed to it |
| Run: `POST /runs`, then `PATCH /runs/{id}` to close it | One span, closed on the write: `POST /v1/metrics/traces/write` carries `startNs` and `endNs` together |
| `dotted_order`, the ancestor chain encoded into a string | `parentId`. A span names its parent and derives nothing |
| `POST /runs/batch` | `POST /v1/event` — one ingest door; an authenticated body is offered to the observability plane, which claims LLM ingestion batches |
| `POST /runs/batch` | `POST /v1/event` — one ingest endpoint; an authenticated body is offered to the observability plane, which claims LLM ingestion batches |
| LLM run list | `GET /v1/o11y/llm/observations` — model, token counts, cost and latency per call |
| Trace view | `GET /v1/o11y/llm/traces`; the waterfall at `POST /v1/o11y/traces/{traceId}/waterfall` |
| Threads | `GET /v1/o11y/llm/sessions` — spans grouped by `session.id` |
+1 -1
View File
@@ -85,7 +85,7 @@ Four tables, because OpenAI is four products stacked on one another.
| Tool of type `function` | A name from `GET /v1/tools` (19), dispatched with `POST /v1/tools/call` |
| Tool of type `code_interpreter` | `POST /v1/sandbox/run` (19) — exit code, stdout and stderr returned as data |
| Tool of type `file_search` | `POST /v1/ai/rag/query` over what `POST /v1/ai/rag/ingest` indexed |
| A remote MCP server named on a response | `POST /v1/tools/mcp/servers` registers it; `POST /v1/mcp` is the door |
| A remote MCP server named on a response | `POST /v1/tools/mcp/servers` registers it; `POST /v1/mcp` is the endpoint |
| Streaming run events | `GET /v1/agents/sessions/stream` — server-sent events, filtered on tenant before fan-out |
| Scheduling a run yourself | `executionMode: long-running` with a 5-field cron, parsed at create |
+1 -1
View File
@@ -196,7 +196,7 @@ coordinate beneath it, and per-end-user scoping is a filterable attribute — a
filter, not a partition. Deleting one user's records is a list of keys at
`POST /v1/index/indexes/{uid}/documents/delete-batch`, not a namespace drop.
**You cannot query by a vector you computed yourself.** Every query door in this
**You cannot query by a vector you computed yourself.** Every query endpoint in this
document takes text: `query` on `POST /v1/search`, `q` on the index search,
`query` on `POST /v1/knowledge/search`. If you embed with your own model —
images, a fine-tune, anything outside `POST /v1/embeddings` — the answer is
+4 -4
View File
@@ -65,8 +65,8 @@ browser, which is what proves the write is yours to read.
| Personal API key `phx_…` | A bearer, minted at `POST /v1/account/keys` |
| Project, by numeric id in every path | Your org and project, from the validated key |
| `POST /capture/` | `POST /v1/event` |
| `POST /batch/` | The same door. A batch is a body, not a path — an array already is one |
| `$pageview`, `$identify`, custom events | The same names, the same door |
| `POST /batch/` | The same endpoint. A batch is a body, not a path — an array already is one |
| `$pageview`, `$identify`, custom events | The same names, the same endpoint |
| `posthog-js` snippet | `GET /v1/event/tag.js` — one script tag carrying `data-key="pk-…"` |
| Autocapture | The tag captures pageviews, initial and SPA, plus uncaught errors |
| Web analytics tiles | `GET /v1/event/overview` — pageviews, visitors, sessions for one window |
@@ -74,7 +74,7 @@ browser, which is what proves the write is yours to read.
| Activity / live events | `GET /v1/event/insights/events` |
| Group analytics | `groupType` and `groupId` on the event; `groups` on the decide call |
| Error tracking | `GET /v1/event/errors`. Sentry SDKs report unchanged at `POST /v1/event/{project}/envelope` |
| Replay chunks, sent as `$snapshot` events | `POST /v1/event/replay` — its own door, rrweb carried byte for byte |
| Replay chunks, sent as `$snapshot` events | `POST /v1/event/replay` — its own endpoint, rrweb carried byte for byte |
| Data pipelines / destinations | `/v1/destination` (5) — ga4, meta, tiktok, linkedin, x, reddit |
### Flags and experiments
@@ -241,7 +241,7 @@ control collapses to zero exposed, and the numbers mean nothing. Deciding is
also not terminal and does not revert: a second call overwrites the winner with
no record of the first, and restoring a split is a flag write.
**Replay is an ingest door, not a search.** `POST /v1/event/replay` takes rrweb
**Replay is an ingest endpoint, not a search.** `POST /v1/event/replay` takes rrweb
batches keyed by `sessionId` — 70 characters at most, 512 KiB a batch — and is
all-or-nothing, so a 200 means the recording is durable rather than buffered.
Filtering recordings by rage click or console error is not an API route, and a
+3 -3
View File
@@ -7,7 +7,7 @@ Sentry takes a stack trace off a running application, groups it by fingerprint
into an issue, and keeps the traces, logs and recordings around it. Two
capabilities answer that: **`/v1/o11y`** (381 operations) holds the issues,
traces, alert rules and dashboards, and **`/v1/event`** (12 operations) is the
ingest door, which accepts the Sentry envelope byte-for-byte — an application
ingest endpoint, which accepts the Sentry envelope byte-for-byte — an application
already instrumented reports here by changing its DSN and nothing else.
The structural difference is where the tenant comes from. Sentry names the
@@ -41,7 +41,7 @@ curl -sS "https://api.hanzo.ai/v1/o11y/sentinel/issues?status=unresolved&period=
Step 2 returns `data.dsn`. Paste it into `Sentry.init` and an already-instrumented
application reports to `POST /v1/event/{project}/envelope` with nothing else
changed — that door is credentialed by the DSN key, not by the bearer above. Step
changed — that endpoint is credentialed by the DSN key, not by the bearer above. Step
3 is the read side: the org came from the key, so `project` is an optional
narrowing rather than a required path segment.
@@ -50,7 +50,7 @@ narrowing rather than a required path segment.
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Issues, traces, alert rules, dashboards and logs — everything you read | 381, across 287 paths |
| `/v1/event` | The ingest door. Takes the Sentry envelope byte-for-byte, keyed by the DSN | 12 |
| `/v1/event` | The ingest endpoint. Takes the Sentry envelope byte-for-byte, keyed by the DSN | 12 |
| `/v1/webhook` | Where an issue leaves the platform. Each endpoint carries its 7-day delivery and failure counts | 8 |
## Nouns
+1 -1
View File
@@ -188,7 +188,7 @@ API over your data is what you actually wanted, that is Base.
**The key you publish cannot read.** Supabase's anon key ships in a browser
bundle and reads tables, with RLS as the backstop. There is a publishable key
here — `POST /v1/account/keys` with `type: publishable`, or an org key at scope
`publish` — but it is write-only: a `pk-` resolves an org at the ingest door and
`publish` — but it is write-only: a `pk-` resolves an org at the ingest endpoint and
never a principal, so it writes an event and reads nothing back. A browser that
reads gets a user token through `GET /v1/iam/oauth/authorize` and
`POST /v1/iam/oauth/token`. The tenant still comes from the credential rather
+1 -1
View File
@@ -129,6 +129,6 @@ and deduplicated by normalised URL, and the list is capped at 30. Ranking is
deterministic rather than scored: the first configured engine's hits lead.
**`/v1/websearch/scrape` is not the one to reach for.** It is the
service-to-service door and takes the shared service key as its `Authorization`
service-to-service endpoint and takes the shared service key as its `Authorization`
Bearer — a validated principal does not substitute for it. Use `/v1/crawl`,
which takes either.
+2 -2
View File
@@ -158,7 +158,7 @@ curl -X POST https://api.hanzo.ai/v1/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
One door, one tool per capability. `tools/call` names the capability and the
One endpoint, one tool per capability. `tools/call` names the capability and the
operation; `describe` explains any operation's input.
</Tab>
</Tabs>
@@ -169,7 +169,7 @@ difference. Pick the one you will write in:
- **[CLI](/docs/cli)** — every capability as a command group, one page each.
- **[SDKs](/docs/sdks)** — Python, TypeScript, Go, Rust, C++, Swift and Kotlin,
generated from the same description the CLI and the tools are.
- **[MCP](/docs/mcp)** — one door an agent connects to; [the tools](/docs/mcp-tools)
- **[MCP](/docs/mcp)** — one endpoint an agent connects to; [the tools](/docs/mcp-tools)
are listed one per capability.
HTTP needs no page: it is `https://api.hanzo.ai` and the key you just exported.
+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/account/keys answers 403
{"code":"forbidden","error":"sign in to manage API keys"} with NO key and
with a bogus one, while GET /v1/account/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 route. 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.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -66,7 +66,7 @@ export function domains(file: string = CAPABILITIES): Domain[] {
}
/**
* The door a capability answers on when it is NOT a `/v1` address.
* Where a capability answers when it is NOT at a `/v1` address.
*
* A wire adaptor binds a port, a discovery convention answers under
* `/.well-known/`, a co-resident claims requests on another app's router, and a
+1 -1
View File
@@ -12,7 +12,7 @@ import { DOCUMENT, loadDocument, type KeyType } from './openapi-doc';
//
// It was written because docs.hanzo.ai documented three key types and cloud has
// two. `hk-` and `hz-` appeared on 148 pages, including the authentication page,
// and neither has ever existed: cloud's door takes `pk-` and `sk-` and refuses
// and neither has ever existed: cloud's edge takes `pk-` and `sk-` and refuses
// everything else. Prose cannot be trusted to remember that, so it is checked.
//
// BOTH directions, which is what makes the pages underivable-from-memory:
+3 -3
View File
@@ -21,7 +21,7 @@ import { doors } from './capabilities';
// CLI hanzoai/cli's generated command table, joined on method+path.
// SDK the methods the PUBLISHED clients declare, joined on the id each
// language spells the operation with.
// MCP the door's `tools/list`, joined on tool name and on operationId.
// MCP the server's `tools/list`, joined on tool name and on operationId.
//
// Three of those four joins cross a CLOCK. The CLI table, the tool list and the
// clients are each generated from cloud at their own lock, so each may spell a
@@ -50,7 +50,7 @@ export interface Coverage {
/** The tool that serves this capability, and the operations it names. */
tool?: string;
toolOps: number;
/** Of this capability's operations, those the door names by the document's id. */
/** Of this capability's operations, those the server names by the document's id. */
mcp: number;
}
@@ -117,7 +117,7 @@ if (import.meta.main) {
const say = (label: string, names: string[]) =>
console.log(`\n${label} (${names.length})\n ${names.join(', ') || '—'}`);
console.log(`capabilities: ${rows.length} (${rest.length} at /v1, ${rows.length - rest.length} at another door)`);
console.log(`capabilities: ${rows.length} (${rest.length} at /v1, ${rows.length - rest.length} at another address)`);
console.log(`operations: ${rest.reduce((n, r) => n + r.rest, 0)}`);
console.log(
`full: ${rest.filter((r) => r.cli === r.rest && r.sdk === r.rest && r.mcp === r.rest).length} capabilities where all four surfaces reach every operation`,
+3 -3
View File
@@ -54,14 +54,14 @@ function readFlows(file: string): Flow[] {
/**
* The MCP door is real and undeclared.
* The MCP endpoint is real and undeclared.
*
* `POST /v1/mcp` answers JSON-RPC `tools/list` — verified on the wire, and the
* count is passed in rather than written here, because it has already moved
* twice (833 flat tools, then 109 subsystem tools reaching 1384 operations) and
* a number in a sentence does not move with it.
* hanzo.yaml declares only `/v1/mcp/servers`, so the spec lane recorded
* the door as "not routed": a true statement about the document and a false one
* the endpoint as "not routed": a true statement about the document and a false one
* about the fleet. The docs must describe what actually answers, and must say
* out loud that the document does not, so it gets declared instead of quietly
* forgotten.
@@ -74,7 +74,7 @@ function doorNotice(doc: Document, reach: number): string[] {
const declared = Object.keys(doc.raw.paths ?? {}).includes('/v1/mcp');
if (declared) return [];
return [
`> **The MCP door is not in the OpenAPI document.** \`POST ${MCP_DOOR}\` answers` +
`> **The MCP endpoint is not in the OpenAPI document.** \`POST ${MCP_DOOR}\` answers` +
` JSON-RPC 2.0 \`tools/list\` and \`tools/call\`, reaching ${reach} operations —` +
' it is live and is what the MCP column below calls. The document declares' +
' only `/v1/mcp/servers`, so this route is undeclared and needs adding to' +
+1 -1
View File
@@ -58,7 +58,7 @@ ${
server.length === 1
? `\`${server[0].prefix}\` belongs on a server and \`${browser.prefix}\` may ship in a browser bundle.`
: `\`${browser.prefix}\` may ship in a browser bundle; every other type belongs on a server.`
} A key spelled any other way is refused at the door — never guessed at, never
} A key spelled any other way is refused at the edge — never guessed at, never
defaulted to the safer-sounding one.
`;
+5 -5
View File
@@ -35,7 +35,7 @@ import { code, fence, firstSentence, prose, text, yamlString } from './mdx';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
// This reference joins a LIVE door against a PINNED document, so the two can
// This reference joins a LIVE server against a PINNED document, so the two can
// legitimately disagree — a route MCP has renamed since the pin resolves
// to no operation. Pages that report such a tool name the release, so the
// reader can tell "the document does not describe this" from "the copy we hold
@@ -572,13 +572,13 @@ function productOf(ops: Operation[] | undefined): string {
* A tool whose operations are ALL internal is internal. Without this the
* /v1/admin routes were printed in full on 78 tool pages, again in the catalogue
* and again in the section nav — the same leak the REST reference had, through a
* different door. MCP still answers them to whoever is authorised to call
* different surface. MCP still answers them to whoever is authorised to call
* them; what changes is only what docs.hanzo.ai publishes.
*
* `some` was right while a tool was one operation and is wrong now that a tool is
* a subsystem: one operator operation among thirty would have withdrawn the whole
* subsystem's page. `every` withholds exactly the tool that is nothing but the
* operator surface — which on this door is the one named `admin`, all 56 of it.
* operator surface — which on this server is the one named `admin`, all 56 of it.
*/
export function published(cat: McpCatalog, mapped: Map<string, Operation[]>): McpTool[] {
return cat.tools.filter((t) => {
@@ -587,7 +587,7 @@ export function published(cat: McpCatalog, mapped: Map<string, Operation[]>): Mc
});
}
// `meta.count` is what the DOOR answered and stays that; `tools.length` is what
// `meta.count` is what the SERVER answered and stays that; `tools.length` is what
// this reference documents. Where they differ the stamp says so, so a reader
// who counts the pages and compares is not left thinking one of them is wrong.
const provenance = (cat: McpCatalog): string =>
@@ -1032,7 +1032,7 @@ function renderIndex(cat: McpCatalog, doc: Document, groups: Map<string, McpTool
// The summary whole, not a first sentence: these summaries carry dotted
// hostnames, and a sentence-splitter cuts them at the first full stop.
L.push(
`Registering by \`url\` wires up a server the org runs or trusts. The other source is \`${code(catalog.method.toUpperCase())} ${code(catalog.path)}\` — ${text(catalog.summary || firstSentence(catalog.description))} Its entries are enabled by id through the \`listing\` field above, and either way the server's tools appear on this door.`,
`Registering by \`url\` wires up a server the org runs or trusts. The other source is \`${code(catalog.method.toUpperCase())} ${code(catalog.path)}\` — ${text(catalog.summary || firstSentence(catalog.description))} Its entries are enabled by id through the \`listing\` field above, and either way the registered server's tools appear on this endpoint.`,
);
L.push('');
}
+6 -6
View File
@@ -255,12 +255,12 @@ function leadProse(op: Operation): string {
* One name reaches this capability four ways — the address, the command group,
* the client class, the MCP tool — and HIP-0139 is the reason they are the same
* word. What HIP-0139 does not promise is that all four are FINISHED at the same
* moment: the CLI folds a document it pins on its own clock, and the MCP door
* moment: the CLI folds a document it pins on its own clock, and the MCP server
* declares its own verbs for operations it chooses to name.
*
* So this is a count, not a claim. Every number is a join performed here against
* the artefact that surface actually ships — cloud's document, the CLI's
* committed command table, the door's `tools/list` answer — and where a join
* committed command table, the server's `tools/list` answer — and where a join
* yields nothing the row says none rather than omitting itself. A reader asking
* "can I drive this from the CLI?" gets a number; a reader who gets no row would
* assume yes.
@@ -594,8 +594,8 @@ function specification(name: string, hips: Corpus): string[] {
* matters: it would tell an operator the console does not exist.
*
* So the sentence states only what is true of all of them — it is not in the
* public contract, it is GA, and here is the door — and the door line, written
* per capability in `capabilities.yaml`, carries the specific why. The COUNT is
* public contract, it is GA, and here is where it is reached — and the `doors:`
* line, written per capability in `capabilities.yaml`, carries the specific why. The COUNT is
* not written here at all: two of these were retired the week this was authored
* (`catalogsync` and `rollingcap`, neither of which was a process), and a number
* in a comment is the one part of that change nothing would have caught.
@@ -683,7 +683,7 @@ function renderProduct(
L.push(`| **Base URL** | \`${doc.server}\` |`);
L.push(`| **Operations** | ${p.operations.length} |`);
L.push(`| **Auth** | \`Authorization: Bearer $HANZO_API_KEY\` |`);
// A second door, where there is one. `pubsub` answers at `/v1/pubsub` AND on
// A second address, where there is one. `pubsub` answers at `/v1/pubsub` AND on
// `:4222`; printing only the first describes half the capability to the half
// of its readers who came for a NATS client.
const second = doors().get(p.name);
@@ -1091,7 +1091,7 @@ export async function genOpenapiPages(
);
}
// The quickstart's CLI and MCP columns are the CLI's own table and the door's
// The quickstart's CLI and MCP columns are the CLI's own table and the server's
// own answer, read here once and handed down — the same two artefacts the flow
// pages read, through the same two functions.
const table = loadCliTable();
+1 -1
View File
@@ -25,7 +25,7 @@ import { text, yamlString } from './mdx';
// catalogue carries prices and context windows but no benchmark, so the quality
// half of each tier's contract comes from hanzo.ai/enso. See GPQA below.
//
// Snapshot, like the document and the MCP door: fetched when reachable, and the
// Snapshot, like the document and the MCP server: fetched when reachable, and the
// committed copy when not, so a builder with no egress still renders a truthful
// page. `meta.captured` records when the copy on disk was taken and
// `meta.source` whether THIS build refreshed it, so the page states its own
+22 -22
View File
@@ -14,11 +14,11 @@ import { constraintsOf, genMcpPages, published, slugOf } from './gen-mcp-pages';
// not against a fixture, which could agree with a broken generator.
//
// uniform every tool page carries the same sections in the same order
// complete every field the door declares appears in the page's table
// complete every field the server declares appears in the page's table
// exemplified every page carries a tools/call envelope that parses
// reachable every page is linked from the catalogue and its product index
//
// A tool page states nothing the door did not say, so "generated" is not a test
// A tool page states nothing the server did not say, so "generated" is not a test
// but a property of gen-mcp-pages: no sentence about a tool is written there.
const catalog = load();
@@ -27,7 +27,7 @@ const doc = loadDocument(DOCUMENT);
const mappedOps = toolOperations(doc, catalog.tools);
// The reference documents the PUBLISHED tools, so the claims below are read
// against the same list the generator writes — see published(). Reading the
// door's whole answer here would fail every count by the 78 operator tools that
// server's whole answer here would fail every count by the 78 operator tools that
// deliberately have no page.
catalog.tools = published(catalog, mappedOps);
@@ -62,7 +62,7 @@ beforeAll(async () => {
}, 120_000);
// The join is one rule read in both directions, so it is pinned in both. The
// cases are taken from the live door's own tools/list answer.
// cases are taken from the live server's own tools/list answer.
describe('the tool -> operation rule', () => {
it('names an operation only where a tool enumerates its operationId', () => {
const index = door(catalog.tools).index;
@@ -71,9 +71,9 @@ describe('the tool -> operation rule', () => {
expect(doorOps(t)).toContain(id);
}
// And the other way: nothing outside the enums is claimed. `get_agents` is
// the case worth pinning — the door DISPATCHES it (probed live) but names it
// the case worth pinning — the server DISPATCHES it (probed live) but names it
// `list_agents` in the enum, so the index must not claim it, and the page
// must send the reader to `describe` instead of guessing the door's verb.
// must send the reader to `describe` instead of guessing the server's verb.
const agents = catalog.tools.find((t) => t.name === 'agents');
if (agents) {
expect(doorOps(agents)).toContain('list_agents');
@@ -90,7 +90,7 @@ describe('the tool -> operation rule', () => {
it('resolves the tools whose enums the document ids, and counts the rest', () => {
const mapped = toolOperations(doc, catalog.tools);
// The door groups its own way and names most operations with its own verb,
// The server groups its own way and names most operations with its own verb,
// so a tool resolving to nothing is normal and is published as `unmapped`
// rather than dropped. What must hold is that the join is alive and never
// invents: some tools resolve, none resolves outside the list.
@@ -139,9 +139,9 @@ describe('complete — every declared field is enumerated', () => {
});
it('attributes every column to the source that filled it', () => {
// The door publishes a type and a description and nothing else, so the
// The server publishes a type and a description and nothing else, so the
// Required, Default and Values columns come from the operation. A page that
// printed them without saying so would imply the door declares them.
// printed them without saying so would imply the server declares them.
const withFields = catalog.tools.filter((t) => Object.keys(t.inputSchema?.properties ?? {}).length);
expect(withFields.length).toBeGreaterThan(0);
const silent = withFields.filter((t) => !pageOf.get(slugOf(t.name))!.includes('and nothing further'));
@@ -149,9 +149,9 @@ describe('complete — every declared field is enumerated', () => {
});
it('marks every field either source requires', () => {
// WHO SAYS REQUIRED changed with the door. It used to say nothing, so the
// WHO SAYS REQUIRED changed with the server. It used to say nothing, so the
// operation was the only source and this asserted the join carried it onto a
// fifth of the pages. The subsystem door declares `required: ["op"]` itself,
// fifth of the pages. The subsystem tool declares `required: ["op"]` itself,
// and its two fields — `op` and `input` — are not the operation's fields, so
// the join contributes nothing HERE and the count guard measured a join that
// has moved rather than collapsed. The property that still matters, and is
@@ -226,16 +226,16 @@ describe('complete — every declared field is enumerated', () => {
}
}
expect(missing).toEqual([]);
// The subsystem door declares `input` as a bare object with no properties —
// The subsystem tool declares `input` as a bare object with no properties —
// what goes in it belongs to the operation you name in `op`, not to the tool
// — so this set is empty at this capture and the rule holds over it. Said out
// loud, like the enum case above, rather than asserted away with a count that
// would fail on a door that simply declares less.
// would fail on a server that simply declares less.
console.log(`[mcp-ref] inline object fields checked: ${checked}`);
});
it('states a required argument the door never declares, instead of an empty object', () => {
// Some operations need a path parameter the door's schema omits entirely.
it('states a required argument the server never declares, instead of an empty object', () => {
// Some operations need a path parameter the server's schema omits entirely.
// The page cannot say where the value goes, so it must say that.
const silent: string[] = [];
let checked = 0;
@@ -278,7 +278,7 @@ describe('exemplified — every page carries a call that parses', () => {
const declared = Object.keys(t.inputSchema?.properties ?? {});
const con = constraintsOf(mappedOps.get(t.name));
// Required as the ENVELOPE reads it: either source. This read only the
// operation, which was every source there was until the door began
// operation, which was every source there was until the server began
// declaring `required` itself — after which the envelope correctly sent
// just `op` and the test asked why it had not sent `input` too.
const required = declared.filter(
@@ -291,7 +291,7 @@ describe('exemplified — every page carries a call that parses', () => {
if (args.join(',') !== want.sort().join(','))
bad.push(`${t.name}: sent [${args}] for [${want}]`);
for (const n of args) {
if (!declared.includes(n)) bad.push(`${t.name}: sent ${n}, which the door does not declare`);
if (!declared.includes(n)) bad.push(`${t.name}: sent ${n}, which the server does not declare`);
}
}
expect(bad).toEqual([]);
@@ -302,7 +302,7 @@ describe('exemplified — every page carries a call that parses', () => {
// cannot, so an unannounced one is indistinguishable from a value the API
// declares — the page would be inventing quietly. Checked against the
// RENDERED envelope, and the schema walk below is this test's own: it
// resolves a dotted path through the door's schema without asking the
// resolves a dotted path through the server's schema without asking the
// generator what it decided.
const nodeAt = (schema: any, path: string): any => {
const defs: Record<string, any> = schema?.$defs ?? {};
@@ -347,11 +347,11 @@ describe('exemplified — every page carries a call that parses', () => {
}
}
expect(quiet).toEqual([]);
// How many opaque values there are to attribute is the DOOR'S business, and
// How many opaque values there are to attribute is the SERVER'S business, and
// it is now few: a subsystem call carries one string, `op`, and that string
// is an enumerated operation name, which announces itself. The flat door
// is an enumerated operation name, which announces itself. The flat shape
// forced hundreds — a count that pinned that shape in place, and would now
// fail a door that invites nothing. The rule is the assertion; the count is
// fail a server that invites nothing. The rule is the assertion; the count is
// reported.
console.log(`[mcp-ref] opaque values attributed: ${checked}`);
});
@@ -431,7 +431,7 @@ describe('reachable — no orphans', () => {
});
describe('provenance — the reference says where it came from', () => {
it('stamps the door, the count and the capture date on every page', () => {
it('stamps the address, the count and the capture date on every page', () => {
const unstamped = [...pageOf.entries()].filter(
([, src]) => !src.includes(catalog.door) || !src.includes(catalog.meta.captured),
);
+4 -4
View File
@@ -76,7 +76,7 @@ export interface Operation {
/** Full operationId, e.g. `get_v1_tools`. */
id: string;
/**
* The MCP door's tool name for this operation, which is the operationId
* The MCP server's tool name for this operation, which is the operationId
* itself — see the note where it is computed.
*/
name: string;
@@ -447,7 +447,7 @@ export function spellings(name: string): string[] {
/**
* A PROJECTION'S name for a capability, resolved to the DOCUMENT'S name.
*
* The CLI's command table and the MCP door's tool list are each generated from
* The CLI's command table and the MCP server's tool list are each generated from
* cloud at their own lock, so each can spell a capability the way cloud spelled
* it then. When cloud swept its addresses to the singular, `hanzo sandboxes`
* and the `sandboxes` tool went on saying `sandboxes` while the document said
@@ -592,11 +592,11 @@ export function loadDocument(file: string): Document {
const resolved: Operation = {
product: product_,
id,
// The door's tool name IS the operationId. It was the id minus its own
// The server's tool name IS the operationId. It was the id minus its own
// first segment while hanzoai/openapi prefixed every id with the spec it
// came from (`cloud_get_v1_tools`); one document needs no such
// disambiguator and that prefix is gone, so the ids are bare. Measured
// against the door's own tools/list: 802 of 833 names are the
// against the server's own tools/list: 802 of 833 names are the
// operationId exactly, where stripping a segment resolves 89.
name: id,
method,
+6 -6
View File
@@ -29,8 +29,8 @@ describe('tag -> client class', () => {
});
});
// The MCP door names a tool for the operationId minus its OWN first segment.
// Verified against the live door: `POST https://api.hanzo.ai/v1/mcp` answers
// The MCP server names a tool for the operationId minus its OWN first segment.
// Verified against the live server: `POST https://api.hanzo.ai/v1/mcp` answers
// tools/list with 730 tools, 729 of which this rule reproduces exactly (the
// odd one out is `get_v1_pricing_policy`). The cases below are taken from that
// answer, including the pair that proves the rule is NOT product-relative:
@@ -75,16 +75,16 @@ describe('operationId -> method name', () => {
// A GROUPED TOOL NAMES A SUBSYSTEM, NOT AN OPERATION.
//
// The door re-projected: it used to publish one tool per operation, so the tool
// The server re-projected: it used to publish one tool per operation, so the tool
// name WAS the operationId and the join was a name lookup. It now publishes one
// tool per subsystem carrying its operations in an `op` enum — `account` is not
// an operationId, so a name lookup answers nothing and every tool page would
// read "the document does not describe this".
//
// The enum is therefore the join, tried first. The name path stays for a door
// The enum is therefore the join, tried first. The name path stays for a server
// that still serves the flat shape, and this pins BOTH so neither can be
// dropped as dead code while the fleet is mid-projection.
describe('the tool -> operation join reads the shape the door serves', () => {
describe('the tool -> operation join reads the shape the server serves', () => {
const opWith = (id: string, method: any, p: string): any => ({
product: 'x', id, name: id, tag: 'x', method, path: p,
summary: '', description: '', parameters: [], deprecated: false,
@@ -103,7 +103,7 @@ describe('the tool -> operation join reads the shape the door serves', () => {
});
it('names no operation for a tool whose enum the document does not carry', () => {
// The reference joins a LIVE door to a PINNED document, so a door ahead of
// The reference joins a LIVE server to a PINNED document, so a server ahead of
// the pin legitimately names ids the document lacks. That must read as
// unmapped, never as a wrong operation.
const m = toolOperations(doc, [{ name: 'admission', inputSchema: { properties: { op: { enum: ['get_flag_waitlist'] } } } }]);
+6 -6
View File
@@ -329,13 +329,13 @@ const py = (v: any): string =>
/**
* THE TOOL RULE, in one place and read in both directions.
*
* MCP is a SUBSYSTEM door: `tools/list` answers 109 tools — `agents`,
* MCP is a SUBSYSTEM server: `tools/list` answers 109 tools — `agents`,
* `billing`, `iam` — and each declares an `op` enum naming the operations it
* dispatches to. So the unit a caller names is a pair, `{name: <subsystem>,
* arguments: {op, input}}`, not a tool per operation.
*
* A tool OWNS an operation when its enum names that operation's `operationId`
* VERBATIM. Exact, nothing else — and measured on the live door: 470 of the
* VERBATIM. Exact, nothing else — and measured on the live server: 470 of the
* document's 2480 operations are named that way, and not one is claimed by two
* tools, so the map needs no tie-break.
*
@@ -347,7 +347,7 @@ const py = (v: any): string =>
* which resolves an operationId whichever verb MCP prefers for it.
*
* The rule this replaces keyed a tool by the operationId AND by a method+path
* slug. Under the flat door both were true; under this one the second key still
* slug. Under the flat shape both were true; under this one the second key still
* matches — against tool names MCP stopped serving twelve days ago — so it
* printed `get_v1_agents` as a tool for an operation MCP now answers
* "unknown tool" to. A key that keeps matching after the thing it names is gone
@@ -386,7 +386,7 @@ export const door = (tools: Iterable<McpTool>): Door => {
* alias, and survives any renaming on either side — the two vocabularies meet at
* the operation, which is the one thing both of them spell the same way.
*
* `named` is the count under the DOCUMENT's own ids. A door may serve an
* `named` is the count under the DOCUMENT's own ids. An MCP server may serve an
* operation under a verb of its own, so `named` is a floor on what is reachable
* and never a ceiling, and a page that prints it says which it is.
*/
@@ -483,7 +483,7 @@ export const describeCall = (op: string): string =>
* Ranked, never hand-picked, so a product added to the document arrives with a
* first call already chosen and nothing to remember. Measured on the document:
* 152 of 188 products have one that runs on a key alone; the other 36 lead with
* a POST or an id because that is genuinely their front door, and the page says
* a POST or an id because that is genuinely their entry point, and the page says
* so rather than pretending otherwise — see `runnable`.
*/
/**
@@ -531,7 +531,7 @@ export function firstCall(p: Product): Operation {
* of the document at ITS OWN lock, and cloud dropping the default version from
* its operationIds moved every name at once. So the column prints the method at
* the current release — which is the one that agrees with the endpoint, the
* operation id and the MCP door — and this line names the client that does not
* operation id and the MCP server — and this line names the client that does not
* have it yet. It is computed from the two, so it cannot outlive the gap: the
* release that regenerates the clients deletes this sentence by itself.
*/
+2 -2
View File
@@ -25,8 +25,8 @@ async function main() {
//
// The MCP tool list is NOT synced here. It is vendored
// (openapi-specs/mcp-tools.json) and refreshed by `pnpm sync:mcp`, a commit
// someone makes and someone reads. Calling the live door from the build made
// the site a function of a running service instead of this commit: the door
// someone makes and someone reads. Calling the live server from the build made
// the site a function of a running service instead of this commit: the server
// gained a tool named `index`, that name is one this section spends on its own
// landing page, and the collision gate — correctly — failed every build from
// that moment, on commits that had already gone green hours earlier. A build
+38 -38
View File
@@ -2,24 +2,24 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// The MCP door's tool list, taken from the MCP door.
// The MCP server's tool list, taken from the MCP server.
//
// `POST https://api.hanzo.ai/v1/mcp` speaks JSON-RPC 2.0 and answers
// `tools/list` with the tools this fleet actually exposes — a subset of the
// document's operations, not all of them. So "is there an MCP tool for this
// operation?" is a question only the door can answer, and the docs ask it
// operation?" is a question only the server can answer, and the docs ask it
// rather than assuming.
//
// The WHOLE answer is kept, `inputSchema` included: the tool reference renders
// one page per tool straight from this file, so what the door declares is what
// the page prints. A summary would drift from the door; a verbatim copy cannot.
// one page per tool straight from this file, so what the server declares is what
// the page prints. A summary would drift from the server; a verbatim copy cannot.
//
// Snapshot, like the document and the CLI table: fetched when reachable, and
// the committed copy when not, so a builder with no egress still renders a
// truthful reference. `meta.captured` records when the copy on disk was taken,
// which is what a page's provenance line states instead of implying it is live.
//
// The door names operations TWO ways and both are here, because it changed
// The server names operations TWO ways and both are here, because it changed
// shape. It used to answer one flat tool per operation (833 of them). It now
// answers 109 SUBSYSTEM tools — `agents`, `billing`, `iam` — each declaring an
// `op` enum of the operations it dispatches to, 1383 in all. `ops()` reads
@@ -29,14 +29,14 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
const OUT = path.join(APP_ROOT, 'openapi-specs/mcp-tools.json');
/** The live JSON-RPC door. It is NOT declared in hanzo.yaml — see doorNotice. */
/** The live JSON-RPC endpoint. It is NOT declared in hanzo.yaml — see doorNotice. */
// HANZO_MCP_DOOR points the capture at another host — a local cloud binary
// serving the contract a release has not shipped yet — and nothing else.
export const MCP_DOOR = process.env.HANZO_MCP_DOOR ?? 'https://api.hanzo.ai/v1/mcp';
export interface McpTool {
name: string;
/** The tool's description, verbatim — the door's own prose. */
/** The tool's description, verbatim — the server's own prose. */
description: string;
/** The tool's declared arguments, verbatim: a JSON Schema object. */
inputSchema?: Record<string, any>;
@@ -46,8 +46,8 @@ export interface McpTool {
* The operations a tool dispatches to, as the tool itself declares them.
*
* A subsystem tool names them in its `op` enum; a flat tool IS one operation and
* names itself. One function so no caller has to ask which door answered, and so
* "how many operations does this door reach?" has a single meaning across a
* names itself. One function so no caller has to ask which shape answered, and so
* "how many operations does this server reach?" has a single meaning across a
* shape change — which is exactly what the count below needs.
*/
export const ops = (t: McpTool): string[] => {
@@ -55,32 +55,32 @@ export const ops = (t: McpTool): string[] => {
return Array.isArray(e) && e.length ? e.map(String) : [t.name];
};
/** Every operation this door reaches, across every tool. */
/** Every operation this server reaches, across every tool. */
export const reach = (tools: McpTool[]): number =>
tools.reduce((n, t) => n + ops(t).length, 0);
/**
* A read among a tool's operations, in the DOOR'S vocabulary.
* A read among a tool's operations, in the SERVER'S vocabulary.
*
* `get_`/`list_` is how the door spells a read, so this is its judgement and not
* `get_`/`list_` is how the server spells a read, so this is its judgement and not
* ours. Two callers want it and it is stated once: the sync probes a read when
* it asks the door what an uncredentialed call is refused with, and the tool
* it asks the server what an uncredentialed call is refused with, and the tool
* reference prints a read in the example call it invites people to paste.
*/
export const readOp = (t: McpTool): string | undefined =>
ops(t).find((o) => /^(get|list)_/.test(o));
/** What the door answered `initialize` with — its own account of itself. */
/** What the server answered `initialize` with — its own account of itself. */
export interface McpHandshake {
protocolVersion: string;
serverName: string;
serverVersion: string;
/** Capabilities the door advertises, verbatim. */
/** Capabilities the server advertises, verbatim. */
capabilities: Record<string, any>;
/**
* What ONE `tools/call` answers with NO credential. The door replies 200 with
* What ONE `tools/call` answers with NO credential. The server replies 200 with
* a JSON-RPC result whose `isError` is set, so the docs can state the auth
* rule from the door's own words instead of asserting it.
* rule from the server's own words instead of asserting it.
*
* The message is the probed tool's, not a universal one — each tool refuses
* in its own terms — so `anonymousProbe` records which tool was asked and
@@ -97,14 +97,14 @@ export interface McpCatalog {
count: number;
/** Operations those tools reach — see reach(). Stable across a regroup. */
reach: number;
/** ISO date the list on disk was taken from the door. */
/** ISO date the list on disk was taken from the server. */
captured: string;
};
handshake?: McpHandshake;
tools: McpTool[];
}
/** One JSON-RPC round trip to the door, with no credential attached. */
/** One JSON-RPC round trip to the server, with no credential attached. */
async function rpc(method: string, params?: Record<string, any>): Promise<any | null> {
try {
const r = await fetch(MCP_DOOR, {
@@ -114,17 +114,17 @@ async function rpc(method: string, params?: Record<string, any>): Promise<any |
signal: AbortSignal.timeout(60_000),
});
if (!r.ok) {
console.warn(`[mcp] door answered ${r.status} to ${method}`);
console.warn(`[mcp] server answered ${r.status} to ${method}`);
return null;
}
return await r.json();
} catch (e) {
console.warn(`[mcp] door unreachable for ${method}: ${(e as Error).message}`);
console.warn(`[mcp] server unreachable for ${method}: ${(e as Error).message}`);
return null;
}
}
/** Ask the door for its tools. Null when it does not answer with a usable list. */
/** Ask the server for its tools. Null when it does not answer with a usable list. */
async function fetchTools(): Promise<McpTool[] | null> {
const list = (await rpc('tools/list'))?.result?.tools;
if (!Array.isArray(list) || !list.length) return null;
@@ -136,7 +136,7 @@ async function fetchTools(): Promise<McpTool[] | null> {
}
/**
* Ask the door to describe itself, and ask it once WITHOUT a credential so the
* Ask the server to describe itself, and ask it once WITHOUT a credential so the
* docs can quote its own refusal rather than assert an auth rule.
* `tools/list` is answered anonymously — this file is the proof — but a
* `tools/call` is not, and the difference matters to anyone wiring a client up.
@@ -163,9 +163,9 @@ async function fetchHandshake(tool: string, op?: string): Promise<McpHandshake |
}
/**
* A read the door itself names as a read: a `get_`/`list_` operation on a tool
* A read the server itself names as a read: a `get_`/`list_` operation on a tool
* that is not the operator surface. Reading, so a probe that really dispatches
* cannot change anything; named by the door, so it survives the door renaming
* cannot change anything; named by the server, so it survives the server renaming
* everything.
*/
function pickRead(tools: McpTool[]): { tool: string; op?: string } | null {
@@ -181,51 +181,51 @@ export async function syncMcpTools(): Promise<void> {
const tools = await fetchTools();
const have = fs.existsSync(OUT) ? load() : null;
// Say it in the build log, loudly: this build's tool pages describe the door
// Say it in the build log, loudly: this build's tool pages describe the server
// as it was, not as it is. And say it ONLY there. Stamping `source:
// "snapshot"` back into the vendored file rewrote a TRACKED 1.1 MB artefact
// on every offline build, so a developer with no network produced a one-flag
// diff that the next online build flipped straight back — churn that says
// nothing about the door and buries the changes that do.
// nothing about the server and buries the changes that do.
const keep = (why: string): void =>
console.log(`[mcp] ${why} — falling back to the vendored list: ${have!.meta.count} tools captured ${have!.meta.captured}`);
if (!tools) {
if (!have) throw new Error(`[mcp] no tool list available and no snapshot at ${OUT}`);
keep('door unreachable');
keep('server unreachable');
return;
}
// A door answering with a fraction of what it had is a door mid-deploy, not a
// door that lost tools. Keep the copy rather than publishing a reference that
// A server answering with a fraction of what it had is a server mid-deploy, not a
// server that lost tools. Keep the copy rather than publishing a reference that
// silently drops hundreds of pages.
//
// MEASURE THE REACH, NOT THE TOOL COUNT. The door regrouped 833 flat per-operation
// MEASURE THE REACH, NOT THE TOOL COUNT. The server regrouped 833 flat per-operation
// tools into 109 subsystem tools reaching 1383 operations — it gained 550 and this
// guard read it as losing 724, so it held the pre-regroup list and every rebuild
// held it again. Twelve days of tool pages named tools the door answers "unknown
// held it again. Twelve days of tool pages named tools the server answers "unknown
// tool" to, and the guard could never unstick itself because the number it compared
// was a fact about the door's SHAPE. Reach is a fact about what the door can do,
// was a fact about the server's SHAPE. Reach is a fact about what the server can do,
// which is what "mid-deploy" was always trying to ask about.
if (have && have.meta.reach && reach(tools) < have.meta.reach * 0.8) {
keep(`door reaches only ${reach(tools)} operations, down from ${have.meta.reach}`);
keep(`server reaches only ${reach(tools)} operations, down from ${have.meta.reach}`);
return;
}
tools.sort((a, b) => a.name.localeCompare(b.name));
// Probe a READ, never a write. This call really is dispatched at the door, so
// Probe a READ, never a write. This call really is dispatched at the server, so
// the operation it names must be one that cannot change anything even if the
// credential rule ever loosens — and not an `admin` one, whose refusal is
// narrower than the general "you sent no principal" we want to quote.
//
// The read is chosen in the DOOR'S vocabulary, not ours. This looked for a
// The read is chosen in the SERVER'S vocabulary, not ours. This looked for a
// tool named `get*`, which every flat tool was and no subsystem tool is, so
// the probe silently stopped happening and the vendored handshake kept quoting
// a refusal from `get_v1_ads_campaigns` — a tool the door had stopped serving.
// a refusal from `get_v1_ads_campaigns` — a tool the server had stopped serving.
// A probe that cannot find a subject must say so, not keep the last answer.
const probe = pickRead(tools);
const handshake = probe ? await fetchHandshake(probe.tool, probe.op) : null;
if (!probe) console.warn('[mcp] no read-only operation to probe — the door states no auth rule this build can quote');
if (!probe) console.warn('[mcp] no read-only operation to probe — the server states no auth rule this build can quote');
const catalog: McpCatalog = {
door: MCP_DOOR,
meta: { count: tools.length, reach: reach(tools), captured: new Date().toISOString().slice(0, 10) },
+1 -1
View File
@@ -9,7 +9,7 @@
# them: it reads paths, tags, summaries and schemas, all of which cloud emits
# itself. So the projection bought the reference nothing and cost it currency.
# Measured the day this changed, the pinned projection was 19 cloud releases
# behind, and four relay-door products — download, files, upload, exec — were
# behind, and four relay products — download, files, upload, exec — were
# rendered with twelve operations each where cloud's document has one, one, one
# and two. Every one of those extra entries was the same heading repeated.
#
+1 -1
View File
@@ -143,7 +143,7 @@ export async function syncSdkClients(): Promise<void> {
}
}
// A registry that answers with nothing is a registry having a bad day, not a
// client that lost its methods — same rule as the door's tool list.
// client that lost its methods — same rule as the server's tool list.
if (!clients.length) {
if (!have) throw new Error('[sdk] no published client could be read and none is vendored');
console.log(`[sdk] no registry answered — keeping the vendored clients captured ${have.captured}`);
+1 -1
View File
@@ -46,7 +46,7 @@ test:
- name: unit
# vitest over packages/* and apps/docs. The apps/docs project is what pins
# the four surfaces: the SDK method names the docs print are openapi-
# generator's own derivation, and the MCP tool names are the door's, both
# generator's own derivation, and the MCP tool names are MCP's own, both
# asserted against real generated clients and a real tools/list answer. A
# drift in either naming rule fails here rather than teaching a call that
# does not exist.