mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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' +
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'] } } } }]);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user