From f716424429bdb3518a6ab08bcb078ba69439fce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:01:13 +0000 Subject: [PATCH 1/4] feat(mcp): bridge a host-injected authoring ToolRegistry over HTTP behind a metadata:write scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM --- .changeset/26464-mcp-metadata-write-scope.md | 18 + content/docs/ai/connect-mcp.mdx | 7 +- packages/mcp/README.md | 11 + packages/mcp/src/index.ts | 14 +- packages/mcp/src/mcp-http-tools.ts | 28 + ...mcp-server-runtime.authoring-scope.test.ts | 514 ++++++++++++++++++ packages/mcp/src/mcp-server-runtime.ts | 259 ++++++--- .../mcp/src/skill-md-surface-guard.test.ts | 75 ++- packages/mcp/src/skill-md.authoring.test.ts | 103 ++++ packages/mcp/src/skill-md.ts | 80 ++- packages/mcp/src/types.ts | 85 ++- packages/spec/api-surface/ai.json | 2 + packages/spec/export-origins/ai.json | 2 + packages/spec/src/ai/mcp-oauth-scopes.test.ts | 92 ++++ packages/spec/src/ai/mcp.zod.ts | 41 +- 15 files changed, 1243 insertions(+), 88 deletions(-) create mode 100644 .changeset/26464-mcp-metadata-write-scope.md create mode 100644 packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts create mode 100644 packages/mcp/src/skill-md.authoring.test.ts create mode 100644 packages/spec/src/ai/mcp-oauth-scopes.test.ts diff --git a/.changeset/26464-mcp-metadata-write-scope.md b/.changeset/26464-mcp-metadata-write-scope.md new file mode 100644 index 00000000000..524c8e58cb2 --- /dev/null +++ b/.changeset/26464-mcp-metadata-write-scope.md @@ -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 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; 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`. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 53ec2fbe196..78ff6091e62 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -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. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index a73b2ab9f8d..35f08a0da0f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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 diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 4ea10aff27b..1ed204a538b 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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, @@ -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'; diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 378ec1bff87..cff767857d4 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -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 @@ -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 diff --git a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts new file mode 100644 index 00000000000..36597402978 --- /dev/null +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -0,0 +1,514 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26464] The host-injected authoring family over Streamable HTTP, behind the + * `metadata:write` OAuth scope. + * + * What is pinned, and why each half matters as a security boundary: + * + * 1. The GATE. A `data:write` token, an `actions:execute` token, an empty + * grant and the undefined grant of an API key or a session can neither + * list nor call an authoring tool, and the registry is never touched. The + * refusal is the one the native families already use — the tool is not + * registered, so the SDK answers `tools/call` as an unknown tool — pinned + * here as the SAME JSON-RPC error code `create_record` gets without + * `data:write`, so the two families cannot drift into two dialects. + * 2. The PROVENANCE. Every call that does reach the registry carries + * `door: 'mcp'`, the host's token principal and the host's environment in + * its tool context, and `actor.id` is the principal's user. A request that + * spoofs every plausible provenance header changes nothing a tool sees: + * the context is built from `handleHttpRequest`'s inputs alone. + * 3. PARITY. The native tool surface is byte-identical with and without an + * injected registry when the scope is absent, and the long-lived stdio + * bridge (`bridgeTools`) still calls its registry with ONE argument — the + * shared registration body gained a seam, not a behaviour change. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, +} from '@objectstack/spec/ai'; +import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts'; + +import { MCPServerRuntime, registerAuthoringTools } from './mcp-server-runtime.js'; +import { grantsAuthoringTools } from './mcp-http-tools.js'; +import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js'; +import { MCP_TOOL_DOOR } from './types.js'; +import type { + McpAuthoringToolContext, + McpAuthoringToolRegistry, + McpTokenPrincipal, + ToolExecutionResult, + ToolRegistry, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const READ_TOOLS = ['list_objects', 'describe_object', 'validate_expression', 'query_records', 'get_record', 'aggregate_records']; +const WRITE_TOOLS = ['create_record', 'update_record', 'delete_record']; +const ACTION_TOOLS = ['list_actions', 'run_action']; +const NATIVE_TOOLS = [...READ_TOOLS, ...WRITE_TOOLS, ...ACTION_TOOLS].sort(); + +/** The shape a host injects: names the host chose, none of them native. */ +const AUTHORING_TOOLS: AIToolDefinition[] = [ + { + name: 'create_object', + description: 'Create a draft object in this environment.', + parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + }, + { + name: 'verify_build', + description: 'Validate the current drafts and report what the build refuses.', + parameters: { type: 'object', properties: {} }, + }, +]; +const AUTHORING_NAMES = AUTHORING_TOOLS.map((t) => t.name).sort(); + +const PRINCIPAL: McpTokenPrincipal = { + userId: 'usr_dev_1', + organizationId: 'org_acme', + clientId: 'claude-code', + scopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], +}; +const ENVIRONMENT_ID = 'env_hosted_7'; + +interface RecordedCall { + toolCall: ToolCallPart; + context: McpAuthoringToolContext; +} + +interface RecordingAuthoringRegistry extends McpAuthoringToolRegistry { + calls: RecordedCall[]; + /** How many arguments `execute` received on each call. */ + arities: number[]; +} + +function makeAuthoringRegistry( + tools: AIToolDefinition[] = AUTHORING_TOOLS, + behaviour: 'ok' | 'isError' | 'throw' = 'ok', +): RecordingAuthoringRegistry { + const calls: RecordedCall[] = []; + const arities: number[] = []; + return { + calls, + arities, + getAll: () => tools, + async execute(toolCall: ToolCallPart, context: McpAuthoringToolContext): Promise { + // eslint-disable-next-line prefer-rest-params + arities.push(arguments.length); + calls.push({ toolCall, context }); + if (behaviour === 'throw') throw new Error(`refused: ${toolCall.toolName}`); + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: `authored ${toolCall.toolName}` }, + ...(behaviour === 'isError' ? { isError: true } : {}), + } as ToolExecutionResult; + }, + }; +} + +function makeBridge(): McpDataBridge & McpActionBridge & { calls: any[] } { + const calls: any[] = []; + return { + calls, + async listObjects() { calls.push(['listObjects']); return [{ name: 'task', label: 'Task' }]; }, + async describeObject(name: string) { calls.push(['describeObject', name]); return { name }; }, + async query(object: string, opts: any) { calls.push(['query', object, opts]); return { object, records: [] }; }, + async get(object: string, id: string) { calls.push(['get', object, id]); return { id }; }, + async aggregate(object: string, opts: any) { calls.push(['aggregate', object, opts]); return []; }, + async create(object: string, data: any) { calls.push(['create', object, data]); return { object, id: 'n1' }; }, + async update(object: string, id: string, data: any) { calls.push(['update', object, id, data]); return { object, id }; }, + async remove(object: string, id: string) { calls.push(['remove', object, id]); return { object, id, success: true }; }, + async listActions() { calls.push(['listActions']); return [{ name: 'complete_task', objectName: 'task' }]; }, + async runAction(name: string, input: any) { calls.push(['runAction', name, input]); return { ok: true }; }, + }; +} + +interface CallOptions { + grantedScopes?: string[]; + registry?: McpAuthoringToolRegistry; + headers?: Record; + /** Omit the data bridge entirely (authoring-only host). */ + noBridge?: boolean; +} + +async function call(runtime: MCPServerRuntime, bridge: any, body: unknown, o: CallOptions = {}) { + const req = new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(o.headers ?? {}), + }, + body: JSON.stringify(body), + }); + const res = await runtime.handleHttpRequest(req, { + ...(o.noBridge ? {} : { bridge }), + parsedBody: body, + ...(o.grantedScopes ? { toolOptions: { grantedScopes: o.grantedScopes } } : {}), + ...(o.registry + ? { authoring: { registry: o.registry, principal: PRINCIPAL, environmentId: ENVIRONMENT_ID } } + : {}), + }); + return { status: res.status, json: res.status === 202 ? null : await res.json() }; +} + +async function listTools(runtime: MCPServerRuntime, bridge: any, o: CallOptions = {}): Promise { + const { json } = await call(runtime, bridge, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, o); + return (json.result?.tools ?? []).map((t: any) => t.name).sort(); +} + +function callTool(runtime: MCPServerRuntime, bridge: any, name: string, args: unknown, o: CallOptions = {}) { + return call(runtime, bridge, { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name, arguments: args } }, o); +} + +// --------------------------------------------------------------------------- + +describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + let registry: RecordingAuthoringRegistry; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + bridge = makeBridge(); + registry = makeAuthoringRegistry(); + }); + + it('grantsAuthoringTools admits only a scope list that names metadata:write', () => { + expect(grantsAuthoringTools([MCP_OAUTH_SCOPE_METADATA_WRITE])).toBe(true); + expect(grantsAuthoringTools(['openid', MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE])).toBe(true); + expect(grantsAuthoringTools([MCP_OAUTH_SCOPE_DATA_WRITE])).toBe(false); + expect(grantsAuthoringTools([MCP_OAUTH_SCOPE_ACTIONS])).toBe(false); + expect(grantsAuthoringTools([])).toBe(false); + // The undefined grant (API key / session) keeps the full NATIVE surface + // elsewhere; here it admits nothing — authoring follows the token's consent. + expect(grantsAuthoringTools(undefined)).toBe(false); + }); + + it('a data:write token can neither list nor call an authoring tool, and the registry is never touched', async () => { + const names = await listTools(runtime, bridge, { grantedScopes: [MCP_OAUTH_SCOPE_DATA_WRITE], registry }); + expect(names).toEqual([...WRITE_TOOLS].sort()); + + const { json } = await callTool(runtime, bridge, 'create_object', { name: 'invoice' }, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_WRITE], + registry, + }); + expect(json.error).toBeDefined(); + expect(json.result).toBeUndefined(); + expect(registry.calls).toEqual([]); + }); + + it('an actions:execute token can neither list nor call an authoring tool, and the registry is never touched', async () => { + const names = await listTools(runtime, bridge, { grantedScopes: [MCP_OAUTH_SCOPE_ACTIONS], registry }); + expect(names).toEqual([...ACTION_TOOLS].sort()); + + const { json } = await callTool(runtime, bridge, 'verify_build', {}, { + grantedScopes: [MCP_OAUTH_SCOPE_ACTIONS], + registry, + }); + expect(json.error).toBeDefined(); + expect(registry.calls).toEqual([]); + }); + + it('the undefined grant (API key / session) keeps the full native surface and no authoring tool', async () => { + const withRegistry = await listTools(runtime, bridge, { registry }); + const withoutRegistry = await listTools(runtime, bridge, {}); + expect(withRegistry).toEqual(NATIVE_TOOLS); + expect(withRegistry).toEqual(withoutRegistry); + + const { json } = await callTool(runtime, bridge, 'create_object', { name: 'invoice' }, { registry }); + expect(json.error).toBeDefined(); + expect(registry.calls).toEqual([]); + }); + + it('an empty grant registers nothing even with a registry injected (fail-closed)', async () => { + const { json } = await call(runtime, bridge, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, { + grantedScopes: [], + registry, + }); + expect(json.result).toBeUndefined(); + expect(json.error).toBeDefined(); + }); + + it('the refusal is the SAME answer create_record gets without data:write — one mechanism, not two', async () => { + // Control: a native write tool under a read-only grant. + const native = await callTool(runtime, bridge, 'create_record', { objectName: 'task', data: { title: 'x' } }, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ], + registry, + }); + // Subject: an authoring tool under a grant that lacks metadata:write. + const authoring = await callTool(runtime, bridge, 'create_object', { name: 'invoice' }, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS], + registry, + }); + expect(native.json.error).toBeDefined(); + expect(authoring.json.error).toBeDefined(); + expect(authoring.json.error.code).toBe(native.json.error.code); + // Both name the tool and nothing else: the SDK's unknown-tool answer. + expect(native.json.error.message).toMatch(/create_record/); + expect(authoring.json.error.message).toMatch(/create_object/); + expect(authoring.json.error.message.replace('create_object', 'create_record')).toBe(native.json.error.message); + expect(registry.calls).toEqual([]); + expect(bridge.calls.find((c: any[]) => c[0] === 'create')).toBeUndefined(); + }); + + it('a metadata:write token lists exactly the injected tools (no data scope ⇒ no data tool)', async () => { + const names = await listTools(runtime, bridge, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry }); + expect(names).toEqual(AUTHORING_NAMES); + }); + + it('metadata:write beside the data scopes lists the native surface AND the injected tools', async () => { + const names = await listTools(runtime, bridge, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS, MCP_OAUTH_SCOPE_METADATA_WRITE], + registry, + }); + expect(names).toEqual([...NATIVE_TOOLS, ...AUTHORING_NAMES].sort()); + }); + + it('a metadata:write token with no registry injected is byte-identical to today (nothing extra registered)', async () => { + const names = await listTools(runtime, bridge, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE], + }); + expect(names).toEqual([...READ_TOOLS].sort()); + }); + + it('an authoring-only host (no data bridge) still serves the injected tools under the scope', async () => { + const names = await listTools(runtime, bridge, { + grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], + registry, + noBridge: true, + }); + expect(names).toEqual(AUTHORING_NAMES); + }); +}); + +describe('[#26464] authoring tools over HTTP — door provenance in the tool context', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + let registry: RecordingAuthoringRegistry; + const GRANT = [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE]; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + bridge = makeBridge(); + registry = makeAuthoringRegistry(); + }); + + it('a call through the bridge reaches the handler with door: mcp, the token principal and the environment', async () => { + const { json } = await callTool(runtime, bridge, 'create_object', { name: 'invoice' }, { + grantedScopes: GRANT, + registry, + }); + expect(json.result.isError).toBeFalsy(); + expect(json.result.content[0].text).toBe('authored create_object'); + + expect(registry.calls).toHaveLength(1); + const { toolCall, context } = registry.calls[0]; + expect(toolCall).toMatchObject({ type: 'tool-call', toolName: 'create_object', input: { name: 'invoice' } }); + expect(context.door).toBe(MCP_TOOL_DOOR); + expect(context.door).toBe('mcp'); + expect(context.principal).toEqual(PRINCIPAL); + expect(context.environmentId).toBe(ENVIRONMENT_ID); + // A reader that knows only `ToolExecutionContext` runs as the token's user. + expect(context.actor).toEqual({ id: PRINCIPAL.userId }); + // The context is `(toolCall, context)` — two arguments, the second never omitted. + expect(registry.arities).toEqual([2]); + }); + + it('spoofed provenance headers are ignored: the context is built from the host inputs alone', async () => { + const spoof = { + 'x-door': 'studio', + 'x-objectstack-door': 'cli', + 'x-environment-id': 'env_victim', + 'x-user-id': 'usr_admin', + 'x-forwarded-user': 'usr_admin', + 'x-organization-id': 'org_victim', + 'x-principal': 'usr_admin', + }; + const { json } = await callTool(runtime, bridge, 'verify_build', {}, { + grantedScopes: GRANT, + registry, + headers: spoof, + }); + expect(json.result.isError).toBeFalsy(); + expect(registry.calls).toHaveLength(1); + const { context } = registry.calls[0]; + expect(context.door).toBe('mcp'); + expect(context.principal).toEqual(PRINCIPAL); + expect(context.environmentId).toBe(ENVIRONMENT_ID); + expect(context.actor.id).toBe(PRINCIPAL.userId); + // None of the spoofed values reaches any member of the context, under any key. + const serialised = JSON.stringify(context); + for (const value of Object.values(spoof)) expect(serialised).not.toContain(value); + }); + + it('every call gets a fresh context — a handler mutating its context cannot taint the next call', async () => { + const tainting: McpAuthoringToolRegistry = { + getAll: () => AUTHORING_TOOLS, + async execute(toolCall, context) { + registry.calls.push({ toolCall, context }); + (context as any).door = 'studio'; + (context as any).principal = { userId: 'usr_admin' }; + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: 'ok' }, + } as ToolExecutionResult; + }, + }; + const o: CallOptions = { grantedScopes: GRANT, registry: tainting }; + await callTool(runtime, bridge, 'verify_build', {}, o); + await callTool(runtime, bridge, 'verify_build', {}, o); + expect(registry.calls).toHaveLength(2); + // The second call's context was built fresh: the first handler's writes are not in it. + const second = registry.calls[1].context; + expect(second.door).toBe('studio'); // the handler's own write, on its own object … + // … so compare what the runtime HANDED it: the first call's object is not the second's, + expect(registry.calls[0].context).not.toBe(second); + // and the host's principal object was not replaced on the injection. + expect(PRINCIPAL.userId).toBe('usr_dev_1'); + }); + + it('an environment the host did not name is absent from the context, not undefined-valued', async () => { + const server = new McpServer({ name: 'x', version: '0' }); + const names = registerAuthoringTools(server, { registry, principal: PRINCIPAL }, GRANT); + expect(names).toEqual(AUTHORING_TOOLS.map((t) => t.name)); + // Drive one call through the SDK-registered handler via a second runtime + // path is not needed: the provenance builder is what `registerAuthoringTools` + // closes over, so assert through the HTTP door with no environment instead. + const rt = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + const body = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'verify_build', arguments: {} } }; + await rt.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }), + { parsedBody: body, toolOptions: { grantedScopes: GRANT }, authoring: { registry, principal: PRINCIPAL } }, + ); + expect(registry.calls).toHaveLength(1); + expect('environmentId' in registry.calls[0].context).toBe(false); + }); + + it('registerAuthoringTools registers nothing and returns [] for a grant that lacks the scope', () => { + for (const grant of [undefined, [], [MCP_OAUTH_SCOPE_DATA_WRITE], [MCP_OAUTH_SCOPE_ACTIONS]] as const) { + const server = new McpServer({ name: 'x', version: '0' }); + expect(registerAuthoringTools(server, { registry, principal: PRINCIPAL, environmentId: ENVIRONMENT_ID }, grant)).toEqual([]); + } + }); + + it('a registry result flagged isError, and a registry that throws, both come back as tool errors (as on stdio)', async () => { + const flagged = makeAuthoringRegistry(AUTHORING_TOOLS, 'isError'); + const a = await callTool(runtime, bridge, 'verify_build', {}, { grantedScopes: GRANT, registry: flagged }); + expect(a.json.result.isError).toBe(true); + expect(a.json.result.content[0].text).toBe('authored verify_build'); + + const throwing = makeAuthoringRegistry(AUTHORING_TOOLS, 'throw'); + const b = await callTool(runtime, bridge, 'verify_build', {}, { grantedScopes: GRANT, registry: throwing }); + expect(b.json.result.isError).toBe(true); + expect(b.json.result.content[0].text).toBe('refused: verify_build'); + // Both calls still carried the provenance before the registry answered. + expect(flagged.calls[0].context.door).toBe('mcp'); + expect(throwing.calls[0].context.door).toBe('mcp'); + }); +}); + +// --------------------------------------------------------------------------- +// stdio parity: the long-lived bridge is unchanged +// --------------------------------------------------------------------------- + +interface JsonRpcFrame { + jsonrpc: string; + id?: number; + result?: any; + error?: { code: number; message: string }; +} + +async function openStdio(server: McpServer) { + const serverStdin = new PassThrough(); + const serverStdout = new PassThrough(); + const transport = new StdioServerTransport(serverStdin, serverStdout); + await server.connect(transport); + let nextId = 1; + let buffered = ''; + const waiting = new Map void>(); + serverStdout.on('data', (chunk: Buffer | string) => { + buffered += String(chunk); + let nl = buffered.indexOf('\n'); + while (nl >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + nl = buffered.indexOf('\n'); + if (!line) continue; + let frame: JsonRpcFrame; + try { frame = JSON.parse(line) as JsonRpcFrame; } catch { continue; } + const resolve = typeof frame.id === 'number' ? waiting.get(frame.id) : undefined; + if (resolve && typeof frame.id === 'number') { waiting.delete(frame.id); resolve(frame); } + } + }); + return { + rpc(method: string, params?: unknown): Promise { + const id = nextId++; + return new Promise((resolve, reject) => { + const giveUp = setTimeout(() => reject(new Error(`stdio: no answer to "${method}" within 5s`)), 5_000); + waiting.set(id, (frame) => { clearTimeout(giveUp); resolve(frame); }); + serverStdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) })}\n`); + }); + }, + notify(method: string, params?: unknown) { + serverStdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`); + }, + async close() { await transport.close().catch(() => {}); }, + }; +} + +describe('[#26464] stdio parity — bridgeTools still calls its ToolRegistry with one argument', () => { + let session: Awaited> | undefined; + afterEach(async () => { await session?.close(); session = undefined; }); + + it('the long-lived bridge passes (toolCall) alone — no provenance, no second argument', async () => { + const calls: ToolCallPart[] = []; + const arities: number[] = []; + const legacy: ToolRegistry = { + getAll: () => AUTHORING_TOOLS, + async execute(toolCall: ToolCallPart): Promise { + // eslint-disable-next-line prefer-rest-params + arities.push(arguments.length); + calls.push(toolCall); + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: `executed ${toolCall.toolName}` }, + } as ToolExecutionResult; + }, + }; + const runtime = new MCPServerRuntime({ name: 'stdio-parity', version: '0.0.0' }); + runtime.bridgeTools(legacy); + session = await openStdio(runtime.server); + await session.rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'pin', version: '0' } }); + session.notify('notifications/initialized'); + + const listed = (await session.rpc('tools/list')).result?.tools ?? []; + expect(listed.map((t: any) => t.name).sort()).toEqual(AUTHORING_NAMES); + + const answer = await session.rpc('tools/call', { name: 'create_object', arguments: { name: 'invoice' } }); + expect(answer.result?.isError).toBeFalsy(); + expect(answer.result?.content?.[0]?.text).toBe('executed create_object'); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ type: 'tool-call', toolName: 'create_object', input: { name: 'invoice' } }); + expect(arities).toEqual([1]); + }); +}); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 3e128359cd2..6a28bbbe57a 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -3,11 +3,17 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import type { Logger, IMetadataService, AIToolDefinition } from '@objectstack/spec/contracts'; +import type { Logger, IMetadataService, AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts'; import type { Agent } from '@objectstack/spec/ai'; import { PLATFORM_PROVIDED_TOOL_NAMES } from '@objectstack/spec/system'; -import type { ToolRegistry, ToolExecutionResult } from './types.js'; -import { wireBridgeTools, thrownRefusalEnvelope } from './mcp-http-tools.js'; +import { MCP_TOOL_DOOR } from './types.js'; +import type { + McpAuthoringInjection, + McpAuthoringToolContext, + ToolRegistry, + ToolExecutionResult, +} from './types.js'; +import { wireBridgeTools, grantsAuthoringTools, thrownRefusalEnvelope } from './mcp-http-tools.js'; import type { McpDataBridge, McpActionBridge, @@ -1003,6 +1009,152 @@ function methodNotAllowedResponse(): Response { ); } +/** + * Register a single tool on an {@link McpServer} from an AIToolDefinition. + * + * The definition's JSON Schema `parameters` is forwarded as the tool's + * `inputSchema` (see {@link toolInputSchema} for why it must be converted + * first). Declaring it is what makes the SDK hand the call's arguments to + * this handler at all: `McpServer.executeToolHandler()` branches on + * `tool.inputSchema` and invokes a schema-less tool as `handler(extra)` — + * with no `arguments` anywhere on that `extra` (`RequestHandlerExtra` has no + * such member), which is why a bridged tool used to execute with `{}` no + * matter what the client sent. + * + * The safety annotations come from {@link safetyAnnotations}, which reads + * what the definition DECLARES and omits the hints it cannot source; the + * name-derived `readOnlyHint: false, destructiveHint: false` this call used + * to assert over every unlisted tool is gone. `openWorldHint` now goes + * through {@link worldAnnotation} on the same principle — asserted `false` + * for platform-registered names, omitted for everyone else — but read that + * function before assuming the two omissions cost the same thing: the SDK + * defaults them in opposite directions. + * + * [#26464] `execute` is the ONE seam between the two bridges that share this + * body: the long-lived stdio bridge passes the registry's own + * `execute(call)`, one argument, as it always has; the per-request HTTP + * authoring bridge ({@link registerAuthoringTools}) passes a closure that + * adds the door provenance as the second argument. Everything a client can + * observe — the registration options, the call envelope, the error shape — + * is the same code for both. + */ +function registerToolFromDefinition( + server: McpServer, + tool: AIToolDefinition, + execute: (toolCall: ToolCallPart) => Promise, + logger: Logger | undefined, +): void { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: toolInputSchema(tool, logger), + annotations: { + // Only the hints these two can source — a tool that declares nothing + // and is not a platform name is served neither set, so the MCP + // defaults apply. Those defaults are NOT symmetrical between them; + // {@link worldAnnotation} is where that is written down. + ...safetyAnnotations(tool), + ...worldAnnotation(tool), + }, + }, + async (args) => { + try { + const result = await execute({ + type: 'tool-call', + toolCallId: `mcp-${tool.name}-${Date.now()}`, + toolName: tool.name, + input: args, + }); + + const outputText = MCPServerRuntime.formatToolOutput(result); + + if (result.isError) { + return { + content: [{ type: 'text' as const, text: outputText }], + isError: true, + }; + } + + return { + content: [{ type: 'text' as const, text: outputText }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger?.warn(`[MCP] Tool "${tool.name}" execution failed:`, { error: message }); + return { + content: [{ type: 'text' as const, text: message }], + isError: true, + }; + } + }, + ); +} + +/** + * [#26464] Bridge a host-injected authoring registry onto ONE per-request HTTP + * server, behind the `metadata:write` scope. + * + * ## The gate + * + * Registers nothing unless {@link grantsAuthoringTools} admits the request's + * grant — a scope list that names `metadata:write`. A `data:write` or + * `actions:execute` token, an empty grant, and the undefined grant of an API + * key or a session all register nothing, so `tools/list` omits the family and + * `tools/call` on one of its tools is answered by the SDK as an unknown tool: + * the same refusal `create_record` gets without `data:write`, from the same + * mechanism, not a second one. + * + * ## The provenance + * + * Every call into the registry carries a fresh {@link McpAuthoringToolContext} + * built HERE from the host's inputs and from nothing else: `door` is the + * runtime's own constant, `principal` and `environmentId` are what the host + * resolved and passed to `handleHttpRequest`, and `actor.id` is the + * principal's user so a reader that knows only `ToolExecutionContext` still + * runs as that user. The inbound `Request` is not in scope of this function, + * so no header can reach the context — a client sending `x-door`, + * `x-environment-id` or any other spelling changes nothing a tool sees. The + * host's access check and tier mapping read the door from the context; that is + * the whole reason it is stamped on the server side of the transport. + * + * ## What stays the host's + * + * Which tools the registry holds (cloud ADR-0112 injects no publish or + * discard tool: authoring lands as drafts a human publishes in the UI), how + * each one checks the caller's own metadata permission, and which version of + * the authoring whitelist it answers. This runtime is door-agnostic; it + * registers what it is handed and stamps where the call came from. + * + * @returns the names registered on this call — empty when the grant admits + * none, so a host's report of its surface reads the same source the + * server does. + */ +export function registerAuthoringTools( + server: McpServer, + authoring: McpAuthoringInjection, + grantedScopes: readonly string[] | undefined, + logger?: Logger, +): string[] { + const registered: string[] = []; + if (!grantsAuthoringTools(grantedScopes)) return registered; + + const { registry, principal, environmentId } = authoring; + const provenance = (): McpAuthoringToolContext => ({ + door: MCP_TOOL_DOOR, + actor: { id: principal.userId }, + principal, + ...(environmentId !== undefined ? { environmentId } : {}), + }); + + for (const tool of registry.getAll()) { + registerToolFromDefinition(server, tool, (call) => registry.execute(call, provenance()), logger); + registered.push(tool.name); + } + logger?.debug(`[MCP] Bridged ${registered.length} authoring tools for metadata:write (${registered.join(', ')})`); + return registered; +} + /** * MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol. * @@ -1125,7 +1277,10 @@ export class MCPServerRuntime { const logger = this.config.logger; for (const tool of tools) { - this.registerToolFromDefinition(tool, toolRegistry); + // The long-lived (stdio) bridge: the registry's own `execute(call)`, + // one argument, exactly as before [#26464] moved the registration body + // out of this class so the HTTP authoring bridge could share it. + registerToolFromDefinition(this.mcpServer, tool, (call) => toolRegistry.execute(call), logger); } logger?.info(`[MCP] Bridged ${tools.length} tools from ToolRegistry`); @@ -1169,77 +1324,6 @@ export class MCPServerRuntime { return registered; } - /** - * Register a single tool on the MCP server from an AIToolDefinition. - * - * The definition's JSON Schema `parameters` is forwarded as the tool's - * `inputSchema` (see {@link toolInputSchema} for why it must be converted - * first). Declaring it is what makes the SDK hand the call's arguments to - * this handler at all: `McpServer.executeToolHandler()` branches on - * `tool.inputSchema` and invokes a schema-less tool as `handler(extra)` — - * with no `arguments` anywhere on that `extra` (`RequestHandlerExtra` has no - * such member), which is why a bridged tool used to execute with `{}` no - * matter what the client sent. - * - * The safety annotations come from {@link safetyAnnotations}, which reads - * what the definition DECLARES and omits the hints it cannot source; the - * name-derived `readOnlyHint: false, destructiveHint: false` this call used - * to assert over every unlisted tool is gone. `openWorldHint` now goes - * through {@link worldAnnotation} on the same principle — asserted `false` - * for platform-registered names, omitted for everyone else — but read that - * function before assuming the two omissions cost the same thing: the SDK - * defaults them in opposite directions. - */ - private registerToolFromDefinition(tool: AIToolDefinition, toolRegistry: ToolRegistry): void { - const logger = this.config.logger; - - this.mcpServer.registerTool( - tool.name, - { - description: tool.description, - inputSchema: toolInputSchema(tool, logger), - annotations: { - // Only the hints these two can source — a tool that declares nothing - // and is not a platform name is served neither set, so the MCP - // defaults apply. Those defaults are NOT symmetrical between them; - // {@link worldAnnotation} is where that is written down. - ...safetyAnnotations(tool), - ...worldAnnotation(tool), - }, - }, - async (args) => { - try { - const result = await toolRegistry.execute({ - type: 'tool-call', - toolCallId: `mcp-${tool.name}-${Date.now()}`, - toolName: tool.name, - input: args, - }); - - const outputText = MCPServerRuntime.formatToolOutput(result); - - if (result.isError) { - return { - content: [{ type: 'text' as const, text: outputText }], - isError: true, - }; - } - - return { - content: [{ type: 'text' as const, text: outputText }], - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger?.warn(`[MCP] Tool "${tool.name}" execution failed:`, { error: message }); - return { - content: [{ type: 'text' as const, text: message }], - isError: true, - }; - } - }, - ); - } - // ── Resource Bridge ──────────────────────────────────────────── /** @@ -1662,9 +1746,14 @@ export class MCPServerRuntime { * via `bridge`; the runtime wires that bridge to the existing permission + * RLS path, so an external agent can never exceed the key's authority. * - * Only these native tools are exposed here — the internal AI/authoring - * toolRegistry (which can mutate metadata) is deliberately NOT bridged onto - * the external surface. + * The internal AI/authoring `ToolRegistry` the stdio transport bridges is + * NOT reachable from here by default. [#26464] A host may inject an authoring + * registry for the request (`opts.authoring`); its tools are registered only + * when the token's grant names `metadata:write` (cloud ADR-0112, the + * maintainer's 2026-10-27 ruling), through {@link registerAuthoringTools}, + * which also stamps the door provenance on every call into it. A request + * without that scope — including the undefined grant of an API key or a + * session — serves exactly the native tools above, as before. * * **Prompts (#3905).** When the bridge can also read this environment's * `skill` metadata (`listSkills`), the server additionally serves the MCP @@ -1680,7 +1769,9 @@ export class MCPServerRuntime { * @param opts.bridge Principal-bound data (+ optional action / skill) accessor (required to expose tools). * @param opts.parsedBody Pre-parsed JSON-RPC body (the dispatcher already read it). * @param opts.authInfo Optional auth info forwarded to message handlers. - * @param opts.toolOptions Tool exposure options (system objects, query limits). + * @param opts.toolOptions Tool exposure options (system objects, query limits, the token's `grantedScopes`). + * @param opts.authoring [#26464] The host's authoring registry plus the token principal and environment + * that become each call's provenance; registered only for a `metadata:write` grant. */ async handleHttpRequest( request: Request, @@ -1689,6 +1780,7 @@ export class MCPServerRuntime { parsedBody?: unknown; authInfo?: unknown; toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions; + authoring?: McpAuthoringInjection; } = {}, ): Promise { // [#25759] This transport answers POST and nothing else. It is stateless @@ -1754,6 +1846,15 @@ export class MCPServerRuntime { wireBridgeTools(server, opts.bridge, opts.toolOptions); } + if (opts.authoring) { + // [#26464] The host's authoring family, behind `metadata:write`. The + // grant is read from the same `toolOptions.grantedScopes` the native + // families read, and the provenance from `opts.authoring` alone — the + // `request` is deliberately not passed, so nothing it carries can reach + // a tool's context. + registerAuthoringTools(server, opts.authoring, opts.toolOptions?.grantedScopes, this.config.logger); + } + const transport = new WebStandardStreamableHTTPServerTransport({ // Stateless: no session id, single request/response. sessionIdGenerator: undefined, diff --git a/packages/mcp/src/skill-md-surface-guard.test.ts b/packages/mcp/src/skill-md-surface-guard.test.ts index 778987cea16..ada80b9783e 100644 --- a/packages/mcp/src/skill-md-surface-guard.test.ts +++ b/packages/mcp/src/skill-md-surface-guard.test.ts @@ -1,9 +1,17 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, +} from '@objectstack/spec/ai'; +import type { AIToolDefinition } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; +import type { McpAuthoringToolRegistry, ToolExecutionResult } from './types.js'; import { renderSkillMarkdown } from './skill-md.js'; /** @@ -64,7 +72,35 @@ function makeFullBridge(): McpDataBridge & McpActionBridge { }; } -async function listRegisteredToolNames(): Promise { +/** + * [#26464] A host-injected authoring registry, as the HTTP surface receives + * one. Names deliberately NOT in the data skill, so the only way the rendered + * skill can name them is the authoring section. + */ +const AUTHORING_TOOLS: AIToolDefinition[] = [ + { name: 'create_object', description: 'Create a draft object.', parameters: { type: 'object', properties: {} } }, + { name: 'verify_build', description: 'Validate the drafts.', parameters: { type: 'object', properties: {} } }, +]; + +function makeAuthoringRegistry(): McpAuthoringToolRegistry { + return { + getAll: () => AUTHORING_TOOLS, + async execute(toolCall): Promise { + return { type: 'tool-result', toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, output: { type: 'text', value: '' } } as ToolExecutionResult; + }, + }; +} + +const EVERY_SCOPE = [ + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, +]; + +async function listRegisteredToolNames( + extra: { grantedScopes?: string[]; registry?: McpAuthoringToolRegistry } = {}, +): Promise { const runtime = new MCPServerRuntime({ name: 'skill-guard', version: '0.0.0' }); const body = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; const res = await runtime.handleHttpRequest( @@ -76,7 +112,14 @@ async function listRegisteredToolNames(): Promise { }, body: JSON.stringify(body), }), - { bridge: makeFullBridge(), parsedBody: body }, + { + bridge: makeFullBridge(), + parsedBody: body, + ...(extra.grantedScopes ? { toolOptions: { grantedScopes: extra.grantedScopes } } : {}), + ...(extra.registry + ? { authoring: { registry: extra.registry, principal: { userId: 'usr_guard' }, environmentId: 'env_guard' } } + : {}), + }, ); const json: any = await res.json(); const tools: Array<{ name: string }> = json?.result?.tools ?? []; @@ -93,4 +136,32 @@ describe('SKILL.md ↔ native tool surface drift guard', () => { const undocumented = registered.filter((name) => !md.includes(name)); expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill-md.ts: ${undocumented.join(', ')}`).toEqual([]); }); + + it('[#26464] documents every tool the surface registers for a metadata:write token with an injected registry', async () => { + const registry = makeAuthoringRegistry(); + const registered = await listRegisteredToolNames({ grantedScopes: EVERY_SCOPE, registry }); + // Sanity: native surface plus the two injected tools. + expect(registered.length).toBeGreaterThanOrEqual(11); + for (const t of AUTHORING_TOOLS) expect(registered).toContain(t.name); + + // The skill rendered for the SAME grant and the SAME registry names every one of them … + const md = renderSkillMarkdown({ grantedScopes: EVERY_SCOPE, authoringTools: registry.getAll() }); + const undocumented = registered.filter((name) => !md.includes(name)); + expect(undocumented, `tools registered but missing from the authoring SKILL.md: ${undocumented.join(', ')}`).toEqual([]); + + // … and the skill rendered WITHOUT the scope names none of the injected ones, + // exactly as the surface registers none of them for that grant. + const withoutScope = await listRegisteredToolNames({ + grantedScopes: EVERY_SCOPE.filter((s) => s !== MCP_OAUTH_SCOPE_METADATA_WRITE), + registry, + }); + const mdWithoutScope = renderSkillMarkdown({ + grantedScopes: EVERY_SCOPE.filter((s) => s !== MCP_OAUTH_SCOPE_METADATA_WRITE), + authoringTools: registry.getAll(), + }); + for (const t of AUTHORING_TOOLS) { + expect(withoutScope).not.toContain(t.name); + expect(mdWithoutScope).not.toContain(t.name); + } + }); }); diff --git a/packages/mcp/src/skill-md.authoring.test.ts b/packages/mcp/src/skill-md.authoring.test.ts new file mode 100644 index 00000000000..d5bf62b0107 --- /dev/null +++ b/packages/mcp/src/skill-md.authoring.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26464] The served `SKILL.md` names the host's authoring tools ONLY when the + * caller's grant admits the family — the same `grantsAuthoringTools` predicate + * that registers the tools over HTTP — and only when the host handed it a tool + * to name. Everything else renders the data skill byte-for-byte as before, so + * an agent is never taught a tool the server would refuse, and a reader of + * the public skill route sees no authoring section at all. + */ + +import { describe, it, expect } from 'vitest'; +import { + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, +} from '@objectstack/spec/ai'; + +import { + renderSkillMarkdown, + OBJECTSTACK_SKILL_DESCRIPTION, + OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, +} from './skill-md.js'; + +const TOOLS = [ + { name: 'create_object', description: 'Create a draft object\n in this environment,\n as a draft.' }, + { name: 'verify_build', description: 'Validate the drafts.' }, +]; +const SECTION = '## Build — authoring tools'; + +function frontmatterDescription(md: string): string { + const m = md.match(/^---\n([\s\S]*?)\n---/); + if (!m) throw new Error('no frontmatter'); + const line = m[1].split('\n').find((l) => l.startsWith('description:')); + if (!line) throw new Error('no description'); + return line.slice('description:'.length).trim(); +} + +describe('[#26464] renderSkillMarkdown — the authoring section follows the grant', () => { + const baseline = renderSkillMarkdown(); + + it('renders no authoring section and the data description by default', () => { + expect(baseline).not.toContain(SECTION); + expect(baseline).not.toContain(MCP_OAUTH_SCOPE_METADATA_WRITE); + expect(frontmatterDescription(baseline)).toBe(OBJECTSTACK_SKILL_DESCRIPTION); + }); + + it('a data/actions grant with tools handed in renders the data skill byte-identically', () => { + const md = renderSkillMarkdown({ + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS], + authoringTools: TOOLS, + }); + expect(md).toBe(baseline); + }); + + it('the undefined grant (API key / session / public skill route) with tools handed in renders the data skill byte-identically', () => { + expect(renderSkillMarkdown({ authoringTools: TOOLS })).toBe(baseline); + }); + + it('metadata:write with no tool to name renders the data skill byte-identically', () => { + expect(renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE] })).toBe(baseline); + expect(renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], authoringTools: [] })).toBe(baseline); + }); + + it('metadata:write with tools renders the section, names every tool, and switches the description', () => { + const md = renderSkillMarkdown({ + mcpUrl: 'https://acme.objectos.app/api/v1/mcp', + grantedScopes: ['openid', MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE], + authoringTools: TOOLS, + }); + expect(md).toContain(SECTION); + expect(md).toContain(`\`${MCP_OAUTH_SCOPE_METADATA_WRITE}\``); + for (const t of TOOLS) expect(md).toContain(`- **${t.name}** — `); + // A multi-line description collapses to one bullet line. + expect(md).toContain('- **create_object** — Create a draft object in this environment, as a draft.'); + // The build loop and the landing posture the ruling fixed: drafts a person publishes. + expect(md).toContain('create → verify → draft'); + expect(md).toMatch(/drafts/); + expect(md).toMatch(/publishes the drafts in the UI/); + // Still bounded by the caller's own metadata permission — the scope is never a bypass. + expect(md).toMatch(/scope AND your own metadata permissions/); + expect(frontmatterDescription(md)).toBe(OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING); + expect(OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING.startsWith(OBJECTSTACK_SKILL_DESCRIPTION)).toBe(true); + expect(OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING).not.toContain('\n'); + }); + + it('the section is placed after the native tools and before the conventions, with the data content untouched', () => { + const md = renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], authoringTools: TOOLS }); + const tools = md.indexOf('## Tools'); + const build = md.indexOf(SECTION); + const conventions = md.indexOf('## Conventions & gotchas'); + expect(tools).toBeGreaterThan(0); + expect(build).toBeGreaterThan(tools); + expect(conventions).toBeGreaterThan(build); + // Removing the section (and the one description change) gives back the baseline. + const withoutSection = md.replace(md.slice(build, conventions), '').replace( + OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING, + OBJECTSTACK_SKILL_DESCRIPTION, + ); + expect(withoutSection).toBe(baseline); + }); +}); diff --git a/packages/mcp/src/skill-md.ts b/packages/mcp/src/skill-md.ts index 4f09d770e24..6a8244fd97b 100644 --- a/packages/mcp/src/skill-md.ts +++ b/packages/mcp/src/skill-md.ts @@ -30,6 +30,10 @@ * cloud) by calling {@link renderSkillMarkdown} with the env's MCP URL. */ +import type { AIToolDefinition } from '@objectstack/spec/contracts'; +import { MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai'; +import { grantsAuthoringTools } from './mcp-http-tools.js'; + /** Skill identity (mirrors the `SKILL.md` YAML frontmatter). */ export const OBJECTSTACK_SKILL_NAME = 'objectstack'; /** @@ -50,6 +54,17 @@ export const OBJECTSTACK_SKILL_DESCRIPTION = 'read and filter records, and create/update/delete under your own ' + 'permissions and row-level security. Use when the user wants to inspect or ' + 'change data in their ObjectStack environment.'; +/** + * [#26464] The frontmatter description served when the skill also teaches the + * authoring tools (see {@link RenderSkillOptions.authoringTools}). A + * skills-capable agent decides whether to load a skill from this one line, so + * a skill that teaches building must SAY so here, or an agent asked to build + * an object never opens it. The data sentence stays verbatim in front. + */ +export const OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING = + OBJECTSTACK_SKILL_DESCRIPTION + + ` With the ${MCP_OAUTH_SCOPE_METADATA_WRITE} scope, also build and change the ` + + 'app\'s metadata (objects, fields, views, flows) as drafts a person publishes.'; export interface RenderSkillOptions { /** @@ -60,10 +75,63 @@ export interface RenderSkillOptions { mcpUrl?: string; /** Optional human label for the environment, shown in the intro. */ envName?: string; + /** + * [#26464] The OAuth scopes the caller's token carries. The authoring + * section renders only when they admit the family (`grantsAuthoringTools`: + * a scope list naming `metadata:write`) AND {@link authoringTools} names at + * least one tool — the same predicate that registers the family on the HTTP + * surface, so the skill never teaches a tool the server would refuse. + * Undefined (an API key, a session, or a host that serves the skill + * publicly) renders the data skill exactly as before. + */ + grantedScopes?: readonly string[]; + /** + * The authoring tools the host injects for this caller (name and + * description, as `tools/list` shows them). Which tools these are is the + * host's business — this renderer names what it is handed and nothing else. + */ + authoringTools?: ReadonlyArray>; } const URL_PLACEHOLDER = ''; // e.g. https://.objectos.app/api/v1/mcp +/** One line for a tool's description — a bullet in the skill cannot hold a paragraph break. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** + * [#26464] The authoring section, rendered only for a caller whose grant + * admits the family and whose host injected at least one authoring tool. It + * teaches the build loop the maintainer ruled for hosted MCP (cloud + * ADR-0112): create, verify, and leave a draft that a person publishes in the + * UI — from the same source as the data loop, so the client model learns both + * the same way. Tool names come from the host; none is hard-coded here. + */ +function renderAuthoringSection(tools: ReadonlyArray>): string { + const bullets = tools.map((t) => `- **${t.name}** — ${oneLine(t.description)}`).join('\n'); + return ` +## Build — authoring tools (\`${MCP_OAUTH_SCOPE_METADATA_WRITE}\`) + +Your connection also carries the **\`${MCP_OAUTH_SCOPE_METADATA_WRITE}\`** scope, so this +environment exposes its **authoring tools**: you can build and change the app's +own metadata — objects, fields, views, flows and the rest — not only its +records. Authoring lands as **drafts**: nothing you write is published, and a +person reviews and publishes the drafts in the UI. Every authoring call is +checked against the scope AND your own metadata permissions, so a refused +authoring call means you may not author that here, not that the connection is +broken. + +The build loop is **create → verify → draft**: create or change the metadata, +verify it (the environment validates what you wrote and reports what it +refuses, so read the result before the next step), and leave it as a draft for +a person to publish. Discover first here too — \`describe_object\` on an object +before you change it. + +${bullets} +`; +} + /** * Render the full `SKILL.md` (YAML frontmatter + body). Pass the env's MCP URL * to produce a ready-to-install skill; the body is otherwise generic. @@ -74,10 +142,18 @@ export function renderSkillMarkdown(options: RenderSkillOptions = {}): string { const intro = envLabel ? `This skill connects you to the **${envLabel}** ObjectStack environment.` : 'This skill connects you to an ObjectStack environment.'; + // [#26464] The authoring section exists only for a grant the HTTP surface + // would honour, and only when there is a tool to name — one predicate, + // shared with `registerAuthoringTools`. + const authoringTools = + grantsAuthoringTools(options.grantedScopes) && options.authoringTools && options.authoringTools.length > 0 + ? options.authoringTools + : undefined; + const description = authoringTools ? OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING : OBJECTSTACK_SKILL_DESCRIPTION; return `--- name: ${OBJECTSTACK_SKILL_NAME} -description: ${OBJECTSTACK_SKILL_DESCRIPTION} +description: ${description} --- # ObjectStack @@ -188,7 +264,7 @@ create/update payload. screen, which you fill with another \`resume_run\`. Only the user whose call started the run can resume it, under the same gates as \`run_action\` (including \`confirm: true\` for an action flagged \`requiresConfirmation\`). - +${authoringTools ? renderAuthoringSection(authoringTools) : ''} ## Conventions & gotchas - **Permissions/RLS apply to every call.** Fewer rows than expected, or a diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index ba606c96a30..e53a7cfaa40 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -1,6 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { AIToolDefinition, ToolCallPart, ToolResultPart } from '@objectstack/spec/contracts'; +import type { + AIToolDefinition, + ToolCallPart, + ToolExecutionContext, + ToolResultPart, +} from '@objectstack/spec/contracts'; /** * Minimal ToolRegistry interface consumed by the MCP bridge. @@ -22,3 +27,81 @@ export interface ToolRegistry { export interface ToolExecutionResult extends ToolResultPart { isError?: boolean; } + +// ── Host-injected authoring registry (HTTP, `metadata:write`) ──────────────── + +/** + * The door every call the HTTP MCP runtime makes into a host-injected + * authoring registry is stamped with. ONE spelling, owned by the runtime: the + * host's access check and tier mapping branch on it, and it is written by the + * runtime at the call site — never copied from anything the request carried, + * so no header a client sends can claim another door. + */ +export const MCP_TOOL_DOOR = 'mcp' as const; +export type McpToolDoor = typeof MCP_TOOL_DOOR; + +/** + * The principal an OAuth access token resolves to, as the HOST verified it. + * The runtime copies it into every authoring call's context verbatim and adds + * nothing from the request: the host resolved the token, so the host is the + * only honest source. + */ +export interface McpTokenPrincipal { + /** The user the access token was issued to — the human the agent acts for. */ + userId: string; + /** The organization the token's consent bound, when it bound one. */ + organizationId?: string; + /** The OAuth client the token was issued to, when the host knows it. */ + clientId?: string; + /** The OAuth scopes the token carries, as the host verified them. */ + scopes?: readonly string[]; +} + +/** + * The per-call context the runtime hands a host-injected authoring tool — a + * {@link ToolExecutionContext} whose identity half is filled from the token + * and which carries the door provenance the host's access check and tier + * mapping read. The three provenance members are stamped by the runtime on + * every call (`door` is always {@link MCP_TOOL_DOOR}, `principal` and + * `environmentId` are the host's own inputs to `handleHttpRequest`); none is + * ever read from a request header, so a spoofed header changes nothing here. + */ +export interface McpAuthoringToolContext extends ToolExecutionContext { + /** Always {@link MCP_TOOL_DOOR}: this call came through the MCP door. */ + door: McpToolDoor; + /** The token's user, as the actor a `ToolExecutionContext` reader expects (RLS on, as that user). */ + actor: { id: string }; + /** The token principal, verbatim from the host. */ + principal: McpTokenPrincipal; + /** The environment the request was dispatched for, verbatim from the host. */ + environmentId?: string; +} + +/** + * A host-injected authoring `ToolRegistry`: the same shape as + * {@link ToolRegistry}, except that `execute` takes the per-call + * {@link McpAuthoringToolContext} beside the call — authoring tools act on an + * environment's metadata as a principal, so a call without its context has no + * identity to act as. + */ +export interface McpAuthoringToolRegistry { + /** Return the authoring tool definitions the host chose to expose over MCP. */ + getAll(): AIToolDefinition[]; + /** Execute one authoring tool call under the door provenance the runtime stamped. */ + execute(toolCall: ToolCallPart, context: McpAuthoringToolContext): Promise; +} + +/** + * What a host passes to `handleHttpRequest` to bridge its authoring registry + * for one request. The registry's tools register only when the request's + * token carries `metadata:write`; `principal` and `environmentId` become the + * provenance of every call into it. + */ +export interface McpAuthoringInjection { + /** The authoring registry — which tools it holds is the host's business. */ + registry: McpAuthoringToolRegistry; + /** The token principal the host resolved for this request. */ + principal: McpTokenPrincipal; + /** The environment the host dispatched this request for. */ + environmentId?: string; +} diff --git a/packages/spec/api-surface/ai.json b/packages/spec/api-surface/ai.json index 6cf1090d8eb..8abf02671fb 100644 --- a/packages/spec/api-surface/ai.json +++ b/packages/spec/api-surface/ai.json @@ -93,8 +93,10 @@ "MCP_AGENT_PERMISSION_SET_WRITE (const)", "MCP_OAUTH_SCOPES (const)", "MCP_OAUTH_SCOPE_ACTIONS (const)", + "MCP_OAUTH_SCOPE_CONSENT_COPY (const)", "MCP_OAUTH_SCOPE_DATA_READ (const)", "MCP_OAUTH_SCOPE_DATA_WRITE (const)", + "MCP_OAUTH_SCOPE_METADATA_WRITE (const)", "McpOauthScope (type)", "MessageContent (type)", "MessageContentParsed (type)", diff --git a/packages/spec/export-origins/ai.json b/packages/spec/export-origins/ai.json index 07631f30889..f92853b035c 100644 --- a/packages/spec/export-origins/ai.json +++ b/packages/spec/export-origins/ai.json @@ -93,8 +93,10 @@ "MCP_AGENT_PERMISSION_SET_WRITE": "src/ai/mcp.zod.ts#MCP_AGENT_PERMISSION_SET_WRITE (const)", "MCP_OAUTH_SCOPES": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPES (const)", "MCP_OAUTH_SCOPE_ACTIONS": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPE_ACTIONS (const)", + "MCP_OAUTH_SCOPE_CONSENT_COPY": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPE_CONSENT_COPY (const)", "MCP_OAUTH_SCOPE_DATA_READ": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPE_DATA_READ (const)", "MCP_OAUTH_SCOPE_DATA_WRITE": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPE_DATA_WRITE (const)", + "MCP_OAUTH_SCOPE_METADATA_WRITE": "src/ai/mcp.zod.ts#MCP_OAUTH_SCOPE_METADATA_WRITE (const)", "McpOauthScope": "src/ai/mcp.zod.ts#McpOauthScope (type)", "MessageContent": "src/ai/conversation.zod.ts#MessageContent (type)", "MessageContentParsed": "src/ai/conversation.zod.ts#MessageContentParsed (type)", diff --git a/packages/spec/src/ai/mcp-oauth-scopes.test.ts b/packages/spec/src/ai/mcp-oauth-scopes.test.ts new file mode 100644 index 00000000000..612d8d50ea3 --- /dev/null +++ b/packages/spec/src/ai/mcp-oauth-scopes.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26464] The MCP OAuth scope vocabulary, with `metadata:write` beside the + * three tool-family scopes, and the consent copy declared with it. + * + * Three facts pinned here, each read by a different consumer: + * 1. `MCP_OAUTH_SCOPES` carries the four scopes with the three originals in + * their original order — the authorization server advertises this list + * verbatim (`scopes_supported`), the runtime's up-front 403 names it, and + * a client can request only what is advertised. + * 2. Every scope has one consent sentence — the consent screen renders the + * raw scope string for anything unmapped, and that spelling tells the user + * nothing about what they are granting. Keyed by the scope type, so the + * compiler holds it too; this pin holds the content. + * 3. `metadata:write` is a tool-surface grant and NOT a data grant: alone it + * maps to the no-object-access floor, and beside a data scope it changes + * nothing about the data ceiling. An authoring scope that widened the + * data layer would be the bypass the triage line forbids. + */ + +import { describe, it, expect } from 'vitest'; +import { + MCP_AGENT_PERMISSION_SET_READ, + MCP_AGENT_PERMISSION_SET_RESTRICTED, + MCP_AGENT_PERMISSION_SET_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_CONSENT_COPY, + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_METADATA_WRITE, + MCP_OAUTH_SCOPES, + scopesToAgentPermissionSets, +} from './mcp.zod'; + +describe('[#26464] MCP OAuth scopes — metadata:write', () => { + it('is spelled metadata:write and advertised after the three tool-family scopes, which keep their order', () => { + expect(MCP_OAUTH_SCOPE_METADATA_WRITE).toBe('metadata:write'); + expect([...MCP_OAUTH_SCOPES]).toEqual([ + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, + ]); + expect([...MCP_OAUTH_SCOPES].slice(0, 3)).toEqual(['data:read', 'data:write', 'actions:execute']); + }); + + it('every advertised scope has exactly one consent sentence, and no sentence is for a scope that is not advertised', () => { + expect(Object.keys(MCP_OAUTH_SCOPE_CONSENT_COPY).sort()).toEqual([...MCP_OAUTH_SCOPES].sort()); + for (const scope of MCP_OAUTH_SCOPES) { + const copy = MCP_OAUTH_SCOPE_CONSENT_COPY[scope]; + expect(copy.trim().length, scope).toBeGreaterThan(0); + expect(copy, scope).not.toContain('\n'); + // Each sentence carries the bound the server enforces. + expect(copy, scope).toMatch(/within your own permissions/); + // Runtime strings carry no tracker number. + expect(copy, scope).not.toMatch(/#\d+/); + } + // The authoring sentence states the landing posture the ruling fixed: drafts a person publishes. + expect(MCP_OAUTH_SCOPE_CONSENT_COPY[MCP_OAUTH_SCOPE_METADATA_WRITE]).toMatch(/drafts/); + expect(MCP_OAUTH_SCOPE_CONSENT_COPY[MCP_OAUTH_SCOPE_METADATA_WRITE]).toMatch(/publishes/); + }); + + it('the three original sentences are the ones the Console consent page already renders', () => { + expect(MCP_OAUTH_SCOPE_CONSENT_COPY[MCP_OAUTH_SCOPE_DATA_READ]).toBe('Read records, within your own permissions'); + expect(MCP_OAUTH_SCOPE_CONSENT_COPY[MCP_OAUTH_SCOPE_DATA_WRITE]).toBe( + 'Create, change and delete records, within your own permissions', + ); + expect(MCP_OAUTH_SCOPE_CONSENT_COPY[MCP_OAUTH_SCOPE_ACTIONS]).toBe('Run actions on your behalf, within your own permissions'); + }); + + it('is a tool-surface grant, not a data grant: alone it maps to the no-object-access floor', () => { + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_METADATA_WRITE])).toEqual([MCP_AGENT_PERMISSION_SET_RESTRICTED]); + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_METADATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS])).toEqual([ + MCP_AGENT_PERMISSION_SET_RESTRICTED, + ]); + }); + + it('beside a data scope it changes nothing about the data ceiling', () => { + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE])).toEqual([ + MCP_AGENT_PERMISSION_SET_READ, + ]); + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_METADATA_WRITE])).toEqual([ + MCP_AGENT_PERMISSION_SET_WRITE, + ]); + // Control: the pre-existing mapping is untouched. + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_DATA_READ])).toEqual([MCP_AGENT_PERMISSION_SET_READ]); + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_DATA_WRITE])).toEqual([MCP_AGENT_PERMISSION_SET_WRITE]); + expect(scopesToAgentPermissionSets([MCP_OAUTH_SCOPE_ACTIONS])).toEqual([MCP_AGENT_PERMISSION_SET_RESTRICTED]); + expect(scopesToAgentPermissionSets(undefined)).toEqual([MCP_AGENT_PERMISSION_SET_RESTRICTED]); + }); +}); diff --git a/packages/spec/src/ai/mcp.zod.ts b/packages/spec/src/ai/mcp.zod.ts index 129ee5cfd85..06caffd6c26 100644 --- a/packages/spec/src/ai/mcp.zod.ts +++ b/packages/spec/src/ai/mcp.zod.ts @@ -130,16 +130,51 @@ export const MCP_OAUTH_SCOPE_DATA_READ = 'data:read'; export const MCP_OAUTH_SCOPE_DATA_WRITE = 'data:write'; /** Business-action tools: `list_actions`, `run_action`. */ export const MCP_OAUTH_SCOPE_ACTIONS = 'actions:execute'; +/** + * Authoring-family tools: the authoring `ToolRegistry` a host injects into the + * HTTP MCP runtime (`@objectstack/mcp` `handleHttpRequest`, its first reader). + * With this scope a developer driving an MCP client builds and changes the + * environment's metadata as DRAFTS; a human publishes them in the UI (the + * maintainer's 2026-10-27 ruling, cloud ADR-0112). Which tools the host + * injects is the host's business; the scope decides only whether they are + * served at all. It is a tool-surface grant, not a data grant: it maps to no + * data-layer ceiling set (see `scopesToAgentPermissionSets`), and every + * authoring call is still checked against the caller's own metadata + * permission — the scope is never a bypass. + */ +export const MCP_OAUTH_SCOPE_METADATA_WRITE = 'metadata:write'; /** All MCP tool-family scopes, in the order they are advertised. */ export const MCP_OAUTH_SCOPES = [ MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_METADATA_WRITE, ] as const; export type McpOauthScope = (typeof MCP_OAUTH_SCOPES)[number]; +/** + * The one-sentence consent copy an OAuth consent screen renders for each MCP + * scope — what the human reads before handing an agent the grant. + * + * Declared beside the scopes so a consent screen (the hosted one, the OSS + * Console) renders the same sentence for the same scope, and so a new scope + * ships with its copy in the same change: a scope with no sentence reaches the + * user as its raw spelling, which says nothing about what they are granting. + * Each sentence carries the bound the server enforces — the agent acts within + * the consenting user's own permissions, and a scope never grants more than + * that user could do. Keyed by {@link McpOauthScope}, so adding a scope + * without its sentence fails to compile. + */ +export const MCP_OAUTH_SCOPE_CONSENT_COPY: Readonly> = { + [MCP_OAUTH_SCOPE_DATA_READ]: 'Read records, within your own permissions', + [MCP_OAUTH_SCOPE_DATA_WRITE]: 'Create, change and delete records, within your own permissions', + [MCP_OAUTH_SCOPE_ACTIONS]: 'Run actions on your behalf, within your own permissions', + [MCP_OAUTH_SCOPE_METADATA_WRITE]: + 'Build and change this app\'s metadata as drafts, within your own permissions — a person publishes them', +}; + /** * [ADR-0090 D10] Built-in permission sets that express an MCP agent's * capability CEILING, derived from the OAuth scopes the user consented to. @@ -181,8 +216,10 @@ export const MCP_AGENT_PERMISSION_SETS = [ * [ADR-0090 D10] Map the OAuth scopes on an MCP token to the agent's ceiling * permission set(s). `data:write` (a superset — its set also grants read) wins * over `data:read`; a token with neither data scope (e.g. `actions:execute` - * only) gets the no-object-access floor. Always returns at least one set so the - * agent principal is never empty (which would fail OPEN in the middleware). + * only, or `metadata:write` only — authoring is a tool-surface grant, not a + * data grant) gets the no-object-access floor. Always returns at least one set + * so the agent principal is never empty (which would fail OPEN in the + * middleware). */ export function scopesToAgentPermissionSets(scopes: readonly string[] | undefined): string[] { const s = new Set(scopes ?? []); From 9c3a187adba29c0e6a08a86e8e7aa4f5c704f086 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:08:59 +0000 Subject: [PATCH 2/4] test(mcp): read the SDK's unknown-tool refusal structurally and free 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 Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM --- ...mcp-server-runtime.authoring-scope.test.ts | 65 ++++++++++++++----- packages/mcp/src/mcp-server-runtime.ts | 32 +++++---- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts index 36597402978..50d3f6b1164 100644 --- a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -160,7 +160,10 @@ async function call(runtime: MCPServerRuntime, bridge: any, body: unknown, o: Ca ? { authoring: { registry: o.registry, principal: PRINCIPAL, environmentId: ENVIRONMENT_ID } } : {}), }); - return { status: res.status, json: res.status === 202 ? null : await res.json() }; + // The buffered JSON-RPC answer, read untyped on purpose: these pins assert + // on the wire shape (`result` / `error` / `isError`), not on an SDK type. + const json: any = res.status === 202 ? null : await res.json(); + return { status: res.status, json }; } async function listTools(runtime: MCPServerRuntime, bridge: any, o: CallOptions = {}): Promise { @@ -172,6 +175,26 @@ function callTool(runtime: MCPServerRuntime, bridge: any, name: string, args: un return call(runtime, bridge, { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name, arguments: args } }, o); } +/** + * The refusal a `tools/call` on an UNREGISTERED tool gets, read structurally. + * The pinned SDK answers it as a tool error (`result.isError: true`, the text + * naming the tool); an older SDK answered a JSON-RPC error. Either way it is a + * refusal and never a successful result — what matters here is that the + * authoring family gets the SAME one the native families get. + */ +function refusalOf(json: any): { kind: 'rpc-error'; code: number; message: string } | { kind: 'tool-error'; text: string } { + if (json.error) return { kind: 'rpc-error', code: json.error.code, message: json.error.message }; + if (json.result?.isError === true) { + return { kind: 'tool-error', text: (json.result.content ?? []).map((c: any) => c.text ?? '').join('\n') }; + } + throw new Error(`not a refusal: ${JSON.stringify(json)}`); +} + +/** True when the answer is a refusal of either shape, never a successful result. */ +function refused(json: any): boolean { + return Boolean(json.error) || json.result?.isError === true; +} + // --------------------------------------------------------------------------- describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => { @@ -204,8 +227,7 @@ describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => grantedScopes: [MCP_OAUTH_SCOPE_DATA_WRITE], registry, }); - expect(json.error).toBeDefined(); - expect(json.result).toBeUndefined(); + expect(refused(json)).toBe(true); expect(registry.calls).toEqual([]); }); @@ -217,7 +239,7 @@ describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => grantedScopes: [MCP_OAUTH_SCOPE_ACTIONS], registry, }); - expect(json.error).toBeDefined(); + expect(refused(json)).toBe(true); expect(registry.calls).toEqual([]); }); @@ -228,7 +250,7 @@ describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => expect(withRegistry).toEqual(withoutRegistry); const { json } = await callTool(runtime, bridge, 'create_object', { name: 'invoice' }, { registry }); - expect(json.error).toBeDefined(); + expect(refused(json)).toBe(true); expect(registry.calls).toEqual([]); }); @@ -252,13 +274,20 @@ describe('[#26464] authoring tools over HTTP — the metadata:write gate', () => grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS], registry, }); - expect(native.json.error).toBeDefined(); - expect(authoring.json.error).toBeDefined(); - expect(authoring.json.error.code).toBe(native.json.error.code); - // Both name the tool and nothing else: the SDK's unknown-tool answer. - expect(native.json.error.message).toMatch(/create_record/); - expect(authoring.json.error.message).toMatch(/create_object/); - expect(authoring.json.error.message.replace('create_object', 'create_record')).toBe(native.json.error.message); + const control = refusalOf(native.json); + const subject = refusalOf(authoring.json); + // Same shape … + expect(subject.kind).toBe(control.kind); + // … and the same text once the tool name is swapped: the SDK's one + // unknown-tool answer, not a second dialect written for authoring. + if (control.kind === 'rpc-error' && subject.kind === 'rpc-error') { + expect(subject.code).toBe(control.code); + expect(control.message).toMatch(/create_record/); + expect(subject.message.replace('create_object', 'create_record')).toBe(control.message); + } else if (control.kind === 'tool-error' && subject.kind === 'tool-error') { + expect(control.text).toMatch(/create_record/); + expect(subject.text.replace('create_object', 'create_record')).toBe(control.text); + } expect(registry.calls).toEqual([]); expect(bridge.calls.find((c: any[]) => c[0] === 'create')).toBeUndefined(); }); @@ -327,14 +356,16 @@ describe('[#26464] authoring tools over HTTP — door provenance in the tool con }); it('spoofed provenance headers are ignored: the context is built from the host inputs alone', async () => { + // Values chosen so none is a substring of anything the host legitimately + // passes (keys included — `clientId` contains `cli`). const spoof = { - 'x-door': 'studio', - 'x-objectstack-door': 'cli', + 'x-door': 'studio-spoofed', + 'x-objectstack-door': 'cli-spoofed', 'x-environment-id': 'env_victim', - 'x-user-id': 'usr_admin', - 'x-forwarded-user': 'usr_admin', + 'x-user-id': 'usr_admin_spoofed', + 'x-forwarded-user': 'usr_admin_spoofed', 'x-organization-id': 'org_victim', - 'x-principal': 'usr_admin', + 'x-principal': 'usr_admin_spoofed', }; const { json } = await callTool(runtime, bridge, 'verify_build', {}, { grantedScopes: GRANT, diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 6a28bbbe57a..9c2ff8db013 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -1009,6 +1009,20 @@ function methodNotAllowedResponse(): Response { ); } +/** + * Extract the text value from a ToolExecutionResult's output. + * + * The output may be a `{ type: 'text', value: string }` object (from the + * Vercel AI SDK ToolResultPart) or any serialisable value. + */ +function formatToolOutput(result: ToolExecutionResult): string { + const output = result.output; + if (output && typeof output === 'object' && 'value' in output) { + return String((output as { value: unknown }).value); + } + return JSON.stringify(output ?? ''); +} + /** * Register a single tool on an {@link McpServer} from an AIToolDefinition. * @@ -1067,7 +1081,7 @@ function registerToolFromDefinition( input: args, }); - const outputText = MCPServerRuntime.formatToolOutput(result); + const outputText = formatToolOutput(result); if (result.isError) { return { @@ -1244,22 +1258,6 @@ export class MCPServerRuntime { return this.started; } - // ── Helpers ───────────────────────────────────────────────────── - - /** - * Extract the text value from a ToolExecutionResult's output. - * - * The output may be a `{ type: 'text', value: string }` object (from the - * Vercel AI SDK ToolResultPart) or any serialisable value. - */ - private static formatToolOutput(result: ToolExecutionResult): string { - const output = result.output; - if (output && typeof output === 'object' && 'value' in output) { - return String((output as { value: unknown }).value); - } - return JSON.stringify(output ?? ''); - } - // ── Tool Bridge ──────────────────────────────────────────────── /** From cf3ec26820915712eb0a7aea1de8a1ce7a89134a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:19:21 +0000 Subject: [PATCH 3/4] test(mcp): drop two inert eslint-disable directives in the authoring-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 Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM --- packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts index 50d3f6b1164..ab2a288a654 100644 --- a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -102,7 +102,6 @@ function makeAuthoringRegistry( arities, getAll: () => tools, async execute(toolCall: ToolCallPart, context: McpAuthoringToolContext): Promise { - // eslint-disable-next-line prefer-rest-params arities.push(arguments.length); calls.push({ toolCall, context }); if (behaviour === 'throw') throw new Error(`refused: ${toolCall.toolName}`); @@ -515,7 +514,6 @@ describe('[#26464] stdio parity — bridgeTools still calls its ToolRegistry wit const legacy: ToolRegistry = { getAll: () => AUTHORING_TOOLS, async execute(toolCall: ToolCallPart): Promise { - // eslint-disable-next-line prefer-rest-params arities.push(arguments.length); calls.push(toolCall); return { From 7fb21edeac2cd17e8c047e1cc0f8bc6366cc63ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:03:48 +0000 Subject: [PATCH 4/4] fix(mcp): freeze the per-call principal copy and teach describe_object 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 Claude-Session: https://claude.ai/code/session_01WGufziVp7Y4ARxehhrEJbM --- .changeset/26464-mcp-metadata-write-scope.md | 4 +- ...mcp-server-runtime.authoring-scope.test.ts | 60 +++++++++++++++++++ packages/mcp/src/mcp-server-runtime.ts | 34 ++++++++--- .../mcp/src/skill-md-surface-guard.test.ts | 14 +++++ packages/mcp/src/skill-md.authoring.test.ts | 19 ++++++ packages/mcp/src/skill-md.ts | 20 +++++-- packages/mcp/src/types.ts | 14 ++++- 7 files changed, 148 insertions(+), 17 deletions(-) diff --git a/.changeset/26464-mcp-metadata-write-scope.md b/.changeset/26464-mcp-metadata-write-scope.md index 524c8e58cb2..61306f38531 100644 --- a/.changeset/26464-mcp-metadata-write-scope.md +++ b/.changeset/26464-mcp-metadata-write-scope.md @@ -11,8 +11,8 @@ 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 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; every other render is byte-identical to before. +- **`@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`. diff --git a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts index ab2a288a654..b54f5f97833 100644 --- a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -411,6 +411,66 @@ describe('[#26464] authoring tools over HTTP — door provenance in the tool con expect(PRINCIPAL.userId).toBe('usr_dev_1'); }); + it('the principal in the context is a frozen per-call copy: a handler can taint neither the next call nor the host object', async () => { + const attempts: unknown[] = []; + const tainting: McpAuthoringToolRegistry = { + getAll: () => AUTHORING_TOOLS, + async execute(toolCall, context) { + registry.calls.push({ toolCall, context }); + // Each write is what a hostile or buggy tool would try; every one must be refused, + // not silently absorbed into state a later call could read. + for (const write of [ + () => { (context.principal as any).userId = 'usr_admin_evil'; }, + () => { (context.principal as any).organizationId = 'org_victim'; }, + () => { (context.principal.scopes as any).push(MCP_OAUTH_SCOPE_DATA_WRITE); }, + () => { (context.actor as any).id = 'usr_admin_evil'; }, + ]) { + try { write(); attempts.push('absorbed'); } catch (e) { attempts.push(e); } + } + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: 'ok' }, + } as ToolExecutionResult; + }, + }; + const o: CallOptions = { grantedScopes: GRANT, registry: tainting }; // the harness injects PRINCIPAL + ENVIRONMENT_ID + await callTool(runtime, bridge, 'verify_build', {}, o); + await callTool(runtime, bridge, 'verify_build', {}, o); + expect(registry.calls).toHaveLength(2); + // Every write was refused (strict-mode TypeError on a frozen object), none absorbed. + expect(attempts).toHaveLength(8); + for (const a of attempts) expect(a).toBeInstanceOf(TypeError); + const [first, second] = registry.calls.map((c) => c.context); + expect(Object.isFrozen(first.principal)).toBe(true); + expect(Object.isFrozen(first.principal.scopes)).toBe(true); + expect(Object.isFrozen(first.actor)).toBe(true); + // A copy, not the host's reference — and a copy per call, not one shared copy. + expect(first.principal).not.toBe(PRINCIPAL); + expect(second.principal).not.toBe(first.principal); + expect(first.principal.scopes).not.toBe(PRINCIPAL.scopes); + // The second call still sees the host's principal, byte for byte … + expect(second.principal).toEqual(PRINCIPAL); + expect(second.actor).toEqual({ id: PRINCIPAL.userId }); + // … and the host's own object is untouched. + expect(PRINCIPAL).toEqual({ + userId: 'usr_dev_1', + organizationId: 'org_acme', + clientId: 'claude-code', + scopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], + }); + }); + + it('a tool name already on the server is refused at registration (the SDK throws), so a host keeps its names unique', () => { + const server = new McpServer({ name: 'x', version: '0' }); + const first = registerAuthoringTools(server, { registry, principal: PRINCIPAL }, GRANT); + expect(first).toEqual(AUTHORING_TOOLS.map((t) => t.name)); + // The same names a second time — as a registry that duplicates one of its own, or one + // that reuses a native name, would — is thrown back by the SDK, not merged or shadowed. + expect(() => registerAuthoringTools(server, { registry, principal: PRINCIPAL }, GRANT)).toThrow(/already registered/i); + }); + it('an environment the host did not name is absent from the context, not undefined-valued', async () => { const server = new McpServer({ name: 'x', version: '0' }); const names = registerAuthoringTools(server, { registry, principal: PRINCIPAL }, GRANT); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 9c2ff8db013..cd9b926a77a 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -10,6 +10,7 @@ import { MCP_TOOL_DOOR } from './types.js'; import type { McpAuthoringInjection, McpAuthoringToolContext, + McpTokenPrincipal, ToolRegistry, ToolExecutionResult, } from './types.js'; @@ -1130,7 +1131,16 @@ function registerToolFromDefinition( * so no header can reach the context — a client sending `x-door`, * `x-environment-id` or any other spelling changes nothing a tool sees. The * host's access check and tier mapping read the door from the context; that is - * the whole reason it is stamped on the server side of the transport. + * the whole reason it is stamped on the server side of the transport. The + * principal (and its `scopes` array) is copied and frozen per call, 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 refuses a second + * registration under a name already on the server — a native name such as + * `describe_object`, or a duplicate within the registry — by throwing, and + * the request fails with it. A host checks its names against the native + * surface before injecting. * * ## What stays the host's * @@ -1154,12 +1164,22 @@ export function registerAuthoringTools( if (!grantsAuthoringTools(grantedScopes)) return registered; const { registry, principal, environmentId } = authoring; - const provenance = (): McpAuthoringToolContext => ({ - door: MCP_TOOL_DOOR, - actor: { id: principal.userId }, - principal, - ...(environmentId !== undefined ? { environmentId } : {}), - }); + // Each call gets its own FROZEN copy of the principal (and of its one array, + // `scopes`): a tool that writes into its context taints neither the next + // call in this request nor the host's own object. The context object itself + // is fresh per call for the same reason. + const provenance = (): McpAuthoringToolContext => { + const frozenPrincipal: McpTokenPrincipal = Object.freeze({ + ...principal, + ...(principal.scopes !== undefined ? { scopes: Object.freeze([...principal.scopes]) } : {}), + }); + return { + door: MCP_TOOL_DOOR, + actor: Object.freeze({ id: frozenPrincipal.userId }), + principal: frozenPrincipal, + ...(environmentId !== undefined ? { environmentId } : {}), + }; + }; for (const tool of registry.getAll()) { registerToolFromDefinition(server, tool, (call) => registry.execute(call, provenance()), logger); diff --git a/packages/mcp/src/skill-md-surface-guard.test.ts b/packages/mcp/src/skill-md-surface-guard.test.ts index ada80b9783e..f5f171d5b8a 100644 --- a/packages/mcp/src/skill-md-surface-guard.test.ts +++ b/packages/mcp/src/skill-md-surface-guard.test.ts @@ -164,4 +164,18 @@ describe('SKILL.md ↔ native tool surface drift guard', () => { expect(mdWithoutScope).not.toContain(t.name); } }); + + it('[#26464] the Build section names no native tool the same grant does not register (metadata:write alone has no data tool)', async () => { + const registry = makeAuthoringRegistry(); + const native = await listRegisteredToolNames({ grantedScopes: EVERY_SCOPE }); + expect(native.length).toBeGreaterThanOrEqual(9); + const metadataOnly = await listRegisteredToolNames({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry }); + expect(metadataOnly.sort()).toEqual(AUTHORING_TOOLS.map((t) => t.name).sort()); + + const md = renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], authoringTools: registry.getAll() }); + const section = md.slice(md.indexOf('## Build — authoring tools'), md.indexOf('## Conventions & gotchas')); + expect(section.length).toBeGreaterThan(0); + const taughtButUnregistered = native.filter((name) => section.includes(name) && !metadataOnly.includes(name)); + expect(taughtButUnregistered, `the Build section teaches a tool this grant does not register: ${taughtButUnregistered.join(', ')}`).toEqual([]); + }); }); diff --git a/packages/mcp/src/skill-md.authoring.test.ts b/packages/mcp/src/skill-md.authoring.test.ts index d5bf62b0107..476c456d133 100644 --- a/packages/mcp/src/skill-md.authoring.test.ts +++ b/packages/mcp/src/skill-md.authoring.test.ts @@ -85,6 +85,25 @@ describe('[#26464] renderSkillMarkdown — the authoring section follows the gra expect(OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING).not.toContain('\n'); }); + it('metadata:write alone does not teach describe_object in the Build section — that grant registers no data tool', () => { + const md = renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], authoringTools: TOOLS }); + const section = md.slice(md.indexOf(SECTION), md.indexOf('## Conventions & gotchas')); + expect(section).not.toContain('describe_object'); + expect(section).not.toContain('list_objects'); + expect(section).toContain('no `data:read`'); + expect(section).toMatch(/before you change an object/); + }); + + it('metadata:write beside data:read teaches describe_object before a change — the same grant registers it', () => { + const md = renderSkillMarkdown({ + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE], + authoringTools: TOOLS, + }); + const section = md.slice(md.indexOf(SECTION), md.indexOf('## Conventions & gotchas')); + expect(section).toContain('`describe_object` on an object'); + expect(section).not.toContain('no `data:read`'); + }); + it('the section is placed after the native tools and before the conventions, with the data content untouched', () => { const md = renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], authoringTools: TOOLS }); const tools = md.indexOf('## Tools'); diff --git a/packages/mcp/src/skill-md.ts b/packages/mcp/src/skill-md.ts index 6a8244fd97b..664bb847833 100644 --- a/packages/mcp/src/skill-md.ts +++ b/packages/mcp/src/skill-md.ts @@ -31,7 +31,7 @@ */ import type { AIToolDefinition } from '@objectstack/spec/contracts'; -import { MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai'; +import { MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai'; import { grantsAuthoringTools } from './mcp-http-tools.js'; /** Skill identity (mirrors the `SKILL.md` YAML frontmatter). */ @@ -108,8 +108,17 @@ function oneLine(text: string): string { * UI — from the same source as the data loop, so the client model learns both * the same way. Tool names come from the host; none is hard-coded here. */ -function renderAuthoringSection(tools: ReadonlyArray>): string { +function renderAuthoringSection( + tools: ReadonlyArray>, + describeObjectRegistered: boolean, +): string { const bullets = tools.map((t) => `- **${t.name}** — ${oneLine(t.description)}`).join('\n'); + // Name `describe_object` only when the same grant registers it (the data + // gate: `data:read`). A metadata:write-only connection has no data tool, so + // teaching one there would be teaching a tool the server refuses. + const discover = describeObjectRegistered + ? 'Discover first here too — `describe_object` on an object\nbefore you change it.' + : 'Your grant carries no `data:read`, so the data tools are not on this\nconnection: read the current state through the authoring tools\' own verify\nstep before you change an object.'; return ` ## Build — authoring tools (\`${MCP_OAUTH_SCOPE_METADATA_WRITE}\`) @@ -125,8 +134,7 @@ broken. The build loop is **create → verify → draft**: create or change the metadata, verify it (the environment validates what you wrote and reports what it refuses, so read the result before the next step), and leave it as a draft for -a person to publish. Discover first here too — \`describe_object\` on an object -before you change it. +a person to publish. ${discover} ${bullets} `; @@ -150,6 +158,8 @@ export function renderSkillMarkdown(options: RenderSkillOptions = {}): string { ? options.authoringTools : undefined; const description = authoringTools ? OBJECTSTACK_SKILL_DESCRIPTION_WITH_AUTHORING : OBJECTSTACK_SKILL_DESCRIPTION; + // Mirrors the native data gate in `wireBridgeTools` (`!scopes || scopes.includes(data:read)`). + const describeObjectRegistered = !options.grantedScopes || options.grantedScopes.includes(MCP_OAUTH_SCOPE_DATA_READ); return `--- name: ${OBJECTSTACK_SKILL_NAME} @@ -264,7 +274,7 @@ create/update payload. screen, which you fill with another \`resume_run\`. Only the user whose call started the run can resume it, under the same gates as \`run_action\` (including \`confirm: true\` for an action flagged \`requiresConfirmation\`). -${authoringTools ? renderAuthoringSection(authoringTools) : ''} +${authoringTools ? renderAuthoringSection(authoringTools, describeObjectRegistered) : ''} ## Conventions & gotchas - **Permissions/RLS apply to every call.** Fewer rows than expected, or a diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index e53a7cfaa40..4d5b9b83c9d 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -71,7 +71,7 @@ export interface McpAuthoringToolContext extends ToolExecutionContext { door: McpToolDoor; /** The token's user, as the actor a `ToolExecutionContext` reader expects (RLS on, as that user). */ actor: { id: string }; - /** The token principal, verbatim from the host. */ + /** The token principal: a frozen per-call copy of what the host passed, so a tool cannot taint the next call. */ principal: McpTokenPrincipal; /** The environment the request was dispatched for, verbatim from the host. */ environmentId?: string; @@ -98,9 +98,17 @@ export interface McpAuthoringToolRegistry { * provenance of every call into it. */ export interface McpAuthoringInjection { - /** The authoring registry — which tools it holds is the host's business. */ + /** + * The authoring registry — which tools it holds is the host's business. Its + * names must not collide with the native surface or with each other: the SDK + * throws on a duplicate registration and the request fails with it. + */ registry: McpAuthoringToolRegistry; - /** The token principal the host resolved for this request. */ + /** + * The token principal the host resolved for this request — from the SAME + * token whose scopes the host passes as `toolOptions.grantedScopes`, since + * that list is what admits the registry. Copied and frozen per call. + */ principal: McpTokenPrincipal; /** The environment the host dispatched this request for. */ environmentId?: string;