Skip to content

Commit bdd0b19

Browse files
committed
improvement(search): send repeated account notes and fixed fields once in Search MCP results
1 parent e6a9dbe commit bdd0b19

6 files changed

Lines changed: 262 additions & 19 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,28 @@ export const organizationKnowledgeMcpContract = defineRouteContract({
1919

2020
const LIVE_SEARCH_MCP_DEFAULT_TOP_K = { federated: 30, scoped: 20 } as const
2121

22+
/**
23+
* A datetime field without the regex its JSON Schema would otherwise carry beside
24+
* `format: "date-time"`. Every tools/list call resends the schema, and parsing still rejects a
25+
* string that is not an ISO datetime with an offset.
26+
*/
27+
function withoutPatternInJsonSchema<T extends z.ZodType>(schema: T): T {
28+
return schema.meta({ pattern: undefined })
29+
}
30+
31+
const { startDate, endDate, modifiedAfter, modifiedBefore } = workspaceSearchFiltersSchema.shape
32+
33+
/** The date filters of the Search MCP tools, as their tools/list schema presents them. */
34+
const mcpDateFilterFields = {
35+
startDate: withoutPatternInJsonSchema(startDate),
36+
endDate: withoutPatternInJsonSchema(endDate),
37+
modifiedAfter: withoutPatternInJsonSchema(modifiedAfter),
38+
modifiedBefore: withoutPatternInJsonSchema(modifiedBefore),
39+
}
40+
2241
export const liveSearchMcpSchema = searchWorkspaceInputFieldsSchema
2342
.extend({
43+
...mcpDateFilterFields,
2444
topK: searchWorkspaceInputFieldsSchema.shape.topK
2545
.unwrap()
2646
.optional()
@@ -81,6 +101,7 @@ export const readLiveDocumentMcpSchema = readDocumentInputSchema
81101

82102
export const chatSearchMcpSchema = workspaceSearchFiltersSchema
83103
.extend({
104+
...mcpDateFilterFields,
84105
query: z.string().trim().min(1, 'A question is required').max(8192),
85106
})
86107
.strict()

‎apps/sim/lib/knowledge/mcp/server.protocol.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({
1818
v2RateLimits: { publicApi: { enforce: vi.fn().mockResolvedValue(null) } },
1919
}))
2020
vi.mock('@/lib/sim-search/live/application', () => ({
21+
LIVE_MORE_RANKED_BELOW_MESSAGE: 'More matches ranked below.',
22+
LIVE_DATE_ORDER_MESSAGE: 'Date order covers retrieved results.',
23+
LIVE_NOT_STARTED_MESSAGE: 'Not searched before the deadline.',
24+
LIVE_OVER_ACCOUNT_LIMIT_MESSAGE: 'Not searched: over the account limit.',
2125
searchLiveKnowledge: { execute: hoisted.liveSearch },
2226
readLiveDocument: { execute: hoisted.liveRead },
2327
}))
@@ -44,6 +48,7 @@ describe('Search MCP protocol', () => {
4448
}
4549
mocks.liveSearch.mockResolvedValue({
4650
results: [row],
51+
retrieval: { status: 'complete', timedOutLegs: [] },
4752
live: { backend: 'live', accounts: [], guidance: '' },
4853
})
4954
mocks.liveRead.mockResolvedValue({
@@ -74,6 +79,8 @@ describe('Search MCP protocol', () => {
7479
const searchSchema = tools.find((tool) => tool.name === 'search')?.inputSchema
7580
const readSchema = tools.find((tool) => tool.name === 'read_document')?.inputSchema
7681
expect(searchSchema?.properties).toHaveProperty('nativeQueries')
82+
expect(searchSchema?.properties?.startDate).toMatchObject({ format: 'date-time' })
83+
expect(searchSchema?.properties?.startDate).not.toHaveProperty('pattern')
7784
expect(readSchema?.properties).toHaveProperty('startOffset')
7885
expect(readSchema?.properties).not.toHaveProperty('url')
7986
const search = await client.callTool({

‎apps/sim/lib/knowledge/mcp/server.test.ts‎

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ const hoisted = vi.hoisted(() => ({
2121
rateLimit: vi.fn(),
2222
afterResponse: vi.fn<(task: () => Promise<void>) => void>(),
2323
recordActivity: vi.fn(),
24+
statusMessages: {
25+
LIVE_MORE_RANKED_BELOW_MESSAGE: 'More matches ranked below.',
26+
LIVE_DATE_ORDER_MESSAGE: 'Date order covers retrieved results.',
27+
LIVE_NOT_STARTED_MESSAGE: 'Not searched before the deadline.',
28+
LIVE_OVER_ACCOUNT_LIMIT_MESSAGE: 'Not searched: over the account limit.',
29+
},
2430
}))
2531
vi.mock('@/lib/core/utils/after-response', () => ({ afterResponse: hoisted.afterResponse }))
2632
vi.mock('@/lib/knowledge/mcp/activity', () => ({
@@ -46,6 +52,7 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({
4652
v2RateLimits: { publicApi: { enforce: hoisted.rateLimit } },
4753
}))
4854
vi.mock('@/lib/sim-search/live/application', () => ({
55+
...hoisted.statusMessages,
4956
searchLiveKnowledge: { execute: hoisted.liveSearch },
5057
readLiveDocument: { execute: hoisted.liveRead },
5158
}))
@@ -58,6 +65,7 @@ import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server'
5865
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5966

6067
const mocks = hoisted
68+
const retrieval = { status: 'complete', timedOutLegs: [] }
6169

6270
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.example')
6371

@@ -83,7 +91,7 @@ beforeEach(() => {
8391
mocks.tools.clear()
8492
mocks.configs.clear()
8593
mocks.rateLimit.mockReset().mockResolvedValue(null)
86-
mocks.liveSearch.mockResolvedValue({ results: [] })
94+
mocks.liveSearch.mockResolvedValue({ results: [], retrieval })
8795
mocks.chat.mockResolvedValue({ content: 'An answer', citations: [] })
8896
})
8997

@@ -110,7 +118,7 @@ describe('live organization Search MCP', () => {
110118
mocks.liveSearch.mockResolvedValue({
111119
results: [document],
112120
live: coverage,
113-
retrieval: { status: 'partial' },
121+
retrieval: { status: 'partial', timedOutLegs: [] },
114122
})
115123
mocks.liveRead.mockResolvedValue({
116124
...document,
@@ -197,7 +205,7 @@ describe('live organization Search MCP', () => {
197205
mocks.liveSearch.mockResolvedValueOnce({
198206
results: [message, event],
199207
live: coverage,
200-
retrieval: { status: 'complete' },
208+
retrieval,
201209
})
202210
const { results } = textOf(await call('search', { query: 'launch' }))
203211
expect(results).toEqual([
@@ -225,6 +233,123 @@ describe('live organization Search MCP', () => {
225233
])
226234
})
227235

236+
it('states each repeated account status once and flags the entries that carried it', async () => {
237+
create()
238+
const {
239+
LIVE_MORE_RANKED_BELOW_MESSAGE: moreBelow,
240+
LIVE_DATE_ORDER_MESSAGE: dateOrder,
241+
LIVE_NOT_STARTED_MESSAGE: deadline,
242+
LIVE_OVER_ACCOUNT_LIMIT_MESSAGE: overLimit,
243+
} = mocks.statusMessages
244+
const emptyPage = 'No readable matches on this page.'
245+
const unconnected = 'No connection with this provider is configured.'
246+
mocks.liveSearch.mockResolvedValueOnce({
247+
results: [],
248+
retrieval: { status: 'partial', timedOutLegs: [] },
249+
live: {
250+
backend: 'live',
251+
accounts: [
252+
{
253+
accountId: 'a1',
254+
provider: 'slack',
255+
displayName: 'Slack',
256+
status: 'partial',
257+
message: `${dateOrder} ${moreBelow}`,
258+
},
259+
{
260+
accountId: 'a2',
261+
provider: 'gmail',
262+
queryIndex: 1,
263+
displayName: 'Mail',
264+
status: 'partial',
265+
message: `Page note. ${dateOrder} ${emptyPage} ${moreBelow}`,
266+
nextCursor: 'c2',
267+
},
268+
{
269+
accountId: 'a3',
270+
provider: 'notion',
271+
displayName: 'Notion',
272+
status: 'timeout',
273+
message: deadline,
274+
},
275+
{
276+
accountId: 'a4',
277+
provider: 'linear',
278+
displayName: 'Linear',
279+
status: 'unavailable',
280+
message: overLimit,
281+
},
282+
{
283+
accountId: '',
284+
provider: 'jira',
285+
queryIndex: 2,
286+
displayName: 'jira',
287+
status: 'reconnect',
288+
message: unconnected,
289+
},
290+
{ accountId: 'a5', provider: 'jira', displayName: 'jira', status: 'ok' },
291+
],
292+
guidance: 'Use nativeQueries to continue.',
293+
},
294+
})
295+
const { live, retrieval: projected } = textOf(await call('search', { query: 'launch' }))
296+
expect(projected).toEqual({ status: 'partial' })
297+
expect(live).toEqual({
298+
notes: {
299+
moreBelow,
300+
dateOrderPartial: dateOrder,
301+
notSearchedByDeadline: deadline,
302+
notSearchedOverLimit: overLimit,
303+
},
304+
accounts: [
305+
{
306+
accountId: 'a1',
307+
displayName: 'Slack',
308+
provider: 'slack',
309+
status: 'partial',
310+
moreBelow: true,
311+
dateOrderPartial: true,
312+
},
313+
{
314+
accountId: 'a2',
315+
displayName: 'Mail',
316+
provider: 'gmail',
317+
queryIndex: 1,
318+
status: 'partial',
319+
nextCursor: 'c2',
320+
message: `Page note. ${emptyPage}`,
321+
moreBelow: true,
322+
dateOrderPartial: true,
323+
},
324+
{
325+
accountId: 'a3',
326+
displayName: 'Notion',
327+
provider: 'notion',
328+
status: 'timeout',
329+
notSearchedByDeadline: true,
330+
},
331+
{
332+
accountId: 'a4',
333+
displayName: 'Linear',
334+
provider: 'linear',
335+
status: 'unavailable',
336+
notSearchedOverLimit: true,
337+
},
338+
{ provider: 'jira', queryIndex: 2, status: 'reconnect', message: unconnected },
339+
{ accountId: 'a5', displayName: 'jira', provider: 'jira', status: 'ok' },
340+
],
341+
guidance: 'Use nativeQueries to continue.',
342+
})
343+
})
344+
345+
it('rejects a date filter that is not an ISO datetime with an offset', () => {
346+
create()
347+
for (const tool of ['search', 'chat'])
348+
expect(() =>
349+
mocks.configs.get(tool)?.inputSchema.parse({ query: 'launch', startDate: '2026-09-01' })
350+
).toThrow()
351+
})
352+
228353
it('returns a read document title and link once', async () => {
229354
create()
230355
const read = textOf(await call('read_document', { documentId }))
@@ -241,7 +366,7 @@ describe('live organization Search MCP', () => {
241366
backend.mockImplementationOnce(
242367
async ({ input }: { input: { resultSecretRegistry: ResolvedSecretTraceRegistry } }) => {
243368
input.resultSecretRegistry.markIncomplete('source-provenance-incomplete')
244-
return tool === 'search' ? { results: [document] } : document
369+
return tool === 'search' ? { results: [document], retrieval } : document
245370
}
246371
)
247372
const result = await call(tool, tool === 'search' ? { query: 'release' } : { documentId })

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

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
liveSearchMcpSchema,
1212
readLiveDocumentMcpSchema,
1313
} from '@/lib/api/contracts/knowledge/mcp'
14+
import type { WorkspaceKnowledgeSearchData } from '@/lib/api/contracts/mothership-assistant-tools'
1415
import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth'
1516
import { v2RateLimits } from '@/lib/api/server/routes/v2-json-route'
1617
import { afterResponse } from '@/lib/core/utils/after-response'
@@ -28,7 +29,15 @@ import {
2829
} from '@/lib/knowledge/mcp/tool-runner'
2930
import { liveCitationId } from '@/lib/knowledge/search/citation'
3031
import { toolError } from '@/lib/mcp/tool-result'
31-
import { readLiveDocument, searchLiveKnowledge } from '@/lib/sim-search/live/application'
32+
import {
33+
LIVE_DATE_ORDER_MESSAGE,
34+
LIVE_MORE_RANKED_BELOW_MESSAGE,
35+
LIVE_NOT_STARTED_MESSAGE,
36+
LIVE_OVER_ACCOUNT_LIMIT_MESSAGE,
37+
readLiveDocument,
38+
searchLiveKnowledge,
39+
} from '@/lib/sim-search/live/application'
40+
import { separateMessages } from '@/lib/sim-search/live/pages'
3241
import { LiveReadError } from '@/lib/sim-search/live/read-error'
3342
import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response'
3443
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -74,6 +83,55 @@ function sourceFacts(row: SourceRow) {
7483
}
7584
}
7685

86+
/**
87+
* Account status sentences that repeat across `live.accounts`, keyed by the flag that marks an
88+
* entry carrying one. The search result states each sentence once in `live.notes` under its flag.
89+
*/
90+
const LIVE_ACCOUNT_NOTES = {
91+
moreBelow: LIVE_MORE_RANKED_BELOW_MESSAGE,
92+
dateOrderPartial: LIVE_DATE_ORDER_MESSAGE,
93+
notSearchedByDeadline: LIVE_NOT_STARTED_MESSAGE,
94+
notSearchedOverLimit: LIVE_OVER_ACCOUNT_LIMIT_MESSAGE,
95+
}
96+
97+
/**
98+
* Live coverage with each repeated status sentence moved to `live.notes` and flagged on the
99+
* entries that carried it. A reconnect entry for a provider with no connection has no account ID
100+
* and is named by its provider, so it sends neither. `backend` is always `live` here.
101+
*/
102+
function projectLiveCoverage(live: NonNullable<WorkspaceKnowledgeSearchData['live']>) {
103+
const notes: Record<string, string> = {}
104+
const accounts = live.accounts.map((entry) => {
105+
const { accountId, displayName, message, ...status } = entry
106+
const { found, rest: remaining } = separateMessages(message, LIVE_ACCOUNT_NOTES)
107+
const flags: Record<string, true> = {}
108+
for (const flag of found) {
109+
flags[flag] = true
110+
notes[flag] = LIVE_ACCOUNT_NOTES[flag]
111+
}
112+
return {
113+
...(accountId ? { accountId } : {}),
114+
...(accountId || displayName !== entry.provider ? { displayName } : {}),
115+
...status,
116+
...(remaining === undefined ? {} : { message: remaining }),
117+
...flags,
118+
}
119+
})
120+
return {
121+
...(Object.keys(notes).length ? { notes } : {}),
122+
accounts,
123+
guidance: live.guidance,
124+
}
125+
}
126+
127+
/** A search result's retrieval status, without the timed-out legs a live search never has. */
128+
function projectRetrieval({
129+
timedOutLegs,
130+
...retrieval
131+
}: WorkspaceKnowledgeSearchData['retrieval']) {
132+
return timedOutLegs.length ? { ...retrieval, timedOutLegs } : retrieval
133+
}
134+
77135
/** One read_document result in the shape the tool returns it. */
78136
function projectReadResult(result: Awaited<ReturnType<typeof readLiveDocument.execute>>) {
79137
const {
@@ -173,7 +231,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
173231
{
174232
title: 'Search',
175233
description:
176-
'Search this organization’s sources through their live APIs, within your access and the admin’s source settings. Use source and date filters to narrow results, or nativeQueries for provider queries and pagination. Results carry a passage around their match where the source provides text. Inspect live.accounts for provider status and continuation cursors, and live.guidance for query syntax. Results are candidates, not proof of complete coverage. When a passage does not answer the question, use read_document with the exact returned documentId, and cite citationUrl when available.',
234+
'Search this organization’s sources through their live APIs, within your access and the admin’s source settings. Use source and date filters to narrow results, or nativeQueries for provider queries and pagination. Results carry a passage around their match where the source provides text. Inspect live.accounts for provider status and continuation cursors (a true flag on an account, such as moreBelow, means the live.notes sentence under that key applies to it), and live.guidance for query syntax. Results are candidates, not proof of complete coverage. When a passage does not answer the question, use read_document with the exact returned documentId, and cite citationUrl when available.',
177235
inputSchema: liveSearchMcpSchema,
178236
annotations: KNOWLEDGE_MCP_READ_ONLY,
179237
},
@@ -206,8 +264,8 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
206264
connectorType: row.connectorType,
207265
content: row.content,
208266
})),
209-
retrieval: result.retrieval,
210-
live: result.live,
267+
retrieval: projectRetrieval(result.retrieval),
268+
...(result.live ? { live: projectLiveCoverage(result.live) } : {}),
211269
},
212270
registry
213271
)

0 commit comments

Comments
 (0)