Skip to content

Commit 8c14aef

Browse files
committed
fix(mcp): let the read tool carry a request body
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.
1 parent a4730ea commit 8c14aef

2 files changed

Lines changed: 44 additions & 31 deletions

File tree

‎apps/sim/lib/api/mcp/route-handler.test.ts‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ function rpc(
4646
})
4747
}
4848

49+
interface ListedTool {
50+
annotations: Record<string, boolean>
51+
inputSchema: { properties: Record<string, unknown> }
52+
}
53+
54+
async function listTools(): Promise<Record<string, ListedTool>> {
55+
const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined)
56+
const { result } = await response.json()
57+
return Object.fromEntries(
58+
result.tools.map((tool: ListedTool & { name: string }) => [tool.name, tool])
59+
)
60+
}
61+
4962
async function callTool(
5063
name: string,
5164
args: Record<string, unknown>,
@@ -112,22 +125,27 @@ describe('Sim MCP admission', () => {
112125

113126
describe('Sim MCP tools', () => {
114127
it('lists four tools with reads and writes annotated apart', async () => {
115-
const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined)
116-
const { result } = await response.json()
117-
const tools = Object.fromEntries(
118-
result.tools.map((tool: { name: string; annotations: Record<string, boolean> }) => [
119-
tool.name,
120-
tool.annotations,
121-
])
122-
)
128+
const tools = await listTools()
123129
expect(Object.keys(tools).sort()).toEqual([
124130
'call_read_operation',
125131
'call_write_operation',
126132
'describe_operation',
127133
'search_operations',
128134
])
129-
expect(tools.call_read_operation.readOnlyHint).toBe(true)
130-
expect(tools.call_write_operation.destructiveHint).toBe(true)
135+
expect(tools.call_read_operation.annotations.readOnlyHint).toBe(true)
136+
expect(tools.call_write_operation.annotations.destructiveHint).toBe(true)
137+
})
138+
139+
/**
140+
* A read is not always a GET: searchKnowledge, queryRows, and searchTableRows
141+
* among others post their filter as JSON, so a read tool whose schema has no
142+
* body cannot call them at all.
143+
*/
144+
it('lets both call tools carry a request body', async () => {
145+
const tools = await listTools()
146+
for (const name of ['call_read_operation', 'call_write_operation']) {
147+
expect(tools[name].inputSchema.properties, name).toHaveProperty('body')
148+
}
131149
})
132150

133151
it('finds operations by keyword', async () => {

‎apps/sim/lib/api/mcp/server.ts‎

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,6 @@ const operationName = z
3333
.max(128)
3434
.describe('Operation name from search_operations, e.g. "listTables".')
3535

36-
const operationArgs = {
37-
params: z
38-
.record(z.string(), z.string())
39-
.optional()
40-
.describe('Path parameters by name, e.g. { "tableId": "..." }.'),
41-
query: z
42-
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
43-
.optional()
44-
.describe('Query-string parameters by name.'),
45-
headers: z
46-
.record(z.string(), z.string())
47-
.optional()
48-
.describe('Request headers the operation declares, such as upload-token.'),
49-
}
50-
5136
const searchInput = z
5237
.object({
5338
query: z
@@ -65,12 +50,22 @@ const searchInput = z
6550

6651
const describeInput = z.object({ operation: operationName }).strict()
6752

68-
const readInput = z.object({ operation: operationName, ...operationArgs }).strict()
69-
70-
const writeInput = z
53+
/** Shared because a read is not always a GET: searching and querying post their filter as JSON. */
54+
const callInput = z
7155
.object({
7256
operation: operationName,
73-
...operationArgs,
57+
params: z
58+
.record(z.string(), z.string())
59+
.optional()
60+
.describe('Path parameters by name, e.g. { "tableId": "..." }.'),
61+
query: z
62+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
63+
.optional()
64+
.describe('Query-string parameters by name.'),
65+
headers: z
66+
.record(z.string(), z.string())
67+
.optional()
68+
.describe('Request headers the operation declares, such as upload-token.'),
7469
body: z.unknown().optional().describe('JSON request body, as describe_operation specifies.'),
7570
})
7671
.strict()
@@ -142,7 +137,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
142137
title: 'Read from Sim',
143138
description:
144139
'Run a Sim API operation that only reads, such as listWorkspaces, listTables, queryRows, or getWorkflowRun. search_operations says which tool runs each operation.',
145-
inputSchema: readInput,
140+
inputSchema: callInput,
146141
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
147142
},
148143
async (input, extra) => call('read', input, extra.signal)
@@ -154,7 +149,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
154149
title: 'Change Sim',
155150
description:
156151
'Run a Sim API operation that creates, changes, runs, or deletes something, such as createTable, executeWorkflow, or deleteFile.',
157-
inputSchema: writeInput,
152+
inputSchema: callInput,
158153
annotations: {
159154
readOnlyHint: false,
160155
destructiveHint: true,

0 commit comments

Comments
 (0)