From b2cfb6a1ef04bc8ecc2f887954d74461e9472c64 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:49:05 -0700 Subject: [PATCH 1/3] docs(plans): a read pin from the vendor's own GET line and our review (TASK-258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare annotates 2 of its 3,766 per-tool MCP tools, so no read pin can carry the vendor's readOnlyHint. §3 now admits a read pin without the hint when the pin records admittedWithoutHint { methodLine, review }: the vendor's own first description line (GET and a path, verbatim) and our one-sentence review of that tool. The review is the gate; the line is required and never sufficient; a name is never evidence. The load refuses the field on a non-read pin, a bad methodLine, an empty review, beside any readOnlyHint, on the subject tool, and in an entry without readOnlyScopes or requesting a scope outside them. Drift compares the stored line and refuses a live claim to write. A row whose recorded scope is not inside readOnlyScopes is credential_not_read_only, an extra condition and never the gate, since 74 non-GET Cloudflare operations accept a read permission. A listing is read under one byte budget, the JSON branch included. §4, §10 step 2 and §11 conform. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/hosted-mcp-connection-scope.md | 46 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/plans/hosted-mcp-connection-scope.md b/docs/plans/hosted-mcp-connection-scope.md index 9ce1cebb20..6c9eb96e4a 100644 --- a/docs/plans/hosted-mcp-connection-scope.md +++ b/docs/plans/hosted-mcp-connection-scope.md @@ -30,6 +30,8 @@ **Amended 2026-10-21 (TASK-213):** connector-ops ruled, for the CTO's security gate, that §12's 2026-10-21 amendment at `b99e23fb` is the build spec for A–D, and that E is built after them as TASK-257 (the TASK-213 row). §12 now carries the ruling's three notes: the route-table test covers every router that accepts a runtime token, an agent upload resolves to a pod or is refused, and a seat narrowed in two or more pods is told why in plain words. The code gate's census of those routers (Vera, the same row) found three files the ruling's list lacked. Reading every router against A's table then found a class it had no cell for: a seat's own event acknowledgements and spawn credentials, which pass. The same reading added two precisions: a write's pod is read from the record it writes, and a per-spawn child meets B's point 4 through the seat token that minted it. The code this amendment cites is read at `main` `cd37b32e`. +**Amended 2026-10-22 (TASK-258, a read pin from the vendor's method line):** connector-ops asked how a read can be pinned at a vendor that annotates nothing (the TASK-258 row; the Cloudflare entry, TASK-245, waits on it). Cloudflare's per-tool server annotates 2 of its 3,766 tools. Each of the other 3,764 opens its description with the method and route it calls, alone on the first line, such as `GET /accounts` (captured 2026-10-21). A `read` pin still needs the vendor's `readOnlyHint: true`, with one exemption, made per tool and recorded on the pin: the vendor's own `GET` line, stored verbatim, and our review of that tool. The review is the gate, the line is required and never sufficient, and a tool's name is never evidence. Drift compares the stored line, and refuses a live annotation saying the tool writes. For a pin that records no annotation, that line is the only drift guard that reads what the tool does. The entry also requests only scopes it names read-only, and each row is checked against them. That condition is extra, never the gate, because Cloudflare accepts a read permission on 74 operations that are not `GET`. A listing is read under one byte budget, since Cloudflare's is 3.5 MiB in a single reply (§3). §4's subject tool and §11 are conformed, and step 2 names the tests Vera asked for on the row (§10). **Gate:** CTO. The code this amendment cites is read at `main` `1b0b3da9`. + **In short, a hosted MCP server is a second tool source for the grants plan's §10, not a new connector.** It adds one new `Integration.type`, `hosted-mcp`, a per-person Connection (§10.1). Its tools come from a catalogue entry this repo pins, not from the hand-written map in `toolBrokerService.ts`. A member connects their own account through the vendor's authorization server. The broker holds the credential, a room grant scopes it, the vendor runs the tools, and the trail records who acted. Grants work as they do today: tiers, attenuation, budget, expiry, the confirmation floor, approval and the trail. What is new is who wrote the tool list, and most of this note is about that. In this note, `§10.x` means that section of [tools-catalogue-room-grants.md](tools-catalogue-room-grants.md), "the grants plan", and "the entry" means one catalogue entry (§3). @@ -132,6 +134,8 @@ Entry ids are checked at load against both the hosted catalogue and the builtin | a `read` pin's `inputSchema` differs from the pin only compatibly (below) | offered and callable under the pin's schema, and logged; the annotations row still applies | | otherwise, `inputSchema` differs from the pin | `tool_drift` until a PR re-pins it: the owner approves canonical args under the pinned schema, and a changed schema can change what those args mean | | annotations moved toward write since the pin: a claim it recorded (`readOnlyHint: true`, `destructiveHint: false`) is no longer made, or a tool pinned with `readOnlyHint: true` now also says `destructiveHint: true` | `tool_drift`: the hint grants nothing, but a vendor withdrawing it is a reason to stop | +| a pin admitted by its method line (below): the live description's first line is not its `methodLine`, or the live annotations say `readOnlyHint: false` or `destructiveHint: true` | `tool_drift`: the line is the evidence the pin was admitted on, and the load refuses a `read` pin that says either | +| a pin admitted by its method line, on a row whose recorded scope is not inside the entry's `readOnlyScopes` (below) | `credential_not_read_only` | | present and unchanged | offered and callable | **A `read` pin survives a compatible change upstream, and a `write` pin does not** (Wren 77134–77135). Linear made compatible changes to five of 36 pins in 13 days (connector-ops 77128), and under exact equality each is an outage until a re-pin lands. The comparison is mechanical. Remove the `description` keyword from each schema on both sides, walking only the keywords whose values are schemas. A key named `description` anywhere else is data and is compared, such as a property of that name under `properties` or a key inside an `enum`, `const` or `default` value. A position the walk does not know is compared whole, so the unknown case withholds. The live schema must then equal the pin, except for top-level `properties` the pin lacks and the live `required` does not list. Any other difference withholds the pin: a property removed, retyped or newly required, a `default` changed, a property added below the top level, or an enum changed in either direction. A widened enum withholds because the broker checks names and not values, so a seat could send the new value unreviewed, and a value can open a fetch path, as `get_attachment`'s `format` does above. The annotations are checked as before, and a compatible schema does not excuse a move toward write. A compatible change assesses `ok` with a detail saying so, so the call and the listing need no change of their own (`assessEntryTools`, `services/hostedMcpEntryService.ts:202`). A seat is offered the pin's schema and descriptions, never the live ones. @@ -146,9 +150,34 @@ Upstream tools that are not in the entry are never offered and never callable, a **A list that fails is not a comparison.** The list is the call's first request. An answer that would fail the call fails it with the same reason (§9), and `tools/call` is not sent. A failed list is not cached, so the next call lists again, and no assessment is used past its TTL, so a vendor that stops answering is never read as unchanged. `createHostedMcpDriftCache` (`services/hostedMcpEntryService.ts:374`) caches no failed list and serves no assessment past its TTL. Its `provider_unavailable` outcome keeps the failed request's own code (`:416–420`), and the call refuses with that code, or `provider_error` when there is none, so the trail names the reason and never `provider_unavailable` (`services/hostedMcpToolDefinitions.ts:222–227`, TASK-199). A credential that cannot be read or refreshed before the list makes a failed list too, and the call keeps that credential's own code (`:225`). `reconnect_required` and `connection_mismatch` are refusals of authority, not outages, so the cache passes them through rather than reporting them unavailable (`hostedMcpEntryService.ts:414`). `listToolsForGrant` still offers the pinned tools after a failed list, a credential's failure included (`toolBrokerService.ts:797–818`). An outage is not a refusal: the call names it when one is made, and a tool left out of the list would name nothing. +**A listing is read under one byte budget** (TASK-258). Cloudflare's per-tool list arrives in a single reply of about 3.5 MiB (its tools array is 3,670,528 bytes compacted, captured 2026-10-21), about seven-eighths of the 4 MiB cap #2397 put on each event-stream reply (`services/hostedMcpToolDefinitions.ts:142–144`). A JSON reply has no cap (`:105`), and a listing may run to 100 pages (`:338`). So every reply is read under a cap, the JSON branch included, and the pages of one listing share one budget, counted while they are read. An overrun is a failed list, `provider_error` with the failure `reply_too_large` as #2397 names it, and is handled as above: not cached, the call refused with that code, the pins still listed. A list cut short is never compared. Each entry PR records its measured list size, and the budget stays at least twice the largest of them. + **A claim the pin recorded must still be made, and silence withdraws it.** The spec defaults an absent `readOnlyHint` to `false` and an absent `destructiveHint` to `true` (schema 2026-07-28, `ToolAnnotations`), the unsafe value both times. A vendor that stops saying `readOnlyHint: true` or `destructiveHint: false` has withdrawn the claim. A live `destructiveHint: true` also contradicts a recorded `readOnlyHint: true`; the entry load refuses that pair (#2014), and drift does not admit later what the entry refuses. Otherwise a newly spelled default changes nothing, so a pin that recorded neither claim does not drift when the vendor starts saying `destructiveHint: true` (75445, 75449, 75454). Each arm reads the annotations the pin recorded, not its class, and where a withdrawn claim and the contradiction coincide, the refusal names the claim (75457). A `read` pin is not asked to record `destructiveHint: false`: the spec makes that field meaningful only when `readOnlyHint` is `false`, and GitHub's server sets it on none of its 60 read tools (`github/github-mcp-server` at `85598ba`). A vendor that sends `true` there has still said something, and that is what the entry refuses. -**A tool pinned `read` carries the vendor's own `readOnlyHint: true`.** The spec's default for an absent `readOnlyHint` is `false` (schema 2026-07-28, `ToolAnnotations`), so a tool whose vendor says nothing is one the vendor has not called read-only. A pin without the hint would also leave the drift row above dead for that tool, because a pin that never held the hint cannot see it withdrawn (Vera 75423–75424). The entry load refuses such a pin, as it refuses a `read` pin whose annotations say `readOnlyHint: false` or `destructiveHint: true` (#2014). The hint still grants nothing; its absence refuses. **A vendor that sends no `readOnlyHint` gets no entry, not a write-only one** (75440). The class records what a tool does, so a read tool is never pinned `write` to get past a missing hint, and §5's answer on attribution cannot arrive that way. +**A tool pinned `read` carries the vendor's own `readOnlyHint: true`, or the exemption below, recorded on that pin.** The spec's default for an absent `readOnlyHint` is `false` (schema 2026-07-28, `ToolAnnotations`), so a tool whose vendor says nothing is one the vendor has not called read-only. A pin without the hint would also leave the drift row above dead for that tool, because a pin that never held the hint cannot see it withdrawn (Vera 75423–75424). The entry load refuses such a pin unless it carries the exemption, as it refuses a `read` pin whose annotations say `readOnlyHint: false` or `destructiveHint: true` (#2014). The hint still grants nothing; its absence refuses. **A vendor that sends no `readOnlyHint` gets no entry, not a write-only one** (75440), apart from the tools the exemption admits one at a time. The class records what a tool does, so a read tool is never pinned `write` to get past a missing hint, and §5's answer on attribution cannot arrive that way. + +**The exemption is the vendor's own `GET` and our review of that tool** (the TASK-258 row). Cloudflare's per-tool server annotates 2 of its 3,766 tools (`https://mcp.cloudflare.com/mcp?codemode=false`, captured 2026-10-21). Each of the other 3,764 opens its description with the method and route it calls, alone on the first line (`GET /accounts`), and that method agrees with the tool's name on all of them. A pin may be `read` without the hint when it records `admittedWithoutHint: { methodLine, review }`: + +- `methodLine` is that tool's first description line, copied verbatim. It is `GET`, one space and a path, and nothing else. +- `review` is our finding for that tool, in one sentence: what it reads, and that the pin-set rules below admit it. + +The evidence has an order. The review is the gate, and it applies the pin-set rules as for any read. The line is required and never sufficient: 1,842 of Cloudflare's tools say `GET`, and the entry pins only those its review admits. A tool's name is never evidence, nor a title reworded from it. A line naming any other method is no evidence either way, and that tool is not pinned `read`. The line is still only the vendor's word, as a `readOnlyHint` is. It says which request the tool makes, and RFC 9110 §9.2.1 defines `GET` as safe. + +**The exemption is a field on the pin, never a looser predicate** (Vera, on the row). The load's refusals apply to every `read` pin of every entry (`integrations/hostedMcp/entries.ts:195–203`). A predicate that admitted Cloudflare's tools would also admit a mispinned tool in Linear's entry or Airtable's, and the load is the one place that catches one before a person holds a grant to it. So the load keeps all three refusals for every pin without the field, still refuses `destructiveHint: true` on any `read` pin, and refuses: + +- the field on a pin that is not `read`; +- a `methodLine` that is not `GET`, a space and a path with no space in it, or an empty `review`; +- the field on a pin whose annotations carry a `readOnlyHint`, since `true` needs no exemption and `false` is refused already; +- the field on the entry's `subject` tool, which the callback calls with no grant (§4); +- the field in an entry that declares no `readOnlyScopes`, or that requests a scope outside them (below). + +A pin under the exemption records every annotation its vendor sends, as any pin does. Where a vendor sends `destructiveHint: false` with no `readOnlyHint`, the pin records it, and the drift arm that sees it withdrawn stays live (Vera, on the row). Cloudflare sends none on these tools, so its pins record none. + +**Drift compares the method line, and refuses a live claim to write.** For a pin under the exemption, the live description's first line must equal `methodLine` exactly. Only that line is compared, since the rest is the vendor's prose and the pin offers our own description anyway. A live `readOnlyHint: false` or `destructiveHint: true` on such a pin is `tool_drift` as well, because the load refuses a `read` pin that says either, and drift does not admit later what the entry refuses. Today's arms cannot see either: each keys on a claim the pin recorded, and this pin recorded none (`movedTowardWrite`, `services/hostedMcpEntryService.ts:244–254`). Both checks go in `assessEntryTools` (`:277`) beside the schema compare, which keeps the read pin's compatible rule. So the listing, the call and the approved call all reach them through the one helper they share (`assertHostedToolUnchanged`, `services/hostedMcpToolDefinitions.ts:395`). + +**For a pin that records no annotation, the stored method line is the only drift guard that reads what the tool does** (Vera, on the row). The annotation arm fires only if the vendor starts saying the tool writes, and a vendor that annotates nothing never says so. A green verdict on such a pin means the schema and the method line are unchanged and the vendor has not said the tool writes. It is never evidence that the vendor calls the tool read-only. + +**A read-only credential is an extra condition, never the gate.** An entry with a pin under the exemption declares `readOnlyScopes`: the scopes its vendor documents as read-only, named one by one and never matched by a pattern. It requests no scope outside them. The listing returns the row's recorded `grantedScope` (§4, `routes/hostedMcpConnect.ts:597`) with the tools, read from the same row whose credential took the list (`services/hostedMcpToolDefinitions.ts:325`, `:329`). Each fresh assessment refuses a pin under the exemption `credential_not_read_only` unless that set is non-empty and inside `readOnlyScopes`, and a row with no recorded scope fails. Like any other verdict, it withholds the pin and is logged (above). A row connected under the entry's current request passes unless its vendor granted no scope at all, since the callback refuses a scope the entry did not request and records the requested set when the token response names none (TASK-193, `routes/hostedMcpConnect.ts:471–489`). So the check catches a row connected under an earlier request. A reconnect never takes a row outside `readOnlyScopes`, so a cached verdict is never stale toward write. The condition narrows what a wrong method line can reach, but does not bound it: Cloudflare's API schema accepts a read permission on 74 operations that are not `GET`, among them `POST /accounts/{account_id}/ai/run` (Workers AI Read) (`cloudflare/api-schemas` at `eb291fe`, each operation's `x-api-token-group`). That is why the review is the gate. **An entry's credential may write, and each write parks for the member who owns it** (Sam, 2026-10-19, relayed by connector-ops on the Wave 1 rows). From 2026-10-01 this paragraph required every entry's credential to be unable to write (76043), and the ruling lifts that bar. A tool that writes is pinned `class: 'write'`, which `writeModeFor` turns into `write-with-confirm` (`services/hostedMcpEntryService.ts:133`), so a `read` grant cannot call it (`write_mode_not_allowed`, `roomGrantService.ts:417`). The park comes from the grant's mode, not the class: the broker parks every non-read call under `write-with-confirm`, while `write` runs a reversible one unattended (`toolBrokerService.ts:1161–1162`). No hosted grant holds `write`, because the mint refuses it (`routes/grants.ts:337`, §5) and attenuation never raises a grant above its parent (`roomGrantService.ts:346`). So every call to a write tool parks for the credential's owner, whose card shows its exact arguments. A destructive write also carries `irreversible`. Notion and Sentry, which the bar kept out, are Wave 1 entries. Granola and Attio, which it also kept out, are not, and Stripe is off (§10, step 8). The ruling accepts two residuals: @@ -190,7 +219,7 @@ Nothing is registered and no client secret is stored anywhere. A self-hosted ins **Every entry requests the identity scope its AS offers** (the TASK-172 row): `openid` where the AS has it, and never `email` or `profile`, as for Linear above. Without a subject, every reconnect is one §2 cannot compare. It revokes every grant on the row even when the account is the same, and it withdraws nothing at the vendor. Google returns an ID token "only … if your request included an identity scope, such as openid, profile, or email" (developers.google.com/identity/protocols/oauth2/native-app), so Calendar requests `openid` beside its three scopes (§10, step 8). -**An entry whose AS offers no `openid` may read the subject from its own server** (connector-ops 77008 (g), Wren 77045). Of the six vendors below that register by DCR, only Miro's AS lists `openid` (fetched 2026-10-14), and without a subject every reconnect is one §2 cannot compare. Such an entry declares `subject: { tool, field }` (§3). `tool` is one of the entry's own `read` pins, so it carries `readOnlyHint: true`, and its pinned `inputSchema` requires no argument; it is called with `{}` at the entry's `resource`. `field` is a dotted path into the result's `structuredContent`, or, when there is none, into the JSON its single text item holds. A non-empty string counts, and so does a positive integer no larger than `Number.MAX_SAFE_INTEGER`, which is recorded as its decimal string. Nothing else counts: a larger integer may have lost digits when the result was parsed, so two accounts could read as one. The entry PR cites the vendor's doc that the field is the account's stable id, never an email or a display name, since either can change or pass to another account. The entry load refuses a `subject` whose `tool` is not one of the entry's `read` pins or whose schema requires an argument, and an entry that names a `subject` and requests `openid`, since a row's subject has one source. +**An entry whose AS offers no `openid` may read the subject from its own server** (connector-ops 77008 (g), Wren 77045). Of the six vendors below that register by DCR, only Miro's AS lists `openid` (fetched 2026-10-14), and without a subject every reconnect is one §2 cannot compare. Such an entry declares `subject: { tool, field }` (§3). `tool` is one of the entry's own `read` pins that carries `readOnlyHint: true`, never one admitted by its method line (§3), and its pinned `inputSchema` requires no argument; it is called with `{}` at the entry's `resource`. `field` is a dotted path into the result's `structuredContent`, or, when there is none, into the JSON its single text item holds. A non-empty string counts, and so does a positive integer no larger than `Number.MAX_SAFE_INTEGER`, which is recorded as its decimal string. Nothing else counts: a larger integer may have lost digits when the result was parsed, so two accounts could read as one. The entry PR cites the vendor's doc that the field is the account's stable id, never an email or a display name, since either can change or pass to another account. The entry load refuses a `subject` whose `tool` is not one of the entry's `read` pins, is admitted by its method line, or has a schema that requires an argument, and an entry that names a `subject` and requests `openid`, since a row's subject has one source. The callback reads the subject after TASK-193's scope check and before it stores or revokes anything. With the new token it lists the server's tools, compares them with the pins as drift does (`assessEntryTools`, `services/hostedMcpEntryService.ts:277`), and calls the subject tool once. No grant is involved and no trail row is written, since no agent made the call. Any failure refuses the connect `subject_unavailable`: the tool missing or drifted, the call failing or answering `isError`, or nothing at `field` that counts. That holds on a first connect as on a reconnect, since a row connected without a subject makes its person's next reconnect one §2 cannot compare. The new token goes to `revokeRefusedGrant` (`routes/hostedMcpConnect.ts:102`), which revokes it when the entry has an endpoint and otherwise discards it, as TASK-193's refusal does. The row, its credentials and its grants stay as they were. @@ -414,7 +443,16 @@ Each step can be tested without the vendor, except the live measurements in step - `a property named description is compared, not stripped`, with a `default` whose value holds a `description` key among its arms - `a write pin whose upstream adds an optional property is refused tool_drift`; its mutation applies the read rule to every pin - `a compatible schema does not excuse annotations moved toward write` - - `a read pin carrying no readOnlyHint is refused`, with #2014's other entry-time refusals (§3) + - `a read pin carrying no readOnlyHint is refused`, with #2014's other entry-time refusals (§3), and with a pin that has no `admittedWithoutHint` among its arms in an entry where other pins have one + - The method-line exemption (§3, TASK-258): + - `a read pin admitted by its method line loads`, with a `review` and no annotations + - `the exemption is refused at load on a write pin, on a methodLine that is not GET and a path, with an empty review, beside a readOnlyHint, on the subject tool, and in an entry without readOnlyScopes or requesting a scope outside them`, one arm each + - `a pin admitted by its method line is refused tool_drift when only its method line changes upstream`. The live tool keeps the pin's name, `inputSchema` and absent annotations, and its first line changes from `GET` to `POST` on the same path, so the arm cannot pass on another difference (Vera, on the row). It runs through the broker, with a mutation that deletes the comparison. + - `a pin admitted by its method line is ok when only the description after the first line changes` + - `a pin admitted by its method line is refused tool_drift when upstream newly says readOnlyHint: false or destructiveHint: true`, one arm each + - `a pin admitted by its method line is refused credential_not_read_only on a row whose recorded scope is empty, missing, or holds a scope outside readOnlyScopes`, one arm each, through the broker for the listing and the call, with a mutation that deletes the check + - `a hinted read pin is unaffected by the row's recorded scope` + - The listing budget (§3): `a listing over its budget is a failed list, provider_error with failure reply_too_large, and is not cached`, with a JSON reply, an event-stream reply and a budget crossed only across pages among its arms - the annotation arms (§3): a `destructiveHint: false` gone silent is `tool_drift`; a pin that recorded `readOnlyHint: true` whose tool now also says `destructiveHint: true` is `tool_drift`; a pin that recorded both claims, against that same tool, is refused naming the `destructiveHint: false` it lost; a `destructiveHint: true` newly spelled on a `write` pin that recorded neither claim is `ok` - `an entry whose audience is empty, not https, of another origin, or a path that does not prefix the resource's on a segment boundary is refused at load`, with a port-only difference and a same-origin sibling path among its arms (§3) 3. **Intake.** The CIMD document, start and callback. @@ -549,7 +587,7 @@ Each step can be tested without the vendor, except the live measurements in step The TASK-172 ruling's part 3 wording on `:180` is replaced by §6. -**Other vendors join the catalogue only once their own docs verify** the URL, transport, auth mode, token lifetime and refresh, and revocation (74820; the TASK-172 row). Until 2026-10-19 they also needed a scope that cannot write (§3). A `tools/list` must also show `readOnlyHint: true` on each tool the entry pins `read` (§3). Three facts already shape their entries: +**Other vendors join the catalogue only once their own docs verify** the URL, transport, auth mode, token lifetime and refresh, and revocation (74820; the TASK-172 row). Until 2026-10-19 they also needed a scope that cannot write (§3). A `tools/list` must also show `readOnlyHint: true` on each tool the entry pins `read`, or that tool's own `GET` line where §3's exemption admits it (§3). Three facts already shape their entries: - Notion's only advertised scope is `default`, so a Notion entry cannot narrow its credential to reads the way Linear's `read` does. §3's bar kept Notion out (76043) until Sam's 2026-10-19 ruling lifted it, and Notion is now Wave 1's TASK-240, its writes pinned `write` (§3). - GitHub needs the pre-registered client (§4). From a491abcabe551adcf88b7307e0a26a149fad210d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:38:00 -0700 Subject: [PATCH 2/3] docs(plans): the listing budget as TASK-243 ruled, against the guard's 4 MiB (TASK-258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §3 budget paragraph said a JSON reply has no cap. At 1b0b3da9 the outbound guard (#2358) reads at most 4 MiB of every vendor reply, and an overrun reads as provider_unreachable. It also carried a rule that the budget stays twice the largest measured list, which nothing checks and which TASK-243's ruled 10 MB already breaks at PostHog's 5,210,460 bytes. The paragraph now states the ruling: one 10 MB budget across a listing's pages, each page read under what remains, at the guard and in the event-stream count; every other request keeps 4 MiB. Step 2's arms run through the guard, and add a page over 4 MiB that lists and a non-list request that keeps 4 MiB. Cloudflare's tools array measures 3,670,527 bytes as compact JSON, not 3,670,528. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/hosted-mcp-connection-scope.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/hosted-mcp-connection-scope.md b/docs/plans/hosted-mcp-connection-scope.md index 6c9eb96e4a..70dbb31c17 100644 --- a/docs/plans/hosted-mcp-connection-scope.md +++ b/docs/plans/hosted-mcp-connection-scope.md @@ -30,7 +30,7 @@ **Amended 2026-10-21 (TASK-213):** connector-ops ruled, for the CTO's security gate, that §12's 2026-10-21 amendment at `b99e23fb` is the build spec for A–D, and that E is built after them as TASK-257 (the TASK-213 row). §12 now carries the ruling's three notes: the route-table test covers every router that accepts a runtime token, an agent upload resolves to a pod or is refused, and a seat narrowed in two or more pods is told why in plain words. The code gate's census of those routers (Vera, the same row) found three files the ruling's list lacked. Reading every router against A's table then found a class it had no cell for: a seat's own event acknowledgements and spawn credentials, which pass. The same reading added two precisions: a write's pod is read from the record it writes, and a per-spawn child meets B's point 4 through the seat token that minted it. The code this amendment cites is read at `main` `cd37b32e`. -**Amended 2026-10-22 (TASK-258, a read pin from the vendor's method line):** connector-ops asked how a read can be pinned at a vendor that annotates nothing (the TASK-258 row; the Cloudflare entry, TASK-245, waits on it). Cloudflare's per-tool server annotates 2 of its 3,766 tools. Each of the other 3,764 opens its description with the method and route it calls, alone on the first line, such as `GET /accounts` (captured 2026-10-21). A `read` pin still needs the vendor's `readOnlyHint: true`, with one exemption, made per tool and recorded on the pin: the vendor's own `GET` line, stored verbatim, and our review of that tool. The review is the gate, the line is required and never sufficient, and a tool's name is never evidence. Drift compares the stored line, and refuses a live annotation saying the tool writes. For a pin that records no annotation, that line is the only drift guard that reads what the tool does. The entry also requests only scopes it names read-only, and each row is checked against them. That condition is extra, never the gate, because Cloudflare accepts a read permission on 74 operations that are not `GET`. A listing is read under one byte budget, since Cloudflare's is 3.5 MiB in a single reply (§3). §4's subject tool and §11 are conformed, and step 2 names the tests Vera asked for on the row (§10). **Gate:** CTO. The code this amendment cites is read at `main` `1b0b3da9`. +**Amended 2026-10-22 (TASK-258, a read pin from the vendor's method line):** connector-ops asked how a read can be pinned at a vendor that annotates nothing (the TASK-258 row; the Cloudflare entry, TASK-245, waits on it). Cloudflare's per-tool server annotates 2 of its 3,766 tools. Each of the other 3,764 opens its description with the method and route it calls, alone on the first line, such as `GET /accounts` (captured 2026-10-21). A `read` pin still needs the vendor's `readOnlyHint: true`, with one exemption, made per tool and recorded on the pin: the vendor's own `GET` line, stored verbatim, and our review of that tool. The review is the gate, the line is required and never sufficient, and a tool's name is never evidence. Drift compares the stored line, and refuses a live annotation saying the tool writes. For a pin that records no annotation, that line is the only drift guard that reads what the tool does. The entry also requests only scopes it names read-only, and each row is checked against them. That condition is extra, never the gate, because Cloudflare accepts a read permission on 74 operations that are not `GET`. Cloudflare's list is one reply of about 3.5 MiB, so §3 also states the listing budget TASK-243 ruled: 10 MB, shared by a listing's pages. §4's subject tool and §11 are conformed, and step 2 names the tests Vera asked for on the row (§10). **Gate:** CTO. The code this amendment cites is read at `main` `1b0b3da9`. **In short, a hosted MCP server is a second tool source for the grants plan's §10, not a new connector.** It adds one new `Integration.type`, `hosted-mcp`, a per-person Connection (§10.1). Its tools come from a catalogue entry this repo pins, not from the hand-written map in `toolBrokerService.ts`. A member connects their own account through the vendor's authorization server. The broker holds the credential, a room grant scopes it, the vendor runs the tools, and the trail records who acted. Grants work as they do today: tiers, attenuation, budget, expiry, the confirmation floor, approval and the trail. What is new is who wrote the tool list, and most of this note is about that. @@ -150,7 +150,7 @@ Upstream tools that are not in the entry are never offered and never callable, a **A list that fails is not a comparison.** The list is the call's first request. An answer that would fail the call fails it with the same reason (§9), and `tools/call` is not sent. A failed list is not cached, so the next call lists again, and no assessment is used past its TTL, so a vendor that stops answering is never read as unchanged. `createHostedMcpDriftCache` (`services/hostedMcpEntryService.ts:374`) caches no failed list and serves no assessment past its TTL. Its `provider_unavailable` outcome keeps the failed request's own code (`:416–420`), and the call refuses with that code, or `provider_error` when there is none, so the trail names the reason and never `provider_unavailable` (`services/hostedMcpToolDefinitions.ts:222–227`, TASK-199). A credential that cannot be read or refreshed before the list makes a failed list too, and the call keeps that credential's own code (`:225`). `reconnect_required` and `connection_mismatch` are refusals of authority, not outages, so the cache passes them through rather than reporting them unavailable (`hostedMcpEntryService.ts:414`). `listToolsForGrant` still offers the pinned tools after a failed list, a credential's failure included (`toolBrokerService.ts:797–818`). An outage is not a refusal: the call names it when one is made, and a tool left out of the list would name nothing. -**A listing is read under one byte budget** (TASK-258). Cloudflare's per-tool list arrives in a single reply of about 3.5 MiB (its tools array is 3,670,528 bytes compacted, captured 2026-10-21), about seven-eighths of the 4 MiB cap #2397 put on each event-stream reply (`services/hostedMcpToolDefinitions.ts:142–144`). A JSON reply has no cap (`:105`), and a listing may run to 100 pages (`:338`). So every reply is read under a cap, the JSON branch included, and the pages of one listing share one budget, counted while they are read. An overrun is a failed list, `provider_error` with the failure `reply_too_large` as #2397 names it, and is handled as above: not cached, the call refused with that code, the pins still listed. A list cut short is never compared. Each entry PR records its measured list size, and the budget stays at least twice the largest of them. +**A listing is read under one byte budget** (TASK-243, ruled 2026-10-21). The outbound guard reads at most 4 MiB of any vendor reply, JSON or event stream (#2358, `services/hostedVendorFetch.ts:20`, `:61`). An overrun there is a plain error, which the session reads as `provider_unreachable` (`services/outboundUrlGuard.ts:113–115`, `services/hostedMcpToolDefinitions.ts:221–222`), and #2397's own 4 MiB count on an event stream (`:142–144`) sees only bytes the guard has passed. A listing may run to 100 pages (`:338`), and nothing counts across them. Cloudflare's per-tool list is one reply whose tools array alone is 3,670,527 bytes as compact JSON (captured 2026-10-21), about seven-eighths of that 4 MiB. PostHog's is one page of 5,210,460 bytes, over it (TASK-243). So the pages of one listing share one budget of 10 MB, and each page is read under what remains of it, at the guard and in the event-stream count alike. Every other vendor request keeps 4 MiB, and its overrun stays `provider_unreachable`. A listing over its budget is a failed list, `provider_error` with the failure `reply_too_large` as #2397 names it, and is handled as above: not cached, the call refused with that code, the pins still listed. A list cut short is never compared. A list that outgrows the budget fails loudly with that code, at the entry's walk or later. TASK-243 builds the budget. **A claim the pin recorded must still be made, and silence withdraws it.** The spec defaults an absent `readOnlyHint` to `false` and an absent `destructiveHint` to `true` (schema 2026-07-28, `ToolAnnotations`), the unsafe value both times. A vendor that stops saying `readOnlyHint: true` or `destructiveHint: false` has withdrawn the claim. A live `destructiveHint: true` also contradicts a recorded `readOnlyHint: true`; the entry load refuses that pair (#2014), and drift does not admit later what the entry refuses. Otherwise a newly spelled default changes nothing, so a pin that recorded neither claim does not drift when the vendor starts saying `destructiveHint: true` (75445, 75449, 75454). Each arm reads the annotations the pin recorded, not its class, and where a withdrawn claim and the contradiction coincide, the refusal names the claim (75457). A `read` pin is not asked to record `destructiveHint: false`: the spec makes that field meaningful only when `readOnlyHint` is `false`, and GitHub's server sets it on none of its 60 read tools (`github/github-mcp-server` at `85598ba`). A vendor that sends `true` there has still said something, and that is what the entry refuses. @@ -452,7 +452,7 @@ Each step can be tested without the vendor, except the live measurements in step - `a pin admitted by its method line is refused tool_drift when upstream newly says readOnlyHint: false or destructiveHint: true`, one arm each - `a pin admitted by its method line is refused credential_not_read_only on a row whose recorded scope is empty, missing, or holds a scope outside readOnlyScopes`, one arm each, through the broker for the listing and the call, with a mutation that deletes the check - `a hinted read pin is unaffected by the row's recorded scope` - - The listing budget (§3): `a listing over its budget is a failed list, provider_error with failure reply_too_large, and is not cached`, with a JSON reply, an event-stream reply and a budget crossed only across pages among its arms + - The listing budget (§3, TASK-243), each arm composed through the outbound guard with its lookup and transport stubbed, never an injected `Response`: `a listing over its budget is a failed list, provider_error with failure reply_too_large, and is not cached`, with a JSON reply, an event-stream reply and a budget crossed only across pages among its arms; `a single list page over 4 MiB and under the budget lists`, in both reply forms; `a request other than tools/list keeps 4 MiB, and its overrun stays provider_unreachable` - the annotation arms (§3): a `destructiveHint: false` gone silent is `tool_drift`; a pin that recorded `readOnlyHint: true` whose tool now also says `destructiveHint: true` is `tool_drift`; a pin that recorded both claims, against that same tool, is refused naming the `destructiveHint: false` it lost; a `destructiveHint: true` newly spelled on a `write` pin that recorded neither claim is `ok` - `an entry whose audience is empty, not https, of another origin, or a path that does not prefix the resource's on a segment boundary is refused at load`, with a port-only difference and a same-origin sibling path among its arms (§3) 3. **Intake.** The CIMD document, start and callback. From b2153408365aab92aa2ef39c8c319879c3c17efe Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:38:33 -0700 Subject: [PATCH 3/3] docs(plans): mark the TASK-258 contract proposed, and re-read its cites at main 87687037 (TASK-258) Main has none of the method-line exemption yet: the entry load still refuses every read pin without readOnlyHint: true (entries.ts:204-205), and HostedMcpToolVerdict has no credential_not_read_only (:114). The Amended line, the lead-in to the drift table, its two new rows, the scope check and the new verdict now say so. TASK-254 (#2357) moved the reply-cap code under the budget paragraph; it now cites the session's own 4 MiB count on JSON and event streams alike. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/hosted-mcp-connection-scope.md | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/plans/hosted-mcp-connection-scope.md b/docs/plans/hosted-mcp-connection-scope.md index 70dbb31c17..bb42bbfba5 100644 --- a/docs/plans/hosted-mcp-connection-scope.md +++ b/docs/plans/hosted-mcp-connection-scope.md @@ -30,7 +30,7 @@ **Amended 2026-10-21 (TASK-213):** connector-ops ruled, for the CTO's security gate, that §12's 2026-10-21 amendment at `b99e23fb` is the build spec for A–D, and that E is built after them as TASK-257 (the TASK-213 row). §12 now carries the ruling's three notes: the route-table test covers every router that accepts a runtime token, an agent upload resolves to a pod or is refused, and a seat narrowed in two or more pods is told why in plain words. The code gate's census of those routers (Vera, the same row) found three files the ruling's list lacked. Reading every router against A's table then found a class it had no cell for: a seat's own event acknowledgements and spawn credentials, which pass. The same reading added two precisions: a write's pod is read from the record it writes, and a per-spawn child meets B's point 4 through the seat token that minted it. The code this amendment cites is read at `main` `cd37b32e`. -**Amended 2026-10-22 (TASK-258, a read pin from the vendor's method line):** connector-ops asked how a read can be pinned at a vendor that annotates nothing (the TASK-258 row; the Cloudflare entry, TASK-245, waits on it). Cloudflare's per-tool server annotates 2 of its 3,766 tools. Each of the other 3,764 opens its description with the method and route it calls, alone on the first line, such as `GET /accounts` (captured 2026-10-21). A `read` pin still needs the vendor's `readOnlyHint: true`, with one exemption, made per tool and recorded on the pin: the vendor's own `GET` line, stored verbatim, and our review of that tool. The review is the gate, the line is required and never sufficient, and a tool's name is never evidence. Drift compares the stored line, and refuses a live annotation saying the tool writes. For a pin that records no annotation, that line is the only drift guard that reads what the tool does. The entry also requests only scopes it names read-only, and each row is checked against them. That condition is extra, never the gate, because Cloudflare accepts a read permission on 74 operations that are not `GET`. Cloudflare's list is one reply of about 3.5 MiB, so §3 also states the listing budget TASK-243 ruled: 10 MB, shared by a listing's pages. §4's subject tool and §11 are conformed, and step 2 names the tests Vera asked for on the row (§10). **Gate:** CTO. The code this amendment cites is read at `main` `1b0b3da9`. +**Amended 2026-10-22 (TASK-258, a read pin from the vendor's method line):** connector-ops asked how a read can be pinned at a vendor that annotates nothing (the TASK-258 row; the Cloudflare entry, TASK-245, waits on it). Cloudflare's per-tool server annotates 2 of its 3,766 tools. Each of the other 3,764 opens its description with the method and route it calls, alone on the first line, such as `GET /accounts` (captured 2026-10-21). **What follows is the proposed TASK-258 contract, and none of it is built.** Until it is, the entry load refuses every `read` pin without the vendor's `readOnlyHint: true` (`integrations/hostedMcp/entries.ts:204–205`), so no entry can use the exemption below. Main has none of its pieces yet: no `admittedWithoutHint` on the pin type or `readOnlyScopes` on the entry type, no load refusal or drift row for either, and no `credential_not_read_only` verdict (`services/hostedMcpEntryService.ts:49`, `:64`, `:114`). Under the contract, a `read` pin still needs the vendor's `readOnlyHint: true`, with one exemption, made per tool and recorded on the pin: the vendor's own `GET` line, stored verbatim, and our review of that tool. The review is the gate, the line is required and never sufficient, and a tool's name is never evidence. Drift compares the stored line, and refuses a live annotation saying the tool writes. For a pin that records no annotation, that line is the only drift guard that reads what the tool does. The entry also requests only scopes it names read-only, and each row is checked against them. That condition is extra, never the gate, because Cloudflare accepts a read permission on 74 operations that are not `GET`. Cloudflare's list is one reply of about 3.5 MiB, so §3 also states the listing budget TASK-243 ruled and builds: 10 MB, shared by a listing's pages. §4's subject tool and §11 are conformed, and step 2 names the tests Vera asked for on the row (§10). **Gate:** CTO. The code this amendment cites is read at `main` `87687037`. **In short, a hosted MCP server is a second tool source for the grants plan's §10, not a new connector.** It adds one new `Integration.type`, `hosted-mcp`, a per-person Connection (§10.1). Its tools come from a catalogue entry this repo pins, not from the hand-written map in `toolBrokerService.ts`. A member connects their own account through the vendor's authorization server. The broker holds the credential, a room grant scopes it, the vendor runs the tools, and the trail records who acted. Grants work as they do today: tiers, attenuation, budget, expiry, the confirmation floor, approval and the trail. What is new is who wrote the tool list, and most of this note is about that. @@ -126,7 +126,7 @@ Entry ids are checked at load against both the hosted catalogue and the builtin `getToolDefinitions()` (`:429`) returns the hand-written map plus every entry's definitions. Names are namespaced by entry (`linear.`), so no two entries collide with each other or with the GitHub tools. Required mode still "comes only from this server-side definition map" (`:700`). The native projection's filter (`grantBrokerProjectionService.ts:180`) reads the definition's `requiredWriteMode`, so it reads the entry's class with no change to that line. -**Drift is a named refusal, not a silent change.** The broker compares each pinned tool with the upstream `tools/list`, taken with the row's own credential. The spec says the list "MAY vary by the authorization presented on the request", so a list taken with any other credential proves nothing about this row. The comparison is cached per row with a TTL, which the build measures (minutes, not per call). For each pinned tool: +**Drift is a named refusal, not a silent change.** The broker compares each pinned tool with the upstream `tools/list`, taken with the row's own credential. The spec says the list "MAY vary by the authorization presented on the request", so a list taken with any other credential proves nothing about this row. The comparison is cached per row with a TTL, which the build measures (minutes, not per call). The two rows marked proposed are TASK-258's (below), and neither is built: until they are, the load refuses every `read` pin without `readOnlyHint: true`, so no pin reaches them. For each pinned tool: | upstream | result for that tool | |---|---| @@ -134,8 +134,8 @@ Entry ids are checked at load against both the hosted catalogue and the builtin | a `read` pin's `inputSchema` differs from the pin only compatibly (below) | offered and callable under the pin's schema, and logged; the annotations row still applies | | otherwise, `inputSchema` differs from the pin | `tool_drift` until a PR re-pins it: the owner approves canonical args under the pinned schema, and a changed schema can change what those args mean | | annotations moved toward write since the pin: a claim it recorded (`readOnlyHint: true`, `destructiveHint: false`) is no longer made, or a tool pinned with `readOnlyHint: true` now also says `destructiveHint: true` | `tool_drift`: the hint grants nothing, but a vendor withdrawing it is a reason to stop | -| a pin admitted by its method line (below): the live description's first line is not its `methodLine`, or the live annotations say `readOnlyHint: false` or `destructiveHint: true` | `tool_drift`: the line is the evidence the pin was admitted on, and the load refuses a `read` pin that says either | -| a pin admitted by its method line, on a row whose recorded scope is not inside the entry's `readOnlyScopes` (below) | `credential_not_read_only` | +| proposed, not built (TASK-258): a pin admitted by its method line (below) whose live description's first line is not its `methodLine`, or whose live annotations say `readOnlyHint: false` or `destructiveHint: true` | `tool_drift`: the line is the evidence the pin was admitted on, and the load refuses a `read` pin that says either | +| proposed, not built (TASK-258): a pin admitted by its method line, on a row whose recorded scope is not inside the entry's `readOnlyScopes` (below) | `credential_not_read_only` | | present and unchanged | offered and callable | **A `read` pin survives a compatible change upstream, and a `write` pin does not** (Wren 77134–77135). Linear made compatible changes to five of 36 pins in 13 days (connector-ops 77128), and under exact equality each is an outage until a re-pin lands. The comparison is mechanical. Remove the `description` keyword from each schema on both sides, walking only the keywords whose values are schemas. A key named `description` anywhere else is data and is compared, such as a property of that name under `properties` or a key inside an `enum`, `const` or `default` value. A position the walk does not know is compared whole, so the unknown case withholds. The live schema must then equal the pin, except for top-level `properties` the pin lacks and the live `required` does not list. Any other difference withholds the pin: a property removed, retyped or newly required, a `default` changed, a property added below the top level, or an enum changed in either direction. A widened enum withholds because the broker checks names and not values, so a seat could send the new value unreviewed, and a value can open a fetch path, as `get_attachment`'s `format` does above. The annotations are checked as before, and a compatible schema does not excuse a move toward write. A compatible change assesses `ok` with a detail saying so, so the call and the listing need no change of their own (`assessEntryTools`, `services/hostedMcpEntryService.ts:202`). A seat is offered the pin's schema and descriptions, never the live ones. @@ -150,20 +150,20 @@ Upstream tools that are not in the entry are never offered and never callable, a **A list that fails is not a comparison.** The list is the call's first request. An answer that would fail the call fails it with the same reason (§9), and `tools/call` is not sent. A failed list is not cached, so the next call lists again, and no assessment is used past its TTL, so a vendor that stops answering is never read as unchanged. `createHostedMcpDriftCache` (`services/hostedMcpEntryService.ts:374`) caches no failed list and serves no assessment past its TTL. Its `provider_unavailable` outcome keeps the failed request's own code (`:416–420`), and the call refuses with that code, or `provider_error` when there is none, so the trail names the reason and never `provider_unavailable` (`services/hostedMcpToolDefinitions.ts:222–227`, TASK-199). A credential that cannot be read or refreshed before the list makes a failed list too, and the call keeps that credential's own code (`:225`). `reconnect_required` and `connection_mismatch` are refusals of authority, not outages, so the cache passes them through rather than reporting them unavailable (`hostedMcpEntryService.ts:414`). `listToolsForGrant` still offers the pinned tools after a failed list, a credential's failure included (`toolBrokerService.ts:797–818`). An outage is not a refusal: the call names it when one is made, and a tool left out of the list would name nothing. -**A listing is read under one byte budget** (TASK-243, ruled 2026-10-21). The outbound guard reads at most 4 MiB of any vendor reply, JSON or event stream (#2358, `services/hostedVendorFetch.ts:20`, `:61`). An overrun there is a plain error, which the session reads as `provider_unreachable` (`services/outboundUrlGuard.ts:113–115`, `services/hostedMcpToolDefinitions.ts:221–222`), and #2397's own 4 MiB count on an event stream (`:142–144`) sees only bytes the guard has passed. A listing may run to 100 pages (`:338`), and nothing counts across them. Cloudflare's per-tool list is one reply whose tools array alone is 3,670,527 bytes as compact JSON (captured 2026-10-21), about seven-eighths of that 4 MiB. PostHog's is one page of 5,210,460 bytes, over it (TASK-243). So the pages of one listing share one budget of 10 MB, and each page is read under what remains of it, at the guard and in the event-stream count alike. Every other vendor request keeps 4 MiB, and its overrun stays `provider_unreachable`. A listing over its budget is a failed list, `provider_error` with the failure `reply_too_large` as #2397 names it, and is handled as above: not cached, the call refused with that code, the pins still listed. A list cut short is never compared. A list that outgrows the budget fails loudly with that code, at the entry's walk or later. TASK-243 builds the budget. +**A listing is read under one byte budget** (TASK-243, ruled 2026-10-21). The outbound guard reads at most 4 MiB of any vendor reply, JSON or event stream (#2358, `services/hostedVendorFetch.ts:20`, `:61`). An overrun there is a plain error, which the session reads as `provider_unreachable` (`services/outboundUrlGuard.ts:113–115`, `services/hostedMcpToolDefinitions.ts:303–304`). The session's own 4 MiB count (`:52`), on a JSON reply (`:187`) and an event stream (`:227`) alike, sees only bytes the guard has passed. A listing may run to 100 pages (`:420`), and nothing counts across them. Cloudflare's per-tool list is one reply whose tools array alone is 3,670,527 bytes as compact JSON (captured 2026-10-21), about seven-eighths of that 4 MiB. PostHog's is one page of 5,210,460 bytes, over it (TASK-243). So the pages of one listing share one budget of 10 MB, and each page is read under what remains of it, at the guard and in the session's own count alike. Every other vendor request keeps 4 MiB, and its overrun stays `provider_unreachable`. A listing over its budget is a failed list, `provider_error` with the failure `reply_too_large` as #2397 names it (`:111–116`), and is handled as above: not cached, the call refused with that code, the pins still listed. A list cut short is never compared. A list that outgrows the budget fails loudly with that code, at the entry's walk or later. TASK-243 builds the budget. **A claim the pin recorded must still be made, and silence withdraws it.** The spec defaults an absent `readOnlyHint` to `false` and an absent `destructiveHint` to `true` (schema 2026-07-28, `ToolAnnotations`), the unsafe value both times. A vendor that stops saying `readOnlyHint: true` or `destructiveHint: false` has withdrawn the claim. A live `destructiveHint: true` also contradicts a recorded `readOnlyHint: true`; the entry load refuses that pair (#2014), and drift does not admit later what the entry refuses. Otherwise a newly spelled default changes nothing, so a pin that recorded neither claim does not drift when the vendor starts saying `destructiveHint: true` (75445, 75449, 75454). Each arm reads the annotations the pin recorded, not its class, and where a withdrawn claim and the contradiction coincide, the refusal names the claim (75457). A `read` pin is not asked to record `destructiveHint: false`: the spec makes that field meaningful only when `readOnlyHint` is `false`, and GitHub's server sets it on none of its 60 read tools (`github/github-mcp-server` at `85598ba`). A vendor that sends `true` there has still said something, and that is what the entry refuses. -**A tool pinned `read` carries the vendor's own `readOnlyHint: true`, or the exemption below, recorded on that pin.** The spec's default for an absent `readOnlyHint` is `false` (schema 2026-07-28, `ToolAnnotations`), so a tool whose vendor says nothing is one the vendor has not called read-only. A pin without the hint would also leave the drift row above dead for that tool, because a pin that never held the hint cannot see it withdrawn (Vera 75423–75424). The entry load refuses such a pin unless it carries the exemption, as it refuses a `read` pin whose annotations say `readOnlyHint: false` or `destructiveHint: true` (#2014). The hint still grants nothing; its absence refuses. **A vendor that sends no `readOnlyHint` gets no entry, not a write-only one** (75440), apart from the tools the exemption admits one at a time. The class records what a tool does, so a read tool is never pinned `write` to get past a missing hint, and §5's answer on attribution cannot arrive that way. +**A tool pinned `read` carries the vendor's own `readOnlyHint: true`, or, once TASK-258 is built, the exemption below, recorded on that pin.** The spec's default for an absent `readOnlyHint` is `false` (schema 2026-07-28, `ToolAnnotations`), so a tool whose vendor says nothing is one the vendor has not called read-only. A pin without the hint would also leave the drift row above dead for that tool, because a pin that never held the hint cannot see it withdrawn (Vera 75423–75424). The entry load refuses such a pin, as it refuses a `read` pin whose annotations say `readOnlyHint: false` or `destructiveHint: true` (#2014, `integrations/hostedMcp/entries.ts:198–206`). Today that refusal has no exception: the exemption below is TASK-258's proposed contract, and until it is built the load refuses every `read` pin without the hint. The hint still grants nothing; its absence refuses. **A vendor that sends no `readOnlyHint` gets no entry, not a write-only one** (75440), apart from the tools the exemption will admit one at a time. The class records what a tool does, so a read tool is never pinned `write` to get past a missing hint, and §5's answer on attribution cannot arrive that way. -**The exemption is the vendor's own `GET` and our review of that tool** (the TASK-258 row). Cloudflare's per-tool server annotates 2 of its 3,766 tools (`https://mcp.cloudflare.com/mcp?codemode=false`, captured 2026-10-21). Each of the other 3,764 opens its description with the method and route it calls, alone on the first line (`GET /accounts`), and that method agrees with the tool's name on all of them. A pin may be `read` without the hint when it records `admittedWithoutHint: { methodLine, review }`: +**The exemption is the vendor's own `GET` and our review of that tool** (the TASK-258 row; proposed, not built). Cloudflare's per-tool server annotates 2 of its 3,766 tools (`https://mcp.cloudflare.com/mcp?codemode=false`, captured 2026-10-21). Each of the other 3,764 opens its description with the method and route it calls, alone on the first line (`GET /accounts`), and that method agrees with the tool's name on all of them. Under the contract, a pin may be `read` without the hint when it records `admittedWithoutHint: { methodLine, review }`: - `methodLine` is that tool's first description line, copied verbatim. It is `GET`, one space and a path, and nothing else. - `review` is our finding for that tool, in one sentence: what it reads, and that the pin-set rules below admit it. The evidence has an order. The review is the gate, and it applies the pin-set rules as for any read. The line is required and never sufficient: 1,842 of Cloudflare's tools say `GET`, and the entry pins only those its review admits. A tool's name is never evidence, nor a title reworded from it. A line naming any other method is no evidence either way, and that tool is not pinned `read`. The line is still only the vendor's word, as a `readOnlyHint` is. It says which request the tool makes, and RFC 9110 §9.2.1 defines `GET` as safe. -**The exemption is a field on the pin, never a looser predicate** (Vera, on the row). The load's refusals apply to every `read` pin of every entry (`integrations/hostedMcp/entries.ts:195–203`). A predicate that admitted Cloudflare's tools would also admit a mispinned tool in Linear's entry or Airtable's, and the load is the one place that catches one before a person holds a grant to it. So the load keeps all three refusals for every pin without the field, still refuses `destructiveHint: true` on any `read` pin, and refuses: +**The exemption is a field on the pin, never a looser predicate** (Vera, on the row). The load's refusals apply to every `read` pin of every entry (`integrations/hostedMcp/entries.ts:198–206`). A predicate that admitted Cloudflare's tools would also admit a mispinned tool in Linear's entry or Airtable's, and the load is the one place that catches one before a person holds a grant to it. So TASK-258's load keeps all three refusals for every pin without the field, still refuses `destructiveHint: true` on any `read` pin, and adds a refusal of: - the field on a pin that is not `read`; - a `methodLine` that is not `GET`, a space and a path with no space in it, or an empty `review`; @@ -173,11 +173,11 @@ The evidence has an order. The review is the gate, and it applies the pin-set ru A pin under the exemption records every annotation its vendor sends, as any pin does. Where a vendor sends `destructiveHint: false` with no `readOnlyHint`, the pin records it, and the drift arm that sees it withdrawn stays live (Vera, on the row). Cloudflare sends none on these tools, so its pins record none. -**Drift compares the method line, and refuses a live claim to write.** For a pin under the exemption, the live description's first line must equal `methodLine` exactly. Only that line is compared, since the rest is the vendor's prose and the pin offers our own description anyway. A live `readOnlyHint: false` or `destructiveHint: true` on such a pin is `tool_drift` as well, because the load refuses a `read` pin that says either, and drift does not admit later what the entry refuses. Today's arms cannot see either: each keys on a claim the pin recorded, and this pin recorded none (`movedTowardWrite`, `services/hostedMcpEntryService.ts:244–254`). Both checks go in `assessEntryTools` (`:277`) beside the schema compare, which keeps the read pin's compatible rule. So the listing, the call and the approved call all reach them through the one helper they share (`assertHostedToolUnchanged`, `services/hostedMcpToolDefinitions.ts:395`). +**Drift compares the method line, and refuses a live claim to write.** For a pin under the exemption, the live description's first line must equal `methodLine` exactly. Only that line is compared, since the rest is the vendor's prose and the pin offers our own description anyway. A live `readOnlyHint: false` or `destructiveHint: true` on such a pin is `tool_drift` as well, because the load refuses a `read` pin that says either, and drift does not admit later what the entry refuses. Today's arms cannot see either: each keys on a claim the pin recorded, and this pin recorded none (`movedTowardWrite`, `services/hostedMcpEntryService.ts:251–261`). TASK-258 puts both checks in `assessEntryTools` (`:285`) beside the schema compare, which keeps the read pin's compatible rule. So the listing, the call and the approved call will all reach them through the one helper they share (`assertHostedToolUnchanged`, `services/hostedMcpToolDefinitions.ts:477`). **For a pin that records no annotation, the stored method line is the only drift guard that reads what the tool does** (Vera, on the row). The annotation arm fires only if the vendor starts saying the tool writes, and a vendor that annotates nothing never says so. A green verdict on such a pin means the schema and the method line are unchanged and the vendor has not said the tool writes. It is never evidence that the vendor calls the tool read-only. -**A read-only credential is an extra condition, never the gate.** An entry with a pin under the exemption declares `readOnlyScopes`: the scopes its vendor documents as read-only, named one by one and never matched by a pattern. It requests no scope outside them. The listing returns the row's recorded `grantedScope` (§4, `routes/hostedMcpConnect.ts:597`) with the tools, read from the same row whose credential took the list (`services/hostedMcpToolDefinitions.ts:325`, `:329`). Each fresh assessment refuses a pin under the exemption `credential_not_read_only` unless that set is non-empty and inside `readOnlyScopes`, and a row with no recorded scope fails. Like any other verdict, it withholds the pin and is logged (above). A row connected under the entry's current request passes unless its vendor granted no scope at all, since the callback refuses a scope the entry did not request and records the requested set when the token response names none (TASK-193, `routes/hostedMcpConnect.ts:471–489`). So the check catches a row connected under an earlier request. A reconnect never takes a row outside `readOnlyScopes`, so a cached verdict is never stale toward write. The condition narrows what a wrong method line can reach, but does not bound it: Cloudflare's API schema accepts a read permission on 74 operations that are not `GET`, among them `POST /accounts/{account_id}/ai/run` (Workers AI Read) (`cloudflare/api-schemas` at `eb291fe`, each operation's `x-api-token-group`). That is why the review is the gate. +**A read-only credential is an extra condition, never the gate.** This check is TASK-258's too, and is not built. Under the contract, an entry with a pin under the exemption declares `readOnlyScopes`: the scopes its vendor documents as read-only, named one by one and never matched by a pattern. It requests no scope outside them. The listing will return the row's recorded `grantedScope` (§4, `routes/hostedMcpConnect.ts:599`) with the tools, read from the same row whose credential took the list (`services/hostedMcpToolDefinitions.ts:407`, `:411`); today it returns no scope. Each fresh assessment will refuse a pin under the exemption `credential_not_read_only`, a verdict main does not have yet (`services/hostedMcpEntryService.ts:114`), unless that set is non-empty and inside `readOnlyScopes`, and a row with no recorded scope will fail. Like any other verdict, it withholds the pin and is logged (above). A row connected under the entry's current request passes unless its vendor granted no scope at all, since the callback refuses a scope the entry did not request and records the requested set when the token response names none (TASK-193, `routes/hostedMcpConnect.ts:473–491`). So the check catches a row connected under an earlier request. A reconnect never takes a row outside `readOnlyScopes`, so a cached verdict is never stale toward write. The condition narrows what a wrong method line can reach, but does not bound it: Cloudflare's API schema accepts a read permission on 74 operations that are not `GET`, among them `POST /accounts/{account_id}/ai/run` (Workers AI Read) (`cloudflare/api-schemas` at `eb291fe`, each operation's `x-api-token-group`). That is why the review is the gate. **An entry's credential may write, and each write parks for the member who owns it** (Sam, 2026-10-19, relayed by connector-ops on the Wave 1 rows). From 2026-10-01 this paragraph required every entry's credential to be unable to write (76043), and the ruling lifts that bar. A tool that writes is pinned `class: 'write'`, which `writeModeFor` turns into `write-with-confirm` (`services/hostedMcpEntryService.ts:133`), so a `read` grant cannot call it (`write_mode_not_allowed`, `roomGrantService.ts:417`). The park comes from the grant's mode, not the class: the broker parks every non-read call under `write-with-confirm`, while `write` runs a reversible one unattended (`toolBrokerService.ts:1161–1162`). No hosted grant holds `write`, because the mint refuses it (`routes/grants.ts:337`, §5) and attenuation never raises a grant above its parent (`roomGrantService.ts:346`). So every call to a write tool parks for the credential's owner, whose card shows its exact arguments. A destructive write also carries `irreversible`. Notion and Sentry, which the bar kept out, are Wave 1 entries. Granola and Attio, which it also kept out, are not, and Stripe is off (§10, step 8). The ruling accepts two residuals: @@ -219,7 +219,7 @@ Nothing is registered and no client secret is stored anywhere. A self-hosted ins **Every entry requests the identity scope its AS offers** (the TASK-172 row): `openid` where the AS has it, and never `email` or `profile`, as for Linear above. Without a subject, every reconnect is one §2 cannot compare. It revokes every grant on the row even when the account is the same, and it withdraws nothing at the vendor. Google returns an ID token "only … if your request included an identity scope, such as openid, profile, or email" (developers.google.com/identity/protocols/oauth2/native-app), so Calendar requests `openid` beside its three scopes (§10, step 8). -**An entry whose AS offers no `openid` may read the subject from its own server** (connector-ops 77008 (g), Wren 77045). Of the six vendors below that register by DCR, only Miro's AS lists `openid` (fetched 2026-10-14), and without a subject every reconnect is one §2 cannot compare. Such an entry declares `subject: { tool, field }` (§3). `tool` is one of the entry's own `read` pins that carries `readOnlyHint: true`, never one admitted by its method line (§3), and its pinned `inputSchema` requires no argument; it is called with `{}` at the entry's `resource`. `field` is a dotted path into the result's `structuredContent`, or, when there is none, into the JSON its single text item holds. A non-empty string counts, and so does a positive integer no larger than `Number.MAX_SAFE_INTEGER`, which is recorded as its decimal string. Nothing else counts: a larger integer may have lost digits when the result was parsed, so two accounts could read as one. The entry PR cites the vendor's doc that the field is the account's stable id, never an email or a display name, since either can change or pass to another account. The entry load refuses a `subject` whose `tool` is not one of the entry's `read` pins, is admitted by its method line, or has a schema that requires an argument, and an entry that names a `subject` and requests `openid`, since a row's subject has one source. +**An entry whose AS offers no `openid` may read the subject from its own server** (connector-ops 77008 (g), Wren 77045). Of the six vendors below that register by DCR, only Miro's AS lists `openid` (fetched 2026-10-14), and without a subject every reconnect is one §2 cannot compare. Such an entry declares `subject: { tool, field }` (§3). `tool` is one of the entry's own `read` pins that carries `readOnlyHint: true`, never one admitted by its method line (§3), and its pinned `inputSchema` requires no argument; it is called with `{}` at the entry's `resource`. `field` is a dotted path into the result's `structuredContent`, or, when there is none, into the JSON its single text item holds. A non-empty string counts, and so does a positive integer no larger than `Number.MAX_SAFE_INTEGER`, which is recorded as its decimal string. Nothing else counts: a larger integer may have lost digits when the result was parsed, so two accounts could read as one. The entry PR cites the vendor's doc that the field is the account's stable id, never an email or a display name, since either can change or pass to another account. The entry load refuses a `subject` whose `tool` is not one of the entry's `read` pins, is admitted by its method line (once TASK-258 is built), or has a schema that requires an argument, and an entry that names a `subject` and requests `openid`, since a row's subject has one source. The callback reads the subject after TASK-193's scope check and before it stores or revokes anything. With the new token it lists the server's tools, compares them with the pins as drift does (`assessEntryTools`, `services/hostedMcpEntryService.ts:277`), and calls the subject tool once. No grant is involved and no trail row is written, since no agent made the call. Any failure refuses the connect `subject_unavailable`: the tool missing or drifted, the call failing or answering `isError`, or nothing at `field` that counts. That holds on a first connect as on a reconnect, since a row connected without a subject makes its person's next reconnect one §2 cannot compare. The new token goes to `revokeRefusedGrant` (`routes/hostedMcpConnect.ts:102`), which revokes it when the entry has an endpoint and otherwise discards it, as TASK-193's refusal does. The row, its credentials and its grants stay as they were. @@ -587,7 +587,7 @@ Each step can be tested without the vendor, except the live measurements in step The TASK-172 ruling's part 3 wording on `:180` is replaced by §6. -**Other vendors join the catalogue only once their own docs verify** the URL, transport, auth mode, token lifetime and refresh, and revocation (74820; the TASK-172 row). Until 2026-10-19 they also needed a scope that cannot write (§3). A `tools/list` must also show `readOnlyHint: true` on each tool the entry pins `read`, or that tool's own `GET` line where §3's exemption admits it (§3). Three facts already shape their entries: +**Other vendors join the catalogue only once their own docs verify** the URL, transport, auth mode, token lifetime and refresh, and revocation (74820; the TASK-172 row). Until 2026-10-19 they also needed a scope that cannot write (§3). A `tools/list` must also show `readOnlyHint: true` on each tool the entry pins `read`, or, once TASK-258 is built, that tool's own `GET` line where §3's exemption admits it (§3). Three facts already shape their entries: - Notion's only advertised scope is `default`, so a Notion entry cannot narrow its credential to reads the way Linear's `read` does. §3's bar kept Notion out (76043) until Sam's 2026-10-19 ruling lifted it, and Notion is now Wave 1's TASK-240, its writes pinned `write` (§3). - GitHub needs the pre-registered client (§4).