diff --git a/apps/sim/lib/sim-search/live/fireflies-mcp.ts b/apps/sim/lib/sim-search/live/fireflies-mcp.ts index 13bfcd74d0d..6b623831827 100644 --- a/apps/sim/lib/sim-search/live/fireflies-mcp.ts +++ b/apps/sim/lib/sim-search/live/fireflies-mcp.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@sim/logger' import { toArray, toRecord } from '@sim/utils/object' import { nativeText } from '@/lib/sim-search/live/dates' import { NativeSearchError, string } from '@/lib/sim-search/live/http' @@ -6,7 +7,49 @@ import { boundedMeetingContent } from '@/lib/sim-search/live/meeting-content' import type { NativeDocument, NativePage, NativeSearchInput } from '@/lib/sim-search/live/types' const MAX_MEETING_CONTENT = 200_000 +const MAX_DIAGNOSTIC_TEXT_LENGTH = 65_536 const UTC_DAY_MS = 86_400_000 +const logger = createLogger('FirefliesMcpSearch', { logLevel: 'WARN' }) + +/** Only fixed envelope keys and value kinds may enter diagnostics, never provider keys or values. */ +function responseShape(value: unknown) { + const record = toRecord(value) + const kind = (entry: unknown) => + entry === null ? 'null' : Array.isArray(entry) ? 'array' : typeof entry + return { + type: kind(value), + fields: Object.fromEntries( + [ + 'transcripts', + 'data', + 'result', + 'results', + 'items', + 'output', + 'text', + 'error', + 'errors', + ].flatMap((key) => (Object.hasOwn(record, key) ? [[key, kind(record[key])]] : [])) + ), + } +} + +function responseTextShape(value: unknown) { + if (typeof value !== 'string') return undefined + if (value.length > MAX_DIAGNOSTIC_TEXT_LENGTH) + return { length: value.length, inspection: 'skipped_size_limit' } + const text = value.trim() + const fenced = text.match(/^```(?:json)?\r?\n([\s\S]*)\r?\n```$/i)?.[1] + let jsonShape: ReturnType | undefined + try { + jsonShape = responseShape(JSON.parse(fenced ?? text)) + } catch {} + return { + length: value.length, + format: fenced !== undefined ? 'code_fence' : jsonShape ? 'json' : 'text', + ...(jsonShape ? { jsonShape } : {}), + } +} function requireMeetingId(id: string): string { if (!/^[\w-]{1,200}$/.test(id)) @@ -139,11 +182,19 @@ export async function searchFirefliesMcp( const rows = Array.isArray(result) ? result : (data.transcripts ?? toRecord(data.data).transcripts) - if (!Array.isArray(rows)) + if (!Array.isArray(rows)) { + logger.warn('Unsupported Fireflies transcript list response shape', { + response: responseShape(result), + data: responseShape(data.data), + result: responseShape(data.result), + output: responseShape(data.output), + text: responseTextShape(typeof result === 'string' ? result : data.text), + }) throw new NativeSearchError( 'unavailable', 'Fireflies returned an unsupported transcript list format.' ) + } const documents = rows.slice(0, limit).flatMap((value) => { const document = meetingDocument(toRecord(value)) return document ? [document] : [] diff --git a/apps/sim/lib/sim-search/live/meeting-mcp.test.ts b/apps/sim/lib/sim-search/live/meeting-mcp.test.ts index 00fbdae8334..62ceaf86aa8 100644 --- a/apps/sim/lib/sim-search/live/meeting-mcp.test.ts +++ b/apps/sim/lib/sim-search/live/meeting-mcp.test.ts @@ -1,3 +1,4 @@ +import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock' import { describe, expect, it } from 'vitest' import { readFirefliesMcp, searchFirefliesMcp } from '@/lib/sim-search/live/fireflies-mcp' import { readGranolaMcp, searchGranolaMcp } from '@/lib/sim-search/live/granola-mcp' @@ -157,6 +158,60 @@ describe('meeting MCP provider wire contracts', () => { await expect(searchFirefliesMcp(client, input)).rejects.toThrow('unsupported') }) + it.each(['text', 'json', 'code_fence', 'oversized'] as const)( + 'diagnoses unsupported Fireflies %s envelopes without logging private values or arbitrary keys', + async (format) => { + const privateMarker = 'PRIVATE_MEETING_OR_CREDENTIAL' + const json = JSON.stringify({ items: [{ title: privateMarker }], [privateMarker]: 'secret' }) + const text = + format === 'oversized' + ? JSON.stringify({ items: [privateMarker.repeat(3000)] }) + : format === 'code_fence' + ? ['```json', json, '```'].join('\n') + : format === 'json' + ? json + : privateMarker.repeat(1000) + const client: ManagedSearchMcpClient = { + async call() { + return { + text, + data: { error: privateMarker }, + result: { items: [{ title: privateMarker }] }, + ...Object.fromEntries( + Array.from({ length: 1000 }, (_, index) => [ + `${privateMarker}_${index}`, + privateMarker, + ]) + ), + } + }, + } + await expect(searchFirefliesMcp(client, { ...input, query: privateMarker })).rejects.toThrow( + 'unsupported' + ) + const warnings = getAllMockLoggers().flatMap((logger) => logger.warn.mock.calls) + expect(warnings).toHaveLength(1) + expect(warnings[0]?.[1]).toMatchObject({ + response: { type: 'object', fields: { text: 'string', data: 'object', result: 'object' } }, + data: { type: 'object', fields: { error: 'string' } }, + result: { type: 'object', fields: { items: 'array' } }, + text: + format === 'oversized' + ? { length: text.length, inspection: 'skipped_size_limit' } + : { + length: text.length, + format, + ...(format === 'text' + ? {} + : { jsonShape: { type: 'object', fields: { items: 'array' } } }), + }, + }) + const serialized = JSON.stringify(warnings) + expect(serialized).not.toContain(privateMarker) + expect(serialized.length).toBeLessThan(2000) + } + ) + it('rejects invalid continuation offsets before Fireflies receives a query', async () => { const client = { async call() {