From 05aacb8df5a7d5534195a617d69359d98c9bb900 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:08:45 +0000 Subject: [PATCH] fix(mcp): the stdio record resource honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six object-CRUD verbs all flow through `createStdioDataBridge` and have been gated since #8083. The ADR-0101 record resource does not use that bridge — its reader is a separate closure built inline in `plugin.ts` that calls `ql.find` directly and is handed to `bridgeResources` — so gating the bridge never reached it. One object declaring `enable.apiEnabled: false` was refused by the `get_record` tool and still readable at `objectstack://objects/{objectName}/records/{recordId}`: same transport, same key, same declaration, two answers. That reader now applies the same gate, with the same action word (`get`), from the same helper, which takes its decision from the spec's single source of truth (`resolveEffectiveApiMethods` / `isApiOperationAllowed`) rather than re-deriving it. `enforceApiExposure` is exported from `stdio-data-bridge.ts` for that one consumer; `index.ts` publishes neither it nor the bridge builder. This is a SURFACE-AREA declaration leak, not an authorization bypass: the read passed CRUD/FLS/RLS under the key's `ExecutionContext` before and after. What was leaking is the author's exposure declaration. Tests drive BOTH read paths down a real `StdioServerTransport` from ONE plugin boot against ONE declaration, so the parity claim is a comparison rather than two independent literals, plus an ablation that moves only the declaration and requires both paths to serve — a parity that held because both paths refused for an unrelated reason would be the failure mode here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- .../mcp-record-resource-exposure-gate.md | 41 ++ .../plugin.record-resource-exposure.test.ts | 453 ++++++++++++++++++ packages/mcp/src/plugin.ts | 26 +- packages/mcp/src/stdio-data-bridge.ts | 16 +- 4 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-record-resource-exposure-gate.md create mode 100644 packages/mcp/src/plugin.record-resource-exposure.test.ts diff --git a/.changeset/mcp-record-resource-exposure-gate.md b/.changeset/mcp-record-resource-exposure-gate.md new file mode 100644 index 0000000000..3b983188bb --- /dev/null +++ b/.changeset/mcp-record-resource-exposure-gate.md @@ -0,0 +1,41 @@ +--- +"@objectstack/mcp": patch +--- + +fix(mcp): the stdio record resource honours the ADR-0049 `apiEnabled` / `apiMethods` exposure declaration (#8266) + +An object that declares `enable.apiEnabled: false` — or narrows `enable.apiMethods` +so a single-record read is outside the whitelist — was refused by the `get_record` +tool and **still readable** through the ADR-0101 record resource +(`objectstack://objects/{objectName}/records/{recordId}`): same transport, same key, +same declaration, two answers. + +**This is a surface-area declaration leak, not an authorization bypass.** The gate is +a surface-area control by `api-exposure.ts`'s own ADR note, and this read passed the +ObjectQL security middleware (CRUD / FLS / RLS) — under the key's `ExecutionContext` +— before this change and after it. What was leaking is the author's *exposure +declaration*, not the data guard. + +Why the resource was missed when the tool was fixed: the six object-CRUD verbs all +flow through `createStdioDataBridge`, which has been gated since #8083. The record +resource does not use that bridge — its reader is a separate closure built inline in +the plugin that calls `ql.find` directly and is handed to `bridgeResources`. Gating +the bridge therefore never reached it. That reader now applies the same gate, taking +its decision from the same single source of truth every other enforcement point +delegates to (the spec's `resolveEffectiveApiMethods` / `isApiOperationAllowed`), so +the three-state whitelist and the derived verbs resolve identically on all surfaces. + +The gated action is `get`, matching what the HTTP path sends for a single-record +read. Refusals carry the same machine codes the REST surface answers with — +`OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` — and reach an MCP client +as the resource's `{ "error": ... }` body, which is how that resource has always +reported a failed read. The behaviours matched to the HTTP path in #8083 are matched +here for the same reasons: a system context bypasses, and unresolvable metadata fails +open to the schema defaults. + +Unaffected: the object schema and object list resources stay ungated, because the +HTTP bridge answers both straight off the metadata service — refusing a *schema* read +here would be a fresh divergence pointing the other way. The remaining known +divergences between the two MCP bridges (the protocol layer's ingress `readonly` +strip, its existence probes, its spec-shaped receipts and `expand` / `select`) are +unchanged and still filed as follow-up work. diff --git a/packages/mcp/src/plugin.record-resource-exposure.test.ts b/packages/mcp/src/plugin.record-resource-exposure.test.ts new file mode 100644 index 0000000000..25ae917f05 --- /dev/null +++ b/packages/mcp/src/plugin.record-resource-exposure.test.ts @@ -0,0 +1,453 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8266 — the ADR-0101 record RESOURCE honours the ADR-0049 exposure + * declaration the `get_record` TOOL already honours. + * + * The defect: #8083 (PR #8265) gated the six object-CRUD verbs, which all flow + * through `createStdioDataBridge`. The record resource does not use that bridge + * — its reader is a separate closure built inline in `plugin.ts` that calls + * `ql.find` directly and is handed to `bridgeResources`. So one object + * declaring `enable.apiEnabled: false` was refused by `get_record` and STILL + * READABLE at `objectstack://objects/{objectName}/records/{recordId}` — same + * transport, same key, same declaration, two answers. + * + * **What leaks is the author's DECLARATION, not the data guard.** The gate is a + * surface-area control by `api-exposure.ts`'s own ADR note; this read passed the + * engine's CRUD/FLS/RLS through `ql.find` with the key's `ExecutionContext` + * before this change and after it. Every case below asserts an EXPOSURE + * verdict, never a data-authorization one. + * + * ## Why these tests drive both paths through ONE plugin boot + * + * The acceptance shape is a parity claim — "one declaration, both read paths, + * same verdict" — and a parity claim is only worth what its two halves share. + * Asserting the resource in isolation would go green on a build where the tool + * had silently stopped gating too. So each case below boots the REAL + * `MCPServerPlugin` once, against ONE fake metadata service holding ONE + * declaration, and drives BOTH reads down a real `StdioServerTransport`: + * `resources/read` on the record URI and `tools/call get_record`. The verdicts + * are then asserted against each other, not against two hand-written literals. + * + * ## Why the ablation case is not optional + * + * The failure mode this file is most exposed to is a parity assertion that + * passes because BOTH paths refuse for some unrelated reason (an unresolvable + * principal, a metadata outage, a typo'd object name) — the gate could be + * absent entirely and the file would stay green. So `the gate is what refuses` + * below re-runs the identical boot with the identical fixture and the ONLY + * difference being the declaration, and requires both paths to SERVE the row. + * A refusal that does not move when the declaration moves is not this gate. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { IMetadataService } from '@objectstack/spec/contracts'; + +import type { MCPServerRuntime } from './mcp-server-runtime.js'; +import { MCPServerPlugin } from './plugin.js'; +import { enforceApiExposure, GATED_ACTIONS, type McpExposureError } from './stdio-data-bridge.js'; + +// --------------------------------------------------------------------------- +// A real stdio client: newline-delimited JSON-RPC over the transport's pipes +// --------------------------------------------------------------------------- + +interface JsonRpcFrame { + jsonrpc: string; + id?: number; + result?: any; + error?: { code: number; message: string }; +} + +interface StdioSession { + rpc(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): void; + close(): Promise; +} + +/** + * Attach a real `StdioServerTransport` to `server` and talk JSON-RPC to it — + * the same harness shape `mcp-stdio-tools.test.ts` uses, and for the same + * reason: the resource surface and the tool surface are only comparable on the + * wire a desktop MCP host actually speaks. Fed `PassThrough` pipes instead of + * `process.stdin`/`stdout` so the frames are readable without spawning. + */ +async function openStdio(server: McpServer): Promise { + 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 newline = buffered.indexOf('\n'); + while (newline >= 0) { + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + newline = buffered.indexOf('\n'); + if (!line) continue; + let frame: JsonRpcFrame; + try { + frame = JSON.parse(line) as JsonRpcFrame; + } catch { + continue; // a host printing to stdout — not a frame (see #7915) + } + 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, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + const giveUp = setTimeout( + () => reject(new Error(`stdio: no answer to "${method}" (id ${id}) 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, params) { + serverStdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`); + }, + async close() { + await transport.close().catch(() => {}); + }, + }; +} + +async function handshake(session: StdioSession): Promise { + await session.rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'record-resource-exposure-pin', version: '0.0.0' }, + }); + session.notify('notifications/initialized'); +} + +// --------------------------------------------------------------------------- +// Doubles +// --------------------------------------------------------------------------- + +const RECORD_URI = 'objectstack://objects/task/records/t1'; + +/** + * The `objectql` service, faked at the one seam the plugin uses: `find`. + * `sys_api_key` resolves the stdio principal through the REAL + * `resolveAuthzContext` chain; `task` is the row both read paths are after. + */ +function fakeObjectQL() { + return { + find: vi.fn(async (object: string, _query?: unknown, _options?: unknown) => { + if (object === 'sys_api_key') return [{ id: 'k1', user_id: 'usr_stdio', revoked: false }]; + if (object === 'task') return [{ id: 't1', title: 'the row behind the declaration' }]; + return []; + }), + insert: vi.fn(async () => ({ id: 'new1' })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => true), + aggregate: vi.fn(async () => []), + count: vi.fn(async () => 0), + findOne: vi.fn(async () => null), + }; +} + +/** A metadata service serving ONE object definition — the single declaration under test. */ +function fakeMetadata(def: unknown) { + return { + listObjects: vi.fn(async () => [{ name: 'task', label: 'Task', fields: { title: {} } }]), + getObject: vi.fn(async (name: string) => (name === 'task' ? def : null)), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => true), + getRegisteredTypes: vi.fn(async () => ['object']), + register: vi.fn(), + unregister: vi.fn(), + }; +} + +function mockContext(services: Record) { + const registry = new Map(Object.entries(services)); + return { + registerService: vi.fn((name: string, service: unknown) => registry.set(name, service)), + getService: vi.fn((name: string) => { + if (!registry.has(name)) throw new Error(`Service "${name}" not found`); + return registry.get(name); + }), + replaceService: vi.fn(), + getServices: vi.fn(() => registry), + hook: vi.fn(), + trigger: vi.fn(async () => {}), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(() => ({})), + }; +} + +/** + * One read path's answer, normalized to the same shape for both surfaces. + * + * The two surfaces serialize a refusal differently and neither is this card's + * to change: the resource wraps it as a JSON `{ error }` body (its `catch` in + * `bridgeResources`), the tool answers `isError: true` with the bare message as + * text (`errorResult(messageOf(err))`). Normalizing here is what lets the cases + * below compare VERDICTS rather than two serialization accidents. + */ +interface PathAnswer { + /** The refusal message, or `undefined` when the path served a row. */ + error?: string; + /** The row, or `undefined` when the path refused. */ + record?: any; +} + +interface BothPaths { + /** What `resources/read` on the record URI answered. */ + resource: PathAnswer; + /** What `tools/call get_record` answered. */ + tool: PathAnswer; + /** Every `ql.find` call that asked for ROW data (the principal reads are excluded). */ + rowReads: any[][]; +} + +/** + * The `ExecutionContext` a `ql.find` call carried. + * + * Read from EITHER argument position on purpose. The two read paths assemble + * the call differently today — the bridge sends `find(obj, query, { context })` + * and the plugin's inline reader sends `find(obj, { where, limit, context })` — + * so a helper that knew only one position would silently report "no principal" + * for the other path and turn an ADR-0101 assertion into a no-op. + */ +function contextOf(call: any[]): any { + return call[2]?.context ?? call[1]?.context; +} + +/** + * Boot the real plugin against ONE declaration and drive BOTH read paths. + * + * Only the transport attach is stubbed — `start()` would claim this test + * process's real stdin/stdout. Everything this card is about (building the + * reader, gating it, registering the resource) runs for real. + */ +async function readBothPaths(def: unknown): Promise { + const ql = fakeObjectQL(); + const ctx = mockContext({ metadata: fakeMetadata(def), objectql: ql }); + + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx as any); + const runtime = (ctx.registerService as any).mock.calls.find( + (c: any[]) => c[0] === 'mcp', + )[1] as MCPServerRuntime; + vi.spyOn(runtime, 'start').mockResolvedValue(undefined); + await plugin.start(ctx as any); + + const session = await openStdio(runtime.server); + try { + await handshake(session); + + const resourceFrame = await session.rpc('resources/read', { uri: RECORD_URI }); + expect(resourceFrame.error, 'resources/read failed at the protocol level').toBeUndefined(); + const resourceBody = JSON.parse(resourceFrame.result.contents[0].text); + const resource: PathAnswer = + typeof resourceBody?.error === 'string' + ? { error: resourceBody.error } + : { record: resourceBody }; + + const toolFrame = await session.rpc('tools/call', { + name: 'get_record', + arguments: { objectName: 'task', recordId: 't1' }, + }); + expect(toolFrame.error, 'tools/call failed at the protocol level').toBeUndefined(); + const toolText = toolFrame.result.content[0].text as string; + const tool: PathAnswer = toolFrame.result.isError + ? { error: toolText } + : { record: JSON.parse(toolText) }; + + return { + resource, + tool, + rowReads: ql.find.mock.calls.filter((c: any[]) => c[0] === 'task'), + }; + } finally { + await session.close(); + } +} + +// --------------------------------------------------------------------------- +// The parity claim +// --------------------------------------------------------------------------- + +describe('#8266 one declaration, both read paths, same verdict', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.OS_MCP_SERVER_TRANSPORT; + delete process.env.OS_MCP_STDIO_ENABLED; + process.env.OS_MCP_STDIO_API_KEY = 'osk_record_resource_pin'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + it('refuses the record RESOURCE when apiEnabled is false, exactly as the tool does', async () => { + const both = await readBothPaths({ name: 'task', enable: { apiEnabled: false } }); + + // The half this card exists to fix. Before the change this read returned + // the row — the whole defect in one assertion. The ROW is asserted first + // on purpose: when this regresses, the leaked row is the value the failure + // prints, rather than "expected string, got undefined" pointing at the + // absent refusal. + expect( + both.resource.record, + 'the record resource SERVED a row for an object declaring apiEnabled: false', + ).toBeUndefined(); + expect(both.resource.error).toMatch(/not exposed via the API/i); + + // The half #8083 already fixed, re-read here so the claim is a COMPARISON + // rather than two independent literals. + expect(both.tool.error).toMatch(/not exposed via the API/i); + expect(both.tool.record).toBeUndefined(); + + // Same verdict, not merely both-unhappy: the two surfaces answer the SAME + // sentence because they take the same decision from the same helper. + expect(both.resource.error).toBe(both.tool.error); + + // Refused BEFORE dispatch — the engine was never asked for the row, by + // either path. A gate that refused after reading would still have read. + expect(both.rowReads).toHaveLength(0); + }); + + it('the gate is what refuses — the ABLATION', async () => { + // Identical boot, identical fixture, identical reads. The ONLY difference + // is the declaration. If this case did not serve both rows, every refusal + // above would be evidence of nothing: a parity that holds because both + // paths break for an unrelated reason is the failure mode this card's + // acceptance explicitly names. + const both = await readBothPaths({ name: 'task', enable: { apiEnabled: true } }); + + expect(both.resource.error).toBeUndefined(); + expect(both.resource.record.id).toBe('t1'); + expect(both.resource.record.title).toBe('the row behind the declaration'); + + expect(both.tool.error).toBeUndefined(); + expect(both.tool.record.id).toBe('t1'); + + // Both paths really did reach the engine — the reachability half of the + // ablation, and the thing that was true of the RESOURCE in the pre-change + // state for `apiEnabled: false` too. + expect(both.rowReads).toHaveLength(2); + // Still under the key's identity: this gate is surface area, and the + // ADR-0101 principal binding is untouched by it. + for (const call of both.rowReads) { + expect(contextOf(call).userId).toBe('usr_stdio'); + expect(contextOf(call).isSystem).toBe(false); + } + }); + + it('refuses the record resource when the whitelist excludes `get`', async () => { + // Not a second spelling of the case above: `apiEnabled: false` is one + // branch of the helper and the `apiMethods` whitelist is another. A reader + // gated on a MIS-SPELLED action word (`'read'`, say) would still pass the + // first case — `apiEnabled: false` refuses before the action is consulted + // — and only fails here, where the action word decides. + const both = await readBothPaths({ name: 'task', enable: { apiMethods: ['list'] } }); + + expect( + both.resource.record, + 'the record resource SERVED a row for an object whose whitelist excludes `get`', + ).toBeUndefined(); + expect(both.resource.error).toMatch(/not allowed on object/i); + expect(both.resource.error).toContain('get'); + + expect(both.tool.record).toBeUndefined(); + expect(both.resource.error).toBe(both.tool.error); + expect(both.rowReads).toHaveLength(0); + }); + + it('serves the record resource when the whitelist includes `get`', async () => { + // The whitelist branch's own ablation — the same declaration axis moved + // one notch, so the refusal above is attributable to `get` being absent + // rather than to any whitelist at all being present. + const both = await readBothPaths({ name: 'task', enable: { apiMethods: ['get'] } }); + + expect(both.resource.error).toBeUndefined(); + expect(both.resource.record.id).toBe('t1'); + expect(both.tool.record.id).toBe('t1'); + expect(both.rowReads).toHaveLength(2); + }); + + it('leaves the record resource ungated when the object declares nothing', async () => { + // The overwhelmingly common case, pinned so the gate cannot regress into + // refusing by default: an object with no exposure declaration at all is + // unrestricted, on this path as on every other. + const both = await readBothPaths({ name: 'task', label: 'Task' }); + + expect(both.resource.record.id).toBe('t1'); + expect(both.tool.record.id).toBe('t1'); + }); +}); + +// --------------------------------------------------------------------------- +// The envelope — asserted where it is observable +// --------------------------------------------------------------------------- + +describe('#8266 the refusal carries the ADR-0112 envelope', () => { + /** + * The wire assertions above can only read `.message`: BOTH the resource + * serializer (`bridgeResources`' `catch`) and the `get_record` tool + * (`errorResult(messageOf(err))`) forward the message alone. That is + * pre-existing on both surfaces and is not this card's to change — so the + * envelope is asserted here, at the seam that actually carries it, rather + * than left unasserted or faked into a wire test that cannot see it. + */ + async function refusalFor(def: unknown, action: string): Promise { + const metadataService = fakeMetadata(def) as unknown as IMetadataService; + const context = { userId: 'u1', isSystem: false } as unknown as ExecutionContext; + const err = (await enforceApiExposure(metadataService, 'task', action, context).then( + () => null, + (e: unknown) => e, + )) as McpExposureError | null; + expect(err, 'the gate allowed — no exposure refusal was raised').toBeTruthy(); + return err!; + } + + it('answers OBJECT_API_DISABLED / 404 for a hidden object', async () => { + const err = await refusalFor({ name: 'task', enable: { apiEnabled: false } }, GATED_ACTIONS.get); + expect(err.code).toBe('OBJECT_API_DISABLED'); + expect(err.status).toBe(404); + }); + + it('answers OBJECT_API_METHOD_NOT_ALLOWED / 405 with the effective set', async () => { + const err = await refusalFor({ name: 'task', enable: { apiMethods: ['list'] } }, GATED_ACTIONS.get); + expect(err.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(err.status).toBe(405); + expect(err.allowedOperations).toContain('list'); + expect(err.allowedOperations).not.toContain('get'); + }); + + it('gates the record resource under the same action word the tool gates `bridge.get` under', () => { + // The resource reader and the bridge's `get` verb must not drift apart on + // the action word — that is the whole seam this card closes. Pinned as + // data rather than prose: `plugin.ts` passes `GATED_ACTIONS.get`. + expect(GATED_ACTIONS.get).toBe('get'); + }); +}); diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 735b1a36fb..871b79724a 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -8,7 +8,7 @@ import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spe import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; import type { ToolRegistry } from './types.js'; -import { createStdioDataBridge } from './stdio-data-bridge.js'; +import { createStdioDataBridge, enforceApiExposure, GATED_ACTIONS } from './stdio-data-bridge.js'; import type { McpDataBridge } from './mcp-http-tools.js'; import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js'; @@ -236,8 +236,32 @@ export class MCPServerPlugin implements Plugin { + 'Fix: register the metadata service (the metadata plugin) in this assembly.', ); } + // [#8266] The metadata service the exposure gate reads the declaration + // from, captured once rather than re-read off the outer `let` inside the + // closure — the reader must not be able to see a different service from + // the one `bridgeResources` was registered with. + const gateMetadata = metadataService; getRecord = async (objectName, recordId) => { const ec = await resolvePrincipal(); + // [#8266] The ADR-0049 exposure gate, applied BEFORE the read: the same + // decision, from the same helper, that `bridge.get` applies for the + // `get_record` tool (#8083). Without it, one `enable.apiEnabled: false` + // declaration was refused by the TOOL and still served by this + // RESOURCE — same transport, same key, two answers. + // + // This is a SURFACE-AREA control, not the authorization boundary (see + // `api-exposure.ts`'s own ADR note): the `find` below runs under the + // key's `ExecutionContext` and passes CRUD/FLS/RLS either way. What was + // leaking is the author's exposure DECLARATION. + // + // No metadata service ⇒ no declaration to read ⇒ open, exactly as + // `enforceApiExposure` itself falls open when `getObject` throws or + // resolves nothing. Unreachable in practice: `bridgeResources` — the + // only consumer of this reader — is called under `if (metadataService)` + // below, so the resource is never registered without one. + if (gateMetadata) { + await enforceApiExposure(gateMetadata, objectName, GATED_ACTIONS.get, ec); + } const res = (await scopedQl.find(objectName, { where: { id: recordId }, limit: 1, diff --git a/packages/mcp/src/stdio-data-bridge.ts b/packages/mcp/src/stdio-data-bridge.ts index 6ca39102bb..07fe7e5ab9 100644 --- a/packages/mcp/src/stdio-data-bridge.ts +++ b/packages/mcp/src/stdio-data-bridge.ts @@ -188,6 +188,20 @@ function exposureError( * (#8083). Throws {@link McpExposureError} when the object's own declaration * does not expose `action`; returns normally when it does. * + * EXPORTED for the ADR-0101 record resource (#8266), which is the one read path + * on this transport that does NOT go through this bridge: its reader is built + * inline in `plugin.ts` and handed to `bridgeResources`, so it called `ql.find` + * with no gate at all and served rows for objects the tool surface refused. + * That reader now calls this function with {@link GATED_ACTIONS}`.get` — the + * same action word `bridge.get` gates under — so one declaration yields one + * verdict on both read paths. Exported within the package only; `index.ts` + * publishes neither this nor `createStdioDataBridge`. + * + * Any FUTURE read path added to this transport belongs here too. The decision + * is deliberately one function rather than a per-seam re-derivation, because + * the two defects this file has now paid for (#8083, #8266) were both a seam + * that skipped the decision, never a seam that got the decision wrong. + * * The DECISION is not re-implemented here — it comes from the spec's single * source of truth (`resolveEffectiveApiMethods` / `isApiOperationAllowed`), * the same functions `checkApiExposure` (runtime, the HTTP/MCP path) and @@ -211,7 +225,7 @@ function exposureError( * definition be gated on HTTP and ungated on stdio — the very divergence * this function closes, re-opened one shape down. */ -async function enforceApiExposure( +export async function enforceApiExposure( metadataService: IMetadataService, object: string, action: string,