Skip to content

Commit 50ae020

Browse files
committed
fix(search): look up tool schemas by own name and map only real provider IDs to source
1 parent 4136625 commit 50ae020

2 files changed

Lines changed: 35 additions & 13 deletions

File tree

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ urlsMockFns.mockGetBaseUrl.mockReturnValue('http://localhost')
5858
const handlers = createKnowledgeMcpHandlers()
5959
const principal = createPersonalApiKeyPrincipal({ userId: 'person-1' })
6060

61-
/** Calls the search tool over the real route, SDK transport, and Search MCP server. */
62-
async function callSearch(args: Record<string, unknown>) {
61+
/** Calls a tool over the real route, SDK transport, and Search MCP server. */
62+
async function callTool(name: string, args: Record<string, unknown>) {
6363
const response = await handlers.POST(
6464
createMockRequest({
6565
method: 'POST',
@@ -74,14 +74,18 @@ async function callSearch(args: Record<string, unknown>) {
7474
jsonrpc: '2.0',
7575
id: 1,
7676
method: 'tools/call',
77-
params: { name: 'search', arguments: args },
77+
params: { name, arguments: args },
7878
},
7979
}),
8080
createRouteContext({ organizationId: 'org-1' })
8181
)
8282
return { status: response.status, body: await response.json() }
8383
}
8484

85+
function callSearch(args: Record<string, unknown>) {
86+
return callTool('search', args)
87+
}
88+
8589
function searchedInput() {
8690
const call = mocks.liveSearch.mock.lastCall?.[0]
8791
if (!call) throw new Error('Expected the search use case to run')
@@ -174,4 +178,19 @@ describe('Search MCP tool arguments with one possible reading', () => {
174178
expect(JSON.stringify(body)).toContain('startDate')
175179
expect(mocks.liveSearch).not.toHaveBeenCalled()
176180
})
181+
182+
it('still rejects a top-level provider that names no provider', async () => {
183+
const { body } = await callSearch({ query: 'standup', provider: 'Google Calendar' })
184+
expect(JSON.stringify(body)).toContain('provider')
185+
expect(mocks.liveSearch).not.toHaveBeenCalled()
186+
})
187+
188+
it.each(['constructor', 'toString', '__proto__'])(
189+
'answers a call to the unknown tool %s with an MCP error, not a server error',
190+
async (name) => {
191+
const { status, body } = await callTool(name, {})
192+
expect(status).toBe(200)
193+
expect(JSON.stringify(body)).toContain(name)
194+
}
195+
)
177196
})

‎apps/sim/lib/knowledge/mcp/tool-arguments.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
readLiveDocumentMcpSchema,
77
} from '@/lib/api/contracts/knowledge/mcp'
88
import { mcpToolCallParamsSchema } from '@/lib/api/contracts/mcp'
9+
import { liveSearchProviderSchema } from '@/lib/api/contracts/mothership-assistant-tools'
910

1011
/** Search filters a client sometimes writes on each native query instead of on the search. */
1112
const SEARCH_FILTERS_ON_NATIVE_QUERIES = [
@@ -17,11 +18,12 @@ const SEARCH_FILTERS_ON_NATIVE_QUERIES = [
1718
'topK',
1819
] as const
1920

20-
const TOOL_INPUT_SCHEMAS: Record<string, z.ZodType> = {
21-
search: liveSearchMcpSchema,
22-
read_document: readLiveDocumentMcpSchema,
23-
chat: chatSearchMcpSchema,
24-
}
21+
/** Keyed by client-supplied tool names, so a name such as `constructor` finds nothing. */
22+
const TOOL_INPUT_SCHEMAS = new Map<string, z.ZodType>([
23+
['search', liveSearchMcpSchema],
24+
['read_document', readLiveDocumentMcpSchema],
25+
['chat', chatSearchMcpSchema],
26+
])
2527

2628
function readToolCall(message: Record<string, unknown>) {
2729
if (message.method !== 'tools/call') return undefined
@@ -31,16 +33,17 @@ function readToolCall(message: Record<string, unknown>) {
3133

3234
/**
3335
* Search arguments rewritten where the schema rejects them but they have exactly one reading: an
34-
* empty `nativeQueries` list, a null optional native query field, a top-level `provider` (the
35-
* `source` filter), and a search filter every native query carries with the same value. Native
36+
* empty `nativeQueries` list, a null optional native query field, a top-level `provider` naming a
37+
* provider (the `source` filter), and a search filter every native query carries with the same value. Native
3638
* queries that disagree on a filter are left for the schema to reject.
3739
*/
3840
function repairSearchArguments(args: Record<string, unknown>) {
3941
const { nativeQueries: queries, provider, ...rest } = args
4042
const repaired: Record<string, unknown> = rest
4143
const repairs = new Set<string>()
42-
if (typeof provider === 'string' && (rest.source === undefined || rest.source === provider)) {
43-
repaired.source = provider
44+
const providerId = liveSearchProviderSchema.safeParse(provider)
45+
if (providerId.success && (rest.source === undefined || rest.source === providerId.data)) {
46+
repaired.source = providerId.data
4447
repairs.add('providerAsSource')
4548
} else if (provider !== undefined) repaired.provider = provider
4649
if (Array.isArray(queries) && queries.length === 0) repairs.add('emptyNativeQueries')
@@ -90,7 +93,7 @@ export function repairToolCallMessage(message: Record<string, unknown>) {
9093
*/
9194
export function toolCallInputIssues(message: Record<string, unknown>) {
9295
const call = readToolCall(message)
93-
const schema = call ? TOOL_INPUT_SCHEMAS[call.name] : undefined
96+
const schema = call ? TOOL_INPUT_SCHEMAS.get(call.name) : undefined
9497
if (!call || !schema) return undefined
9598
const parsed = schema.safeParse(call.arguments ?? {})
9699
if (parsed.success) return undefined

0 commit comments

Comments
 (0)