') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(mcp): the tool bridge forwards `AIToolDefinition.parameters` as the tool's input schema, and the docblock stops describing a workaround that was never implemented by os-trump · Pull Request #13317 · objectstack-ai/objectstack · GitHub
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
48 changes: 48 additions & 0 deletions .changeset/mcp-bridge-forwards-tool-input-schema.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
'@objectstack/mcp': minor
---

Tools bridged from an AI service's `ToolRegistry` now reach MCP clients with the
input schema their definition declares, and the arguments a client sends now
reach the tool.

`MCPServerRuntime.registerToolFromDefinition` passed a name, a description and
three annotation hints to `McpServer.registerTool` and never read
`tool.parameters`. Measured over a real `StdioServerTransport` at `74049254`, a
bridged `query_records` declaring
`{ objectName: string (required), limit: number }` was served to `tools/list` as
`inputSchema: { "type": "object", "properties": {} }` — the SDK's synthesised
empty schema, i.e. a positive claim that the tool takes no arguments — and a
`tools/call` carrying `{ objectName: 'task', limit: 5 }` reached
`toolRegistry.execute` as `input: {}`.

The second half was invisible for the same reason as the first. The handler read
`extra.arguments`, a member `RequestHandlerExtra` does not have in any version of
the SDK this package has depended on, so it was always `undefined`; and
`McpServer.executeToolHandler()` branches on `tool.inputSchema`, invoking a
schema-less tool as `handler(extra)`. Declaring the schema is what makes the SDK
hand the call's arguments to the handler at all, so both halves are one fix.

`AIToolDefinition.parameters` is JSON Schema and `registerTool` accepts only Zod
— a raw JSON Schema object reaches the SDK's `getZodSchemaObject()` and throws
`inputSchema must be a Zod schema or raw shape, received an unrecognized object`
— so the new `toolInputSchema()` converts it with `zod@4`'s own
`fromJSONSchema`, adding no dependency. The SDK converts the result straight back
to JSON Schema for `tools/list`; properties, types, descriptions, `required`,
enums, nested objects and `anyOf`/`oneOf` survive the round trip.

Two consequences worth stating rather than discovering. Declaring an
`inputSchema` is also what turns on `McpServer.validateToolInput()`, which this
SDK offers no way to decline: a call whose arguments do not match the declared
schema is now answered with an `isError` result naming the offending field
instead of being executed with `{}`. And a definition whose `parameters` does not
describe an object — absent, `{}`, or untyped — is bridged with a loose empty
object, which advertises exactly what the SDK synthesised before and constrains
nothing, so a tool that genuinely declares no arguments behaves as it did.

The docblocks were the reason this survived a reading: `bridgeTools` claimed each
tool became "an MCP tool with the same name, description, and JSON Schema
parameters", and the comment on the call claimed the schema was passed "as
annotations metadata" — through an `annotations` object that carries only
`destructiveHint` / `readOnlyHint` / `openWorldHint`, and is typed to accept
nothing else. Both now describe what the code does.
86 changes: 73 additions & 13 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,6 +73,63 @@ const DESTRUCTIVE_TOOLS = new Set([
'delete_field',
]);

// ── AIToolDefinition.parameters → MCP inputSchema ────────────────────────────

/**
* Convert an {@link AIToolDefinition}'s JSON Schema `parameters` into the Zod
* schema `McpServer.registerTool` requires for `inputSchema`.
*
* ⚠️ The conversion is not a stylistic choice, it is the only door. Measured
* against `@modelcontextprotocol/sdk` 1.30.0: `registerTool`'s `inputSchema`
* is typed `ZodRawShapeCompat | AnySchema`, and a raw JSON Schema object
* reaches the SDK's `getZodSchemaObject()`, which throws `inputSchema must be
* a Zod schema or raw shape, received an unrecognized object`. `zod@4`'s own
* `fromJSONSchema` opens that door with no new dependency (this package
* already depends on `zod`), and the SDK converts the result straight back to
* JSON Schema for `tools/list` — so what a client receives is the shape the
* definition declared.
*
* Skipping `inputSchema` is NOT the cheaper half of the same behaviour. The
* SDK synthesises `{ type: 'object', properties: {} }` for a tool registered
* without one — a positive claim that the tool takes no arguments — and
* `executeToolHandler()` then invokes the handler as `handler(extra)`, where
* `RequestHandlerExtra` carries no `arguments` member at all. A schema-less
* bridged tool therefore both mis-advertises itself AND executes with `{}`
* whatever the client sent.
*
* A `parameters` that does not describe an object — absent, `{}`, or untyped,
* all of which `fromJSONSchema` turns into `z.any()` — becomes a LOOSE EMPTY
* OBJECT. MCP requires `Tool.inputSchema.type` to be `"object"`, and a loose
* empty object is the honest report of "this definition declares no
* arguments": it advertises exactly what the SDK would have synthesised, it
* constrains nothing, and it keeps the arguments flowing to the handler.
*
* A `parameters` that cannot be converted at all is logged and gets the same
* loose empty object. Deliberately not a throw: this runs inside
* {@link MCPServerRuntime.bridgeTools}, so one unconvertible definition would
* otherwise take the server's ENTIRE tool surface down.
*/
function toolInputSchema(tool: AIToolDefinition, logger?: Logger): z.ZodType<Record<string, unknown>> {
const declaresNothing = () => z.looseObject({}) as unknown as z.ZodType<Record<string, unknown>>;

let converted: unknown;
try {
converted = z.fromJSONSchema(tool.parameters as never);
} catch (err) {
logger?.warn(`[MCP] Tool "${tool.name}" has unconvertible JSON Schema parameters; bridged with no declared arguments`, {
error: err instanceof Error ? err.message : String(err),
});
return declaresNothing();
}

if (converted instanceof z.ZodObject) {
return converted as unknown as z.ZodType<Record<string, unknown>>;
}

logger?.debug(`[MCP] Tool "${tool.name}" declares no object parameters; bridged with no declared arguments`);
return declaresNothing();
}

// ── Metadata outage vs. metadata miss (#6055, ADR-0110 D3) ───────────────────

/**
Expand DownExpand Up@@ -753,9 +810,11 @@ export class MCPServerRuntime {
/**
* Bridge all tools from the ToolRegistry to MCP tools.
*
* Each registered tool becomes an MCP tool with the same name, description,
* and JSON Schema parameters. The handler delegates to the ToolRegistry's
* execute path.
* Each registered tool becomes an MCP tool with the same name, description
* and declared arguments: `AIToolDefinition.parameters` is JSON Schema, and
* {@link toolInputSchema} converts it into the Zod schema the SDK requires
* for `inputSchema`. The handler delegates to the ToolRegistry's execute
* path.
*/
bridgeTools(toolRegistry: ToolRegistry): void {
const tools = toolRegistry.getAll();
Expand DownExpand Up@@ -808,31 +867,32 @@ export class MCPServerRuntime {

/**
* Register a single tool on the MCP server from an AIToolDefinition.
*
* The definition's JSON Schema `parameters` is forwarded as the tool's
* `inputSchema` (see {@link toolInputSchema} for why it must be converted
* first). Declaring it is what makes the SDK hand the call's arguments to
* this handler at all: `McpServer.executeToolHandler()` branches on
* `tool.inputSchema` and invokes a schema-less tool as `handler(extra)` —
* with no `arguments` anywhere on that `extra` (`RequestHandlerExtra` has no
* such member), which is why a bridged tool used to execute with `{}` no
* matter what the client sent.
*/
private registerToolFromDefinition(tool: AIToolDefinition, toolRegistry: ToolRegistry): void {
const logger = this.config.logger;

// Convert JSON Schema parameters to Zod-compatible format for MCP SDK
// The MCP SDK registerTool with inputSchema expects a Zod raw shape or AnySchema.
// Since our tools use JSON Schema, we use the low-level .tool() with a raw callback
// and pass the JSON Schema as annotations metadata.
this.mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: toolInputSchema(tool, logger),
annotations: {
// Mark tools with write side-effects for destructive operations
destructiveHint: this.isDestructiveTool(tool.name),
readOnlyHint: this.isReadOnlyTool(tool.name),
openWorldHint: false,
},
},
async (extra) => {
// The MCP SDK passes tool arguments via the extra.arguments property
// when registerTool is called without an inputSchema.
const rawExtra = extra as Record<string, unknown>;
const args = (rawExtra.arguments ?? {}) as Record<string, unknown>;

async (args) => {
try {
const result = await toolRegistry.execute({
type: 'tool-call',
Expand Down
Loading
Loading