Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/sim/lib/api/contracts/knowledge/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,28 @@ export const organizationKnowledgeMcpContract = defineRouteContract({

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

/**
* A datetime field without the regex its JSON Schema would otherwise carry beside
* `format: "date-time"`. Every tools/list call resends the schema, and parsing still rejects a
* string that is not an ISO datetime with an offset.
*/
function withoutPatternInJsonSchema<T extends z.ZodType>(schema: T): T {
return schema.meta({ pattern: undefined })
}

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: withoutPatternInJsonSchema(startDate),
endDate: withoutPatternInJsonSchema(endDate),
modifiedAfter: withoutPatternInJsonSchema(modifiedAfter),
modifiedBefore: withoutPatternInJsonSchema(modifiedBefore),
}

export const liveSearchMcpSchema = searchWorkspaceInputFieldsSchema
.extend({
...mcpDateFilterFields,
topK: searchWorkspaceInputFieldsSchema.shape.topK
.unwrap()
.optional()
Expand Down Expand Up @@ -81,6 +101,7 @@ export const readLiveDocumentMcpSchema = readDocumentInputSchema

export const chatSearchMcpSchema = workspaceSearchFiltersSchema
.extend({
...mcpDateFilterFields,
query: z.string().trim().min(1, 'A question is required').max(8192),
})
.strict()
6 changes: 5 additions & 1 deletion apps/sim/lib/knowledge/mcp/server.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/lib/sim-search/live/application')>()),
searchLiveKnowledge: { execute: hoisted.liveSearch },
readLiveDocument: { execute: hoisted.liveRead },
}))
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
133 changes: 128 additions & 5 deletions apps/sim/lib/knowledge/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/lib/sim-search/live/application')>()),
searchLiveKnowledge: { execute: hoisted.liveSearch },
readLiveDocument: { execute: hoisted.liveRead },
}))
Expand All @@ -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')

Expand All @@ -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: [] })
})

Expand All @@ -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,
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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 }))
Expand All @@ -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 })
Expand Down
66 changes: 62 additions & 4 deletions apps/sim/lib/knowledge/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<WorkspaceKnowledgeSearchData['live']>) {
const notes: Record<string, string> = {}
const accounts = live.accounts.map((entry) => {
const { accountId, displayName, message, ...status } = entry
const { found, rest: remaining } = separateMessages(message, LIVE_ACCOUNT_NOTES)
const flags: Record<string, true> = {}
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<ReturnType<typeof readLiveDocument.execute>>) {
const {
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
)
Expand Down
Loading
Loading