Repository navigation
Commit 9bf6598
feat(mcp): bridge a host-injected authoring ToolRegistry over HTTP behind a metadata:write OAuth scope (#26550)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBQgB4FMqdjyELZJBca9aoRG)_
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
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fd97351 commit 9bf6598
15 files changed
Lines changed: 1417 additions & 104 deletions
File tree
- .changeset
- content/docs/ai
- packages
- mcp
- src
- spec
- api-surface
- export-origins
- src/ai
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
314 | 314 | | |
315 | 315 | | |
316 | 316 | | |
317 | | - | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
318 | 323 | | |
319 | 324 | | |
320 | 325 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
135 | 135 | | |
136 | 136 | | |
137 | 137 | | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
138 | 149 | | |
139 | 150 | | |
140 | 151 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
18 | 29 | | |
19 | 30 | | |
20 | 31 | | |
| |||
30 | 41 | | |
31 | 42 | | |
32 | 43 | | |
| 44 | + | |
33 | 45 | | |
34 | 46 | | |
35 | 47 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
627 | 628 | | |
628 | 629 | | |
629 | 630 | | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
630 | 658 | | |
631 | 659 | | |
632 | 660 | | |
| |||
0 commit comments