diff --git a/.changeset/stdio-mcp-registers-tools.md b/.changeset/stdio-mcp-registers-tools.md new file mode 100644 index 0000000000..7b9f17d9ca --- /dev/null +++ b/.changeset/stdio-mcp-registers-tools.md @@ -0,0 +1,11 @@ +--- +'@objectstack/mcp': patch +--- + +Serve the object tools over the stdio MCP transport instead of only advertising them + +The stdio MCP server advertised `capabilities.tools` in its `initialize` result and then answered `-32601 Method not found` to every `tools/list` and `tools/call`, so an MCP client that connected successfully could not query or mutate a single object. The same process answered the same requests correctly over HTTP (`POST /api/v1/mcp`), which is what made the cause visible: `registerObjectTools` / `registerActionTools` were reachable only from `handleHttpRequest()`'s throwaway per-request server, and the long-lived server behind stdio received only the AI service's function-calling `ToolRegistry` — a different surface, empty on any app that registers no AI tools. + +Both transports now register through one composition (`wireBridgeTools`), and the stdio host builds a principal-bound data bridge from the `OS_MCP_STDIO_API_KEY` identity, re-resolved per call so a revoked key stops working on the next tool call (ADR-0101 D1). Permissions, RLS and FLS apply exactly as they do to the same identity over REST. + +The `tools`, `resources` and `prompts` capabilities are no longer hand-declared at construction: the MCP SDK declares each one when something is actually registered, so what a server advertises and what it serves can no longer disagree (ADR-0076 D12). A deployment with no principal to bind — or no metadata service — now advertises no tool capability instead of advertising an empty one, and says so in the boot log. diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 3bcf222590..ffffb366fd 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -1,17 +1,29 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * mcp-http-tools — object CRUD exposed as MCP tools for the HTTP transport. + * mcp-http-tools — object CRUD exposed as MCP tools, for EVERY transport. * - * These are the tools an external agent (Claude Desktop / Cursor) drives over - * the network. Unlike the stdio bridge — which is a trusted local process — - * the HTTP surface is reached by arbitrary callers, so every operation MUST - * run under the caller's resolved principal. We never touch the data engine - * directly here: all reads/writes go through an injected {@link McpDataBridge} - * that the runtime wires to the SAME permission/RLS-enforcing path the REST - * API uses (`callData` with the request's ExecutionContext). This module owns + * These are the tools an external agent (Claude Desktop / Cursor) drives, over + * the network or down a local pipe. Every operation MUST run under the caller's + * resolved principal: we never touch the data engine directly here, all + * reads/writes go through an injected {@link McpDataBridge} that the host wires + * to the SAME permission/RLS-enforcing path the REST API uses. This module owns * the tool *shape*; the bridge owns *execution + security*. * + * [#8034] The file name says `http` for historical reasons only, and believing + * it cost this package a transport. Until #8034 {@link registerObjectTools} and + * {@link registerActionTools} were called from exactly one place — + * `MCPServerRuntime.handleHttpRequest()`, on the throwaway per-request server — + * so the LONG-LIVED server behind the stdio transport reached `tools/list` with + * an empty registry and answered `-32601` while its `initialize` result + * advertised `capabilities.tools`. {@link wireBridgeTools} is now the one + * composition both transports call, so a tool added here reaches both by + * construction and neither can silently serve a different set. + * + * The bridge, not the transport, is what varies: the HTTP host binds it to the + * request's ExecutionContext, the stdio host to the `OS_MCP_STDIO_API_KEY` + * identity (re-resolved per call, ADR-0101). Both hand the same interface here. + * * SECURITY (zero-tolerance): * - System objects (`sys_*`) are NOT exposed by default — fail-closed guard on * every tool that takes an object name, independent of the bridge. @@ -211,15 +223,53 @@ const VALIDATE_SITE_MAP: Record, + options: RegisterObjectToolsOptions & RegisterActionToolsOptions = {}, +): string[] { + const registered = registerObjectTools(server, bridge, options); + if (typeof bridge.listActions === 'function' && typeof bridge.runAction === 'function') { + registered.push(...registerActionTools(server, bridge as McpActionBridge, options)); + } + return registered; +} + +/** + * 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 + * delegated to `bridge`, which the host binds to the caller's principal. + * + * @returns the names registered on this call (the set varies with + * `grantedScopes` and with whether the bridge implements `aggregate`). */ export function registerObjectTools( server: McpServer, bridge: McpDataBridge, options: RegisterObjectToolsOptions = {}, -): void { +): string[] { + // Recorded AT the registration site (`note('…')` below) rather than as a + // second list here: a literal list would be a parallel spelling of the same + // fact, and the first tool added without updating it would make every + // caller's report of this surface wrong while every test stayed green. + const registered: string[] = []; + const note = (name: string): string => { + registered.push(name); + return name; + }; const allowSystem = options.allowSystemObjects === true; const maxLimit = options.maxQueryLimit ?? DEFAULT_MAX_LIMIT; // OAuth tool-family gating (#2698). undefined = not scope-limited. @@ -240,7 +290,7 @@ export function registerObjectTools( if (canRead) { server.registerTool( - 'list_objects', + note('list_objects'), { description: 'List the data objects (tables) available in this app. Returns each object\'s name, label and field count.', @@ -259,7 +309,7 @@ export function registerObjectTools( ); server.registerTool( - 'describe_object', + note('describe_object'), { description: 'Get the schema of a data object: its fields (name, type, label, required) and enabled features.', @@ -285,7 +335,7 @@ export function registerObjectTools( // self-correct, instead of shipping a formula that silently evaluates to // `null` (#1928). Read-only (schema introspection); no data is touched. server.registerTool( - 'validate_expression', + note('validate_expression'), { description: 'Validate a CEL expression against an object\'s schema before authoring it into metadata. Returns ' + @@ -343,7 +393,7 @@ export function registerObjectTools( ); server.registerTool( - 'query_records', + note('query_records'), { description: 'Query records from an object with optional filter, field selection, sorting and pagination. ' + @@ -385,7 +435,7 @@ export function registerObjectTools( if (typeof bridge.aggregate === 'function') { const aggregateFn = bridge.aggregate.bind(bridge); server.registerTool( - 'aggregate_records', + note('aggregate_records'), { description: 'Aggregate records with GROUP BY: count/sum/avg/min/max/count_distinct over an object, ' + @@ -459,7 +509,7 @@ export function registerObjectTools( } server.registerTool( - 'get_record', + note('get_record'), { description: 'Fetch a single record by id.', inputSchema: { @@ -484,7 +534,7 @@ export function registerObjectTools( if (canWrite) { server.registerTool( - 'create_record', + note('create_record'), { description: 'Create a new record. Runs under the caller\'s permissions and validations.', inputSchema: { @@ -505,7 +555,7 @@ export function registerObjectTools( ); server.registerTool( - 'update_record', + note('update_record'), { description: 'Update fields on an existing record by id.', inputSchema: { @@ -527,7 +577,7 @@ export function registerObjectTools( ); server.registerTool( - 'delete_record', + note('delete_record'), { description: 'Delete a record by id. This is destructive.', inputSchema: { @@ -547,11 +597,13 @@ export function registerObjectTools( }, ); } // end canWrite (data:write) + + return registered; } /** - * Register the business-action tool set (`list_actions`, `run_action`) on a - * fresh per-request {@link McpServer}. This is the action analogue of + * Register the business-action tool set (`list_actions`, `run_action`) on an + * {@link McpServer}. This is the action analogue of * {@link registerObjectTools}: it owns the tool *shape* and delegates all * resolution + dispatch + security to `bridge`, which the runtime binds to the * caller's principal. @@ -571,16 +623,21 @@ export function registerActionTools( server: McpServer, bridge: McpActionBridge, options: RegisterActionToolsOptions = {}, -): void { +): string[] { + const registered: string[] = []; + const note = (name: string): string => { + registered.push(name); + return name; + }; const allowSystem = options.allowSystemObjects === true; // OAuth tool-family gating (#2698): the whole action surface requires // `actions:execute`. Not registered = unknown tool = fail-closed. if (options.grantedScopes && !options.grantedScopes.includes(MCP_OAUTH_SCOPE_ACTIONS)) { - return; + return registered; } server.registerTool( - 'list_actions', + note('list_actions'), { description: 'List the business actions you can invoke in this app (e.g. "complete task", "convert lead"). ' + @@ -604,7 +661,7 @@ export function registerActionTools( ); server.registerTool( - 'run_action', + note('run_action'), { description: 'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic — ' + @@ -647,6 +704,8 @@ export function registerActionTools( } }, ); + + return registered; } function messageOf(err: unknown): string { diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 2930c69954..60c48e68b4 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -6,7 +6,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import type { Logger, IMetadataService, AIToolDefinition } from '@objectstack/spec/contracts'; import type { Agent } from '@objectstack/spec/ai'; import type { ToolRegistry, ToolExecutionResult } from './types.js'; -import { registerObjectTools, registerActionTools } from './mcp-http-tools.js'; +import { wireBridgeTools } from './mcp-http-tools.js'; import type { McpDataBridge, McpActionBridge, @@ -614,10 +614,38 @@ export class MCPServerRuntime { version: this.config.version, }, { + // [#8034] `resources` / `tools` / `prompts` are DELIBERATELY absent + // here — they are declared by the SDK when something is actually + // registered, never by hand. + // + // Until #8034 this object hand-declared all three, and the `tools` one + // was a lie on the transport that mattered most: `McpServer.registerTool` + // is what installs the `tools/list` + `tools/call` handlers (its + // `setToolRequestHandlers()` also calls `server.registerCapabilities({ + // tools: … })`), so a long-lived server that registered NO tool + // advertised `capabilities.tools: {}` in its `initialize` result and + // then answered `-32601 Method not found` to every `tools/list` and + // `tools/call`. That is the dishonest self-report ADR-0076 D12 / #2462 + // forbid — "advertise what you actually serve" — and this lane closed + // the same shape twice on other surfaces (#7939 `handlerReady: true` + // for an empty slot, #7602 `capabilities.search` with no route). + // + // Deriving them is what makes the two halves agree STRUCTURALLY rather + // than by two literals that can drift: there is now no way to advertise + // a primitive without also installing its handlers, because the SDK + // does both in one call. Registration order is unchanged and already + // correct — every bridge runs before `start()` connects the transport, + // which is also what `Server.registerCapabilities` requires (it throws + // once a transport is attached). The per-request HTTP server in + // {@link handleHttpRequest} has always built its capabilities this way + // (see the `skillBridge ? { prompts: {} }` line there); this brings the + // long-lived server to the same contract. + // + // `logging` STAYS hand-declared: it is honest. The SDK has no + // `registerLogging` to derive it from, and the declaration is itself + // what wires the `logging/setLevel` request handler and enables + // `sendLoggingMessage` — so here, declared IS served. capabilities: { - resources: {}, - tools: {}, - prompts: {}, logging: {}, }, instructions: this.config.instructions ?? 'ObjectStack MCP Server — access data objects, AI tools, and agent prompts.', @@ -671,6 +699,44 @@ export class MCPServerRuntime { logger?.info(`[MCP] Bridged ${tools.length} tools from ToolRegistry`); } + /** + * [#8034] Bridge a principal-bound {@link McpDataBridge} onto the LONG-LIVED + * server — the object-CRUD tools, plus the business-action pair when the + * bridge carries that seam. + * + * This is the stdio counterpart of what {@link handleHttpRequest} does per + * request, and it exists because that per-request call used to be the ONLY + * one. `registerObjectTools` / `registerActionTools` were reachable from + * nowhere else, so the long-lived server's entire tool surface was whatever + * {@link bridgeTools} found in the AI service's function-calling + * `ToolRegistry` — a DIFFERENT surface, empty on any app that registers no AI + * tools. The stdio transport therefore served zero tools while advertising + * the `tools` capability, and every `tools/list` / `tools/call` answered + * `-32601 Method not found`. Both transports now register through the one + * {@link wireBridgeTools} composition. + * + * Ordering: call this BEFORE {@link start}. Tool registration is also what + * declares the `tools` capability (see the constructor), and the SDK refuses + * to register capabilities once a transport is attached. The plugin bridges + * everything ahead of `start()` for exactly that reason. + * + * Not called for a host that has no principal to bind: no bridge means no + * tools registered and no `tools` capability advertised, which is the honest + * report rather than an empty promise (ADR-0076 D12). + * + * @returns the tool names registered, for the caller's boot log. + */ + bridgeDataTools( + bridge: McpDataBridge & Partial, + toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions, + ): string[] { + const registered = wireBridgeTools(this.mcpServer, bridge, toolOptions); + this.config.logger?.info( + `[MCP] Bridged ${registered.length} data tools (${registered.join(', ')})`, + ); + return registered; + } + /** * Register a single tool on the MCP server from an AIToolDefinition. */ @@ -1142,7 +1208,20 @@ export class MCPServerRuntime { const server = new McpServer( { name: this.config.name, version: this.config.version }, { - capabilities: { tools: {}, ...(skillBridge ? { prompts: {} } : {}) }, + // [#8034] `tools` is DERIVED, exactly as on the long-lived server: + // `registerObjectTools` declares it when it registers the first tool, + // so a request that supplies no bridge (or a grant that registers + // nothing) now advertises no tool capability instead of advertising one + // and answering `-32601` — which is what the two "registers nothing" + // pins in this package already describe in their titles. + // + // `prompts` STAYS hand-declared and is not the same case: + // `registerSkillPrompts` installs LOW-LEVEL request handlers so the + // list can be read at call time, and `Server.setRequestHandler` refuses + // a handler whose capability was not declared first. Here the + // declaration is what makes the handlers installable, and it is gated + // on the seam actually being there — declared IS served. + capabilities: { ...(skillBridge ? { prompts: {} } : {}) }, instructions: this.config.instructions ?? 'ObjectStack MCP Server — query and modify your app\'s data objects as tools.', @@ -1154,17 +1233,10 @@ export class MCPServerRuntime { } if (opts.bridge) { - registerObjectTools(server, opts.bridge, opts.toolOptions); - // The action surface is wired by capability: only when the runtime's - // bridge can resolve + dispatch the framework's actions. A host with no - // action mechanism keeps serving object tools unchanged (graceful - // degradation, mirroring how record resources need a dataEngine). - if ( - typeof opts.bridge.listActions === 'function' && - typeof opts.bridge.runAction === 'function' - ) { - registerActionTools(server, opts.bridge as McpActionBridge, opts.toolOptions); - } + // [#8034] The SAME composition the long-lived server uses in + // {@link bridgeDataTools} — including the by-capability action wiring + // that used to be open-coded here. Two transports, one call site. + wireBridgeTools(server, opts.bridge, opts.toolOptions); } const transport = new WebStandardStreamableHTTPServerTransport({ diff --git a/packages/mcp/src/mcp-stdio-tools.test.ts b/packages/mcp/src/mcp-stdio-tools.test.ts new file mode 100644 index 0000000000..4c59ec599e --- /dev/null +++ b/packages/mcp/src/mcp-stdio-tools.test.ts @@ -0,0 +1,607 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8034 — the stdio transport must SERVE the tools it advertises. + * + * The defect: `initialize` over stdio answered `capabilities.tools: {}` while + * `tools/list` and every `tools/call` answered `-32601 Method not found`. In + * the same process, with the same key, HTTP returned the full tool set — so + * neither the bridge nor the principal was at fault. The object/action tools + * were registered ONLY inside `handleHttpRequest()`, on its throwaway + * per-request server; the long-lived server behind stdio got whatever the AI + * service's function-calling `ToolRegistry` held, which on an app that + * registers no AI tools is nothing. + * + * WHY THESE TESTS DRIVE A REAL `StdioServerTransport`. The 17 pins that were + * green through the whole outage exercised `handleHttpRequest` and + * `bridgeTools` separately, and neither can see a transport serving a + * different surface from the other. Every case below speaks newline-delimited + * JSON-RPC down a real `StdioServerTransport` attached to the real long-lived + * server — the wire a desktop MCP host actually uses, and the exact shape of + * the card's repro. The transport is fed `PassThrough` pipes instead of + * `process.stdin`/`stdout` (its constructor takes both) so the frames are + * readable without spawning a process; `MCPServerRuntime.start()` attaches + * this same class to the same `this.mcpServer`. + * + * WHY THE CAPABILITY PIN COMPARES TWO LIVE READS. Asserting "advertises tools" + * and "lists 9 tools" as two literals is what the old pins effectively did, and + * both halves stayed true of a server that served neither. `capability surface + * agreement` below reads the advertised set out of `initialize` and the served + * set out of the list methods on the SAME connection, and asserts them against + * each other in BOTH directions — so it goes red on an advertisement without a + * handler AND on a handler without an advertisement, whichever way a future + * change breaks it. + */ + +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 { MCPServerRuntime } from './mcp-server-runtime.js'; +import { MCPServerPlugin } from './plugin.js'; +import type { McpDataBridge } from './mcp-http-tools.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. */ +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 { + // Not a frame (a host that prints to stdout — see the banner note in + // #7915). Skipped rather than failed: this file pins the MCP surface, + // and a strict parse here would turn someone else's noise into a red + // build for a defect it says nothing about. + 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, 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(() => {}); + }, + }; +} + +const INITIALIZE_PARAMS = { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'stdio-pin', version: '0.0.0' }, +}; + +/** `initialize` + `notifications/initialized`, as a real client does. */ +async function handshake(session: StdioSession): Promise { + const init = await session.rpc('initialize', INITIALIZE_PARAMS); + session.notify('notifications/initialized'); + return init; +} + +// --------------------------------------------------------------------------- +// Bridge double — records every call so delegation can be asserted +// --------------------------------------------------------------------------- + +function makeBridge(): McpDataBridge & { calls: any[][] } { + const calls: any[][] = []; + return { + calls, + async listObjects() { + calls.push(['listObjects']); + return [ + { name: 'task', label: 'Task', fieldCount: 2 }, + { name: 'sys_user', label: 'User', fieldCount: 9 }, + ]; + }, + async describeObject(name: string) { + calls.push(['describeObject', name]); + return name === 'task' ? { name: 'task', fields: [{ name: 'title', type: 'text' }] } : null; + }, + async query(object: string, opts: any) { + calls.push(['query', object, opts]); + return { object, records: [{ id: 't1', title: 'from the stdio transport' }], total: 1 }; + }, + async get(object: string, id: string) { + calls.push(['get', object, id]); + return { id, title: 'a' }; + }, + async create(object: string, data: any) { + calls.push(['create', object, data]); + return { object, id: 'new1', record: data }; + }, + async update(object: string, id: string, data: any) { + calls.push(['update', object, id, data]); + return { object, id, record: data }; + }, + async remove(object: string, id: string) { + calls.push(['remove', object, id]); + return { object, id, success: true }; + }, + }; +} + +/** The object-CRUD surface a bridge without the optional seams yields. */ +const OBJECT_TOOLS = [ + 'create_record', + 'delete_record', + 'describe_object', + 'get_record', + 'list_objects', + 'query_records', + 'update_record', + 'validate_expression', +].sort(); + +describe('#8034 stdio transport: tools/list', () => { + let sessions: StdioSession[]; + + beforeEach(() => { + sessions = []; + }); + + afterEach(async () => { + for (const session of sessions) await session.close(); + }); + + async function connect(runtime: MCPServerRuntime): Promise { + const session = await openStdio(runtime.server); + sessions.push(session); + return session; + } + + it('answers tools/list with the object tool NAMES, not -32601', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + runtime.bridgeDataTools(makeBridge()); + + const session = await connect(runtime); + await handshake(session); + + const listed = await session.rpc('tools/list'); + + // The reported symptom, stated as the failure message so a regression + // reads as itself rather than as "undefined is not an object". + expect( + listed.error, + `tools/list answered an error over stdio (#8034 was ${JSON.stringify(listed.error)})`, + ).toBeUndefined(); + + const names = (listed.result.tools as Array<{ name: string }>).map((t) => t.name).sort(); + expect(names).toEqual(OBJECT_TOOLS); + expect(names.length).toBeGreaterThan(0); + }); + + it('registers the action tools too when the bridge carries that seam', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + runtime.bridgeDataTools({ + ...makeBridge(), + async listActions() { + return [{ name: 'complete_task', objectName: 'task' }]; + }, + async runAction() { + return { ok: true }; + }, + }); + + const session = await connect(runtime); + await handshake(session); + const listed = await session.rpc('tools/list'); + const names = (listed.result.tools as Array<{ name: string }>).map((t) => t.name); + + expect(names).toContain('list_actions'); + expect(names).toContain('run_action'); + }); + + it('serves NO tools and advertises none when no bridge was given', async () => { + // The honest end of the same contract: a host with no principal to bind + // registers nothing, so the capability is absent rather than advertised + // over an empty surface. Before #8034 this server advertised `tools` here. + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + + const session = await connect(runtime); + const init = await handshake(session); + + expect(init.result.capabilities.tools).toBeUndefined(); + const listed = await session.rpc('tools/list'); + expect(listed.result).toBeUndefined(); + expect(listed.error).toBeDefined(); + }); +}); + +describe('#8034 stdio transport: capability ↔ served surface agreement', () => { + /** Every MCP primitive this server can carry, and how a client asks for it. */ + const LIST_METHOD: Record = { + tools: 'tools/list', + resources: 'resources/list', + prompts: 'prompts/list', + }; + + function metadataDouble() { + return { + listObjects: vi.fn(async () => [{ name: 'task', label: 'Task', fields: { title: {} } }]), + getObject: vi.fn(async () => null), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + getRegisteredTypes: vi.fn(async () => ['object']), + register: vi.fn(), + unregister: vi.fn(), + }; + } + + /** + * Read both sides off ONE live connection and reconcile them. + * + * Neither side is written down here: the advertised set comes from the + * server's own `initialize` result, the served set from whether each list + * method answers or raises. That is the whole point — two literals would + * agree with each other while agreeing with nothing the server does. + */ + async function reconcile(runtime: MCPServerRuntime) { + const session = await openStdio(runtime.server); + try { + const init = await handshake(session); + const advertised = new Set(Object.keys((init.result.capabilities ?? {}) as object)); + const served = new Set(); + for (const [capability, method] of Object.entries(LIST_METHOD)) { + const answer = await session.rpc(method); + if (!answer.error) served.add(capability); + } + return { advertised, served }; + } finally { + await session.close(); + } + } + + it('advertises exactly the primitives it serves — bridged server', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const metadataService = metadataDouble(); + runtime.bridgeResources(metadataService as any); + await runtime.bridgePrompts(metadataService as any); + runtime.bridgeDataTools(makeBridge()); + + const { advertised, served } = await reconcile(runtime); + + // A primitive is advertised if and only if its method answers. + for (const capability of Object.keys(LIST_METHOD)) { + expect( + advertised.has(capability), + `capabilities.${capability} advertised=${advertised.has(capability)} but ${LIST_METHOD[capability]} served=${served.has(capability)}`, + ).toBe(served.has(capability)); + } + // …and this assembly really does serve all three, so the agreement above + // is not the vacuous "nothing advertised, nothing served". + expect([...served].sort()).toEqual(['prompts', 'resources', 'tools']); + }); + + it('advertises exactly the primitives it serves — bare server', async () => { + // The direction that used to fail: nothing bridged at all. Every + // capability the constructor once hand-declared was unserved here. + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + + const { advertised, served } = await reconcile(runtime); + + for (const capability of Object.keys(LIST_METHOD)) { + expect( + advertised.has(capability), + `capabilities.${capability} advertised=${advertised.has(capability)} but ${LIST_METHOD[capability]} served=${served.has(capability)}`, + ).toBe(served.has(capability)); + } + expect([...served]).toEqual([]); + }); +}); + +describe('#8034 transport parity: one bridge, one tool surface', () => { + async function stdioToolNames(bridge: McpDataBridge): Promise { + const runtime = new MCPServerRuntime({ name: 'parity', version: '1.0.0' }); + runtime.bridgeDataTools(bridge); + const session = await openStdio(runtime.server); + try { + await handshake(session); + const listed = await session.rpc('tools/list'); + expect(listed.error).toBeUndefined(); + return (listed.result.tools as Array<{ name: string }>).map((t) => t.name).sort(); + } finally { + await session.close(); + } + } + + async function httpToolNames(bridge: McpDataBridge): Promise { + const runtime = new MCPServerRuntime({ name: 'parity', version: '1.0.0' }); + const body = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; + const res = await runtime.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), + }), + { bridge, parsedBody: body }, + ); + const json: any = await res.json(); + expect(json.error).toBeUndefined(); + return (json.result.tools as Array<{ name: string }>).map((t) => t.name).sort(); + } + + it('exposes the SAME tool names on stdio and over HTTP for the same bridge', async () => { + // The gate the card asks for. Divergence — not absence — was the bug: HTTP + // served 11 tools while stdio served none, and no pin compared the two. + const bridge = makeBridge(); + const overStdio = await stdioToolNames(bridge); + const overHttp = await httpToolNames(bridge); + + expect(overStdio).toEqual(overHttp); + expect(overStdio.length).toBeGreaterThan(0); + }); + + it('stays in agreement when the bridge grows an optional seam', async () => { + // `aggregate` is registered by capability, so a bridge that has it must + // gain the tool on BOTH transports or neither. + const bridge: McpDataBridge = { + ...makeBridge(), + async aggregate() { + return [{ status: 'open', n: 1 }]; + }, + }; + const overStdio = await stdioToolNames(bridge); + const overHttp = await httpToolNames(bridge); + + expect(overStdio).toContain('aggregate_records'); + expect(overStdio).toEqual(overHttp); + }); +}); + +describe('#8034 stdio transport: a tool actually runs', () => { + it('tools/call reaches the bridge and returns its result over the wire', async () => { + // `tools/list` answering while every call fails would be the same defect + // one layer down, so the invocation is driven end to end on the same wire. + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const bridge = makeBridge(); + runtime.bridgeDataTools(bridge); + + const session = await openStdio(runtime.server); + try { + await handshake(session); + const called = await session.rpc('tools/call', { + name: 'query_records', + arguments: { objectName: 'task', where: { status: 'open' }, limit: 5 }, + }); + + expect(called.error).toBeUndefined(); + expect(called.result.isError).toBeFalsy(); + const payload = JSON.parse(called.result.content[0].text); + expect(payload.records[0].title).toBe('from the stdio transport'); + + const queryCall = bridge.calls.find((c) => c[0] === 'query'); + expect(queryCall?.[1]).toBe('task'); + expect(queryCall?.[2].where).toEqual({ status: 'open' }); + } finally { + await session.close(); + } + }); + + it('keeps the fail-closed system-object guard on this transport', async () => { + // The guard lives in the tool, not the transport — pinned here because + // stdio now reaches those tools for the first time. + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const bridge = makeBridge(); + runtime.bridgeDataTools(bridge); + + const session = await openStdio(runtime.server); + try { + await handshake(session); + const called = await session.rpc('tools/call', { + name: 'describe_object', + arguments: { objectName: 'sys_user' }, + }); + + expect(called.result.isError).toBe(true); + expect(called.result.content[0].text).toMatch(/system object/i); + expect(bridge.calls.find((c) => c[0] === 'describeObject')).toBeUndefined(); + } finally { + await session.close(); + } + }); +}); + +describe('#8034 plugin composition: os serve stdio wiring', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.OS_MCP_SERVER_TRANSPORT; + delete process.env.OS_MCP_STDIO_ENABLED; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + /** + * The `objectql` service, faked at the ONE seam the plugin uses: `find`. + * `sys_api_key` resolves the stdio principal through the real + * `resolveAuthzContext` chain (a row with a `user_id` is all it needs); every + * other object is the data the tools read. + */ + 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: 'wired through the plugin' }]; + 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), + }; + } + + function fakeMetadata() { + return { + listObjects: vi.fn(async () => [{ name: 'task', label: 'Task', fields: { title: {} } }]), + getObject: vi.fn(async () => null), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + 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(() => ({})), + }; + } + + it('registers the tools on the long-lived server and runs one under the key identity', async () => { + process.env.OS_MCP_STDIO_API_KEY = 'osk_stdio_pin'; + const ql = fakeObjectQL(); + const ctx = mockContext({ metadata: fakeMetadata(), 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; + + // Only the transport attach is stubbed: `start()` would claim this test + // process's real stdin/stdout. Everything the card is about — building the + // bridge and registering the tools — runs for real, and the assertions + // below speak to the same server `start()` would have connected. + const start = vi.spyOn(runtime, 'start').mockResolvedValue(undefined); + await plugin.start(ctx as any); + expect(start).toHaveBeenCalled(); + + const session = await openStdio(runtime.server); + try { + const init = await handshake(session); + expect(init.result.capabilities.tools).toBeDefined(); + + const listed = await session.rpc('tools/list'); + expect(listed.error).toBeUndefined(); + const names = (listed.result.tools as Array<{ name: string }>).map((t) => t.name); + expect(names).toContain('query_records'); + expect(names).toContain('list_objects'); + + const called = await session.rpc('tools/call', { + name: 'query_records', + arguments: { objectName: 'task' }, + }); + expect(called.result.isError).toBeFalsy(); + const payload = JSON.parse(called.result.content[0].text); + expect(payload.records[0].title).toBe('wired through the plugin'); + + // The read ran AS the key's identity — the ADR-0101 property the record + // resource already had, now covering the tool surface as well. + const dataRead = ql.find.mock.calls.find((c: any[]) => c[0] === 'task'); + expect(dataRead).toBeDefined(); + expect((dataRead as any[])[2].context.userId).toBe('usr_stdio'); + expect((dataRead as any[])[2].context.isSystem).toBe(false); + } finally { + await session.close(); + } + }); + + it('bridges no tools — and advertises none — without a metadata service', async () => { + process.env.OS_MCP_STDIO_API_KEY = 'osk_stdio_pin'; + const ctx = mockContext({ objectql: fakeObjectQL() }); + + 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); + + // Loud, once, naming the remedy — never a silent empty surface. + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('stdio transport starting WITHOUT object tools'), + ); + + const session = await openStdio(runtime.server); + try { + const init = await handshake(session); + expect(init.result.capabilities.tools).toBeUndefined(); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 7a01c6f529..735b1a36fb 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -4,10 +4,12 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext } from '@objectstack/core'; import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { IAIService, IMetadataService } from '@objectstack/spec/contracts'; +import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; 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 type { McpDataBridge } from './mcp-http-tools.js'; import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js'; /** @@ -175,9 +177,15 @@ export class MCPServerPlugin implements Plugin { let getRecord: | ((objectName: string, recordId: string) => Promise | null>) | undefined; + // [#8034] The object-tool surface for the long-lived server, bound to the + // same identity as `getRecord` above. Built only on the stdio path, because + // that is the only path with a principal to bind: no principal ⇒ no bridge + // ⇒ no tools registered ⇒ no `tools` capability advertised, which is the + // honest report rather than the empty promise this issue is about. + let dataBridge: McpDataBridge | undefined; if (shouldStart) { const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true }); - let ql: { find: (object: string, opts: unknown) => Promise } | undefined; + let ql: (IDataEngine & { find: (object: string, opts: unknown) => Promise }) | undefined; try { ql = ctx.getService('objectql'); } catch { @@ -206,9 +214,30 @@ export class MCPServerPlugin implements Plugin { } const scopedQl = ql; // Re-resolve per call so a revoked/expired key stops working on the next read. - getRecord = async (objectName, recordId) => { + const resolvePrincipal = async (): Promise => { const ec = await resolveStdioExecutionContext(scopedQl, apiKey); if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)'); + return ec; + }; + if (metadataService) { + dataBridge = createStdioDataBridge({ + engine: scopedQl, + metadataService, + resolvePrincipal, + }); + } else { + // Functional degradation, said once and naming the remedy: two of the + // object tools read the schema, so without a metadata service the + // surface cannot be served at all. Nothing is advertised in its place. + ctx.logger.warn( + '[MCP] stdio transport starting WITHOUT object tools — the metadata service is not registered, ' + + 'so list_objects/describe_object have nothing to read and no tool surface is bridged. ' + + 'An MCP client will see resources and prompts but no tools. ' + + 'Fix: register the metadata service (the metadata plugin) in this assembly.', + ); + } + getRecord = async (objectName, recordId) => { + const ec = await resolvePrincipal(); const res = (await scopedQl.find(objectName, { where: { id: recordId }, limit: 1, @@ -231,6 +260,14 @@ export class MCPServerPlugin implements Plugin { await this.runtime.bridgePrompts(metadataService); } + // [#8034] BEFORE `start()`, with the resources and prompts: registering a + // tool is also what declares the `tools` capability, and the SDK refuses to + // register capabilities once a transport is attached. Every bridge on this + // server is complete before the transport claims stdin/stdout. + if (dataBridge) { + this.runtime.bridgeDataTools(dataBridge); + } + if (shouldStart) { await this.runtime.start(); ctx.logger.info('[MCP] Server started automatically'); diff --git a/packages/mcp/src/stdio-data-bridge.ts b/packages/mcp/src/stdio-data-bridge.ts new file mode 100644 index 0000000000..d65c8fa63a --- /dev/null +++ b/packages/mcp/src/stdio-data-bridge.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * stdio-data-bridge — the principal-bound {@link McpDataBridge} the LONG-LIVED + * (stdio) MCP server serves its object tools from (#8034). + * + * ## Why this exists + * + * `McpDataBridge` is an injected seam by design: the tool *shape* and every + * fail-closed guard live in `mcp-http-tools.ts` (one owner), and each host + * supplies the *execution + security* half bound to whatever principal that + * host resolved. The HTTP dispatcher supplies one built from the request's + * ExecutionContext (`packages/runtime/src/domains/mcp.ts` → `buildMcpBridge`). + * The stdio transport had none at all — which is the whole of #8034: with no + * bridge, nothing ever called `registerObjectTools`, so the long-lived server + * advertised `capabilities.tools` and answered `-32601` to `tools/list`. + * + * The runtime's builder cannot be reused here, and not for want of trying: it + * closes over an `HttpProtocolContext` (the request, its resolved kernel, its + * per-environment data driver) and runs every verb through `callData`, whose + * whole signature is request-shaped. A long-lived stdio session has no request + * — it has ONE identity, resolved from `OS_MCP_STDIO_API_KEY` at boot and + * re-resolved on every call so a revoked key stops working on the next read + * (ADR-0101 D1). + * + * ## What it runs on + * + * {@link IDataEngine} with a per-call `context` — the SAME seam this plugin's + * ADR-0101 record resource (`getRecord`) has used since #7645, and a contract + * `packages/spec` actually declares. The security property rides on the engine, + * not on this file: RBAC / RLS / FLS are the engine's middleware chain, so a + * tool call here is bounded exactly like the same identity over REST. This file + * decides no policy — if it ever appears to, that is a bug in this file. + * + * ## Known divergences from the HTTP bridge (deliberate, filed, not security) + * + * `callData` prefers the `protocol` service (metadata-protocol) and falls back + * to the engine; this bridge is engine-only. So the HTTP tools additionally get + * that layer's ingress `readonly` strip, its existence probes, its spec-shaped + * receipts and `expand`/`select`, and the ADR-0049 `apiEnabled` / `apiMethods` + * exposure gate `callData` applies before dispatch. None of those is the + * authorization boundary — the exposure gate is a SURFACE-AREA control by its + * own ADR note, and every call here still passes the engine's CRUD/FLS/RLS — + * but the two transports should not differ at all, and unifying them behind one + * transport-neutral data seam is filed as follow-up work rather than forked + * here (route-ownership rule 1: a mirrored copy of `callData` would be a second + * implementation that drifts). + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { EngineAggregateOptions } from '@objectstack/spec/data'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import type { McpDataBridge, McpObjectSummary } from './mcp-http-tools.js'; + +/** The engine's own aggregation-node list — see the cast note in `aggregate`. */ +type EngineAggregations = NonNullable; + +/** What {@link createStdioDataBridge} needs from the host plugin. */ +export interface StdioDataBridgeDeps { + /** The ObjectQL engine — the `objectql` service, where RLS/FLS/permissions run. */ + engine: IDataEngine; + /** The metadata service behind `list_objects` / `describe_object`. */ + metadataService: IMetadataService; + /** + * Re-resolve the stdio identity for THIS call and throw when it no longer + * resolves (revoked / expired / owner-less key). Per call, never cached: + * ADR-0101 D1 requires a revocation to take effect on the next read of a + * live session, and a bridge built once at boot would outlive it. + */ + resolvePrincipal: () => Promise; +} + +/** An object definition as `IMetadataService.getObject` hands it back. */ +interface ObjectDef { + name: string; + label?: string; + fields?: Record; + enable?: Record; +} + +/** + * Unwrap what the engine's read path resolves to. + * + * Same shape-tolerance as the ADR-0101 record reader next door: an engine may + * answer a bare array or an envelope carrying `value`. Tolerating BOTH here is + * not the consumer-side aliasing Prime Directive #12 forbids — it is the one + * spelling the existing stdio reader already accepts, kept identical so the two + * readers on this transport cannot disagree about what a row list is. + */ +function unwrapRows(res: unknown): Array> { + const rows = + res && typeof res === 'object' && 'value' in (res as Record) + ? (res as { value: unknown }).value + : res; + if (Array.isArray(rows)) return rows as Array>; + return rows ? [rows as Record] : []; +} + +/** The one row this id names, or `null`. */ +async function findById( + engine: IDataEngine, + object: string, + id: string, + context: ExecutionContext, +): Promise | null> { + const res = await engine.find(object, { where: { id }, limit: 1 }, { context }); + return unwrapRows(res)[0] ?? null; +} + +/** + * The "this id names no row" refusal, raised BEFORE a write is attempted. + * + * A write path that answers success for an id that matched nothing is the + * #5138 / #5581 defect the HTTP path already paid for: an integrator reading + * a success receipt records the change as landed. `registerObjectTools` turns + * a throw into a tool error, so the caller is told. + */ +function recordNotFound(object: string, id: string): Error { + return new Error(`Record "${id}" not found in "${object}"`); +} + +/** + * Build the stdio transport's principal-bound data bridge. + * + * `aggregate` is attached only when the engine implements it, so a partial + * engine degrades to the same "no `aggregate_records` tool" outcome the HTTP + * bridge produces — the graceful-degradation contract `McpDataBridge` declares, + * honoured rather than re-decided. + */ +export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge { + const { engine, metadataService, resolvePrincipal } = deps; + + const bridge: McpDataBridge = { + async listObjects(): Promise { + const objects = ((await metadataService.listObjects()) ?? []) as ObjectDef[]; + return objects.map((o) => ({ + name: o.name, + label: o.label ?? o.name, + fieldCount: o.fields ? Object.keys(o.fields).length : undefined, + })); + }, + + async describeObject(name: string): Promise { + const def = (await metadataService.getObject(name)) as ObjectDef | undefined | null; + if (!def) return null; + const fields = def.fields ?? {}; + // The field list is an ARRAY here, not the stored map: `validate_expression` + // reads `Array.isArray(def.fields)` off this very value, and the HTTP + // bridge projects the same shape. A map would type-check and silently + // leave that tool with zero fields in scope. + return { + name: def.name, + label: def.label ?? def.name, + fields: Object.entries(fields).map(([key, f]) => ({ + name: key, + type: f?.type, + label: f?.label ?? key, + required: f?.required ?? false, + })), + enableFeatures: def.enable ?? {}, + }; + }, + + async query(object, opts) { + const context = await resolvePrincipal(); + const query: Record = {}; + if (opts?.where) query.where = opts.where; + if (opts?.fields) query.fields = opts.fields; + if (opts?.orderBy) query.orderBy = opts.orderBy; + if (typeof opts?.limit === 'number') query.limit = opts.limit; + if (typeof opts?.offset === 'number') query.offset = opts.offset; + const records = unwrapRows(await engine.find(object, query, { context })); + return { object, records, total: records.length }; + }, + + async get(object, id) { + const context = await resolvePrincipal(); + // `null` rather than a throw: `get_record` owns the not-found wording on + // this path and already branches on a nullish record. + return await findById(engine, object, id, context); + }, + + async create(object, data) { + const context = await resolvePrincipal(); + const written = (await engine.insert(object, data, { context })) as + | Record + | undefined; + const record = { ...data, ...(written ?? {}) }; + return { object, id: record.id, record }; + }, + + async update(object, id, data) { + const context = await resolvePrincipal(); + const existing = await findById(engine, object, id, context); + if (!existing) throw recordNotFound(object, id); + await engine.update(object, data, { where: { id }, context }); + return { object, id, record: { ...existing, ...data } }; + }, + + async remove(object, id) { + const context = await resolvePrincipal(); + const existing = await findById(engine, object, id, context); + if (!existing) throw recordNotFound(object, id); + await engine.delete(object, { where: { id }, context }); + // `success`, not `deleted` — the spec's `DeleteDataResponse` key (#5581). + return { object, id, success: true }; + }, + }; + + if (typeof engine.aggregate === 'function') { + bridge.aggregate = async (object, opts) => { + const context = await resolvePrincipal(); + // Two casts, one cause: `McpDataBridge.aggregate` declares a WIDER input + // than `EngineAggregateOptions` accepts, and the HTTP path never noticed + // because it reaches the engine through `callData`'s untyped `params`. + // + // - `groupBy`: the bridge (and the `aggregate_records` tool schema) + // allow `{ field, dateGranularity, alias }` objects; the engine option + // declares `string[]` — while the `timezone` doc three lines below it + // in that same schema describes "groupBy items carrying a + // dateGranularity". The runtime contract is the object form. + // - `aggregations`: the bridge declares `function: string`; the engine's + // `AggregationNode` closes it to the six-name enum. The tool's own zod + // schema already enforces exactly that enum before a value reaches + // here, so the wide spelling is the interface's, never the caller's. + // + // Casting keeps this transport's request byte-identical to the HTTP one + // rather than narrowing the declared tool input on one transport only. + // The declaration mismatch itself is filed rather than papered over here. + const rows = await engine.aggregate(object, { + ...(opts?.where ? { where: opts.where } : {}), + ...(opts?.groupBy ? { groupBy: opts.groupBy as unknown as string[] } : {}), + aggregations: opts.aggregations as unknown as EngineAggregations, + ...(opts?.timezone ? { timezone: opts.timezone } : {}), + context, + }); + return rows ?? []; + }; + } + + return bridge; +}