Skip to content

Commit 76a3642

Browse files
committed
feat(search-mcp): persist client-attributed tool activity
1 parent 95b5b76 commit 76a3642

12 files changed

Lines changed: 27817 additions & 47 deletions

File tree

apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
oauthConsent,
2727
organization,
2828
organizationSearchIntegration,
29+
organizationSearchMcpInvocation,
2930
rateLimitBucket,
3031
user,
3132
workspace,
@@ -35,9 +36,19 @@ import { generateId } from '@sim/utils/id'
3536
import { isPlainRecord } from '@sim/utils/object'
3637
import { and, eq, inArray } from 'drizzle-orm'
3738
import { NextRequest } from 'next/server'
38-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
39+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
3940

40-
const fixtures = vi.hoisted(() => ({ storageRoot: '' }))
41+
const fixtures = vi.hoisted(() => ({
42+
storageRoot: '',
43+
afterResponse: [] as Array<() => Promise<void>>,
44+
}))
45+
vi.mock('@/lib/core/utils/after-response', () => ({
46+
afterResponse: (task: () => Promise<void>) => fixtures.afterResponse.push(task),
47+
}))
48+
49+
async function flushAfterResponse() {
50+
for (const task of fixtures.afterResponse.splice(0)) await task()
51+
}
4152
vi.mock('@/lib/uploads/core/setup.server', () => ({
4253
get UPLOAD_DIR_SERVER() {
4354
return fixtures.storageRoot
@@ -401,6 +412,8 @@ describe('organization Search MCP with real ingestion and current access', () =>
401412
bobOAuth = await connect(OAUTH_ACCESS_TOKEN_PREFIX + oauthTokens.bob, true)
402413
})
403414

415+
afterEach(flushAfterResponse)
416+
404417
afterAll(async () => {
405418
await Promise.all(clients.map((client) => client.close()))
406419
await db.delete(oauthClient).where(eq(oauthClient.clientId, oauthClientId))
@@ -508,6 +521,72 @@ describe('organization Search MCP with real ingestion and current access', () =>
508521
expect(await applicationSearch(bobPrincipal)).toEqual([])
509522
})
510523

524+
it('persists content-free per-client tool outcomes separately from search counters', async () => {
525+
await db
526+
.delete(organizationSearchMcpInvocation)
527+
.where(eq(organizationSearchMcpInvocation.organizationId, organizationId))
528+
await db
529+
.update(oauthClient)
530+
.set({ name: 'MCP fixture client' })
531+
.where(eq(oauthClient.clientId, oauthClientId))
532+
await aliceOAuth.listTools()
533+
expect(fixtures.afterResponse).toHaveLength(0)
534+
await search(aliceOAuth)
535+
await value(aliceOAuth, 'read_document', { documentId })
536+
expect((await call(bob, 'read_document', { documentId })).isError).toBe(true)
537+
expect(fixtures.afterResponse).toHaveLength(3)
538+
await flushAfterResponse()
539+
const rows = await db
540+
.select()
541+
.from(organizationSearchMcpInvocation)
542+
.where(eq(organizationSearchMcpInvocation.organizationId, organizationId))
543+
.orderBy(organizationSearchMcpInvocation.createdAt)
544+
.limit(10)
545+
expect(rows).toHaveLength(3)
546+
expect(rows).toMatchObject([
547+
{
548+
organizationId,
549+
userId: aliceId,
550+
authKind: 'oauth_access_token',
551+
oauthClientId,
552+
clientName: 'MCP fixture client',
553+
toolName: 'search',
554+
outcome: 'success',
555+
},
556+
{
557+
organizationId,
558+
userId: aliceId,
559+
authKind: 'oauth_access_token',
560+
oauthClientId,
561+
clientName: 'MCP fixture client',
562+
toolName: 'read_document',
563+
outcome: 'success',
564+
},
565+
{
566+
organizationId,
567+
userId: bobId,
568+
authKind: 'personal_api_key',
569+
oauthClientId: null,
570+
clientName: null,
571+
toolName: 'read_document',
572+
outcome: 'error',
573+
},
574+
])
575+
expect(rows.every((row) => row.durationMs >= 0)).toBe(true)
576+
expect(JSON.stringify(rows)).not.toContain(documentId)
577+
expect(JSON.stringify(rows)).not.toContain(oauthTokens.alice)
578+
await db
579+
.update(oauthClient)
580+
.set({ name: 'Renamed client' })
581+
.where(eq(oauthClient.clientId, oauthClientId))
582+
const [historical] = await db
583+
.select()
584+
.from(organizationSearchMcpInvocation)
585+
.where(eq(organizationSearchMcpInvocation.id, rows[0].id))
586+
.limit(1)
587+
expect(historical.clientName).toBe('MCP fixture client')
588+
})
589+
511590
it('enforces current document and organization access on Search OAuth clients', async () => {
512591
expect((await aliceOAuth.listTools()).tools).toHaveLength(3)
513592
expect(await search(aliceOAuth)).toEqual(await search(alice))
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({
5+
values: vi.fn(),
6+
insert: vi.fn(),
7+
execute: vi.fn(),
8+
transaction: vi.fn(),
9+
}))
10+
vi.mock('@sim/db', () => ({ db: { transaction: mocks.transaction } }))
11+
12+
import {
13+
recordOrganizationSearchMcpActivity,
14+
type SearchMcpActivityInput,
15+
} from '@/lib/knowledge/mcp/activity'
16+
17+
const activity: SearchMcpActivityInput = {
18+
organizationId: 'org',
19+
userId: 'actor',
20+
authKind: 'personal_api_key',
21+
oauthClientId: null,
22+
toolName: 'read_document',
23+
outcome: 'success',
24+
durationMs: 42,
25+
createdAt: new Date('2026-01-01T00:00:00Z'),
26+
}
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
mocks.insert.mockReturnValue({ values: mocks.values })
31+
mocks.values.mockResolvedValue(undefined)
32+
mocks.execute.mockResolvedValue(undefined)
33+
mocks.transaction.mockImplementation((callback) =>
34+
callback({ execute: mocks.execute, insert: mocks.insert })
35+
)
36+
})
37+
38+
describe('persistent MCP activity', () => {
39+
it('stores an API-key call without inventing an application name', async () => {
40+
await recordOrganizationSearchMcpActivity(activity)
41+
expect(mocks.values).toHaveBeenCalledExactlyOnceWith({
42+
id: expect.any(String),
43+
...activity,
44+
clientName: null,
45+
})
46+
})
47+
48+
it('only persists the allowlisted metadata when extra content is present', async () => {
49+
const input = {
50+
...activity,
51+
query: 'private question',
52+
content: 'private document',
53+
token: 'private token',
54+
}
55+
await recordOrganizationSearchMcpActivity(input)
56+
expect(mocks.values).toHaveBeenCalledExactlyOnceWith({
57+
id: expect.any(String),
58+
...activity,
59+
clientName: null,
60+
})
61+
})
62+
63+
it('sets the transaction deadline before attempting the insert', async () => {
64+
const ready = Promise.withResolvers<void>()
65+
mocks.execute.mockReturnValueOnce(ready.promise)
66+
const recording = recordOrganizationSearchMcpActivity(activity)
67+
expect(mocks.insert).not.toHaveBeenCalled()
68+
expect(JSON.stringify(mocks.execute.mock.calls[0])).toContain(
69+
"SET LOCAL statement_timeout = '2s'"
70+
)
71+
ready.resolve()
72+
await recording
73+
expect(mocks.insert).toHaveBeenCalledOnce()
74+
})
75+
76+
it('does not insert when the deadline could not be established', async () => {
77+
mocks.execute.mockRejectedValueOnce(new Error('unavailable'))
78+
await expect(recordOrganizationSearchMcpActivity(activity)).resolves.toBeUndefined()
79+
expect(mocks.insert).not.toHaveBeenCalled()
80+
})
81+
82+
it('does not propagate storage failures into the request lifecycle', async () => {
83+
mocks.values.mockRejectedValueOnce(new Error('offline'))
84+
await expect(recordOrganizationSearchMcpActivity(activity)).resolves.toBeUndefined()
85+
})
86+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { db } from '@sim/db'
2+
import { oauthClient, organizationSearchMcpInvocation } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
import { generateId } from '@sim/utils/id'
6+
import { sql } from 'drizzle-orm'
7+
8+
const logger = createLogger('OrganizationSearchMcpActivity')
9+
10+
export type SearchMcpActivityInput = Pick<
11+
typeof organizationSearchMcpInvocation.$inferInsert,
12+
| 'organizationId'
13+
| 'userId'
14+
| 'authKind'
15+
| 'oauthClientId'
16+
| 'toolName'
17+
| 'outcome'
18+
| 'durationMs'
19+
| 'createdAt'
20+
>
21+
22+
/** Stores content-free metadata from an admitted MCP request, independently of tool success. */
23+
export async function recordOrganizationSearchMcpActivity(
24+
input: SearchMcpActivityInput
25+
): Promise<void> {
26+
try {
27+
await db.transaction(async (tx) => {
28+
await tx.execute(sql`SET LOCAL statement_timeout = '2s'`)
29+
await tx.insert(organizationSearchMcpInvocation).values({
30+
id: generateId(),
31+
organizationId: input.organizationId,
32+
userId: input.userId,
33+
authKind: input.authKind,
34+
oauthClientId: input.oauthClientId,
35+
clientName: input.oauthClientId
36+
? sql`(SELECT left(${oauthClient.name}, 256) FROM ${oauthClient} WHERE ${oauthClient.clientId} = ${input.oauthClientId})`
37+
: null,
38+
toolName: input.toolName,
39+
outcome: input.outcome,
40+
durationMs: input.durationMs,
41+
createdAt: input.createdAt,
42+
})
43+
})
44+
} catch (error) {
45+
logger.warn('Failed to record organization Search MCP activity', {
46+
error: getErrorMessage(error),
47+
})
48+
}
49+
}

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @vitest-environment node */
22
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
3+
import { createMockLogger } from '@sim/testing'
34
import { NextRequest } from 'next/server'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

@@ -12,6 +13,16 @@ const mocks = vi.hoisted(() => ({
1213
read: vi.fn(),
1314
chat: vi.fn(),
1415
rateLimit: vi.fn(),
16+
info: vi.fn(),
17+
afterResponse: vi.fn<(task: () => Promise<void>) => void>(),
18+
recordActivity: vi.fn(),
19+
}))
20+
vi.mock('@/lib/core/utils/after-response', () => ({ afterResponse: mocks.afterResponse }))
21+
vi.mock('@/lib/knowledge/mcp/activity', () => ({
22+
recordOrganizationSearchMcpActivity: mocks.recordActivity,
23+
}))
24+
vi.mock('@sim/logger', () => ({
25+
createLogger: () => ({ ...createMockLogger(), info: mocks.info }),
1526
}))
1627
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
1728
McpServer: class {
@@ -314,3 +325,115 @@ describe('organization chat', () => {
314325
})
315326
})
316327
})
328+
329+
describe('MCP tool completion records', () => {
330+
it('schedules only metadata after the response, independently of analytics storage latency', async () => {
331+
createKnowledgeMcpServer({
332+
organizationId: 'org-1',
333+
searchIndexId: 'index-1',
334+
request,
335+
auth: {
336+
...auth,
337+
keyType: 'oauth_access_token',
338+
principal: {
339+
kind: 'oauth_access_token',
340+
userId: 'oauth-person',
341+
clientId: 'registered-client',
342+
tokenId: 'private-token-id',
343+
scopes: ['search:read'],
344+
expiresAt: new Date(Date.now() + 60000),
345+
},
346+
},
347+
})
348+
const response = await call('search', { query: 'private question' })
349+
expect(response.isError).not.toBe(true)
350+
expect(mocks.recordActivity).not.toHaveBeenCalled()
351+
expect(mocks.afterResponse).toHaveBeenCalledOnce()
352+
await mocks.afterResponse.mock.calls[0][0]()
353+
expect(mocks.recordActivity).toHaveBeenCalledExactlyOnceWith({
354+
organizationId: 'org-1',
355+
userId: 'oauth-person',
356+
authKind: 'oauth_access_token',
357+
oauthClientId: 'registered-client',
358+
toolName: 'search',
359+
outcome: 'success',
360+
durationMs: expect.any(Number),
361+
createdAt: expect.any(Date),
362+
})
363+
})
364+
365+
it.each([
366+
['search', { query: 'private query', topK: 10 }, 'knowledge.search'],
367+
['read_document', { documentId: 'doc-1' }, 'knowledge.documents.read'],
368+
['chat', { query: 'private question' }, 'knowledge.chat'],
369+
] as const)('records one content-free completion for %s', async (toolName, input, operation) => {
370+
create()
371+
await call(toolName, input)
372+
expect(mocks.info).toHaveBeenCalledExactlyOnceWith('Knowledge MCP tool completed', {
373+
toolName,
374+
operation,
375+
organizationId: 'org-1',
376+
userId: 'person-1',
377+
outcome: 'success',
378+
durationMs: expect.any(Number),
379+
})
380+
})
381+
382+
it('records a returned tool error as an error even though the HTTP transport can succeed', async () => {
383+
create()
384+
const result = await call('read_document', {})
385+
expect(result.isError).toBe(true)
386+
expect(mocks.info).toHaveBeenCalledExactlyOnceWith(
387+
'Knowledge MCP tool completed',
388+
expect.objectContaining({ toolName: 'read_document', outcome: 'error' })
389+
)
390+
})
391+
392+
it('records an authorization failure without including the query or error message', async () => {
393+
create()
394+
mocks.search.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Private denial reason'))
395+
await call('search', { query: 'private query' })
396+
expect(mocks.info).toHaveBeenCalledExactlyOnceWith('Knowledge MCP tool completed', {
397+
toolName: 'search',
398+
operation: 'knowledge.search',
399+
organizationId: 'org-1',
400+
userId: 'person-1',
401+
outcome: 'error',
402+
durationMs: expect.any(Number),
403+
})
404+
})
405+
406+
it('distinguishes rate limiting from an executed tool', async () => {
407+
create()
408+
mocks.rateLimit.mockResolvedValueOnce(new Response(null, { status: 429 }))
409+
await call('search', { query: 'private query' })
410+
expect(mocks.search).not.toHaveBeenCalled()
411+
expect(mocks.info).toHaveBeenCalledExactlyOnceWith(
412+
'Knowledge MCP tool completed',
413+
expect.objectContaining({ outcome: 'rate_limited' })
414+
)
415+
await mocks.afterResponse.mock.calls[0][0]()
416+
expect(mocks.recordActivity).toHaveBeenCalledWith(
417+
expect.objectContaining({
418+
authKind: 'personal_api_key',
419+
oauthClientId: null,
420+
outcome: 'rate_limited',
421+
})
422+
)
423+
})
424+
425+
it.each(['search', 'read_document', 'chat'])(
426+
'records cancelled %s calls without executing the operation',
427+
async (toolName) => {
428+
create()
429+
await call(toolName, { query: 'private query', documentId: 'doc-1' }, AbortSignal.abort())
430+
expect(mocks.search).not.toHaveBeenCalled()
431+
expect(mocks.read).not.toHaveBeenCalled()
432+
expect(mocks.chat).not.toHaveBeenCalled()
433+
expect(mocks.info).toHaveBeenCalledExactlyOnceWith(
434+
'Knowledge MCP tool completed',
435+
expect.objectContaining({ toolName, outcome: 'cancelled' })
436+
)
437+
}
438+
)
439+
})

0 commit comments

Comments
 (0)