diff --git a/.changeset/26464-mcp-metadata-write-scope.md b/.changeset/26464-mcp-metadata-write-scope.md new file mode 100644 index 00000000000..61306f38531 --- /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 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/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..b54f5f97833 --- /dev/null +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -0,0 +1,603 @@ +// 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 { + 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 } } + : {}), + }); + // 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 { + 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); +} + +/** + * 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', () => { + 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(refused(json)).toBe(true); + 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(refused(json)).toBe(true); + 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(refused(json)).toBe(true); + 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, + }); + 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(); + }); + + 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 () => { + // Values chosen so none is a substring of anything the host legitimately + // passes (keys included — `clientId` contains `cli`). + const spoof = { + 'x-door': 'studio-spoofed', + 'x-objectstack-door': 'cli-spoofed', + 'x-environment-id': 'env_victim', + 'x-user-id': 'usr_admin_spoofed', + 'x-forwarded-user': 'usr_admin_spoofed', + 'x-organization-id': 'org_victim', + 'x-principal': 'usr_admin_spoofed', + }; + 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('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); + 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 { + 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..cd9b926a77a 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -3,11 +3,18 @@ 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, + McpTokenPrincipal, + ToolRegistry, + ToolExecutionResult, +} from './types.js'; +import { wireBridgeTools, grantsAuthoringTools, thrownRefusalEnvelope } from './mcp-http-tools.js'; import type { McpDataBridge, McpActionBridge, @@ -1003,6 +1010,185 @@ 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. + * + * 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 = 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. 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 + * + * 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; + // 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); + 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. * @@ -1092,22 +1278,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 ──────────────────────────────────────────────── /** @@ -1125,7 +1295,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 +1342,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 +1764,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 +1787,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 +1798,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 +1864,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..f5f171d5b8a 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,46 @@ 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); + } + }); + + 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 new file mode 100644 index 00000000000..476c456d133 --- /dev/null +++ b/packages/mcp/src/skill-md.authoring.test.ts @@ -0,0 +1,122 @@ +// 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('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'); + 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..664bb847833 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_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). */ 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,71 @@ 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>, + 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}\`) + +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} + +${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 +150,20 @@ 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; + // 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} -description: ${OBJECTSTACK_SKILL_DESCRIPTION} +description: ${description} --- # ObjectStack @@ -188,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, 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 ba606c96a30..4d5b9b83c9d 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,89 @@ 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: 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; +} + +/** + * 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. 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 — 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; +} 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 ?? []);