Skip to content

Commit f54ff0e

Browse files
author
Vikhyath Mondreti
committed
fix(search): route Search MCP through live retrieval
1 parent d428711 commit f54ff0e

7 files changed

Lines changed: 444 additions & 18 deletions

File tree

‎apps/sim/lib/api/contracts/knowledge/mcp.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,45 @@
22
import { describe, expect, it } from 'vitest'
33
import {
44
chatSearchMcpSchema,
5+
liveSearchMcpSchema,
56
readDocumentMcpSchema,
7+
readLiveDocumentMcpSchema,
68
searchMcpSchema,
79
} from '@/lib/api/contracts/knowledge/mcp'
810

911
describe('Search MCP inputs', () => {
12+
it('accepts bounded date-only live search and provider continuations', () => {
13+
const input = {
14+
startDate: '2026-09-21T00:00:00Z',
15+
nativeQueries: [{ provider: 'gmail', query: 'label:INBOX', cursor: 'next-page' }],
16+
}
17+
expect(liveSearchMcpSchema.parse(input)).toMatchObject({ ...input, query: '', topK: 20 })
18+
for (const invalid of [
19+
{},
20+
{ query: 'notes', organizationId: 'other-org' },
21+
{ query: 'notes', knowledgeBaseIds: ['kb-1'] },
22+
{ query: 'notes', topK: 51 },
23+
{ startDate: '2026-09-22T00:00:00Z', endDate: '2026-09-21T00:00:00Z' },
24+
])
25+
expect(liveSearchMcpSchema.safeParse(invalid).success).toBe(false)
26+
})
27+
28+
it('accepts live document references and character continuation without legacy read options', () => {
29+
const documentId = `live:${'a'.repeat(3995)}`
30+
expect(
31+
readLiveDocumentMcpSchema.parse({ documentId, startChunkIndex: 0, startOffset: 8000 })
32+
).toEqual({ documentId, startChunkIndex: 0, startOffset: 8000, limit: 3 })
33+
for (const invalid of [
34+
{ documentId: `${documentId}a` },
35+
{ documentId, limit: 9 },
36+
{ documentId, startOffset: -1 },
37+
{ documentId, offset: 20 },
38+
{ url: 'https://example.com/doc' },
39+
{ documentId, organizationId: 'other-org' },
40+
])
41+
expect(readLiveDocumentMcpSchema.safeParse(invalid).success).toBe(false)
42+
})
43+
1044
it('keeps workspace and knowledge-base selection outside the organization tools', () => {
1145
expect(searchMcpSchema.safeParse({ query: 'notes', workspaceId: 'workspace-1' }).success).toBe(
1246
false

‎apps/sim/lib/api/contracts/knowledge/mcp.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { z } from 'zod'
22
import { workspaceSearchFiltersSchema } from '@/lib/api/contracts/knowledge/search'
33
import { mcpJsonRpcMessageSchema } from '@/lib/api/contracts/mcp'
4+
import {
5+
readDocumentInputSchema,
6+
searchWorkspaceInputSchema,
7+
} from '@/lib/api/contracts/mothership-assistant-tools'
48
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
59
import { defineRouteContract } from '@/lib/api/contracts/types'
610

@@ -14,6 +18,9 @@ export const organizationKnowledgeMcpContract = defineRouteContract({
1418

1519
const documentIdSchema = z.string().min(1, 'Document ID is required').max(255)
1620

21+
export const liveSearchMcpSchema = searchWorkspaceInputSchema.strict()
22+
export const readLiveDocumentMcpSchema = readDocumentInputSchema.strict()
23+
1724
export const searchMcpSchema = workspaceSearchFiltersSchema
1825
.extend({
1926
query: z.string().trim().min(1, 'Search query is required').max(8192),
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/** @vitest-environment node */
2+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
4+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
liveSearch: vi.fn(),
10+
liveRead: vi.fn(),
11+
indexedSearch: vi.fn(),
12+
indexedRead: vi.fn(),
13+
}))
14+
vi.mock('@/lib/core/utils/after-response', () => ({ afterResponse: vi.fn() }))
15+
vi.mock('@/lib/knowledge/mcp/activity', () => ({ recordOrganizationSearchMcpActivity: vi.fn() }))
16+
vi.mock('@/lib/api/server/routes/v2-json-route', () => ({
17+
v2RateLimits: { publicApi: { enforce: vi.fn().mockResolvedValue(null) } },
18+
}))
19+
vi.mock('@/lib/knowledge/application/search', () => ({
20+
searchKnowledge: { execute: mocks.indexedSearch },
21+
}))
22+
vi.mock('@/lib/knowledge/application/read-indexed-document', () => ({
23+
readIndexedKnowledgeDocument: { execute: mocks.indexedRead },
24+
}))
25+
vi.mock('@/lib/sim-search/live/application', () => ({
26+
searchLiveKnowledge: { execute: mocks.liveSearch },
27+
readLiveDocument: { execute: mocks.liveRead },
28+
}))
29+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'http://localhost' }))
30+
31+
import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server'
32+
33+
describe('Search MCP protocol', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
resetEnvFlagsMock()
37+
})
38+
39+
it.each([true, false])(
40+
'lists and calls the selected backend through the SDK (live: %s)',
41+
async (live) => {
42+
setEnvFlags({ isLiveEnterpriseSearchEnabled: live })
43+
const documentId = live ? `live:${'a'.repeat(500)}` : 'doc-1'
44+
const row = {
45+
documentId,
46+
knowledgeBaseId: live ? '' : 'index-1',
47+
documentName: 'Release notes',
48+
sourceUrl: 'https://example.com/notes',
49+
connectorType: 'google_drive',
50+
content: 'Evidence',
51+
chunkIndex: 0,
52+
similarity: 0.5,
53+
}
54+
mocks.liveSearch.mockResolvedValue({
55+
results: [row],
56+
live: { backend: 'live', accounts: [], guidance: '' },
57+
})
58+
mocks.indexedSearch.mockResolvedValue({ results: [row] })
59+
mocks.liveRead.mockResolvedValue({
60+
...row,
61+
knowledgeBaseName: 'Drive',
62+
chunks: [{ chunkIndex: 0, content: 'Read evidence' }],
63+
hasMore: false,
64+
next: null,
65+
})
66+
mocks.indexedRead.mockResolvedValue({
67+
...row,
68+
title: row.documentName,
69+
chunks: [{ chunkIndex: 0, content: 'Read evidence' }],
70+
})
71+
72+
const server = createKnowledgeMcpServer({
73+
organizationId: 'org-1',
74+
searchIndexId: live ? null : 'index-1',
75+
request: new NextRequest('http://localhost/api/mcp/search/organizations/org-1'),
76+
auth: {
77+
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
78+
keyType: 'personal',
79+
keyExpiresAt: null,
80+
rateLimitSubjectIds: ['user-1'],
81+
rateLimitSubscription: null,
82+
},
83+
})
84+
const client = new Client({ name: 'Search test', version: '1.0.0' })
85+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
86+
try {
87+
await server.connect(serverTransport)
88+
await client.connect(clientTransport)
89+
const { tools } = await client.listTools()
90+
expect(tools.map((tool) => tool.name)).toEqual(['search', 'read_document', 'chat'])
91+
const searchSchema = tools.find((tool) => tool.name === 'search')?.inputSchema
92+
const readSchema = tools.find((tool) => tool.name === 'read_document')?.inputSchema
93+
if (live) {
94+
expect(searchSchema?.properties).toHaveProperty('nativeQueries')
95+
expect(readSchema?.properties).toHaveProperty('startOffset')
96+
expect(readSchema?.properties).not.toHaveProperty('url')
97+
} else {
98+
expect(searchSchema?.properties).not.toHaveProperty('nativeQueries')
99+
expect(readSchema?.properties).toHaveProperty('offset')
100+
expect(readSchema?.properties).toHaveProperty('url')
101+
}
102+
const search = await client.callTool({ name: 'search', arguments: { query: 'release' } })
103+
expect(search.isError).not.toBe(true)
104+
expect(search.content).toEqual([
105+
{ type: 'text', text: expect.stringContaining(documentId) },
106+
])
107+
const read = await client.callTool({ name: 'read_document', arguments: { documentId } })
108+
expect(read.isError).not.toBe(true)
109+
expect(read.content).toEqual([
110+
{ type: 'text', text: expect.stringContaining('Read evidence') },
111+
])
112+
expect(live ? mocks.liveSearch : mocks.indexedSearch).toHaveBeenCalledOnce()
113+
expect(live ? mocks.liveRead : mocks.indexedRead).toHaveBeenCalledOnce()
114+
expect(live ? mocks.indexedSearch : mocks.liveSearch).not.toHaveBeenCalled()
115+
expect(live ? mocks.indexedRead : mocks.liveRead).not.toHaveBeenCalled()
116+
} finally {
117+
await client.close()
118+
await server.close()
119+
}
120+
}
121+
)
122+
})

0 commit comments

Comments
 (0)