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
38 changes: 28 additions & 10 deletions apps/sim/lib/api/mcp/route-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ function rpc(
})
}

interface ListedTool {
annotations: Record<string, boolean>
inputSchema: { properties: Record<string, unknown> }
}

async function listTools(): Promise<Record<string, ListedTool>> {
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<string, unknown>,
Expand Down Expand Up @@ -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<string, boolean> }) => [
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 () => {
Expand Down
37 changes: 16 additions & 21 deletions apps/sim/lib/api/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -142,7 +137,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
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)
Expand All @@ -154,7 +149,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
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,
Expand Down
Loading