Skip to content

Commit 17a0287

Browse files
authored
improvement(search): send each Search MCP result fact once (#9778)
1 parent 63487f7 commit 17a0287

2 files changed

Lines changed: 119 additions & 10 deletions

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,76 @@ describe('live organization Search MCP', () => {
163163
)
164164
})
165165

166+
const textOf = (result: CallToolResult) => {
167+
const [part] = result.content
168+
if (part?.type !== 'text') throw new Error('Expected a text result')
169+
return JSON.parse(part.text) as Record<string, unknown> & { results?: unknown[] }
170+
}
171+
172+
it('returns each search fact once and keeps facts that differ', async () => {
173+
create()
174+
const message = {
175+
documentId: 'live:message',
176+
documentName: '#release',
177+
sourceUrl: 'https://example.slack.com/archives/C1/p1',
178+
sourceModifiedAt: '2026-09-22T10:00:00.000Z',
179+
sourceDate: '2026-09-22T10:00:00Z',
180+
sourceContainerName: '#release',
181+
sourceContainerUrl: 'https://example.slack.com/archives/C1',
182+
connectorType: 'slack',
183+
content: 'Shipping Friday',
184+
chunkIndex: 0,
185+
}
186+
const event = {
187+
documentId: 'live:event',
188+
documentName: 'Launch review',
189+
sourceUrl: 'https://calendar.example.com/event',
190+
sourceModifiedAt: '2026-09-20T08:00:00.000Z',
191+
sourceDate: '2026-09-25T16:00:00Z',
192+
sourceContainerName: 'Team calendar',
193+
connectorType: 'google_calendar',
194+
content: 'Launch review',
195+
chunkIndex: 0,
196+
}
197+
mocks.liveSearch.mockResolvedValueOnce({
198+
results: [message, event],
199+
live: coverage,
200+
retrieval: { status: 'complete' },
201+
})
202+
const { results } = textOf(await call('search', { query: 'launch' }))
203+
expect(results).toEqual([
204+
{
205+
documentId: 'live:message',
206+
title: '#release',
207+
citationId: expect.any(String),
208+
citationUrl: 'https://example.slack.com/archives/C1/p1',
209+
sourceDate: '2026-09-22T10:00:00Z',
210+
sourceContainerUrl: 'https://example.slack.com/archives/C1',
211+
connectorType: 'slack',
212+
content: 'Shipping Friday',
213+
},
214+
{
215+
documentId: 'live:event',
216+
title: 'Launch review',
217+
citationId: expect.any(String),
218+
citationUrl: 'https://calendar.example.com/event',
219+
sourceModifiedAt: '2026-09-20T08:00:00.000Z',
220+
sourceDate: '2026-09-25T16:00:00Z',
221+
sourceContainerName: 'Team calendar',
222+
connectorType: 'google_calendar',
223+
content: 'Launch review',
224+
},
225+
])
226+
})
227+
228+
it('returns a read document title and link once', async () => {
229+
create()
230+
const read = textOf(await call('read_document', { documentId }))
231+
expect(read).toMatchObject({ title: 'Release notes', citationUrl: 'https://example.com/notes' })
232+
expect(read).not.toHaveProperty('documentName')
233+
expect(read).not.toHaveProperty('sourceUrl')
234+
})
235+
166236
it.each(['search', 'read_document'])(
167237
'refuses live %s content with incomplete secret provenance',
168238
async (tool) => {

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

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
23
import { resolvePrincipalSubjectUserId } from '@sim/auth/principal'
34
import { createLogger } from '@sim/logger'
45
import { isPlainRecord } from '@sim/utils/object'
@@ -47,17 +48,58 @@ async function callerErrorMessage(error: unknown): Promise<string | undefined> {
4748
: undefined
4849
}
4950

51+
interface SourceRow {
52+
documentName: string | null
53+
sourceUrl: string | null
54+
sourceModifiedAt?: string | null
55+
sourceDate?: string | null
56+
sourceContainerName?: string
57+
}
58+
59+
/**
60+
* A document's title, link and dates, each sent once: every turn of the client's conversation
61+
* re-reads them. `sourceModifiedAt` repeats `sourceDate` except where the date is an event start,
62+
* and a Slack message is titled by its channel, so each is kept only when it says something else.
63+
*/
64+
function sourceFacts(row: SourceRow) {
65+
const { documentName: title, sourceModifiedAt, sourceDate, sourceContainerName } = row
66+
const modifiedDiffers =
67+
sourceModifiedAt && (!sourceDate || Date.parse(sourceModifiedAt) !== Date.parse(sourceDate))
68+
return {
69+
title,
70+
citationUrl: row.sourceUrl,
71+
...(modifiedDiffers ? { sourceModifiedAt } : {}),
72+
sourceDate: sourceDate ?? null,
73+
...(sourceContainerName && sourceContainerName !== title ? { sourceContainerName } : {}),
74+
}
75+
}
76+
5077
/** One read_document result in the shape the tool returns it. */
5178
function projectReadResult(result: Awaited<ReturnType<typeof readLiveDocument.execute>>) {
52-
const { knowledgeBaseId: _knowledgeBaseId, knowledgeBaseName: _name, ...document } = result
79+
const {
80+
knowledgeBaseId: _knowledgeBaseId,
81+
knowledgeBaseName: _name,
82+
documentName: _documentName,
83+
sourceUrl: _sourceUrl,
84+
sourceModifiedAt: _sourceModifiedAt,
85+
sourceContainerName: _sourceContainerName,
86+
...document
87+
} = result
5388
return {
5489
...document,
55-
title: result.documentName,
5690
citationId: liveCitationId(result.documentId),
57-
citationUrl: result.sourceUrl,
91+
...sourceFacts(result),
5892
}
5993
}
6094

95+
/** Characters of text a tool result hands the client. */
96+
function resultCharacters(result: CallToolResult): number {
97+
return result.content.reduce(
98+
(total, part) => total + (part.type === 'text' ? part.text.length : 0),
99+
0
100+
)
101+
}
102+
61103
interface KnowledgeMcpContext {
62104
organizationId: string
63105
request: NextRequest
@@ -74,6 +116,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
74116
const startedAt = performance.now()
75117
const signal = AbortSignal.any([request.signal, toolSignal])
76118
let outcome: SearchMcpActivityInput['outcome'] = 'error'
119+
let resultChars: number | undefined
77120
try {
78121
signal.throwIfAborted()
79122
const limited = await v2RateLimits.publicApi.enforce(request, auth, operation, cost)
@@ -92,6 +135,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
92135
}
93136
const result = await run(new ResolvedSecretTraceRegistry(), signal)
94137
outcome = signal.aborted ? 'cancelled' : result.isError ? 'error' : 'success'
138+
resultChars = resultCharacters(result)
95139
return result
96140
} catch (error) {
97141
if (signal.aborted) outcome = 'cancelled'
@@ -118,6 +162,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
118162
userId: activity.userId,
119163
outcome,
120164
durationMs: activity.durationMs,
165+
resultChars,
121166
})
122167
afterResponse(() => recordOrganizationSearchMcpActivity(activity))
123168
}
@@ -154,18 +199,12 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
154199
{
155200
results: result.results.map((row) => ({
156201
documentId: row.documentId,
157-
title: row.documentName,
158-
sourceUrl: row.sourceUrl,
159202
citationId: liveCitationId(row.documentId),
160-
citationUrl: row.sourceUrl,
161-
sourceModifiedAt: row.sourceModifiedAt ?? null,
162-
sourceDate: row.sourceDate,
163-
sourceContainerName: row.sourceContainerName,
203+
...sourceFacts(row),
164204
sourceContainerUrl: row.sourceContainerUrl,
165205
...(row.queryIndexes ? { queryIndexes: row.queryIndexes } : {}),
166206
connectorType: row.connectorType,
167207
content: row.content,
168-
chunkIndex: row.chunkIndex,
169208
})),
170209
retrieval: result.retrieval,
171210
live: result.live,

0 commit comments

Comments
 (0)