From 8c14aef0a3e527c7dcfcd1ad47891843a0cd2da7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 19:26:39 -0700 Subject: [PATCH] fix(mcp): let the read tool carry a request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call_read_operation declared no body, so the six read operations that post their filter as JSON — searchKnowledge, queryRows, queryRowsCount, searchTableRows, getSelector, and listSelector — could not be called at all. The dispatcher already sends a body for any non-GET operation that declares one and refuses a body for operations that do not, so both call tools now share one input schema. --- apps/sim/lib/api/mcp/route-handler.test.ts | 38 ++++++++++++++++------ apps/sim/lib/api/mcp/server.ts | 37 +++++++++------------ 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/apps/sim/lib/api/mcp/route-handler.test.ts b/apps/sim/lib/api/mcp/route-handler.test.ts index 271ea4af8ec..3c3f84fbd80 100644 --- a/apps/sim/lib/api/mcp/route-handler.test.ts +++ b/apps/sim/lib/api/mcp/route-handler.test.ts @@ -46,6 +46,19 @@ function rpc( }) } +interface ListedTool { + annotations: Record + inputSchema: { properties: Record } +} + +async function listTools(): Promise> { + const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined) + const { result } = await response.json() + return Object.fromEntries( + result.tools.map((tool: ListedTool & { name: string }) => [tool.name, tool]) + ) +} + async function callTool( name: string, args: Record, @@ -112,22 +125,27 @@ describe('Sim MCP admission', () => { describe('Sim MCP tools', () => { it('lists four tools with reads and writes annotated apart', async () => { - const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined) - const { result } = await response.json() - const tools = Object.fromEntries( - result.tools.map((tool: { name: string; annotations: Record }) => [ - tool.name, - tool.annotations, - ]) - ) + const tools = await listTools() expect(Object.keys(tools).sort()).toEqual([ 'call_read_operation', 'call_write_operation', 'describe_operation', 'search_operations', ]) - expect(tools.call_read_operation.readOnlyHint).toBe(true) - expect(tools.call_write_operation.destructiveHint).toBe(true) + expect(tools.call_read_operation.annotations.readOnlyHint).toBe(true) + expect(tools.call_write_operation.annotations.destructiveHint).toBe(true) + }) + + /** + * A read is not always a GET: searchKnowledge, queryRows, and searchTableRows + * among others post their filter as JSON, so a read tool whose schema has no + * body cannot call them at all. + */ + it('lets both call tools carry a request body', async () => { + const tools = await listTools() + for (const name of ['call_read_operation', 'call_write_operation']) { + expect(tools[name].inputSchema.properties, name).toHaveProperty('body') + } }) it('finds operations by keyword', async () => { diff --git a/apps/sim/lib/api/mcp/server.ts b/apps/sim/lib/api/mcp/server.ts index 805aebd9e7a..7c231625a77 100644 --- a/apps/sim/lib/api/mcp/server.ts +++ b/apps/sim/lib/api/mcp/server.ts @@ -33,21 +33,6 @@ const operationName = z .max(128) .describe('Operation name from search_operations, e.g. "listTables".') -const operationArgs = { - params: z - .record(z.string(), z.string()) - .optional() - .describe('Path parameters by name, e.g. { "tableId": "..." }.'), - query: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional() - .describe('Query-string parameters by name.'), - headers: z - .record(z.string(), z.string()) - .optional() - .describe('Request headers the operation declares, such as upload-token.'), -} - const searchInput = z .object({ query: z @@ -65,12 +50,22 @@ const searchInput = z const describeInput = z.object({ operation: operationName }).strict() -const readInput = z.object({ operation: operationName, ...operationArgs }).strict() - -const writeInput = z +/** Shared because a read is not always a GET: searching and querying post their filter as JSON. */ +const callInput = z .object({ operation: operationName, - ...operationArgs, + params: z + .record(z.string(), z.string()) + .optional() + .describe('Path parameters by name, e.g. { "tableId": "..." }.'), + query: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe('Query-string parameters by name.'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe('Request headers the operation declares, such as upload-token.'), body: z.unknown().optional().describe('JSON request body, as describe_operation specifies.'), }) .strict() @@ -142,7 +137,7 @@ export function createSimMcpServer(context: Omit): title: 'Read from Sim', description: 'Run a Sim API operation that only reads, such as listWorkspaces, listTables, queryRows, or getWorkflowRun. search_operations says which tool runs each operation.', - inputSchema: readInput, + inputSchema: callInput, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, }, async (input, extra) => call('read', input, extra.signal) @@ -154,7 +149,7 @@ export function createSimMcpServer(context: Omit): title: 'Change Sim', description: 'Run a Sim API operation that creates, changes, runs, or deletes something, such as createTable, executeWorkflow, or deleteFile.', - inputSchema: writeInput, + inputSchema: callInput, annotations: { readOnlyHint: false, destructiveHint: true,