diff --git a/apps/sim/lib/api/contracts/knowledge/mcp.ts b/apps/sim/lib/api/contracts/knowledge/mcp.ts index 77e13b40f14..f06b87c0843 100644 --- a/apps/sim/lib/api/contracts/knowledge/mcp.ts +++ b/apps/sim/lib/api/contracts/knowledge/mcp.ts @@ -19,8 +19,35 @@ export const organizationKnowledgeMcpContract = defineRouteContract({ const LIVE_SEARCH_MCP_DEFAULT_TOP_K = { federated: 30, scoped: 20 } as const +const HAS_UTC_OFFSET = /(Z|[+-]\d{2}:\d{2})$/ + +/** + * An ISO datetime filter that reads a value without an offset as UTC, which is what a client + * resends after that value is rejected, so the call succeeds on the first try. Its JSON Schema + * keeps `format: "date-time"` and the field's description but not the regex, which every + * tools/list call would resend. + */ +function mcpDateFilter(field: z.ZodOptional) { + return z.iso + .datetime({ offset: true, local: true }) + .transform((value) => (HAS_UTC_OFFSET.test(value) ? value : `${value}Z`)) + .optional() + .meta({ pattern: undefined, description: field.description }) +} + +const { startDate, endDate, modifiedAfter, modifiedBefore } = workspaceSearchFiltersSchema.shape + +/** The date filters of the Search MCP tools, as their tools/list schema presents them. */ +const mcpDateFilterFields = { + startDate: mcpDateFilter(startDate), + endDate: mcpDateFilter(endDate), + modifiedAfter: mcpDateFilter(modifiedAfter), + modifiedBefore: mcpDateFilter(modifiedBefore), +} + export const liveSearchMcpSchema = searchWorkspaceInputFieldsSchema .extend({ + ...mcpDateFilterFields, topK: searchWorkspaceInputFieldsSchema.shape.topK .unwrap() .optional() @@ -81,6 +108,7 @@ export const readLiveDocumentMcpSchema = readDocumentInputSchema export const chatSearchMcpSchema = workspaceSearchFiltersSchema .extend({ + ...mcpDateFilterFields, query: z.string().trim().min(1, 'A question is required').max(8192), }) .strict() diff --git a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts index 4e8c7099594..62c34aa3dfc 100644 --- a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts @@ -17,7 +17,8 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ ...apiServerRoutesMock, v2RateLimits: { publicApi: { enforce: vi.fn().mockResolvedValue(null) } }, })) -vi.mock('@/lib/sim-search/live/application', () => ({ +vi.mock('@/lib/sim-search/live/application', async (importOriginal) => ({ + ...(await importOriginal()), searchLiveKnowledge: { execute: hoisted.liveSearch }, readLiveDocument: { execute: hoisted.liveRead }, })) @@ -44,6 +45,7 @@ describe('Search MCP protocol', () => { } mocks.liveSearch.mockResolvedValue({ results: [row], + retrieval: { status: 'complete', timedOutLegs: [] }, live: { backend: 'live', accounts: [], guidance: '' }, }) mocks.liveRead.mockResolvedValue({ @@ -74,6 +76,8 @@ describe('Search MCP protocol', () => { const searchSchema = tools.find((tool) => tool.name === 'search')?.inputSchema const readSchema = tools.find((tool) => tool.name === 'read_document')?.inputSchema expect(searchSchema?.properties).toHaveProperty('nativeQueries') + expect(searchSchema?.properties?.startDate).toMatchObject({ format: 'date-time' }) + expect(searchSchema?.properties?.startDate).not.toHaveProperty('pattern') expect(readSchema?.properties).toHaveProperty('startOffset') expect(readSchema?.properties).not.toHaveProperty('url') const search = await client.callTool({ @@ -94,6 +98,26 @@ describe('Search MCP protocol', () => { expect(read.content).toEqual([ { type: 'text', text: expect.stringContaining('Read evidence') }, ]) + const offsetless = await client.callTool({ + name: 'search', + arguments: { + query: 'release', + startDate: '2026-09-28T00:00:00', + endDate: '2026-10-08T00:00', + modifiedAfter: '2026-09-01T08:00:00-07:00', + }, + }) + expect(offsetless.isError).not.toBe(true) + expect(mocks.liveSearch.mock.lastCall?.[0].input.filters).toMatchObject({ + startDate: '2026-09-28T00:00:00Z', + endDate: '2026-10-08T00:00Z', + modifiedAfter: '2026-09-01T08:00:00-07:00', + }) + const notADate = await client.callTool({ + name: 'search', + arguments: { query: 'release', startDate: '2026-09-28' }, + }) + expect(notADate.isError).toBe(true) } finally { await client.close() await server.close() diff --git a/apps/sim/lib/knowledge/mcp/server.test.ts b/apps/sim/lib/knowledge/mcp/server.test.ts index 579a6aa317a..5d40d287377 100644 --- a/apps/sim/lib/knowledge/mcp/server.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.test.ts @@ -45,7 +45,8 @@ vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ ...apiServerRoutesMock, v2RateLimits: { publicApi: { enforce: hoisted.rateLimit } }, })) -vi.mock('@/lib/sim-search/live/application', () => ({ +vi.mock('@/lib/sim-search/live/application', async (importOriginal) => ({ + ...(await importOriginal()), searchLiveKnowledge: { execute: hoisted.liveSearch }, readLiveDocument: { execute: hoisted.liveRead }, })) @@ -55,9 +56,16 @@ vi.mock('@/lib/knowledge/application/chat', () => ({ import { OrchestrationError } from '@/lib/core/orchestration/types' import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server' +import { + LIVE_DATE_ORDER_MESSAGE, + LIVE_MORE_RANKED_BELOW_MESSAGE, + LIVE_NOT_STARTED_MESSAGE, + LIVE_OVER_ACCOUNT_LIMIT_MESSAGE, +} from '@/lib/sim-search/live/application' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const mocks = hoisted +const retrieval = { status: 'complete', timedOutLegs: [] } urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.example') @@ -83,7 +91,7 @@ beforeEach(() => { mocks.tools.clear() mocks.configs.clear() mocks.rateLimit.mockReset().mockResolvedValue(null) - mocks.liveSearch.mockResolvedValue({ results: [] }) + mocks.liveSearch.mockResolvedValue({ results: [], retrieval }) mocks.chat.mockResolvedValue({ content: 'An answer', citations: [] }) }) @@ -110,7 +118,7 @@ describe('live organization Search MCP', () => { mocks.liveSearch.mockResolvedValue({ results: [document], live: coverage, - retrieval: { status: 'partial' }, + retrieval: { status: 'partial', timedOutLegs: [] }, }) mocks.liveRead.mockResolvedValue({ ...document, @@ -197,7 +205,7 @@ describe('live organization Search MCP', () => { mocks.liveSearch.mockResolvedValueOnce({ results: [message, event], live: coverage, - retrieval: { status: 'complete' }, + retrieval, }) const { results } = textOf(await call('search', { query: 'launch' })) expect(results).toEqual([ @@ -225,6 +233,121 @@ describe('live organization Search MCP', () => { ]) }) + it('states each repeated account status once and flags the entries that carried it', async () => { + create() + const moreBelow = LIVE_MORE_RANKED_BELOW_MESSAGE + const dateOrder = LIVE_DATE_ORDER_MESSAGE + const deadline = LIVE_NOT_STARTED_MESSAGE + const overLimit = LIVE_OVER_ACCOUNT_LIMIT_MESSAGE + const emptyPage = 'No readable matches on this page.' + const unconnected = 'No connection with this provider is configured.' + mocks.liveSearch.mockResolvedValueOnce({ + results: [], + retrieval: { status: 'partial', timedOutLegs: [] }, + live: { + backend: 'live', + accounts: [ + { + accountId: 'a1', + provider: 'slack', + displayName: 'Slack', + status: 'partial', + message: `${dateOrder} ${moreBelow}`, + }, + { + accountId: 'a2', + provider: 'gmail', + queryIndex: 1, + displayName: 'Mail', + status: 'partial', + message: `Page note. ${dateOrder} ${emptyPage} ${moreBelow}`, + nextCursor: 'c2', + }, + { + accountId: 'a3', + provider: 'notion', + displayName: 'Notion', + status: 'timeout', + message: deadline, + }, + { + accountId: 'a4', + provider: 'linear', + displayName: 'Linear', + status: 'unavailable', + message: overLimit, + }, + { + accountId: '', + provider: 'jira', + queryIndex: 2, + displayName: 'jira', + status: 'reconnect', + message: unconnected, + }, + { accountId: 'a5', provider: 'jira', displayName: 'jira', status: 'ok' }, + ], + guidance: 'Use nativeQueries to continue.', + }, + }) + const { live, retrieval: projected } = textOf(await call('search', { query: 'launch' })) + expect(projected).toEqual({ status: 'partial' }) + expect(live).toEqual({ + notes: { + moreBelow, + dateOrderPartial: dateOrder, + notSearchedByDeadline: deadline, + notSearchedOverLimit: overLimit, + }, + accounts: [ + { + accountId: 'a1', + displayName: 'Slack', + provider: 'slack', + status: 'partial', + moreBelow: true, + dateOrderPartial: true, + }, + { + accountId: 'a2', + displayName: 'Mail', + provider: 'gmail', + queryIndex: 1, + status: 'partial', + nextCursor: 'c2', + message: `Page note. ${emptyPage}`, + moreBelow: true, + dateOrderPartial: true, + }, + { + accountId: 'a3', + displayName: 'Notion', + provider: 'notion', + status: 'timeout', + notSearchedByDeadline: true, + }, + { + accountId: 'a4', + displayName: 'Linear', + provider: 'linear', + status: 'unavailable', + notSearchedOverLimit: true, + }, + { provider: 'jira', queryIndex: 2, status: 'reconnect', message: unconnected }, + { accountId: 'a5', displayName: 'jira', provider: 'jira', status: 'ok' }, + ], + guidance: 'Use nativeQueries to continue.', + }) + }) + + it('rejects a date filter that is not an ISO datetime with an offset', () => { + create() + for (const tool of ['search', 'chat']) + expect(() => + mocks.configs.get(tool)?.inputSchema.parse({ query: 'launch', startDate: '2026-09-01' }) + ).toThrow() + }) + it('returns a read document title and link once', async () => { create() const read = textOf(await call('read_document', { documentId })) @@ -241,7 +364,7 @@ describe('live organization Search MCP', () => { backend.mockImplementationOnce( async ({ input }: { input: { resultSecretRegistry: ResolvedSecretTraceRegistry } }) => { input.resultSecretRegistry.markIncomplete('source-provenance-incomplete') - return tool === 'search' ? { results: [document] } : document + return tool === 'search' ? { results: [document], retrieval } : document } ) const result = await call(tool, tool === 'search' ? { query: 'release' } : { documentId }) diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index b965738afc9..facc4b8a0d7 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -11,6 +11,7 @@ import { liveSearchMcpSchema, readLiveDocumentMcpSchema, } from '@/lib/api/contracts/knowledge/mcp' +import type { WorkspaceKnowledgeSearchData } from '@/lib/api/contracts/mothership-assistant-tools' import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' import { v2RateLimits } from '@/lib/api/server/routes/v2-json-route' import { afterResponse } from '@/lib/core/utils/after-response' @@ -28,7 +29,15 @@ import { } from '@/lib/knowledge/mcp/tool-runner' import { liveCitationId } from '@/lib/knowledge/search/citation' import { toolError } from '@/lib/mcp/tool-result' -import { readLiveDocument, searchLiveKnowledge } from '@/lib/sim-search/live/application' +import { + LIVE_DATE_ORDER_MESSAGE, + LIVE_MORE_RANKED_BELOW_MESSAGE, + LIVE_NOT_STARTED_MESSAGE, + LIVE_OVER_ACCOUNT_LIMIT_MESSAGE, + readLiveDocument, + searchLiveKnowledge, +} from '@/lib/sim-search/live/application' +import { separateMessages } from '@/lib/sim-search/live/pages' import { LiveReadError } from '@/lib/sim-search/live/read-error' import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -74,6 +83,55 @@ function sourceFacts(row: SourceRow) { } } +/** + * Account status sentences that repeat across `live.accounts`, keyed by the flag that marks an + * entry carrying one. The search result states each sentence once in `live.notes` under its flag. + */ +const LIVE_ACCOUNT_NOTES = { + moreBelow: LIVE_MORE_RANKED_BELOW_MESSAGE, + dateOrderPartial: LIVE_DATE_ORDER_MESSAGE, + notSearchedByDeadline: LIVE_NOT_STARTED_MESSAGE, + notSearchedOverLimit: LIVE_OVER_ACCOUNT_LIMIT_MESSAGE, +} + +/** + * Live coverage with each repeated status sentence moved to `live.notes` and flagged on the + * entries that carried it. A reconnect entry for a provider with no connection has no account ID + * and is named by its provider, so it sends neither. `backend` is always `live` here. + */ +function projectLiveCoverage(live: NonNullable) { + const notes: Record = {} + const accounts = live.accounts.map((entry) => { + const { accountId, displayName, message, ...status } = entry + const { found, rest: remaining } = separateMessages(message, LIVE_ACCOUNT_NOTES) + const flags: Record = {} + for (const flag of found) { + flags[flag] = true + notes[flag] = LIVE_ACCOUNT_NOTES[flag] + } + return { + ...(accountId ? { accountId } : {}), + ...(accountId || displayName !== entry.provider ? { displayName } : {}), + ...status, + ...(remaining === undefined ? {} : { message: remaining }), + ...flags, + } + }) + return { + ...(Object.keys(notes).length ? { notes } : {}), + accounts, + guidance: live.guidance, + } +} + +/** A search result's retrieval status, without the timed-out legs a live search never has. */ +function projectRetrieval({ + timedOutLegs, + ...retrieval +}: WorkspaceKnowledgeSearchData['retrieval']) { + return timedOutLegs.length ? { ...retrieval, timedOutLegs } : retrieval +} + /** One read_document result in the shape the tool returns it. */ function projectReadResult(result: Awaited>) { const { @@ -173,7 +231,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe { title: 'Search', description: - '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.', + '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.', inputSchema: liveSearchMcpSchema, annotations: KNOWLEDGE_MCP_READ_ONLY, }, @@ -206,8 +264,8 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe connectorType: row.connectorType, content: row.content, })), - retrieval: result.retrieval, - live: result.live, + retrieval: projectRetrieval(result.retrieval), + ...(result.live ? { live: projectLiveCoverage(result.live) } : {}), }, registry ) diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index 66bcbfc3433..ee0fb5ac151 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -273,6 +273,18 @@ const ACCOUNT_START_FLOOR_MS = 1500 /** Candidates verified at once; each verification is one or more provider requests. */ const VERIFY_CONCURRENCY = 5 const MAX_ACCOUNTS = 20 + +/** Account status when a merge cut results that its cursor would skip. */ +export const LIVE_MORE_RANKED_BELOW_MESSAGE = + 'More matches ranked below the returned results. Search this account alone to see them.' +/** Account status on every date-sorted search. */ +export const LIVE_DATE_ORDER_MESSAGE = + 'Date order covers retrieved results; follow continuation before claiming an overall earliest or latest match.' +/** Account status when the search deadline arrives before the account gets its turn. */ +export const LIVE_NOT_STARTED_MESSAGE = + 'Not searched before the search deadline. Search this account alone or narrow the query.' +/** Account status for each eligible account beyond {@link MAX_ACCOUNTS}. */ +export const LIVE_OVER_ACCOUNT_LIMIT_MESSAGE = `Not searched: one search covers at most ${MAX_ACCOUNTS} accounts. Search this source or account alone.` /** Previews center on the query's longest matching term, like indexed passages. */ const PREVIEW_CHARACTERS = 1800 /** Characters one read returns, the same page budget indexed document reads use. */ @@ -654,9 +666,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ undatedExcluded ? 'Some results lacked date metadata and were excluded; date coverage is incomplete.' : undefined, - dateSorted - ? 'Date order covers retrieved results; follow continuation before claiming an overall earliest or latest match.' - : undefined, + dateSorted ? LIVE_DATE_ORDER_MESSAGE : undefined, moreUnreachable ? 'More matches exist than this search could return. Narrow the query or target one source.' : undefined, @@ -803,7 +813,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ failed( new NativeSearchError( 'timeout', - 'Not searched before the search deadline. Search this account alone or narrow the query.', + LIVE_NOT_STARTED_MESSAGE, undefined, undefined, 'not_started' @@ -894,10 +904,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ const { nextCursor: _, ...rest } = status return { ...rest, - message: joinMessages([ - status.message, - 'More matches ranked below the returned results. Search this account alone to see them.', - ]), + message: joinMessages([status.message, LIVE_MORE_RANKED_BELOW_MESSAGE]), } }) /** Live coverage guides the providers this call searched or tried to search. */ @@ -917,7 +924,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ displayName: account.displayName, ...(queryIndex === undefined ? {} : { queryIndex }), status: 'unavailable', - message: `Not searched: one search covers at most ${MAX_ACCOUNTS} accounts. Search this source or account alone.`, + message: LIVE_OVER_ACCOUNT_LIMIT_MESSAGE, }) } for (const [queryIndex, query] of (queries ?? []).entries()) { diff --git a/apps/sim/lib/sim-search/live/pages.ts b/apps/sim/lib/sim-search/live/pages.ts index a2f30959c16..8bc84a98170 100644 --- a/apps/sim/lib/sim-search/live/pages.ts +++ b/apps/sim/lib/sim-search/live/pages.ts @@ -14,14 +14,39 @@ export function interleaveByRank(lists: readonly (readonly T[])[]): T[] { return merged } +const MESSAGE_SEPARATOR = ' ' + /** Joins status sentences once each, in first-seen order. */ export function joinMessages(messages: readonly (string | undefined)[]): string | undefined { return ( - [...new Set(messages.filter((message): message is string => Boolean(message)))].join(' ') || - undefined + [...new Set(messages.filter((message): message is string => Boolean(message)))].join( + MESSAGE_SEPARATOR + ) || undefined ) } +/** + * Takes each given sentence out of a message {@link joinMessages} built, matching whole sentences + * only. Returns the sentences found and what remains, undefined when nothing does. + */ +export function separateMessages( + message: string | undefined, + sentences: Readonly> +): { found: Key[]; rest: string | undefined } { + let padded = `${MESSAGE_SEPARATOR}${message ?? ''}${MESSAGE_SEPARATOR}` + const found: Key[] = [] + for (const key of Object.keys(sentences) as Key[]) { + const bounded = `${MESSAGE_SEPARATOR}${sentences[key]}${MESSAGE_SEPARATOR}` + if (!padded.includes(bounded)) continue + padded = padded.replace(bounded, MESSAGE_SEPARATOR) + found.push(key) + } + return { + found, + rest: padded.slice(MESSAGE_SEPARATOR.length, -MESSAGE_SEPARATOR.length) || undefined, + } +} + /** Independent collections retain their successful evidence if another collection fails. */ export async function collectNativePages( requests: Promise[],