Skip to content

Commit 026508b

Browse files
hotlongclaude
andauthored
fix(mcp): register the object tools on the stdio transport (#8034) (#8084)
The long-lived stdio server advertised `capabilities.tools` and answered -32601 to every tools/list and tools/call: registerObjectTools / registerActionTools were called only from handleHttpRequest()'s per-request server, so stdio's whole tool surface was 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 McpDataBridge over the ObjectQL engine with the OS_MCP_STDIO_API_KEY identity re-resolved per call (ADR-0101 D1). The tools/resources/prompts capabilities are derived by the SDK from real registration instead of being hand-declared, so the advertised set and the served set cannot disagree (ADR-0076 D12, #2462). Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7586cd commit 026508b

6 files changed

Lines changed: 1074 additions & 46 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
Serve the object tools over the stdio MCP transport instead of only advertising them
6+
7+
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.
8+
9+
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.
10+
11+
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.

packages/mcp/src/mcp-http-tools.ts

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* mcp-http-tools — object CRUD exposed as MCP tools for the HTTP transport.
4+
* mcp-http-tools — object CRUD exposed as MCP tools, for EVERY transport.
55
*
6-
* These are the tools an external agent (Claude Desktop / Cursor) drives over
7-
* the network. Unlike the stdio bridge — which is a trusted local process —
8-
* the HTTP surface is reached by arbitrary callers, so every operation MUST
9-
* run under the caller's resolved principal. We never touch the data engine
10-
* directly here: all reads/writes go through an injected {@link McpDataBridge}
11-
* that the runtime wires to the SAME permission/RLS-enforcing path the REST
12-
* API uses (`callData` with the request's ExecutionContext). This module owns
6+
* These are the tools an external agent (Claude Desktop / Cursor) drives, over
7+
* the network or down a local pipe. Every operation MUST run under the caller's
8+
* resolved principal: we never touch the data engine directly here, all
9+
* reads/writes go through an injected {@link McpDataBridge} that the host wires
10+
* to the SAME permission/RLS-enforcing path the REST API uses. This module owns
1311
* the tool *shape*; the bridge owns *execution + security*.
1412
*
13+
* [#8034] The file name says `http` for historical reasons only, and believing
14+
* it cost this package a transport. Until #8034 {@link registerObjectTools} and
15+
* {@link registerActionTools} were called from exactly one place —
16+
* `MCPServerRuntime.handleHttpRequest()`, on the throwaway per-request server —
17+
* so the LONG-LIVED server behind the stdio transport reached `tools/list` with
18+
* an empty registry and answered `-32601` while its `initialize` result
19+
* advertised `capabilities.tools`. {@link wireBridgeTools} is now the one
20+
* composition both transports call, so a tool added here reaches both by
21+
* construction and neither can silently serve a different set.
22+
*
23+
* The bridge, not the transport, is what varies: the HTTP host binds it to the
24+
* request's ExecutionContext, the stdio host to the `OS_MCP_STDIO_API_KEY`
25+
* identity (re-resolved per call, ADR-0101). Both hand the same interface here.
26+
*
1527
* SECURITY (zero-tolerance):
1628
* - System objects (`sys_*`) are NOT exposed by default — fail-closed guard on
1729
* every tool that takes an object name, independent of the bridge.
@@ -211,15 +223,53 @@ const VALIDATE_SITE_MAP: Record<string, { role: FieldRole; scope: 'record' | 'fl
211223
};
212224

213225
/**
214-
* Register the object-CRUD tool set on a fresh per-request {@link McpServer}.
215-
* All execution is delegated to `bridge`, which is bound to the caller's
216-
* principal by the runtime.
226+
* Wire the FULL tool surface a bridge can serve onto one {@link McpServer} —
227+
* the single composition both transports call (#8034).
228+
*
229+
* Object CRUD always; the business-action pair only when the bridge implements
230+
* `listActions` + `runAction` (graceful degradation — a host with no action
231+
* mechanism keeps serving object tools unchanged). Whoever owns the server
232+
* decides nothing else: the tool set is a function of the BRIDGE, so the same
233+
* bridge yields the same tools on stdio and over HTTP, which is the property
234+
* `transport-parity` pins.
235+
*
236+
* @returns the names actually registered, so a host can report its surface
237+
* honestly instead of asserting a count that can drift from the code above.
238+
*/
239+
export function wireBridgeTools(
240+
server: McpServer,
241+
bridge: McpDataBridge & Partial<McpActionBridge>,
242+
options: RegisterObjectToolsOptions & RegisterActionToolsOptions = {},
243+
): string[] {
244+
const registered = registerObjectTools(server, bridge, options);
245+
if (typeof bridge.listActions === 'function' && typeof bridge.runAction === 'function') {
246+
registered.push(...registerActionTools(server, bridge as McpActionBridge, options));
247+
}
248+
return registered;
249+
}
250+
251+
/**
252+
* Register the object-CRUD tool set on an {@link McpServer} — the throwaway
253+
* per-request one on HTTP, the long-lived one behind stdio. All execution is
254+
* delegated to `bridge`, which the host binds to the caller's principal.
255+
*
256+
* @returns the names registered on this call (the set varies with
257+
* `grantedScopes` and with whether the bridge implements `aggregate`).
217258
*/
218259
export function registerObjectTools(
219260
server: McpServer,
220261
bridge: McpDataBridge,
221262
options: RegisterObjectToolsOptions = {},
222-
): void {
263+
): string[] {
264+
// Recorded AT the registration site (`note('…')` below) rather than as a
265+
// second list here: a literal list would be a parallel spelling of the same
266+
// fact, and the first tool added without updating it would make every
267+
// caller's report of this surface wrong while every test stayed green.
268+
const registered: string[] = [];
269+
const note = (name: string): string => {
270+
registered.push(name);
271+
return name;
272+
};
223273
const allowSystem = options.allowSystemObjects === true;
224274
const maxLimit = options.maxQueryLimit ?? DEFAULT_MAX_LIMIT;
225275
// OAuth tool-family gating (#2698). undefined = not scope-limited.
@@ -240,7 +290,7 @@ export function registerObjectTools(
240290

241291
if (canRead) {
242292
server.registerTool(
243-
'list_objects',
293+
note('list_objects'),
244294
{
245295
description:
246296
'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(
259309
);
260310

261311
server.registerTool(
262-
'describe_object',
312+
note('describe_object'),
263313
{
264314
description:
265315
'Get the schema of a data object: its fields (name, type, label, required) and enabled features.',
@@ -285,7 +335,7 @@ export function registerObjectTools(
285335
// self-correct, instead of shipping a formula that silently evaluates to
286336
// `null` (#1928). Read-only (schema introspection); no data is touched.
287337
server.registerTool(
288-
'validate_expression',
338+
note('validate_expression'),
289339
{
290340
description:
291341
'Validate a CEL expression against an object\'s schema before authoring it into metadata. Returns ' +
@@ -343,7 +393,7 @@ export function registerObjectTools(
343393
);
344394

345395
server.registerTool(
346-
'query_records',
396+
note('query_records'),
347397
{
348398
description:
349399
'Query records from an object with optional filter, field selection, sorting and pagination. ' +
@@ -385,7 +435,7 @@ export function registerObjectTools(
385435
if (typeof bridge.aggregate === 'function') {
386436
const aggregateFn = bridge.aggregate.bind(bridge);
387437
server.registerTool(
388-
'aggregate_records',
438+
note('aggregate_records'),
389439
{
390440
description:
391441
'Aggregate records with GROUP BY: count/sum/avg/min/max/count_distinct over an object, ' +
@@ -459,7 +509,7 @@ export function registerObjectTools(
459509
}
460510

461511
server.registerTool(
462-
'get_record',
512+
note('get_record'),
463513
{
464514
description: 'Fetch a single record by id.',
465515
inputSchema: {
@@ -484,7 +534,7 @@ export function registerObjectTools(
484534

485535
if (canWrite) {
486536
server.registerTool(
487-
'create_record',
537+
note('create_record'),
488538
{
489539
description: 'Create a new record. Runs under the caller\'s permissions and validations.',
490540
inputSchema: {
@@ -505,7 +555,7 @@ export function registerObjectTools(
505555
);
506556

507557
server.registerTool(
508-
'update_record',
558+
note('update_record'),
509559
{
510560
description: 'Update fields on an existing record by id.',
511561
inputSchema: {
@@ -527,7 +577,7 @@ export function registerObjectTools(
527577
);
528578

529579
server.registerTool(
530-
'delete_record',
580+
note('delete_record'),
531581
{
532582
description: 'Delete a record by id. This is destructive.',
533583
inputSchema: {
@@ -547,11 +597,13 @@ export function registerObjectTools(
547597
},
548598
);
549599
} // end canWrite (data:write)
600+
601+
return registered;
550602
}
551603

552604
/**
553-
* Register the business-action tool set (`list_actions`, `run_action`) on a
554-
* fresh per-request {@link McpServer}. This is the action analogue of
605+
* Register the business-action tool set (`list_actions`, `run_action`) on an
606+
* {@link McpServer}. This is the action analogue of
555607
* {@link registerObjectTools}: it owns the tool *shape* and delegates all
556608
* resolution + dispatch + security to `bridge`, which the runtime binds to the
557609
* caller's principal.
@@ -571,16 +623,21 @@ export function registerActionTools(
571623
server: McpServer,
572624
bridge: McpActionBridge,
573625
options: RegisterActionToolsOptions = {},
574-
): void {
626+
): string[] {
627+
const registered: string[] = [];
628+
const note = (name: string): string => {
629+
registered.push(name);
630+
return name;
631+
};
575632
const allowSystem = options.allowSystemObjects === true;
576633
// OAuth tool-family gating (#2698): the whole action surface requires
577634
// `actions:execute`. Not registered = unknown tool = fail-closed.
578635
if (options.grantedScopes && !options.grantedScopes.includes(MCP_OAUTH_SCOPE_ACTIONS)) {
579-
return;
636+
return registered;
580637
}
581638

582639
server.registerTool(
583-
'list_actions',
640+
note('list_actions'),
584641
{
585642
description:
586643
'List the business actions you can invoke in this app (e.g. "complete task", "convert lead"). ' +
@@ -604,7 +661,7 @@ export function registerActionTools(
604661
);
605662

606663
server.registerTool(
607-
'run_action',
664+
note('run_action'),
608665
{
609666
description:
610667
'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic — ' +
@@ -647,6 +704,8 @@ export function registerActionTools(
647704
}
648705
},
649706
);
707+
708+
return registered;
650709
}
651710

652711
function messageOf(err: unknown): string {

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
66
import type { Logger, IMetadataService, AIToolDefinition } from '@objectstack/spec/contracts';
77
import type { Agent } from '@objectstack/spec/ai';
88
import type { ToolRegistry, ToolExecutionResult } from './types.js';
9-
import { registerObjectTools, registerActionTools } from './mcp-http-tools.js';
9+
import { wireBridgeTools } from './mcp-http-tools.js';
1010
import type {
1111
McpDataBridge,
1212
McpActionBridge,
@@ -615,10 +615,38 @@ export class MCPServerRuntime {
615615
version: this.config.version,
616616
},
617617
{
618+
// [#8034] `resources` / `tools` / `prompts` are DELIBERATELY absent
619+
// here — they are declared by the SDK when something is actually
620+
// registered, never by hand.
621+
//
622+
// Until #8034 this object hand-declared all three, and the `tools` one
623+
// was a lie on the transport that mattered most: `McpServer.registerTool`
624+
// is what installs the `tools/list` + `tools/call` handlers (its
625+
// `setToolRequestHandlers()` also calls `server.registerCapabilities({
626+
// tools: … })`), so a long-lived server that registered NO tool
627+
// advertised `capabilities.tools: {}` in its `initialize` result and
628+
// then answered `-32601 Method not found` to every `tools/list` and
629+
// `tools/call`. That is the dishonest self-report ADR-0076 D12 / #2462
630+
// forbid — "advertise what you actually serve" — and this lane closed
631+
// the same shape twice on other surfaces (#7939 `handlerReady: true`
632+
// for an empty slot, #7602 `capabilities.search` with no route).
633+
//
634+
// Deriving them is what makes the two halves agree STRUCTURALLY rather
635+
// than by two literals that can drift: there is now no way to advertise
636+
// a primitive without also installing its handlers, because the SDK
637+
// does both in one call. Registration order is unchanged and already
638+
// correct — every bridge runs before `start()` connects the transport,
639+
// which is also what `Server.registerCapabilities` requires (it throws
640+
// once a transport is attached). The per-request HTTP server in
641+
// {@link handleHttpRequest} has always built its capabilities this way
642+
// (see the `skillBridge ? { prompts: {} }` line there); this brings the
643+
// long-lived server to the same contract.
644+
//
645+
// `logging` STAYS hand-declared: it is honest. The SDK has no
646+
// `registerLogging` to derive it from, and the declaration is itself
647+
// what wires the `logging/setLevel` request handler and enables
648+
// `sendLoggingMessage` — so here, declared IS served.
618649
capabilities: {
619-
resources: {},
620-
tools: {},
621-
prompts: {},
622650
logging: {},
623651
},
624652
instructions: this.config.instructions ?? 'ObjectStack MCP Server — access data objects, AI tools, and agent prompts.',
@@ -672,6 +700,44 @@ export class MCPServerRuntime {
672700
logger?.info(`[MCP] Bridged ${tools.length} tools from ToolRegistry`);
673701
}
674702

703+
/**
704+
* [#8034] Bridge a principal-bound {@link McpDataBridge} onto the LONG-LIVED
705+
* server — the object-CRUD tools, plus the business-action pair when the
706+
* bridge carries that seam.
707+
*
708+
* This is the stdio counterpart of what {@link handleHttpRequest} does per
709+
* request, and it exists because that per-request call used to be the ONLY
710+
* one. `registerObjectTools` / `registerActionTools` were reachable from
711+
* nowhere else, so the long-lived server's entire tool surface was whatever
712+
* {@link bridgeTools} found in the AI service's function-calling
713+
* `ToolRegistry` — a DIFFERENT surface, empty on any app that registers no AI
714+
* tools. The stdio transport therefore served zero tools while advertising
715+
* the `tools` capability, and every `tools/list` / `tools/call` answered
716+
* `-32601 Method not found`. Both transports now register through the one
717+
* {@link wireBridgeTools} composition.
718+
*
719+
* Ordering: call this BEFORE {@link start}. Tool registration is also what
720+
* declares the `tools` capability (see the constructor), and the SDK refuses
721+
* to register capabilities once a transport is attached. The plugin bridges
722+
* everything ahead of `start()` for exactly that reason.
723+
*
724+
* Not called for a host that has no principal to bind: no bridge means no
725+
* tools registered and no `tools` capability advertised, which is the honest
726+
* report rather than an empty promise (ADR-0076 D12).
727+
*
728+
* @returns the tool names registered, for the caller's boot log.
729+
*/
730+
bridgeDataTools(
731+
bridge: McpDataBridge & Partial<McpActionBridge>,
732+
toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions,
733+
): string[] {
734+
const registered = wireBridgeTools(this.mcpServer, bridge, toolOptions);
735+
this.config.logger?.info(
736+
`[MCP] Bridged ${registered.length} data tools (${registered.join(', ')})`,
737+
);
738+
return registered;
739+
}
740+
675741
/**
676742
* Register a single tool on the MCP server from an AIToolDefinition.
677743
*/
@@ -1150,7 +1216,20 @@ export class MCPServerRuntime {
11501216
const server = new McpServer(
11511217
{ name: this.config.name, version: this.config.version },
11521218
{
1153-
capabilities: { tools: {}, ...(skillBridge ? { prompts: {} } : {}) },
1219+
// [#8034] `tools` is DERIVED, exactly as on the long-lived server:
1220+
// `registerObjectTools` declares it when it registers the first tool,
1221+
// so a request that supplies no bridge (or a grant that registers
1222+
// nothing) now advertises no tool capability instead of advertising one
1223+
// and answering `-32601` — which is what the two "registers nothing"
1224+
// pins in this package already describe in their titles.
1225+
//
1226+
// `prompts` STAYS hand-declared and is not the same case:
1227+
// `registerSkillPrompts` installs LOW-LEVEL request handlers so the
1228+
// list can be read at call time, and `Server.setRequestHandler` refuses
1229+
// a handler whose capability was not declared first. Here the
1230+
// declaration is what makes the handlers installable, and it is gated
1231+
// on the seam actually being there — declared IS served.
1232+
capabilities: { ...(skillBridge ? { prompts: {} } : {}) },
11541233
instructions:
11551234
this.config.instructions ??
11561235
'ObjectStack MCP Server — query and modify your app\'s data objects as tools.',
@@ -1162,17 +1241,10 @@ export class MCPServerRuntime {
11621241
}
11631242

11641243
if (opts.bridge) {
1165-
registerObjectTools(server, opts.bridge, opts.toolOptions);
1166-
// The action surface is wired by capability: only when the runtime's
1167-
// bridge can resolve + dispatch the framework's actions. A host with no
1168-
// action mechanism keeps serving object tools unchanged (graceful
1169-
// degradation, mirroring how record resources need a dataEngine).
1170-
if (
1171-
typeof opts.bridge.listActions === 'function' &&
1172-
typeof opts.bridge.runAction === 'function'
1173-
) {
1174-
registerActionTools(server, opts.bridge as McpActionBridge, opts.toolOptions);
1175-
}
1244+
// [#8034] The SAME composition the long-lived server uses in
1245+
// {@link bridgeDataTools} — including the by-capability action wiring
1246+
// that used to be open-coded here. Two transports, one call site.
1247+
wireBridgeTools(server, opts.bridge, opts.toolOptions);
11761248
}
11771249

11781250
const transport = new WebStandardStreamableHTTPServerTransport({

0 commit comments

Comments
 (0)