Skip to content

Commit 58dad58

Browse files
authored
v0.9.49: search mcp date filters fix, repeated account notes and fixed fields sent once
2 parents 360c86a + 207da65 commit 58dad58

6 files changed

Lines changed: 286 additions & 21 deletions

File tree

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

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

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

22+
const HAS_UTC_OFFSET = /(Z|[+-]\d{2}:\d{2})$/
23+
24+
/**
25+
* An ISO datetime filter that reads a value without an offset as UTC, which is what a client
26+
* resends after that value is rejected, so the call succeeds on the first try. Its JSON Schema
27+
* keeps `format: "date-time"` and the field's description but not the regex, which every
28+
* tools/list call would resend.
29+
*/
30+
function mcpDateFilter(field: z.ZodOptional<z.ZodString>) {
31+
return z.iso
32+
.datetime({ offset: true, local: true })
33+
.transform((value) => (HAS_UTC_OFFSET.test(value) ? value : `${value}Z`))
34+
.optional()
35+
.meta({ pattern: undefined, description: field.description })
36+
}
37+
38+
const { startDate, endDate, modifiedAfter, modifiedBefore } = workspaceSearchFiltersSchema.shape
39+
40+
/** The date filters of the Search MCP tools, as their tools/list schema presents them. */
41+
const mcpDateFilterFields = {
42+
startDate: mcpDateFilter(startDate),
43+
endDate: mcpDateFilter(endDate),
44+
modifiedAfter: mcpDateFilter(modifiedAfter),
45+
modifiedBefore: mcpDateFilter(modifiedBefore),
46+
}
47+
2248
export const liveSearchMcpSchema = searchWorkspaceInputFieldsSchema
2349
.extend({
50+
...mcpDateFilterFields,
2451
topK: searchWorkspaceInputFieldsSchema.shape.topK
2552
.unwrap()
2653
.optional()
@@ -81,6 +108,7 @@ export const readLiveDocumentMcpSchema = readDocumentInputSchema
81108

82109
export const chatSearchMcpSchema = workspaceSearchFiltersSchema
83110
.extend({
111+
...mcpDateFilterFields,
84112
query: z.string().trim().min(1, 'A question is required').max(8192),
85113
})
86114
.strict()

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({
1717
...apiServerRoutesMock,
1818
v2RateLimits: { publicApi: { enforce: vi.fn().mockResolvedValue(null) } },
1919
}))
20-
vi.mock('@/lib/sim-search/live/application', () => ({
20+
vi.mock('@/lib/sim-search/live/application', async (importOriginal) => ({
21+
...(await importOriginal<typeof import('@/lib/sim-search/live/application')>()),
2122
searchLiveKnowledge: { execute: hoisted.liveSearch },
2223
readLiveDocument: { execute: hoisted.liveRead },
2324
}))
@@ -44,6 +45,7 @@ describe('Search MCP protocol', () => {
4445
}
4546
mocks.liveSearch.mockResolvedValue({
4647
results: [row],
48+
retrieval: { status: 'complete', timedOutLegs: [] },
4749
live: { backend: 'live', accounts: [], guidance: '' },
4850
})
4951
mocks.liveRead.mockResolvedValue({
@@ -74,6 +76,8 @@ describe('Search MCP protocol', () => {
7476
const searchSchema = tools.find((tool) => tool.name === 'search')?.inputSchema
7577
const readSchema = tools.find((tool) => tool.name === 'read_document')?.inputSchema
7678
expect(searchSchema?.properties).toHaveProperty('nativeQueries')
79+
expect(searchSchema?.properties?.startDate).toMatchObject({ format: 'date-time' })
80+
expect(searchSchema?.properties?.startDate).not.toHaveProperty('pattern')
7781
expect(readSchema?.properties).toHaveProperty('startOffset')
7882
expect(readSchema?.properties).not.toHaveProperty('url')
7983
const search = await client.callTool({
@@ -94,6 +98,26 @@ describe('Search MCP protocol', () => {
9498
expect(read.content).toEqual([
9599
{ type: 'text', text: expect.stringContaining('Read evidence') },
96100
])
101+
const offsetless = await client.callTool({
102+
name: 'search',
103+
arguments: {
104+
query: 'release',
105+
startDate: '2026-09-28T00:00:00',
106+
endDate: '2026-10-08T00:00',
107+
modifiedAfter: '2026-09-01T08:00:00-07:00',
108+
},
109+
})
110+
expect(offsetless.isError).not.toBe(true)
111+
expect(mocks.liveSearch.mock.lastCall?.[0].input.filters).toMatchObject({
112+
startDate: '2026-09-28T00:00:00Z',
113+
endDate: '2026-10-08T00:00Z',
114+
modifiedAfter: '2026-09-01T08:00:00-07:00',
115+
})
116+
const notADate = await client.callTool({
117+
name: 'search',
118+
arguments: { query: 'release', startDate: '2026-09-28' },
119+
})
120+
expect(notADate.isError).toBe(true)
97121
} finally {
98122
await client.close()
99123
await server.close()

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

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({
4545
...apiServerRoutesMock,
4646
v2RateLimits: { publicApi: { enforce: hoisted.rateLimit } },
4747
}))
48-
vi.mock('@/lib/sim-search/live/application', () => ({
48+
vi.mock('@/lib/sim-search/live/application', async (importOriginal) => ({
49+
...(await importOriginal<typeof import('@/lib/sim-search/live/application')>()),
4950
searchLiveKnowledge: { execute: hoisted.liveSearch },
5051
readLiveDocument: { execute: hoisted.liveRead },
5152
}))
@@ -55,9 +56,16 @@ vi.mock('@/lib/knowledge/application/chat', () => ({
5556

5657
import { OrchestrationError } from '@/lib/core/orchestration/types'
5758
import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server'
59+
import {
60+
LIVE_DATE_ORDER_MESSAGE,
61+
LIVE_MORE_RANKED_BELOW_MESSAGE,
62+
LIVE_NOT_STARTED_MESSAGE,
63+
LIVE_OVER_ACCOUNT_LIMIT_MESSAGE,
64+
} from '@/lib/sim-search/live/application'
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,121 @@ 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 moreBelow = LIVE_MORE_RANKED_BELOW_MESSAGE
239+
const dateOrder = LIVE_DATE_ORDER_MESSAGE
240+
const deadline = LIVE_NOT_STARTED_MESSAGE
241+
const overLimit = LIVE_OVER_ACCOUNT_LIMIT_MESSAGE
242+
const emptyPage = 'No readable matches on this page.'
243+
const unconnected = 'No connection with this provider is configured.'
244+
mocks.liveSearch.mockResolvedValueOnce({
245+
results: [],
246+
retrieval: { status: 'partial', timedOutLegs: [] },
247+
live: {
248+
backend: 'live',
249+
accounts: [
250+
{
251+
accountId: 'a1',
252+
provider: 'slack',
253+
displayName: 'Slack',
254+
status: 'partial',
255+
message: `${dateOrder} ${moreBelow}`,
256+
},
257+
{
258+
accountId: 'a2',
259+
provider: 'gmail',
260+
queryIndex: 1,
261+
displayName: 'Mail',
262+
status: 'partial',
263+
message: `Page note. ${dateOrder} ${emptyPage} ${moreBelow}`,
264+
nextCursor: 'c2',
265+
},
266+
{
267+
accountId: 'a3',
268+
provider: 'notion',
269+
displayName: 'Notion',
270+
status: 'timeout',
271+
message: deadline,
272+
},
273+
{
274+
accountId: 'a4',
275+
provider: 'linear',
276+
displayName: 'Linear',
277+
status: 'unavailable',
278+
message: overLimit,
279+
},
280+
{
281+
accountId: '',
282+
provider: 'jira',
283+
queryIndex: 2,
284+
displayName: 'jira',
285+
status: 'reconnect',
286+
message: unconnected,
287+
},
288+
{ accountId: 'a5', provider: 'jira', displayName: 'jira', status: 'ok' },
289+
],
290+
guidance: 'Use nativeQueries to continue.',
291+
},
292+
})
293+
const { live, retrieval: projected } = textOf(await call('search', { query: 'launch' }))
294+
expect(projected).toEqual({ status: 'partial' })
295+
expect(live).toEqual({
296+
notes: {
297+
moreBelow,
298+
dateOrderPartial: dateOrder,
299+
notSearchedByDeadline: deadline,
300+
notSearchedOverLimit: overLimit,
301+
},
302+
accounts: [
303+
{
304+
accountId: 'a1',
305+
displayName: 'Slack',
306+
provider: 'slack',
307+
status: 'partial',
308+
moreBelow: true,
309+
dateOrderPartial: true,
310+
},
311+
{
312+
accountId: 'a2',
313+
displayName: 'Mail',
314+
provider: 'gmail',
315+
queryIndex: 1,
316+
status: 'partial',
317+
nextCursor: 'c2',
318+
message: `Page note. ${emptyPage}`,
319+
moreBelow: true,
320+
dateOrderPartial: true,
321+
},
322+
{
323+
accountId: 'a3',
324+
displayName: 'Notion',
325+
provider: 'notion',
326+
status: 'timeout',
327+
notSearchedByDeadline: true,
328+
},
329+
{
330+
accountId: 'a4',
331+
displayName: 'Linear',
332+
provider: 'linear',
333+
status: 'unavailable',
334+
notSearchedOverLimit: true,
335+
},
336+
{ provider: 'jira', queryIndex: 2, status: 'reconnect', message: unconnected },
337+
{ accountId: 'a5', displayName: 'jira', provider: 'jira', status: 'ok' },
338+
],
339+
guidance: 'Use nativeQueries to continue.',
340+
})
341+
})
342+
343+
it('rejects a date filter that is not an ISO datetime with an offset', () => {
344+
create()
345+
for (const tool of ['search', 'chat'])
346+
expect(() =>
347+
mocks.configs.get(tool)?.inputSchema.parse({ query: 'launch', startDate: '2026-09-01' })
348+
).toThrow()
349+
})
350+
228351
it('returns a read document title and link once', async () => {
229352
create()
230353
const read = textOf(await call('read_document', { documentId }))
@@ -241,7 +364,7 @@ describe('live organization Search MCP', () => {
241364
backend.mockImplementationOnce(
242365
async ({ input }: { input: { resultSecretRegistry: ResolvedSecretTraceRegistry } }) => {
243366
input.resultSecretRegistry.markIncomplete('source-provenance-incomplete')
244-
return tool === 'search' ? { results: [document] } : document
367+
return tool === 'search' ? { results: [document], retrieval } : document
245368
}
246369
)
247370
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)