From 7ba0558641b84c937e02b4103a902fd9eecbe065 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 7 Nov 2026 12:28:08 -0800 Subject: [PATCH] improvement(search): send each Search MCP result fact once --- apps/sim/lib/knowledge/mcp/server.test.ts | 70 +++++++++++++++++++++++ apps/sim/lib/knowledge/mcp/server.ts | 59 +++++++++++++++---- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/knowledge/mcp/server.test.ts b/apps/sim/lib/knowledge/mcp/server.test.ts index f5bafa7d108..579a6aa317a 100644 --- a/apps/sim/lib/knowledge/mcp/server.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.test.ts @@ -163,6 +163,76 @@ describe('live organization Search MCP', () => { ) }) + const textOf = (result: CallToolResult) => { + const [part] = result.content + if (part?.type !== 'text') throw new Error('Expected a text result') + return JSON.parse(part.text) as Record & { results?: unknown[] } + } + + it('returns each search fact once and keeps facts that differ', async () => { + create() + const message = { + documentId: 'live:message', + documentName: '#release', + sourceUrl: 'https://example.slack.com/archives/C1/p1', + sourceModifiedAt: '2026-09-22T10:00:00.000Z', + sourceDate: '2026-09-22T10:00:00Z', + sourceContainerName: '#release', + sourceContainerUrl: 'https://example.slack.com/archives/C1', + connectorType: 'slack', + content: 'Shipping Friday', + chunkIndex: 0, + } + const event = { + documentId: 'live:event', + documentName: 'Launch review', + sourceUrl: 'https://calendar.example.com/event', + sourceModifiedAt: '2026-09-20T08:00:00.000Z', + sourceDate: '2026-09-25T16:00:00Z', + sourceContainerName: 'Team calendar', + connectorType: 'google_calendar', + content: 'Launch review', + chunkIndex: 0, + } + mocks.liveSearch.mockResolvedValueOnce({ + results: [message, event], + live: coverage, + retrieval: { status: 'complete' }, + }) + const { results } = textOf(await call('search', { query: 'launch' })) + expect(results).toEqual([ + { + documentId: 'live:message', + title: '#release', + citationId: expect.any(String), + citationUrl: 'https://example.slack.com/archives/C1/p1', + sourceDate: '2026-09-22T10:00:00Z', + sourceContainerUrl: 'https://example.slack.com/archives/C1', + connectorType: 'slack', + content: 'Shipping Friday', + }, + { + documentId: 'live:event', + title: 'Launch review', + citationId: expect.any(String), + citationUrl: 'https://calendar.example.com/event', + sourceModifiedAt: '2026-09-20T08:00:00.000Z', + sourceDate: '2026-09-25T16:00:00Z', + sourceContainerName: 'Team calendar', + connectorType: 'google_calendar', + content: 'Launch review', + }, + ]) + }) + + it('returns a read document title and link once', async () => { + create() + const read = textOf(await call('read_document', { documentId })) + expect(read).toMatchObject({ title: 'Release notes', citationUrl: 'https://example.com/notes' }) + expect(read).not.toHaveProperty('documentName') + expect(read).not.toHaveProperty('sourceUrl') + }) + it.each(['search', 'read_document'])( 'refuses live %s content with incomplete secret provenance', async (tool) => { diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index 3edfc867937..b965738afc9 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -1,4 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { isPlainRecord } from '@sim/utils/object' @@ -47,17 +48,58 @@ async function callerErrorMessage(error: unknown): Promise { : undefined } +interface SourceRow { + documentName: string | null + sourceUrl: string | null + sourceModifiedAt?: string | null + sourceDate?: string | null + sourceContainerName?: string +} + +/** + * A document's title, link and dates, each sent once: every turn of the client's conversation + * re-reads them. `sourceModifiedAt` repeats `sourceDate` except where the date is an event start, + * and a Slack message is titled by its channel, so each is kept only when it says something else. + */ +function sourceFacts(row: SourceRow) { + const { documentName: title, sourceModifiedAt, sourceDate, sourceContainerName } = row + const modifiedDiffers = + sourceModifiedAt && (!sourceDate || Date.parse(sourceModifiedAt) !== Date.parse(sourceDate)) + return { + title, + citationUrl: row.sourceUrl, + ...(modifiedDiffers ? { sourceModifiedAt } : {}), + sourceDate: sourceDate ?? null, + ...(sourceContainerName && sourceContainerName !== title ? { sourceContainerName } : {}), + } +} + /** One read_document result in the shape the tool returns it. */ function projectReadResult(result: Awaited>) { - const { knowledgeBaseId: _knowledgeBaseId, knowledgeBaseName: _name, ...document } = result + const { + knowledgeBaseId: _knowledgeBaseId, + knowledgeBaseName: _name, + documentName: _documentName, + sourceUrl: _sourceUrl, + sourceModifiedAt: _sourceModifiedAt, + sourceContainerName: _sourceContainerName, + ...document + } = result return { ...document, - title: result.documentName, citationId: liveCitationId(result.documentId), - citationUrl: result.sourceUrl, + ...sourceFacts(result), } } +/** Characters of text a tool result hands the client. */ +function resultCharacters(result: CallToolResult): number { + return result.content.reduce( + (total, part) => total + (part.type === 'text' ? part.text.length : 0), + 0 + ) +} + interface KnowledgeMcpContext { organizationId: string request: NextRequest @@ -74,6 +116,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe const startedAt = performance.now() const signal = AbortSignal.any([request.signal, toolSignal]) let outcome: SearchMcpActivityInput['outcome'] = 'error' + let resultChars: number | undefined try { signal.throwIfAborted() const limited = await v2RateLimits.publicApi.enforce(request, auth, operation, cost) @@ -92,6 +135,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe } const result = await run(new ResolvedSecretTraceRegistry(), signal) outcome = signal.aborted ? 'cancelled' : result.isError ? 'error' : 'success' + resultChars = resultCharacters(result) return result } catch (error) { if (signal.aborted) outcome = 'cancelled' @@ -118,6 +162,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe userId: activity.userId, outcome, durationMs: activity.durationMs, + resultChars, }) afterResponse(() => recordOrganizationSearchMcpActivity(activity)) } @@ -154,18 +199,12 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe { results: result.results.map((row) => ({ documentId: row.documentId, - title: row.documentName, - sourceUrl: row.sourceUrl, citationId: liveCitationId(row.documentId), - citationUrl: row.sourceUrl, - sourceModifiedAt: row.sourceModifiedAt ?? null, - sourceDate: row.sourceDate, - sourceContainerName: row.sourceContainerName, + ...sourceFacts(row), sourceContainerUrl: row.sourceContainerUrl, ...(row.queryIndexes ? { queryIndexes: row.queryIndexes } : {}), connectorType: row.connectorType, content: row.content, - chunkIndex: row.chunkIndex, })), retrieval: result.retrieval, live: result.live,