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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/stdio-mcp-registers-tools.md
Original file line numberDiff line numberDiff line change
@@ -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.
113 changes: 86 additions & 27 deletions packages/mcp/src/mcp-http-tools.ts
Original file line numberDiff line numberDiff line change
@@ -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.
Expand DownExpand Up@@ -211,15 +223,53 @@ const VALIDATE_SITE_MAP: Record<string, { role: FieldRole; scope: 'record' | 'fl
};

/**
* Register the object-CRUD tool set on a fresh per-request {@link McpServer}.
* All execution is delegated to `bridge`, which is bound to the caller's
* principal by the runtime.
* Wire the FULL tool surface a bridge can serve onto one {@link McpServer} —
* the single composition both transports call (#8034).
*
* Object CRUD always; the business-action pair only when the bridge implements
* `listActions` + `runAction` (graceful degradation — a host with no action
* mechanism keeps serving object tools unchanged). Whoever owns the server
* decides nothing else: the tool set is a function of the BRIDGE, so the same
* bridge yields the same tools on stdio and over HTTP, which is the property
* `transport-parity` pins.
*
* @returns the names actually registered, so a host can report its surface
* honestly instead of asserting a count that can drift from the code above.
*/
export function wireBridgeTools(
server: McpServer,
bridge: McpDataBridge & Partial<McpActionBridge>,
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.
Expand All@@ -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.',
Expand All@@ -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.',
Expand All@@ -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 ' +
Expand DownExpand Up@@ -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. ' +
Expand DownExpand Up@@ -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, ' +
Expand DownExpand Up@@ -459,7 +509,7 @@ export function registerObjectTools(
}

server.registerTool(
'get_record',
note('get_record'),
{
description: 'Fetch a single record by id.',
inputSchema: {
Expand All@@ -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: {
Expand All@@ -505,7 +555,7 @@ export function registerObjectTools(
);

server.registerTool(
'update_record',
note('update_record'),
{
description: 'Update fields on an existing record by id.',
inputSchema: {
Expand All@@ -527,7 +577,7 @@ export function registerObjectTools(
);

server.registerTool(
'delete_record',
note('delete_record'),
{
description: 'Delete a record by id. This is destructive.',
inputSchema: {
Expand All@@ -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.
Expand All@@ -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"). ' +
Expand All@@ -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 — ' +
Expand DownExpand Up@@ -647,6 +704,8 @@ export function registerActionTools(
}
},
);

return registered;
}

function messageOf(err: unknown): string {
Expand Down
104 changes: 88 additions & 16 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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.',
Expand DownExpand Up@@ -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<McpActionBridge>,
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.
*/
Expand DownExpand Up@@ -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.',
Expand All@@ -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({
Expand Down
Loading
Loading