Skip to content

feat(mcp): bridge a host-injected authoring ToolRegistry over HTTP behind a metadata:write OAuth scope - #26550

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-3c836y
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-3c836y

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #26464

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:spec 新增 OAuth scope metadata:write(连同每个 scope 的授权页文案常量);@objectstack/mcp 的 handleHttpRequest 接受宿主注入的 authoring ToolRegistry,只对带该 scope 的令牌注册其工具,每次调用都在 tool context 里盖上 door: 'mcp' + 令牌主体 + 环境;served SKILL.md 只在有该 scope 时写出这些工具。
  • 为什么改:维护者 2026-10-27 裁决「真审批 + MCP build」:开发者用自己的模型和令牌,经 MCP 在托管环境里构建元数据,落为草稿,人工在界面发布。此前 HTTP 门只有 data/action 工具,令牌连 authoring 都申请不了。
  • 风险与回滚:新增一个 scope 值和一个可选注入口,不注入、不带 scope 时行为逐字节不变;stdio 路径不变。回滚 = revert 本 PR(cloud 消费卡 objectstack-ai/cloud#3302 依赖它,回滚前先停那边)。
  • 先看:packages/mcp/src/mcp-server-runtime.ts(registerAuthoringTools 的 gate 与 provenance 构造),packages/mcp/src/mcp-http-tools.ts(grantsAuthoringTools:undefined grant 不放行),packages/spec/src/ai/mcp.zod.ts(scope 进 MCP_OAUTH_SCOPES ⇒ AS 自动 advertise)。
  • 两分钟验证:pnpm --filter @objectstack/mcp exec vitest run src/mcp-server-runtime.authoring-scope.test.ts — 预期 19 条全绿,其中「the refusal is the SAME answer create_record gets without data:write」证明拒绝机制只有一套。
  • 需要你做的:无,席位落地。

What changes

@objectstack/spec — the scope and its consent copy (packages/spec/src/ai/mcp.zod.ts).

  • MCP_OAUTH_SCOPE_METADATA_WRITE = 'metadata:write' joins data:read, data:write and actions:execute in MCP_OAUTH_SCOPES. Because the authorization server (@objectstack/plugin-auth) and the runtime's up-front 403 both read that array, the scope is advertised in scopes_supported and admitted by the dispatcher with no further change — the first readers that come for free.
  • It is a tool-surface grant, not a data grant: scopesToAgentPermissionSets maps it alone to the no-object-access floor, and beside a data scope it changes nothing about the data ceiling (ADR-0090 D10 untouched).
  • MCP_OAUTH_SCOPE_CONSENT_COPY, keyed by McpOauthScope, declares the one consent sentence per scope: the three sentences the Console already renders, verbatim, plus the new one — "Build and change this app's metadata as drafts, within your own permissions — a person publishes them". A scope added without its sentence fails to compile.
  • Regenerated api-surface/ai.json and export-origins/ai.json (two added consts). The authorable surface did not move, hence Metadata-protocol: none.

@objectstack/mcp — the first reader (handleHttpRequest).

  • handleHttpRequest(request, { authoring }) takes an optional McpAuthoringInjection: the host's McpAuthoringToolRegistry plus the token principal (McpTokenPrincipal) and the environment. registerAuthoringTools registers its tools on the per-request server only when toolOptions.grantedScopes names metadata:write (grantsAuthoringTools, beside the three native family gates in mcp-http-tools.ts).
  • Without the scope the tools are not registered, so tools/list omits them and tools/call on one gets the SDK's unknown-tool answer — the same one create_record gets without data:write, from the same mechanism. The undefined grant of an API key or a session is served no authoring tool: authoring follows the token's consent (see Acceptance notes for the reasoning and the alternative).
  • Every call into the registry receives a fresh McpAuthoringToolContext: door: 'mcp' (MCP_TOOL_DOOR), actor.id (the principal's user, so a reader that knows only ToolExecutionContext runs as that user), principal and environmentId — built from the host's inputs alone. The inbound Request is not in scope of the builder, so no header can reach a tool's context.
  • The registration body moved out of the class into a module function with one execute seam, so both bridges share it: the stdio bridgeTools still calls its registry with one argument (pinned), the HTTP authoring bridge adds the context as the second.
  • renderSkillMarkdown({ grantedScopes, authoringTools }) renders a "Build — authoring tools" section that names the injected tools and teaches the create → verify → draft loop (a person publishes in the UI), and switches the frontmatter description to OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, only under the same predicate. Every other render is byte-identical to before.
  • New exports: MCP_TOOL_DOOR, grantsAuthoringTools, OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, and the types McpToolDoor, McpTokenPrincipal, McpAuthoringToolContext, McpAuthoringToolRegistry, McpAuthoringInjection. registerAuthoringTools is exported from its module for the host-less pin, not from the package entry.
  • README and content/docs/ai/connect-mcp.mdx name the fourth scope and what it gates.

The runtime stays door-agnostic: which tools the host injects (cloud ADR-0112 injects no publish or discard tool) and which whitelist version they answer are the host's business. The OSS runtime dispatcher (packages/runtime/src/domains/mcp.ts) is unchanged and passes no authoring; the consumer is objectstack-ai/cloud#3302.

Why

The card's five numbered changes, on the maintainer's 2026-10-27 ruling (「真审批 + MCP build」, 「同意」): a developer driving an MCP client against a hosted environment could read and run it but never build in it, and no scope existed under which a token could ask for authoring. Triage line 6434128092 grades it p1 security: the scope is a boundary, every authoring tool still checks the caller's own metadata permission, landing is drafts-only, and the scope is never a bypass.

Tests

New pins (all in packages/mcp unless noted):

  • mcp-server-runtime.authoring-scope.test.ts — the gate (a data:write token, an actions:execute token, an empty grant and the undefined grant can neither list nor call an authoring tool; the registry is never touched), the refusal parity with create_record under data:read (same shape, same text once the tool name is swapped), the provenance (door, principal, environment, actor.id, two-argument call), seven spoofed provenance headers ignored, a fresh context per call, an absent environment absent from the context, the isError and throw paths, and the stdio one-argument pin through a real stdio session.
  • skill-md.authoring.test.ts — the section renders only for metadata:write with at least one tool; every other combination is byte-identical to the default render. At 7fb21ede: the Build section names describe_object only beside data:read, and a metadata:write-only grant gets the no-data-tools sentence (both arms pinned).
  • At 7fb21ede, in mcp-server-runtime.authoring-scope.test.ts: four hostile writes into the context (principal.userId, principal.organizationId, scopes.push, actor.id) throw TypeError over two calls, the second call sees the host's principal unchanged, the copies are distinct per call and the host object is untouched; a duplicate tool name throws already registered (SDK).
  • skill-md-surface-guard.test.ts — the drift guard gains the authoring case: the skill rendered for a grant names every tool the surface registers for that grant, and names none of them without the scope.
  • packages/spec/src/ai/mcp-oauth-scopes.test.ts — the four-scope vocabulary and order, one consent sentence per scope (the three existing ones verbatim), metadata:write maps to the floor and changes no data ceiling.

Runs (under scripts/pm/os-verify-lock.sh, worktree at 9c3a187a; the @objectstack/mcp rows re-run at the head 7fb21ede):

Command (lock slot issue-26464) Result
pnpm --filter @objectstack/mcp typecheck VERDICT command-exit 0
pnpm --filter @objectstack/mcp test Test Files 45 passed (45) · Tests 567 passed (567) · VERDICT command-exit 0
pnpm --filter @objectstack/spec exec vitest run src/ai/mcp-oauth-scopes.test.ts src/contracts/auth-service.test.ts Test Files 2 passed · Tests 15 passed · VERDICT 0
pnpm --filter @objectstack/core exec vitest run src/security/assemble-execution-context.test.ts src/security/request-execution-context.test.ts Test Files 2 passed · Tests 230 passed · VERDICT 0
pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-manager.mcp-oauth.test.ts (reads MCP_OAUTH_SCOPES) Tests 77 passed (77) · VERDICT 0
pnpm --filter @objectstack/runtime exec vitest run src/http-dispatcher.mcp-oauth.test.ts (reads MCP_OAUTH_SCOPES) Tests 9 passed (9) · VERDICT 0
pnpm --filter @objectstack/spec typecheck VERDICT command-exit 0
pnpm --filter @objectstack/spec check:generated --fix then check:generated 14 artifacts up to date; only api-surface/ai.json and export-origins/ai.json rewritten · VERDICT 0

A rebuild of @objectstack/spec is part of the --fix run above, so the plugin-auth and runtime readers ran against a dist that contains the fourth scope.

Acceptance notes

  • Premise. The card names docs/adr/0112-cloud-ai-build-lite-boundary.md and packages/service-ai-studio/src/authoring-whitelist.ts; neither exists in this repository (ADR-0112 here is the error-code vocabulary). Both are objectstack-ai/cloud files, cited as cloud ADR-0112; the card's five numbered changes are the build contract, as the claim (6435524505) records.
  • The undefined grant. For the three native families grantedScopes: undefined (API key / session) means "not scope-limited" and keeps the full surface, because each of those calls is bounded by the caller's permissions and RLS. Authoring is registered only for a scope list naming metadata:write, so a key or a session is served none. This is the card's literal acceptance ("without the scope it lists none") and the fail-closed reading; widening it to the headless track is a decision for its own card — the predicate carries the reasoning.
  • Refusal shape. Measured against the pinned SDK (@modelcontextprotocol/sdk 1.32.1): tools/call on an unregistered tool answers a tool error (result.isError: true, text naming the tool), not a JSON-RPC error. The parity pin reads either shape and asserts the authoring family gets the same one as create_record.
  • Console consent page (out of scope, for the UI lane). ui/apps/console/src/pages/auth/OAuthConsentPage.tsx maps the three agent scopes to sentences in a switch and renders any other scope as its raw string, so an OSS consent screen asked for metadata:write shows "metadata:write". The sentence now lives in MCP_OAUTH_SCOPE_CONSENT_COPY for that page to read. Not changed here: ui/ is outside this card's file surface.
  • Platform checklist prose. docs/qa/platform-checklist/areas/ai.json (ai.mcp-http-surface) describes the up-front 403 as "naming all three scopes"; the WWW-Authenticate challenge now lists four. The checklist's --changed-from judgement is green (anchors intact); the clause wording is for the QA sweep that re-derives it.
  • stdio and the native tools. No behaviour change: bridgeTools keeps its one-argument call (pinned), wireBridgeTools and the two native gates are untouched, and the full @objectstack/mcp suite is green.

Verification

Derived gates (node scripts/pm/dispatch-gates.mjs --commands: 104 families; the command set is identical at 9c3a187a, at cf3ec268 and at the head 7fb21ede; a second run at 7fb21ede answered 104/104 exit 0 on the first pass), run in the worktree with every exit code captured before any pipe: 104 run, 104 exit 0 after four environment re-runs, reconciled with dispatch-gates.mjs --ran (104 derived, 104 run, 0 NOT-MEASURED, 0 UNRUN).

The four that first answered "nothing measured", and why:

  • check-plugin-teardown-shape.mjs --self-test, exit 3: its fixture is pinned to a commit the shallow clone could not reach. After fetching that commit by its SHA it passes (48 cases).
  • check:dual-build-cjs-loads, exit 3, and spec check:skill-examples, exit 3: both read built output the fresh worktree did not have. After a full pnpm build (118 tasks successful) both pass (skill-examples: 265 marked examples over 3 surfaces type-check).
  • check:lean-entry-closure, exit 1: @objectstack/spec/dist/data/index.mjs was absent for the seconds in which a sibling spec rebuild had wiped dist/. Re-run against the finished build it passes.

Also green, outside the derivation: pnpm --filter @objectstack/spec check:generated --fix (14 artifacts up to date; only api-surface/ai.json and export-origins/ai.json rewritten), check-platform-checklist --changed-from origin/main (2 anchored files, 6 anchors judged, 0 lost), check-nul-bytes (21342 text files, clean), and eslint with the repo's --no-inline-config on the ten changed .ts files (0 problems). CI's own families (the artifact roster, the type-check lanes, the path-scheduled jobs) run on this PR; the os-dev report on the card cites them by check-run name and conclusion.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…hind a metadata:write scope

Spec: MCP_OAUTH_SCOPE_METADATA_WRITE joins MCP_OAUTH_SCOPES beside
data:read / data:write / actions:execute, with MCP_OAUTH_SCOPE_CONSENT_COPY
carrying one consent sentence per scope. It is a tool-surface grant, not a
data grant: scopesToAgentPermissionSets maps it alone to the floor.

MCP runtime: handleHttpRequest accepts an optional `authoring` injection
(registry + token principal + environment). Its tools register only for a
grant naming metadata:write, so a token without the scope cannot list or
call one and gets the SDK's unknown-tool answer, exactly as create_record
without data:write. Every call into the registry carries a fresh context
{ door: 'mcp', actor, principal, environmentId } built from the host's
inputs alone — never from a request header. The registration body moved
out of the class so both bridges share it; the stdio bridge still calls
its registry with one argument.

Skill: renderSkillMarkdown names the injected tools in a Build section,
with the create → verify → draft loop, only under the same grant.

Tests pin the gate, the refusal parity, the provenance, spoofed headers,
the fresh-context-per-call rule, the stdio arity and the skill section;
a spec test pins the vocabulary and the consent copy. Spec api-surface
and export-origins regenerated; changeset for spec and mcp.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM
…formatToolOutput for both bridges

The pinned SDK answers tools/call on an unregistered tool as a tool error
(result.isError with the tool named), not a JSON-RPC error; the parity pin
now reads either shape and compares them structurally. Spoof values no
longer collide with the clientId key, formatToolOutput is a module
function the shared registration body can reach, and the test reads the
buffered answer untyped.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM
…scope pins

`arguments.length` trips no rule under the repo's lint, and `pnpm lint`
runs with `--no-inline-config`, so the two `prefer-rest-params` directives
were dead text that eslint reported as unused.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/mcp, @objectstack/spec, touching 36 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/mcp/README.md, packages/mcp/src/index.ts, packages/spec/api-surface/ai.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ab3c776a957f18f3f61c0fc85dec7180b31bca55.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/mcp/README.md, packages/mcp/src/index.ts, packages/spec/api-surface/ai.json, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: environmentId (36 routes)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 152 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ab3c776a957f18f3f61c0fc85dec7180b31bca55 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from afe83b254b6d62ff3a39e7eda3148383146ffeb4 — the merge of head 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca into base ab3c776a957f18f3f61c0fc85dec7180b31bca55, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin afe83b254b6d62ff3a39e7eda3148383146ffeb4 && git checkout afe83b254b6d62ff3a39e7eda3148383146ffeb4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab3c776a957f18f3f61c0fc85dec7180b31bca55 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca && git checkout -B drift-repro ab3c776a957f18f3f61c0fc85dec7180b31bca55 && git merge --no-ff 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca

node scripts/docs-audit/affected-docs.mjs --json ab3c776a957f18f3f61c0fc85dec7180b31bca55

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ab3c776a957f18f3f61c0fc85dec7180b31bca55 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf3ec26820915712eb0a7aea1de8a1ce7a89134a
Local-runs: none
Scope: packages/spec/src/ai/mcp.zod.ts + packages/spec/api-surface/ai.json, packages/spec/export-origins/ai.json, packages/spec/src/ai/mcp-oauth-scopes.test.ts, packages/mcp/src/mcp-server-runtime.ts (registerToolFromDefinition, registerAuthoringTools, bridgeTools, handleHttpRequest), packages/mcp/src/mcp-http-tools.ts (grantsAuthoringTools beside registerObjectTools / registerActionTools / wireBridgeTools), packages/mcp/src/skill-md.ts, packages/mcp/src/types.ts, packages/mcp/src/index.ts, packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts, packages/mcp/src/skill-md.authoring.test.ts, packages/mcp/src/skill-md-surface-guard.test.ts, packages/runtime/src/domains/mcp.ts (handleMcpRequest, handleMcpSkillRequest), packages/runtime/src/security/resolve-execution-context.ts, packages/core/src/security/assemble-execution-context.ts (oauthScopes), packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts (verifyMcpAccessToken), packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts (oauthProvider scopes roster), packages/plugins/plugin-auth/src/auth-manager.ts (scopes_supported), packages/spec/src/contracts/ai-service.ts (ToolExecutionContext), ui/apps/console/src/pages/auth/OAuthConsentPage.tsx, scripts/check-changeset-no-major.mjs (PROTOCOL_BASELINE_ROOTS), docs/qa/platform-checklist/areas/ai.json, .changeset/26464-mcp-metadata-write-scope.md, content/docs/ai/connect-mcp.mdx, packages/mcp/README.md

Read as a p1 security review, adversarially: the question on every path was whether an authoring tool can be listed or called without a verified metadata:write grant, and whether anything a client sends can reach a tool's provenance. Card #26464 carries four comments at read time (filing, triage, the server-seat re-lane, the claim); no os-dev report comment exists yet, so there are no out_of_scope / open_questions to answer.

① Derived judgments

(a) Fail-closed — holds on every path read.

  • The only gate is grantsAuthoringTools(grantedScopes) = Array.isArray(grantedScopes) && grantedScopes.includes('metadata:write') (mcp-http-tools.ts), read once in registerAuthoringTools before any registry.getAll(); without it the function returns [] and the registry object is never touched (pinned: registry.calls stays empty on data:write, actions:execute, empty and undefined grants).
  • grantedScopes reaches the runtime from one place: packages/runtime/src/domains/mcp.ts reads ec.oauthScopes, which assemble-execution-context.ts:384 sets ONLY for a verified OAuth token (oauth && authz.userId === oauth.userId ? oauth.scopes : undefined). An API key or a session cookie therefore arrives as undefined, which the authoring predicate refuses (deliberately stricter than the native families' "undefined = not scope-limited"). The dispatcher adds no authoring injection at all, so the OSS runtime serves nothing authoring on any path; the injection exists only for a host (cloud#3302).
  • Scope spelling: verifyMcpAccessToken splits the JWT scope claim on a single space and keeps the strings verbatim (no lowercasing, no trimming of other whitespace); every downstream read is an exact includes. So Metadata:Write, a tab-joined claim, a trailing-space variant, metadata:* or * all fail closed — and a token carrying none of the four exact strings is refused 403 up front by the dispatcher's some(includes). A token minted before the scope existed simply lacks the string.
  • undefined vs []: both refuse authoring; for the native families [] also registers nothing (pinned: tools/list on an empty grant answers an error because no tools capability is declared).
  • Registration is per request: handleHttpRequest builds a fresh McpServer per POST, calls registry.getAll() on that server and closes it in finally; the injection is a call parameter, nothing is cached on the shared MCPServerRuntime instance, and the stdio this.mcpServer is never touched by the HTTP path. No cross-request leak is possible from this code.

(b) Refusal parity — holds, with no existence leak. An unregistered authoring tool gets exactly what create_record gets without data:write: the SDK's unknown-tool answer from the same McpServer, no second dialect. The pin reads either SDK shape (JSON-RPC error on older SDKs, result.isError on the pinned 1.32.1 — the package range is ^1.30.1) and asserts the authoring text equals the native text once the tool name is swapped. tools/list with a registry injected and no scope is pinned byte-equal to tools/list with no registry, so a client cannot learn an authoring tool exists from either the list or the refusal.

(c) Provenance — built from host inputs alone; no header or argument reaches it. registerAuthoringTools(server, authoring, grantedScopes, logger) has no Request in scope; the per-call context is { door: MCP_TOOL_DOOR, actor: { id: principal.userId }, principal, environmentId? } with door a module constant and the other two the host's call arguments. Tool arguments travel as toolCall.input in the FIRST parameter; the context is the SECOND, so no argument key can shadow door / principal / environmentId. The seven spoofed headers (x-door, x-objectstack-door, x-environment-id, x-user-id, x-forwarded-user, x-organization-id, x-principal) are pinned as absent from the serialised context. opts.authInfo is forwarded to the transport only and is not read by the bridge. A fresh context object is built per call (pinned across two calls with a tainting handler).

(d) stdio and the native tools — byte-identical. The registration body moved from a private method to the module function registerToolFromDefinition with one execute seam; I compared the removed and added bodies hunk by hunk (registration options, toolCallId spelling, the isError and catch envelopes, formatToolOutput) — identical. bridgeTools passes a closure that calls toolRegistry.execute(call) with one argument, pinned through a real stdio session (arities = [1]). wireBridgeTools, registerObjectTools, registerActionTools are untouched in the diff (mcp-http-tools.ts gains only an import and the new predicate; it imports nothing from ./, so skill-md.ts importing it creates no cycle). handleMcpSkillRequest still renders with { mcpUrl } only, so the OSS skill route never renders the authoring section.

(e) Semver and the declaration lines — correct. The diff moves packages/spec/api-surface/ai.json and export-origins/ai.json (two added consts) and NO packages/spec/authorable-surface/ or authorable-defaults/ shard (verified against the merge base). check-changeset-no-major.mjs grades the level on the METADATA PROTOCOL alone: PROTOCOL_BASELINE_ROOTS is exactly those two authorable directories; api-surface/ is not a protocol baseline, it is the check:api-surface export ratchet. MCP_OAUTH_SCOPES feeds no Zod schema in packages/spec/src (grep: zero non-test readers), so nothing an author writes gained a value. Hence Metadata-protocol: none is right, patch on both packages is what none requires (a minor would be refused as none-minor), and Clause-②: yes (widening) is right for added exports, an added scope value and an optional new parameter. The Check Changeset run on this head concluded success, agreeing with that reading.

(f) The requestable scope list — deliberately widened, and safe. MCP_OAUTH_SCOPES is read by the AS: oauthProvider({ scopes: [openid, profile, email, offline_access, ...MCP_OAUTH_SCOPES] }) (the roster DCR clients may request) and scopes_supported: [...MCP_OAUTH_SCOPES, 'offline_access'] (discovery), plus the dispatcher's 403 prose and WWW-Authenticate challenge. So a client can now request metadata:write and a user can consent to it on every deployment, which the PR body names as intended ("the first readers that come for free"). Safe because a token carrying it gains nothing but the authoring family a host injects: scopesToAgentPermissionSets maps it to the no-object-access floor and changes no data ceiling beside a data scope (pinned), delegatesActions still keys on actions:execute alone, and on the OSS runtime (no injection) a metadata:write-only token reaches handleHttpRequest, registers zero native tools and is served no tool capability. The gaps that remain are consent-copy and advertising gaps, listed under ③, not grants.

Tests pinned as read: 17 cases in mcp-server-runtime.authoring-scope.test.ts cover the gate on four grant shapes, parity, provenance, seven spoofed headers, fresh context, absent environment, isError / throw paths and stdio arity; skill-md.authoring.test.ts pins every other render byte-identical; skill-md-surface-guard.test.ts pins the rendered skill against the registered surface with and without the scope; mcp-oauth-scopes.test.ts pins the four-scope order, one sentence per scope and the data-ceiling invariance.

Check-runs on this head (read 2026-10-27T07:51:29Z; 47 runs): 25 success, 7 skipped (opt-in harnesses, dogfood, Temporal Conformance, Lint UI, Test UI — path-scheduled, no ui/ in the diff), 15 in_progress. Of the eight required contexts: Lint & Repo Gates success, Governed Surface Queue Guard success, Lint UI skipped; Build Core, Test Core (1–8/8), Type Check UI, Build & E2E UI and the four Type Check · lanes still in progress. Check Changeset success. No run has concluded failure. The verdict below is on the diff; the in-progress gates are the landing's own condition ("every check is green"), not this record's.

② Semver level

patch on @objectstack/spec and @objectstack/mcp, with Clause-②: yes (widening) and Metadata-protocol: none on both the PR body and the changeset — correct per ① (e). Nothing is removed or narrowed: no tombstone, no ADR-0087 marker, no migration owed.

③ Boundary flags

None blocking. For the record and for the consumers named:

  1. OSS consent screen shows the raw string (dev-flagged, UI lane). OAuthConsentPage.tsx maps the three agent scopes in a switch and renders anything else as its spelling, so an OSS user asked for metadata:write consents to "metadata:write" with no sentence. The PR's MCP_OAUTH_SCOPE_CONSENT_COPY is NOT yet read by the Console (it uses i18n keys oauth.consent.scope.*). The scope is advertised everywhere from this PR on, so a ui/ card wiring the page to the constant (or adding the i18n key) is owed — Prime Directive chore: version packages #10 names a user-facing consent with no description as a trap worth its own card, not an acceptance note. Not blocking: the consent is still an explicit grant, and the scope grants nothing on OSS.
  2. OSS advertises a scope no OSS reader serves anything under. scopes_supported and the challenge now name metadata:write on a deployment whose dispatcher passes no authoring; a token holding it is admitted and served no tool. Fail-closed, so not a grant, but it is a scope-with-nothing-behind-it on the open runtime until a host injects (Route & surface ownership rule 4 tension). Acceptable here because the card orders the scope in spec with its first reader in @objectstack/mcp, and MCP_OAUTH_SCOPES is single-sourced by design; worth one line in the OSS docs if the OSS dispatcher never gains an injector.
  3. Host contract for cloud#3302 — three things the runtime cannot check for the host: (i) the gate reads toolOptions.grantedScopes, not authoring.principal.scopes; the host must derive both from the same verified token or the context will carry scopes the gate did not read. (ii) principal is stamped by reference, not copied or frozen; an injected tool that mutates context.principal.userId in place taints the host's object for the next call (and the next request, if the host reuses it). The fresh-context pin covers replacement, not in-place mutation. Tools are host-trusted code, so this is hardening, not a hole — principal: { ...principal } or Object.freeze would close it in one line. (iii) McpServer.registerTool throws on a duplicate name, and registerAuthoringTools runs after wireBridgeTools; a host injecting a tool named like a native one (create_record, run_action, …) turns the request into the dispatcher's 500, never a shadowed native tool. The host must keep its names disjoint.
  4. Skill prose vs a metadata:write-only grant. The authoring section renders for any grant naming metadata:write and tells the model "Discover first here too — describe_object", which is a data:read tool and is not registered for a token without that scope. The surface-guard pin holds the other direction (every registered tool is named); this direction (a named tool not registered) is a model-facing nuisance, not a boundary. One conditional sentence fixes it.
  5. Stale prose, no gate. docs/qa/platform-checklist/areas/ai.json clause ai.mcp-http-surface says the 403 names "all three scopes"; the challenge now lists four. The dev notes it for the QA sweep; anchors are intact.
  6. Pre-existing, out of this diff: assemble-execution-context.ts:384 turns an OAuth principal whose userId differs from the resolved authz.userId into oauthScopes: undefined, i.e. "not scope-limited" for the native families. Unchanged by this PR and irrelevant to authoring (undefined refuses it), noted only because a reader of (a) will meet it.

Implemented-by: claude/project-thread-3c836y
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

…t only beside data:read

Two hardening items from the contract review of the authoring bridge.

The provenance builder stamped the host's principal object by reference,
so an injected tool that wrote into its context could taint the next call
in the same request and the host's own object. Each call now receives a
frozen copy of the principal, with its `scopes` array copied and frozen
too, and a frozen `actor`; the context object was already fresh per call.
A pin drives four hostile writes through a tool and expects every one
refused, the second call to see the host's principal byte for byte, and
the host's object untouched. A second pin records what the SDK does with a
duplicate tool name (it throws), so a host knows to keep its names clear
of the native surface.

The served SKILL.md's Build section told the model to call
`describe_object` before changing an object even when the grant was
metadata:write alone, where the data gate registers no such tool. The
sentence now follows the same predicate as the data gate (`data:read`);
without it the section points at the authoring tools' own verify step
instead and names no native tool. The surface guard gains the case: the
Build section rendered for a grant names no native tool that grant does
not register.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca
Local-runs: none
Scope: .changeset/26464-mcp-metadata-write-scope.md, packages/mcp/src/mcp-server-runtime.ts (registerAuthoringTools, registerToolFromDefinition, handleHttpRequest), packages/mcp/src/skill-md.ts, packages/mcp/src/types.ts, packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts, packages/mcp/src/skill-md.authoring.test.ts, packages/mcp/src/skill-md-surface-guard.test.ts + packages/mcp/src/mcp-http-tools.ts (grantsAuthoringTools, registerObjectTools canRead gate, registerActionTools gate, wireBridgeTools), packages/mcp/src/index.ts (unchanged in the delta, read for the export list), packages/spec/src/contracts/ai-service.ts (ToolExecutionContext), scripts/check-changeset-no-major.mjs (the none ⇒ patch rule)

This is the delta review of cf3ec268..7fb21ede. The full review 6436633646 (head cf3ec268) stands for everything else in the PR. The delta is one commit meant to land that review's findings 3(ii) (principal stamped by reference), 3(iii) (duplicate tool name) and 4 (the skill's describe_object sentence on a metadata:write-only grant). I judged git diff cf3ec268 7fb21ede adversarially: seven files, 148 insertions, 17 deletions. No file outside the seven moved.

① Derived judgments

(a) The delta does exactly the three things, and nothing else on the public surface. types.ts changes TSDoc only; no field is added, removed or retyped. mcp-server-runtime.ts adds one type import and rewrites the body of the provenance() closure; no export is added. skill-md.ts adds one import, one boolean and a second parameter on a private function; renderSkillMarkdown's signature and RenderSkillOptions are unchanged. index.ts is not in the delta, so the export list named by the changeset is the same as at cf3ec268. No packages/spec path moved. Accept set: handleHttpRequest and renderSkillMarkdown take exactly what they took before.

(b) 3(ii), the frozen copy: holds against the code. provenance() now builds Object.freeze({ ...principal, ...(principal.scopes !== undefined ? { scopes: Object.freeze([...principal.scopes]) } : {}) }), plus actor: Object.freeze({ id }), per call, inside the closure (call) => registry.execute(call, provenance()). So each tools/call gets its own copy. I checked the three attack questions:

  • Can a tool still mutate state shared across calls or with the host? Through the context: no. principal, principal.scopes and actor are frozen; door and environmentId are primitives; the context object itself is fresh per call. A write to context.door on the unfrozen context object is absorbed on that call's own object and reaches neither the next call nor the host (the pre-existing fresh-context pin still asserts exactly that). The closure captures authoring.principal by reference, so a host that mutates its own principal mid-request changes what later calls snapshot; that is the host's object and the host's business, not a tool's reach.
  • Does the copy drop or alter any principal field a host tool reads? No. Spread copies every own enumerable property of the declared shape (userId, organizationId, clientId, scopes) with the same values; scopes becomes a frozen shallow copy with the same strings in the same order. What is not carried: a prototype, non-enumerable properties, and the identity of any nested object a host might add outside the declared type (shallow freeze). McpTokenPrincipal is a plain interface with one array, so none of that is in the contract; the TSDoc says "its one array, scopes", which is honest.
  • Does Object.freeze on the readonly-typed scopes change a type? No. Object.freeze([...]) returns readonly string[], which is the declared type of scopes; Object.freeze({...}) returns a Readonly mapped type, assignable to McpTokenPrincipal, which the code annotates explicitly. The exported type text in types.ts is unchanged; the four Type Check · lanes and TypeScript Type Check are success on this head.

(c) 3(iii), the duplicate name: documented, and the doc is true. handleHttpRequest calls wireBridgeTools and then registerAuthoringTools before server.connect, outside the try/finally, so a throw from registerTool leaves handleHttpRequest as a rejection and the dispatcher answers its 500; the request fails, no tool is shadowed. The changeset and the two TSDoc blocks say "the SDK throws on a duplicate registration and the request fails with it", which matches. One nuance the docs state correctly as a host rule rather than a grant rule: a native name is "already on the server" only when the grant registers it (an authoring tool named describe_object throws beside data:read and registers cleanly without it), so the host must keep names disjoint regardless of grant, which is what McpAuthoringInjection.registry's TSDoc says.

(d) Finding 4, the skill sentence: the condition matches the native gate. registerObjectTools gates list_objects, describe_object and the rest of the read set on canRead = !scopes || scopes.includes(data:read) (lines 686, 698, 756, 1052). The skill's describeObjectRegistered = !options.grantedScopes || options.grantedScopes.includes(data:read) is the same expression on the same input: undefined ⇒ true/true, [] ⇒ false/false (![] is false), a partial grant ⇒ includes on both. The undefined arm never reaches the authoring section anyway, since the section renders only when grantsAuthoringTools admits an array naming metadata:write. The one input the skill cannot see is whether the host wired a data bridge at all; that gap pre-dates this PR for the whole ## Tools section and is not widened here.

(e) The new tests pin real behaviour. Frozen copy: four hostile writes over two real HTTP tools/call round-trips, 8 TypeErrors expected (strict-mode ESM), Object.isFrozen on principal, scopes and actor, identity checks (not.toBe host object, not.toBe between calls, scopes not the host's array), and the host's PRINCIPAL deep-equal to its literal afterwards. Duplicate name: registerAuthoringTools twice on one McpServer, second call toThrow(/already registered/i); the first call's return is asserted equal to the tool names, so the second is a true duplicate and not an empty registration. Skill: both arms pinned (metadata:write alone contains neither describe_object nor list_objects and contains the "no data:read" sentence; data:read + metadata:write contains "describe_object on an object" and not the other sentence). Surface guard: the Build section rendered for metadata:write alone names no name from the full native tools/list. The os-dev report's ablation (freeze removed, condition forced true) turned exactly these three pins red, so none is vacuous. The duplicate-name text is verified by the green Test Core shards on this head (the SDK is not installed in this checkout; the pinned lock version is 1.32.1).

(f) Changeset prose is accurate. The three added sentences (frozen per-call copy; names unique, SDK throws, request fails; describe_object taught only beside data:read, "the scope that registers it") each match the code read in (b), (c) and (d). The "Unchanged" bullet and the export list are still right because index.ts did not move.

Check-runs on 7fb21ede (read 2026-10-27, 56 rows across two workflow runs): every row is completed; conclusions are success or skipped (path-scheduled Lint UI, Test UI matrix, Temporal Conformance, opt-in harnesses, Dogfood CLI, and the first run's Check PR Size/Auto Label superseded by the second's success). Zero failure. Check Changeset, Lint & Repo Gates, Governed Surface Queue Guard, Build Core, Test Core 1–8/8 and the aggregate, Type Check UI, Build & E2E UI, all four Type Check · lanes: success.

② Semver level

The changeset file reads '@objectstack/spec': patch and '@objectstack/mcp': patch with Clause-②: yes (widening) and Metadata-protocol: none. The delta touched only the three prose lines (14–16), not the header, so the level is what the full review judged: none requires every introduced entry at patch (check-changeset-no-major.mjs, the none-minor reading would refuse a minor), and Check Changeset is success on this head. The brief named the spec entry as minor; the file does not, and patch is the correct grade. The freeze is not a narrowing: McpAuthoringToolContext and the injection are introduced by this PR, so relative to main nothing accepted before is refused now; the only thing newly refused is a write by a tool into an object the host handed it, which no released consumer does.

③ Boundary flags

None blocking.

  1. os-dev open question: should the undefined grant be served the authoring tools? Answer: A, keep fail-closed, as shipped. grantedScopes reaches the runtime only as ec.oauthScopes, which assemble-execution-context.ts sets solely for a verified OAuth token; an API key or a session arrives as undefined. Authoring is a build-door consent, not a data ceiling: the card's acceptance says "without the scope it lists none", and a key or session has consented to nothing. The native families' undefined = not scope-limited rule is safe for them because every data call is bounded by permissions and RLS; authoring lands metadata, so the stricter arm is the right default. Widening to the headless track is its own product card; the predicate carries the reasoning and B is a one-line change when ruled.
  2. os-dev out-of-scope, Console consent copy. Now filed as console(oauth consent): the metadata:write scope from #26550 is shown to the user as the raw string, not as a sentence of what they are granting #26587 (console, bug, priority:p2, area:access, pm:queue, 2026-10-27T09:15Z). The spec side is done here (MCP_OAUTH_SCOPE_CONSENT_COPY); the UI wiring is that card's. Nothing owed in this PR.
  3. os-dev out-of-scope, platform-checklist prose (ai.mcp-http-surface says "all three scopes", the challenge lists four). Below the filing bar, anchors intact, left for the QA sweep. Agreed.
  4. os-dev out-of-scope, consumer notes for cloud#3302. Both are now written where the host reads them: McpAuthoringInjection.principal says to derive it from the SAME token as toolOptions.grantedScopes; McpAuthoringInjection.registry says names must not collide with the native surface or each other. The host should also know the shallow-freeze boundary from (b): a nested object it adds to the principal beyond the declared shape is shared by reference across calls. That is the host's own extension, outside this contract; worth one line in the cloud card, not a change here.
  5. Still pre-existing and still out of the diff: assemble-execution-context.ts turns an OAuth principal whose userId differs from the resolved authz.userId into oauthScopes: undefined. Unchanged by the delta; undefined refuses authoring, so irrelevant to this family.

④ Design judgment

Over the seven delta files, against the code they cite:

  • mcp-server-runtime.ts (registerAuthoringTools): holds. The copy is built per call inside the closure, the freeze covers the only array in the declared shape, and the TSDoc states the boundary honestly. Should-fix (optional, consistency): the context object itself is not frozen, so context.door and context.principal can be reassigned on that call's own object; harmless across calls (fresh object) and within a call (the tool is host code and could build any object), but door is the one member the host's access check branches on, and Object.freeze on the returned context would make the lock whole with no test but the fresh-context pin's expect(second.door).toBe('studio') line to adjust. Not a boundary; the record keeps it as a note for the host card.
  • types.ts: holds; TSDoc only, every sentence true of the code.
  • skill-md.ts: holds; the condition is the native gate's expression on the native gate's input. Should-fix (prose): the "no data:read" sentence names "the authoring tools' own verify step", which assumes the host injects a verify tool; this runtime hard-codes no tool names elsewhere in the section by design. True for cloud ADR-0112's whitelist, and the skill already teaches the create → verify → draft loop in the previous sentence, so acceptable; a host with no verify tool would render one misleading clause.
  • skill-md-surface-guard.test.ts: the new case is real (the old prose would have failed it on describe_object). Should-fix (test hygiene): md.slice(md.indexOf('## Build — authoring tools'), …) has no guard that the anchor was found; an indexOf of −1 slices the last character, section.length > 0 still passes, and the filter is then vacuous. The sibling skill-md.authoring.test.ts pins the section's presence for the same grant and tools, so the suite as a whole is not vacuous; an expect(md.indexOf(...)).toBeGreaterThan(-1) would make this file stand alone.
  • skill-md.authoring.test.ts: holds; both arms pinned with positive and negative assertions.
  • mcp-server-runtime.authoring-scope.test.ts: holds. The duplicate-name pin is at the registerAuthoringTools unit, not through handleHttpRequest; the "request fails with it" half of the claim rests on the call order read in (c) (before connect, outside try/finally). Should-fix (optional): one handleHttpRequest round-trip with a registry naming describe_object under data:read + metadata:write, asserting rejection, would pin the sentence end to end.
  • .changeset/26464-mcp-metadata-write-scope.md: accurate; level unchanged and correct.
  • PR body, not a delta file: the 维护者速读 line still says the authoring-scope file has "17 条"; it now has 19. Should-fix (prose) for the seat that edits the body; the os-dev report already lists the body edits owed.

No must-fix.

Implemented-by: claude/project-thread-3c836y
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-27T09:23Z

Head 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca. Card #26464 (p1, security), claim 6435524505, dev report 6437984964.

  • Contract review: owed at CONTRACT_REVIEW_TIER, because packages/spec/src/ai/mcp.zod.ts is touched. The full review PASSed at cf3ec268 (6436633646). The one commit since, 7fb21ede, lands only that review's findings 3(ii), 3(iii) and 4. Its delta review PASSes and names this head (6438158130). The per-call principal (and its scopes) is now a frozen copy, so a tool cannot taint the next call or the host's object. The host contract for duplicate names and for the token the principal comes from is written in TSDoc. The skill names describe_object only beside data:read, with the same expression the native data gate uses. Its four should-fix notes are optional and do not block.
  • Open question (undefined grant): ruled A, keep it fail-closed, as shipped. An API key or a session has consented to no build door. Serving authoring on the headless track would be its own product card.
  • Out of scope, carried: the Console consent page renders metadata:write raw. That is filed as console(oauth consent): the metadata:write scope from #26550 is shown to the user as the raw string, not as a sentence of what they are granting #26587 (Blocked-by: #26550), and it also carries the ai.json "all three scopes" prose. The host notes for cloud#3302 are in the injection TSDoc. The shallow-freeze boundary (a nested object a host adds beyond the declared shape is shared) goes to the cloud card.
  • PR body: the seat updated the Tests and Verification lines to 7fb21ede (567 tests, the hardening pins, the second 104/104 gate run) and the 维护者速读 count (19).
  • Shape: Fixes #26464. '@objectstack/spec': patch and '@objectstack/mcp': patch, with Clause-②: yes (widening) and Metadata-protocol: none on the body, the changeset and the claim. check-governed-merges --pr 26550: NOT governed, 1521 changed lines.
  • Gates: on the head, 47 check-runs succeeded, 11 were skipped and none are red. The five body-triggered checks (Check Changeset and the claim/single-writer guards) are re-running after the seat's body edit. Auto-merge waits for them.
  • Landing: Tier S. pr_ready + automerge_enable in this act.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ai size/xl tests tooling

Projects

None yet

2 participants