Repository navigation
feat(mcp): bridge a host-injected authoring ToolRegistry over HTTP behind a metadata:write OAuth scope - #26550
Conversation
…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
📓 Docs Drift CheckThis PR changes 2 package(s): 40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 152 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 ① Derived judgments(a) Fail-closed — holds on every path read.
(b) Refusal parity — holds, with no existence leak. An unregistered authoring tool gets exactly what (c) Provenance — built from host inputs alone; no header or argument reaches it. (d) stdio and the native tools — byte-identical. The registration body moved from a private method to the module function (e) Semver and the declaration lines — correct. The diff moves (f) The requestable scope list — deliberately widened, and safe. Tests pinned as read: 17 cases in Check-runs on this head (read 2026-10-27T07:51:29Z; 47 runs): 25 ② Semver level
③ Boundary flagsNone blocking. For the record and for the consumers named:
Implemented-by: 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
Contract reviewServed-tier: This is the delta review of ① Derived judgments(a) The delta does exactly the three things, and nothing else on the public surface. (b) 3(ii), the frozen copy: holds against the code.
(c) 3(iii), the duplicate name: documented, and the doc is true. (d) Finding 4, the skill sentence: the condition matches the native gate. (e) The new tests pin real behaviour. Frozen copy: four hostile writes over two real HTTP (f) Changeset prose is accurate. The three added sentences (frozen per-call copy; names unique, SDK throws, request fails; Check-runs on ② Semver levelThe changeset file reads ③ Boundary flagsNone blocking.
④ Design judgmentOver the seven delta files, against the code they cite:
No must-fix. Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
Fixes #26464
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
metadata:write(连同每个 scope 的授权页文案常量);@objectstack/mcp的handleHttpRequest接受宿主注入的 authoring ToolRegistry,只对带该 scope 的令牌注册其工具,每次调用都在 tool context 里盖上door: 'mcp'+ 令牌主体 + 环境;served SKILL.md 只在有该 scope 时写出这些工具。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'joinsdata:read,data:writeandactions:executeinMCP_OAUTH_SCOPES. Because the authorization server (@objectstack/plugin-auth) and the runtime's up-front 403 both read that array, the scope is advertised inscopes_supportedand admitted by the dispatcher with no further change — the first readers that come for free.scopesToAgentPermissionSetsmaps 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 byMcpOauthScope, 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.api-surface/ai.jsonandexport-origins/ai.json(two added consts). The authorable surface did not move, henceMetadata-protocol: none.@objectstack/mcp— the first reader (handleHttpRequest).handleHttpRequest(request, { authoring })takes an optionalMcpAuthoringInjection: the host'sMcpAuthoringToolRegistryplus the token principal (McpTokenPrincipal) and the environment.registerAuthoringToolsregisters its tools on the per-request server only whentoolOptions.grantedScopesnamesmetadata:write(grantsAuthoringTools, beside the three native family gates inmcp-http-tools.ts).tools/listomits them andtools/callon one gets the SDK's unknown-tool answer — the same onecreate_recordgets withoutdata: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).McpAuthoringToolContext:door: 'mcp'(MCP_TOOL_DOOR),actor.id(the principal's user, so a reader that knows onlyToolExecutionContextruns as that user),principalandenvironmentId— built from the host's inputs alone. The inboundRequestis not in scope of the builder, so no header can reach a tool's context.executeseam, so both bridges share it: the stdiobridgeToolsstill 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 toOBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, only under the same predicate. Every other render is byte-identical to before.MCP_TOOL_DOOR,grantsAuthoringTools,OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, and the typesMcpToolDoor,McpTokenPrincipal,McpAuthoringToolContext,McpAuthoringToolRegistry,McpAuthoringInjection.registerAuthoringToolsis exported from its module for the host-less pin, not from the package entry.content/docs/ai/connect-mcp.mdxname 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 noauthoring; 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/mcpunless noted):mcp-server-runtime.authoring-scope.test.ts— the gate (adata:writetoken, anactions:executetoken, an empty grant and the undefined grant can neither list nor call an authoring tool; the registry is never touched), the refusal parity withcreate_recordunderdata: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 formetadata:writewith at least one tool; every other combination is byte-identical to the default render. At7fb21ede: the Build section namesdescribe_objectonly besidedata:read, and ametadata:write-only grant gets the no-data-tools sentence (both arms pinned).7fb21ede, inmcp-server-runtime.authoring-scope.test.ts: four hostile writes into the context (principal.userId,principal.organizationId,scopes.push,actor.id) throwTypeErrorover 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 throwsalready 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:writemaps to the floor and changes no data ceiling.Runs (under
scripts/pm/os-verify-lock.sh, worktree at9c3a187a; the@objectstack/mcprows re-run at the head7fb21ede):issue-26464)pnpm --filter @objectstack/mcp typecheckpnpm --filter @objectstack/mcp testpnpm --filter @objectstack/spec exec vitest run src/ai/mcp-oauth-scopes.test.ts src/contracts/auth-service.test.tspnpm --filter @objectstack/core exec vitest run src/security/assemble-execution-context.test.ts src/security/request-execution-context.test.tspnpm --filter @objectstack/plugin-auth exec vitest run src/auth-manager.mcp-oauth.test.ts(readsMCP_OAUTH_SCOPES)pnpm --filter @objectstack/runtime exec vitest run src/http-dispatcher.mcp-oauth.test.ts(readsMCP_OAUTH_SCOPES)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:generated --fixthencheck:generatedapi-surface/ai.jsonandexport-origins/ai.jsonrewritten · VERDICT 0A rebuild of
@objectstack/specis part of the--fixrun above, so the plugin-auth and runtime readers ran against adistthat contains the fourth scope.Acceptance notes
docs/adr/0112-cloud-ai-build-lite-boundary.mdandpackages/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.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 namingmetadata: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.@modelcontextprotocol/sdk1.32.1):tools/callon 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 ascreate_record.ui/apps/console/src/pages/auth/OAuthConsentPage.tsxmaps the three agent scopes to sentences in aswitchand renders any other scope as its raw string, so an OSS consent screen asked formetadata:writeshows "metadata:write". The sentence now lives inMCP_OAUTH_SCOPE_CONSENT_COPYfor that page to read. Not changed here:ui/is outside this card's file surface.docs/qa/platform-checklist/areas/ai.json(ai.mcp-http-surface) describes the up-front 403 as "naming all three scopes"; theWWW-Authenticatechallenge now lists four. The checklist's--changed-fromjudgement is green (anchors intact); the clause wording is for the QA sweep that re-derives it.bridgeToolskeeps its one-argument call (pinned),wireBridgeToolsand the two native gates are untouched, and the full@objectstack/mcpsuite is green.Verification
Derived gates (
node scripts/pm/dispatch-gates.mjs --commands: 104 families; the command set is identical at9c3a187a, atcf3ec268and at the head7fb21ede; a second run at7fb21edeanswered 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 withdispatch-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, andspec check:skill-examples, exit 3: both read built output the fresh worktree did not have. After a fullpnpm 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.mjswas absent for the seconds in which a sibling spec rebuild had wipeddist/. 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; onlyapi-surface/ai.jsonandexport-origins/ai.jsonrewritten),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-configon the ten changed.tsfiles (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
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code