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,