Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/26464-mcp-metadata-write-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/mcp': patch
---

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

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

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.

- **`@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.
- **`@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.
- **`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.
- **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`.

What to do: nothing for an existing host. A host that wants the build door passes `authoring` to `handleHttpRequest` and `grantedScopes` / `authoringTools` to `renderSkill`.
7 changes: 6 additions & 1 deletion content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,12 @@ drive this MCP server. Same word, different layer.
- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
`data:write`, and `actions:execute` scopes — tools outside the granted
scopes are not even registered for that session. API-key and session
callers get the full set, still permission-checked per call.
callers get the full set, still permission-checked per call. A fourth
scope, `metadata:write`, admits the **authoring tools** a hosted
deployment may inject (build and change the app's metadata as drafts a
person publishes in the UI); it is served only to a token that carries it,
never to an API key or a session, and every authoring call is still checked
against the caller's own metadata permission.
- **Action bodies run as trusted app code** once invoked (the `ai.exposed`
gate and `requiredPermissions` are checked at *invoke* time). Treat writing
an action as a code-review-worthy act — that's the real security boundary.
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ list/describe/query/aggregate/get, `data:write` covers create/update/delete, and
is **not registered at all**, so the SDK rejects it as an unknown tool — the
grant doubles as dispatch-time enforcement.

A fourth scope, `metadata:write`, gates an **authoring** family the host may
inject per request (`handleHttpRequest(request, { authoring })`): a
`McpAuthoringToolRegistry` plus the token principal and environment. Its tools
register only for a grant that names `metadata:write` — an API key or a
session, which carries no scopes, is served none — and every call into it
carries `{ door: 'mcp', principal, environmentId }` in the tool context, stamped
by the runtime from the host's inputs and never read from a request header.
Which tools the host injects is the host's business (the hosted build door
injects no publish or discard tool: authoring lands as drafts a person publishes
in the UI). The served `SKILL.md` names those tools only under the same grant.

`list_actions` enumerates each object's headless-invokable actions (script /
flow), filtered to what the author exposed and the caller may run: only actions
opted into the AI surface (`ai: { exposed: true }`, ADR-0011 / #2849) are
Expand Down
14 changes: 13 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ export { MCPServerPlugin } from './plugin.js';
export type { MCPServerPluginOptions } from './plugin.js';
export { MCPServerRuntime } from './mcp-server-runtime.js';
export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
export { registerObjectTools, registerActionTools } from './mcp-http-tools.js';
// [#26464] The host-injected authoring bridge behind `metadata:write`: the
// shapes a host passes to `handleHttpRequest` (`authoring`) and the door
// constant its access check branches on.
export { MCP_TOOL_DOOR } from './types.js';
export type {
McpToolDoor,
McpTokenPrincipal,
McpAuthoringToolContext,
McpAuthoringToolRegistry,
McpAuthoringInjection,
} from './types.js';
export { registerObjectTools, registerActionTools, grantsAuthoringTools } from './mcp-http-tools.js';
export type {
McpDataBridge,
McpObjectSummary,
Expand All @@ -30,6 +41,7 @@ export {
renderSkillMarkdown,
OBJECTSTACK_SKILL_NAME,
OBJECTSTACK_SKILL_DESCRIPTION,
OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING,
OBJECTSTACK_MCP_SERVER_NAME,
} from './skill-md.js';
export type { RenderSkillOptions } from './skill-md.js';
Expand Down
28 changes: 28 additions & 0 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
MCP_OAUTH_SCOPE_DATA_READ,
MCP_OAUTH_SCOPE_DATA_WRITE,
MCP_OAUTH_SCOPE_ACTIONS,
MCP_OAUTH_SCOPE_METADATA_WRITE,
} from '@objectstack/spec/ai';
// [#15942 / #16293] The confirmation member the `run_action` door accepts and
// forwards. Imported, never hand-spelled: the door that refuses (the runtime's
Expand Down Expand Up @@ -627,6 +628,33 @@ export function wireBridgeTools(
return registered;
}

/**
* [#26464] Whether a grant admits the AUTHORING family — the host-injected
* authoring `ToolRegistry` the HTTP runtime bridges (`registerAuthoringTools`
* in `mcp-server-runtime.ts`) and the skill section that names those tools
* (`skill-md.ts`). One predicate for both, so the skill never teaches a tool
* the server would refuse.
*
* The refusal shape is the one the three native families already use: a tool
* outside the grant is NOT registered, so `tools/list` omits it and the SDK
* answers `tools/call` on it as an unknown tool — the same answer
* `create_record` gets without `data:write`.
*
* Deliberately stricter than the native families' rule on the UNDEFINED side.
* For data and action tools `grantedScopes: undefined` (API-key / session
* provenance) means "not scope-limited" and keeps the full surface, because
* every one of those calls is bounded by the caller's own permissions and RLS.
* Authoring lands metadata, and the ruling that opens it over MCP (cloud
* ADR-0112, the maintainer's 2026-10-27 ruling) opens it to a developer whose
* TOKEN carries the consent — so the family registers only when a scope list
* is present AND names `metadata:write`. A key or a session, having consented
* to nothing, is served no authoring tool; widening that to the headless track
* is a decision for its own card, not a default this predicate takes.
*/
export function grantsAuthoringTools(grantedScopes: readonly string[] | undefined): boolean {
return Array.isArray(grantedScopes) && grantedScopes.includes(MCP_OAUTH_SCOPE_METADATA_WRITE);
}

/**
* Register the object-CRUD tool set on an {@link McpServer} — the throwaway
* per-request one on HTTP, the long-lived one behind stdio. All execution is
Expand Down
Loading
Loading