Skip to content

Commit 9bf6598

Browse files
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
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/mcp': patch
4+
---
5+
6+
feat(mcp): the HTTP MCP runtime bridges a host-injected authoring `ToolRegistry` behind a new `metadata:write` OAuth scope, with door provenance on every call
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A developer driving an MCP client (Claude Code, Claude Desktop, …) against a hosted environment could read and run it over `/api/v1/mcp` but never build in it: `handleHttpRequest` served the data and action tools only, and no OAuth scope existed under which a token could even ask for authoring. This change adds the scope and its first reader.
12+
13+
- **`@objectstack/spec` — `MCP_OAUTH_SCOPE_METADATA_WRITE` (`metadata:write`)** joins `data:read`, `data:write` and `actions:execute` in `MCP_OAUTH_SCOPES`, so the authorization server advertises it and the runtime's up-front scope check admits it. 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. **`MCP_OAUTH_SCOPE_CONSENT_COPY`** declares the one-sentence consent copy for every scope (the three existing sentences the Console already renders, plus the new one), keyed by `McpOauthScope` so a scope cannot be added without its sentence.
14+
- **`@objectstack/mcp` — `handleHttpRequest(request, { authoring })`** accepts an optional `McpAuthoringInjection`: a host's `McpAuthoringToolRegistry` plus the token principal and the environment. Its tools appear in `tools/list` only when the request's `grantedScopes` names `metadata:write`; without the scope they are not registered, so `tools/call` on one is answered exactly as `create_record` is without `data:write` (the SDK's unknown-tool error, one mechanism). The undefined grant of an API key or a session is served no authoring tool: authoring follows the token's consent. Every call into the registry receives a fresh `McpAuthoringToolContext` — `door: 'mcp'` (`MCP_TOOL_DOOR`), `actor.id`, `principal`, `environmentId` — built from the host's inputs alone, so no request header can claim another door, user or environment; the principal (and its `scopes`) is a frozen per-call copy, so a tool that writes into its context taints neither the next call nor the host's object. Tool names are the host's to keep unique: the SDK throws on a duplicate registration (a native name or a repeat within the registry) and the request fails with it. The runtime stays door-agnostic: which tools the host injects is the host's business.
15+
- **`renderSkillMarkdown({ grantedScopes, authoringTools })`** renders a Build section naming the injected tools, with the build loop (create → verify → draft, a person publishes in the UI) and the switched frontmatter description `OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING`, only under the same grant; it teaches `describe_object` before a change only when the grant also carries `data:read`, the scope that registers it; every other render is byte-identical to before.
16+
- **Unchanged.** The stdio transport's `bridgeTools` still calls its registry with one argument; the data and action tools register, refuse and answer as before. New exports: `MCP_TOOL_DOOR`, `grantsAuthoringTools`, and the types `McpToolDoor`, `McpTokenPrincipal`, `McpAuthoringToolContext`, `McpAuthoringToolRegistry`, `McpAuthoringInjection`.
17+
18+
What to do: nothing for an existing host. A host that wants the build door passes `authoring` to `handleHttpRequest` and `grantedScopes` / `authoringTools` to `renderSkill`.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,12 @@ drive this MCP server. Same word, different layer.
314314
- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
315315
`data:write`, and `actions:execute` scopes — tools outside the granted
316316
scopes are not even registered for that session. API-key and session
317-
callers get the full set, still permission-checked per call.
317+
callers get the full set, still permission-checked per call. A fourth
318+
scope, `metadata:write`, admits the **authoring tools** a hosted
319+
deployment may inject (build and change the app's metadata as drafts a
320+
person publishes in the UI); it is served only to a token that carries it,
321+
never to an API key or a session, and every authoring call is still checked
322+
against the caller's own metadata permission.
318323
- **Action bodies run as trusted app code** once invoked (the `ai.exposed`
319324
gate and `requiredPermissions` are checked at *invoke* time). Treat writing
320325
an action as a code-review-worthy act — that's the real security boundary.

‎packages/mcp/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ list/describe/query/aggregate/get, `data:write` covers create/update/delete, and
135135
is **not registered at all**, so the SDK rejects it as an unknown tool — the
136136
grant doubles as dispatch-time enforcement.
137137

138+
A fourth scope, `metadata:write`, gates an **authoring** family the host may
139+
inject per request (`handleHttpRequest(request, { authoring })`): a
140+
`McpAuthoringToolRegistry` plus the token principal and environment. Its tools
141+
register only for a grant that names `metadata:write` — an API key or a
142+
session, which carries no scopes, is served none — and every call into it
143+
carries `{ door: 'mcp', principal, environmentId }` in the tool context, stamped
144+
by the runtime from the host's inputs and never read from a request header.
145+
Which tools the host injects is the host's business (the hosted build door
146+
injects no publish or discard tool: authoring lands as drafts a person publishes
147+
in the UI). The served `SKILL.md` names those tools only under the same grant.
148+
138149
`list_actions` enumerates each object's headless-invokable actions (script /
139150
flow), filtered to what the author exposed and the caller may run: only actions
140151
opted into the AI surface (`ai: { exposed: true }`, ADR-0011 / #2849) are

‎packages/mcp/src/index.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,18 @@ export { MCPServerPlugin } from './plugin.js';
1414
export type { MCPServerPluginOptions } from './plugin.js';
1515
export { MCPServerRuntime } from './mcp-server-runtime.js';
1616
export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
17-
export { registerObjectTools, registerActionTools } from './mcp-http-tools.js';
17+
// [#26464] The host-injected authoring bridge behind `metadata:write`: the
18+
// shapes a host passes to `handleHttpRequest` (`authoring`) and the door
19+
// constant its access check branches on.
20+
export { MCP_TOOL_DOOR } from './types.js';
21+
export type {
22+
McpToolDoor,
23+
McpTokenPrincipal,
24+
McpAuthoringToolContext,
25+
McpAuthoringToolRegistry,
26+
McpAuthoringInjection,
27+
} from './types.js';
28+
export { registerObjectTools, registerActionTools, grantsAuthoringTools } from './mcp-http-tools.js';
1829
export type {
1930
McpDataBridge,
2031
McpObjectSummary,
@@ -30,6 +41,7 @@ export {
3041
renderSkillMarkdown,
3142
OBJECTSTACK_SKILL_NAME,
3243
OBJECTSTACK_SKILL_DESCRIPTION,
44+
OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING,
3345
OBJECTSTACK_MCP_SERVER_NAME,
3446
} from './skill-md.js';
3547
export type { RenderSkillOptions } from './skill-md.js';

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
MCP_OAUTH_SCOPE_DATA_READ,
4747
MCP_OAUTH_SCOPE_DATA_WRITE,
4848
MCP_OAUTH_SCOPE_ACTIONS,
49+
MCP_OAUTH_SCOPE_METADATA_WRITE,
4950
} from '@objectstack/spec/ai';
5051
// [#15942 / #16293] The confirmation member the `run_action` door accepts and
5152
// forwards. Imported, never hand-spelled: the door that refuses (the runtime's
@@ -627,6 +628,33 @@ export function wireBridgeTools(
627628
return registered;
628629
}
629630

631+
/**
632+
* [#26464] Whether a grant admits the AUTHORING family — the host-injected
633+
* authoring `ToolRegistry` the HTTP runtime bridges (`registerAuthoringTools`
634+
* in `mcp-server-runtime.ts`) and the skill section that names those tools
635+
* (`skill-md.ts`). One predicate for both, so the skill never teaches a tool
636+
* the server would refuse.
637+
*
638+
* The refusal shape is the one the three native families already use: a tool
639+
* outside the grant is NOT registered, so `tools/list` omits it and the SDK
640+
* answers `tools/call` on it as an unknown tool — the same answer
641+
* `create_record` gets without `data:write`.
642+
*
643+
* Deliberately stricter than the native families' rule on the UNDEFINED side.
644+
* For data and action tools `grantedScopes: undefined` (API-key / session
645+
* provenance) means "not scope-limited" and keeps the full surface, because
646+
* every one of those calls is bounded by the caller's own permissions and RLS.
647+
* Authoring lands metadata, and the ruling that opens it over MCP (cloud
648+
* ADR-0112, the maintainer's 2026-10-27 ruling) opens it to a developer whose
649+
* TOKEN carries the consent — so the family registers only when a scope list
650+
* is present AND names `metadata:write`. A key or a session, having consented
651+
* to nothing, is served no authoring tool; widening that to the headless track
652+
* is a decision for its own card, not a default this predicate takes.
653+
*/
654+
export function grantsAuthoringTools(grantedScopes: readonly string[] | undefined): boolean {
655+
return Array.isArray(grantedScopes) && grantedScopes.includes(MCP_OAUTH_SCOPE_METADATA_WRITE);
656+
}
657+
630658
/**
631659
* Register the object-CRUD tool set on an {@link McpServer} — the throwaway
632660
* per-request one on HTTP, the long-lived one behind stdio. All execution is

0 commit comments

Comments
 (0)