Files
CipherTalk/electron/services/mcp/service.ts
T
2026-04-01 22:00:47 +08:00

937 lines
29 KiB
TypeScript

import { existsSync, mkdirSync } from 'fs'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod'
import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService'
import { ConfigService } from '../config'
import { imageDecryptService } from '../imageDecryptService'
import { videoService } from '../videoService'
import { McpToolError } from './result'
import {
MCP_CONTACT_KINDS,
MCP_MESSAGE_KINDS,
type McpContactItem,
type McpContactKind,
type McpContactsPayload,
type McpCursor,
type McpMessageItem,
type McpMessageKind,
type McpMessageMatchField,
type McpMessagesPayload,
type McpSearchHit,
type McpSearchMessagesPayload,
type McpSessionContextPayload,
type McpSessionItem,
type McpSessionKind,
type McpSessionRef,
type McpSessionsPayload
} from './types'
const MAX_LIST_LIMIT = 200
const MAX_SEARCH_LIMIT = 100
const MAX_CONTEXT_LIMIT = 100
const SEARCH_BATCH_SIZE = 200
const MAX_SEARCH_SESSIONS = 20
const MAX_SCAN_PER_SESSION = 1000
const MAX_SCAN_GLOBAL = 10000
const listSessionsArgsSchema = z.object({
q: z.string().optional(),
offset: z.number().int().nonnegative().optional(),
limit: z.number().int().positive().optional(),
unreadOnly: z.boolean().optional()
})
const getMessagesArgsSchema = z.object({
sessionId: z.string().trim().min(1),
offset: z.number().int().nonnegative().optional(),
limit: z.number().int().positive().optional(),
order: z.enum(['asc', 'desc']).optional(),
keyword: z.string().optional(),
startTime: z.number().int().positive().optional(),
endTime: z.number().int().positive().optional(),
includeRaw: z.boolean().optional(),
includeMediaPaths: z.boolean().optional()
})
const listContactsArgsSchema = z.object({
q: z.string().optional(),
offset: z.number().int().nonnegative().optional(),
limit: z.number().int().positive().optional(),
types: z.array(z.enum(MCP_CONTACT_KINDS)).optional()
})
const searchMessagesArgsSchema = z.object({
query: z.string().trim().min(1),
sessionId: z.string().trim().min(1).optional(),
sessionIds: z.array(z.string().trim().min(1)).max(MAX_SEARCH_SESSIONS).optional(),
startTime: z.number().int().positive().optional(),
endTime: z.number().int().positive().optional(),
kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional(),
direction: z.enum(['in', 'out']).optional(),
senderUsername: z.string().trim().min(1).optional(),
limit: z.number().int().positive().optional(),
includeRaw: z.boolean().optional(),
includeMediaPaths: z.boolean().optional()
})
const cursorSchema = z.object({
sortSeq: z.number().int(),
createTime: z.number().int().positive(),
localId: z.number().int()
})
const getSessionContextArgsSchema = z.object({
sessionId: z.string().trim().min(1),
mode: z.enum(['latest', 'around']),
anchorCursor: cursorSchema.optional(),
beforeLimit: z.number().int().positive().optional(),
afterLimit: z.number().int().positive().optional(),
includeRaw: z.boolean().optional(),
includeMediaPaths: z.boolean().optional()
}).superRefine((value, ctx) => {
if (value.mode === 'around' && !value.anchorCursor) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['anchorCursor'],
message: 'anchorCursor is required when mode=around'
})
}
})
type ListSessionsArgs = z.infer<typeof listSessionsArgsSchema>
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
type GetSessionContextArgs = z.infer<typeof getSessionContextArgsSchema>
type ContactWithLastContact = ContactInfo & { lastContactTime?: number }
type MessageNormalizeOptions = {
includeMediaPaths: boolean
includeRaw: boolean
}
type SearchRawHit = {
session: McpSessionRef
message: Message
matchedField: McpMessageMatchField
excerpt: string
}
function toTimestampMs(value?: number | null): number {
if (!value || !Number.isFinite(value) || value <= 0) return 0
return value < 1_000_000_000_000 ? value * 1000 : value
}
function detectSessionKind(sessionId: string): McpSessionKind {
if (sessionId.includes('@chatroom')) return 'group'
if (sessionId.startsWith('gh_')) return 'official'
if (sessionId) return 'friend'
return 'other'
}
function detectMessageKind(message: Pick<Message, 'localType' | 'rawContent' | 'parsedContent'>): McpMessageKind {
const localType = Number(message.localType || 0)
const raw = String(message.rawContent || message.parsedContent || '')
const xmlTypeMatch = raw.match(/<type>\s*([^<]+)\s*<\/type>/i)
const appMsgType = xmlTypeMatch?.[1]?.trim()
if (localType === 1) return 'text'
if (localType === 3) return 'image'
if (localType === 34) return 'voice'
if (localType === 42) return 'contact_card'
if (localType === 43) return 'video'
if (localType === 47) return 'emoji'
if (localType === 48) return 'location'
if (localType === 50) return 'voip'
if (localType === 10000) return 'system'
if (localType === 244813135921) return 'quote'
if (localType === 49 || appMsgType) {
switch (appMsgType) {
case '3':
return 'app_music'
case '5':
case '49':
return 'app_link'
case '6':
return 'app_file'
case '19':
return 'app_chat_record'
case '33':
case '36':
return 'app_mini_program'
case '57':
return 'app_quote'
case '62':
return 'app_pat'
case '87':
return 'app_announcement'
case '115':
return 'app_gift'
case '2000':
return 'app_transfer'
case '2001':
return 'app_red_packet'
default:
return 'app'
}
}
return 'unknown'
}
function compareMessageCursorAsc(
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
): number {
return Number(a.sortSeq || 0) - Number(b.sortSeq || 0)
|| Number(a.createTime || 0) - Number(b.createTime || 0)
|| Number(a.localId || 0) - Number(b.localId || 0)
}
function compareMessageCursorDesc(
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
): number {
return compareMessageCursorAsc(b, a)
}
function buildCursor(message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>): McpCursor {
return {
sortSeq: Number(message.sortSeq || 0),
createTime: Number(message.createTime || 0),
localId: Number(message.localId || 0)
}
}
function sameCursor(
message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
cursor: McpCursor
): boolean {
return Number(message.sortSeq || 0) === cursor.sortSeq
&& Number(message.createTime || 0) === cursor.createTime
&& Number(message.localId || 0) === cursor.localId
}
function uniqueMessageList(messages: Message[]): Message[] {
const seen = new Set<string>()
return messages.filter((message) => {
const key = `${message.serverId}-${message.localId}-${message.createTime}-${message.sortSeq}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function normalizeQuery(value?: string): string {
return String(value || '').trim().toLowerCase()
}
function createExcerpt(source: string, matchedIndex: number, queryLength: number): string {
if (!source) return ''
const radius = 48
const safeIndex = Math.max(0, matchedIndex)
const start = Math.max(0, safeIndex - radius)
const end = Math.min(source.length, safeIndex + queryLength + radius)
const prefix = start > 0 ? '...' : ''
const suffix = end < source.length ? '...' : ''
return `${prefix}${source.slice(start, end)}${suffix}`
}
function findKeywordMatch(message: Message, query: string): { matchedField: McpMessageMatchField; excerpt: string } | null {
const normalizedQuery = normalizeQuery(query)
if (!normalizedQuery) return null
const text = String(message.parsedContent || '')
const raw = String(message.rawContent || '')
const textIndex = text.toLowerCase().indexOf(normalizedQuery)
if (textIndex >= 0) {
return {
matchedField: 'text',
excerpt: createExcerpt(text, textIndex, normalizedQuery.length)
}
}
const rawIndex = raw.toLowerCase().indexOf(normalizedQuery)
if (rawIndex >= 0) {
return {
matchedField: 'raw',
excerpt: createExcerpt(raw, rawIndex, normalizedQuery.length)
}
}
return null
}
function toSessionRef(session: Pick<ChatSession, 'username' | 'displayName'>): McpSessionRef {
return {
sessionId: session.username,
displayName: session.displayName || session.username,
kind: detectSessionKind(session.username)
}
}
function toSessionItem(session: ChatSession): McpSessionItem {
return {
...toSessionRef(session),
lastMessagePreview: session.summary || '',
unreadCount: Number(session.unreadCount || 0),
lastTimestamp: Number(session.lastTimestamp || 0),
lastTimestampMs: toTimestampMs(Number(session.lastTimestamp || 0))
}
}
function toContactItem(contact: ContactWithLastContact): McpContactItem {
const lastContactTimestamp = Number(contact.lastContactTime || 0)
return {
contactId: contact.username,
displayName: contact.displayName,
remark: contact.remark || undefined,
nickname: contact.nickname || undefined,
kind: contact.type as McpContactKind,
lastContactTimestamp,
lastContactTimestampMs: toTimestampMs(lastContactTimestamp)
}
}
function resolveSessionRef(sessionId: string, sessionMap: Map<string, McpSessionRef>): McpSessionRef {
return sessionMap.get(sessionId) || {
sessionId,
displayName: sessionId,
kind: detectSessionKind(sessionId)
}
}
function mapChatError(errorMessage?: string): never {
const message = errorMessage || 'Unknown chat service error.'
if (
message.includes('请先在设置页面配置微信ID') ||
message.includes('请先解密数据库') ||
message.includes('未找到账号') ||
message.includes('未找到 session.db') ||
message.includes('未找到会话表') ||
message.includes('数据库未连接') ||
message.includes('联系人数据库未连接')
) {
throw new McpToolError('DB_NOT_READY', 'Chat database is not ready.', message)
}
if (message.includes('未找到该会话的消息表')) {
throw new McpToolError('SESSION_NOT_FOUND', 'Session not found.', message)
}
throw new McpToolError('INTERNAL_ERROR', 'Failed to query CipherTalk data.', message)
}
async function getEmojiLocalPath(message: Message): Promise<string | null> {
if (!message.emojiMd5 && !message.emojiCdnUrl) return null
try {
const result = await chatService.downloadEmoji(
String(message.emojiCdnUrl || ''),
message.emojiMd5,
message.productId,
Number(message.createTime || 0)
)
return result.success ? result.cachePath || result.localPath || null : null
} catch {
return null
}
}
async function getImageLocalPath(sessionId: string, message: Message): Promise<string | null> {
if (!message.imageMd5 && !message.imageDatName) return null
try {
const resolved = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5: message.imageMd5,
imageDatName: message.imageDatName
})
if (resolved.success && resolved.localPath) {
return resolved.localPath
}
const decrypted = await imageDecryptService.decryptImage({
sessionId,
imageMd5: message.imageMd5,
imageDatName: message.imageDatName,
force: false
})
return decrypted.success ? decrypted.localPath || null : null
} catch {
return null
}
}
function getVideoLocalPath(message: Message): string | null {
if (!message.videoMd5) return null
try {
const info = videoService.getVideoInfo(String(message.videoMd5))
return info.exists ? info.videoUrl || null : null
} catch {
return null
}
}
async function getVoiceLocalPath(sessionId: string, message: Message): Promise<string | null> {
const localId = Number(message.localId || 0)
const createTime = Number(message.createTime || 0)
if (!localId || !createTime) return null
try {
const voiceResult = await chatService.getVoiceData(sessionId, String(localId), createTime)
if (!voiceResult.success || !voiceResult.data) return null
const configService = new ConfigService()
const cachePath = String(configService.get('cachePath') || '')
configService.close()
const baseDir = cachePath || join(process.cwd(), 'cache')
const voiceDir = join(baseDir, 'McpVoices', sessionId.replace(/[\\/:*?"<>|]/g, '_'))
if (!existsSync(voiceDir)) {
mkdirSync(voiceDir, { recursive: true })
}
const absolutePath = join(voiceDir, `${createTime}_${localId}.wav`)
await writeFile(absolutePath, Buffer.from(voiceResult.data, 'base64'))
return absolutePath
} catch {
return null
}
}
function getFileLocalPath(message: Message): string | null {
const fileName = String(message.fileName || '')
if (!fileName) return null
const configService = new ConfigService()
try {
const dbPath = String(configService.get('dbPath') || '')
const myWxid = String(configService.get('myWxid') || '')
if (!dbPath || !myWxid) return null
const createTimeMs = toTimestampMs(Number(message.createTime || 0))
const fileDate = createTimeMs ? new Date(createTimeMs) : new Date()
const monthDir = `${fileDate.getFullYear()}-${String(fileDate.getMonth() + 1).padStart(2, '0')}`
return join(dbPath, myWxid, 'msg', 'file', monthDir, fileName)
} finally {
configService.close()
}
}
async function normalizeMessage(
sessionId: string,
message: Message,
options: MessageNormalizeOptions
): Promise<McpMessageItem> {
const kind = detectMessageKind(message)
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
const normalized: McpMessageItem = {
messageId: Number(message.localId || message.serverId || 0),
timestamp: Number(message.createTime || 0),
timestampMs: toTimestampMs(Number(message.createTime || 0)),
direction,
kind,
text: String(message.parsedContent || message.rawContent || ''),
sender: {
username: message.senderUsername ?? null,
isSelf: direction === 'out'
},
cursor: buildCursor(message)
}
if (options.includeRaw) {
normalized.raw = String(message.rawContent || '')
}
switch (kind) {
case 'emoji':
normalized.media = {
type: 'emoji',
md5: message.emojiMd5 || null
}
if (options.includeMediaPaths) {
normalized.media.localPath = await getEmojiLocalPath(message)
}
break
case 'image':
normalized.media = {
type: 'image',
md5: message.imageMd5 || null,
isLivePhoto: Boolean(message.isLivePhoto)
}
if (options.includeMediaPaths) {
normalized.media.localPath = await getImageLocalPath(sessionId, message)
}
break
case 'video':
normalized.media = {
type: 'video',
md5: message.videoMd5 || null,
durationSeconds: Number(message.videoDuration || 0) || null,
isLivePhoto: Boolean(message.isLivePhoto)
}
if (options.includeMediaPaths) {
normalized.media.localPath = getVideoLocalPath(message)
}
break
case 'voice':
normalized.media = {
type: 'voice',
durationSeconds: Number(message.voiceDuration || 0) || null
}
if (options.includeMediaPaths) {
normalized.media.localPath = await getVoiceLocalPath(sessionId, message)
}
break
case 'app_file': {
const localPath = options.includeMediaPaths ? getFileLocalPath(message) : null
normalized.media = {
type: 'file',
md5: message.fileMd5 || null,
fileName: message.fileName || null,
fileSize: Number(message.fileSize || 0) || null,
localPath,
exists: localPath ? existsSync(localPath) : null
}
break
}
default:
break
}
return normalized
}
async function normalizeMessages(
sessionId: string,
messages: Message[],
options: MessageNormalizeOptions
): Promise<McpMessageItem[]> {
return Promise.all(messages.map((message) => normalizeMessage(sessionId, message, options)))
}
async function getSessionCatalog(): Promise<{ items: McpSessionItem[]; map: Map<string, McpSessionRef> }> {
const result = await chatService.getSessions()
if (!result.success) {
mapChatError(result.error)
}
const items = (result.sessions || [])
.map((session) => toSessionItem(session))
.sort((a, b) => b.lastTimestamp - a.lastTimestamp || a.displayName.localeCompare(b.displayName, 'zh-CN'))
const map = new Map<string, McpSessionRef>()
for (const item of items) {
map.set(item.sessionId, {
sessionId: item.sessionId,
displayName: item.displayName,
kind: item.kind
})
}
return { items, map }
}
function messageMatchesFilters(
message: Message,
filters: {
startTimeMs?: number
endTimeMs?: number
kinds?: Set<McpMessageKind>
direction?: 'in' | 'out'
senderUsername?: string
}
): boolean {
const timestampMs = toTimestampMs(Number(message.createTime || 0))
if (filters.startTimeMs && timestampMs < filters.startTimeMs) return false
if (filters.endTimeMs && timestampMs > filters.endTimeMs) return false
if (filters.kinds?.size) {
const kind = detectMessageKind(message)
if (!filters.kinds.has(kind)) return false
}
if (filters.direction) {
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
if (direction !== filters.direction) return false
}
if (filters.senderUsername) {
const senderUsername = String(message.senderUsername || '').trim().toLowerCase()
if (senderUsername !== filters.senderUsername) return false
}
return true
}
export class McpReadService {
async listSessions(rawArgs: ListSessionsArgs): Promise<McpSessionsPayload> {
const args = listSessionsArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid list_sessions arguments.', args.error.message)
}
const query = normalizeQuery(args.data.q)
const offset = Math.max(0, args.data.offset ?? 0)
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
const unreadOnly = Boolean(args.data.unreadOnly)
let sessions = (await getSessionCatalog()).items
if (query) {
sessions = sessions.filter((session) => {
return [
session.sessionId,
session.displayName,
session.lastMessagePreview
].some((value) => value.toLowerCase().includes(query))
})
}
if (unreadOnly) {
sessions = sessions.filter((session) => session.unreadCount > 0)
}
const total = sessions.length
const items = sessions.slice(offset, offset + limit)
return {
items,
total,
offset,
limit,
hasMore: offset + items.length < total
}
}
async listContacts(rawArgs: ListContactsArgs): Promise<McpContactsPayload> {
const args = listContactsArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid list_contacts arguments.', args.error.message)
}
const query = normalizeQuery(args.data.q)
const offset = Math.max(0, args.data.offset ?? 0)
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
const typeSet = args.data.types?.length ? new Set(args.data.types) : null
const result = await chatService.getContacts()
if (!result.success) {
mapChatError(result.error)
}
let contacts = (result.contacts || []).map((contact) => toContactItem(contact as ContactWithLastContact))
if (typeSet) {
contacts = contacts.filter((contact) => typeSet.has(contact.kind))
}
if (query) {
contacts = contacts.filter((contact) => {
return [
contact.contactId,
contact.displayName,
contact.remark || '',
contact.nickname || ''
].some((value) => value.toLowerCase().includes(query))
})
}
const total = contacts.length
const items = contacts.slice(offset, offset + limit)
return {
items,
total,
offset,
limit,
hasMore: offset + items.length < total
}
}
async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean): Promise<McpMessagesPayload> {
const args = getMessagesArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid get_messages arguments.', args.error.message)
}
const {
sessionId,
keyword,
includeRaw = false,
order = 'asc'
} = args.data
const offset = Math.max(0, args.data.offset ?? 0)
const limit = Math.min(args.data.limit ?? 50, MAX_LIST_LIMIT)
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
const keywordQuery = normalizeQuery(keyword)
const startTimeMs = toTimestampMs(args.data.startTime)
const endTimeMs = toTimestampMs(args.data.endTime)
const matched: Message[] = []
let scanOffset = 0
let scanned = 0
let reachedEnd = false
const targetCount = offset + limit + 1
while (scanned < 5000 && matched.length < targetCount) {
const result = await chatService.getMessages(sessionId, scanOffset, SEARCH_BATCH_SIZE)
if (!result.success) {
mapChatError(result.error)
}
const part = result.messages || []
if (part.length === 0) {
reachedEnd = true
break
}
for (const message of part) {
if (!messageMatchesFilters(message, { startTimeMs, endTimeMs })) continue
if (keywordQuery && !findKeywordMatch(message, keywordQuery)) continue
matched.push(message)
}
scanOffset += part.length
scanned += part.length
if (!result.hasMore) {
reachedEnd = true
break
}
}
matched.sort((a, b) => order === 'asc' ? compareMessageCursorAsc(a, b) : compareMessageCursorDesc(a, b))
const page = matched.slice(offset, offset + limit)
const items = await normalizeMessages(sessionId, page, { includeMediaPaths, includeRaw })
return {
items,
offset,
limit,
hasMore: reachedEnd ? matched.length > offset + items.length : true
}
}
async searchMessages(rawArgs: SearchMessagesArgs, defaultIncludeMediaPaths: boolean): Promise<McpSearchMessagesPayload> {
const args = searchMessagesArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid search_messages arguments.', args.error.message)
}
const { items: sessions, map: sessionMap } = await getSessionCatalog()
const includeRaw = args.data.includeRaw ?? false
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT)
const sessionIdCandidates = Array.from(new Set([
...(args.data.sessionId ? [args.data.sessionId] : []),
...(args.data.sessionIds || [])
]))
if (sessionIdCandidates.length > MAX_SEARCH_SESSIONS) {
throw new McpToolError('BAD_REQUEST', `At most ${MAX_SEARCH_SESSIONS} sessionIds can be searched at once.`)
}
const targetSessions = sessionIdCandidates.length > 0
? sessionIdCandidates.map((sessionId) => resolveSessionRef(sessionId, sessionMap))
: sessions.slice(0, MAX_SEARCH_SESSIONS).map((session) => ({
sessionId: session.sessionId,
displayName: session.displayName,
kind: session.kind
}))
const kindSet = args.data.kinds?.length ? new Set(args.data.kinds) : undefined
const senderUsername = normalizeQuery(args.data.senderUsername)
const startTimeMs = toTimestampMs(args.data.startTime)
const endTimeMs = toTimestampMs(args.data.endTime)
const rawHits: SearchRawHit[] = []
let sessionsScanned = 0
let messagesScanned = 0
let truncated = false
for (const session of targetSessions) {
sessionsScanned += 1
let sessionOffset = 0
let sessionScanned = 0
while (sessionScanned < MAX_SCAN_PER_SESSION && messagesScanned < MAX_SCAN_GLOBAL) {
const fetchLimit = Math.min(
SEARCH_BATCH_SIZE,
MAX_SCAN_PER_SESSION - sessionScanned,
MAX_SCAN_GLOBAL - messagesScanned
)
if (fetchLimit <= 0) {
truncated = true
break
}
const result = await chatService.getMessages(session.sessionId, sessionOffset, fetchLimit)
if (!result.success) {
mapChatError(result.error)
}
const part = result.messages || []
if (part.length === 0) break
sessionOffset += part.length
sessionScanned += part.length
messagesScanned += part.length
for (const message of part) {
if (!messageMatchesFilters(message, {
startTimeMs,
endTimeMs,
kinds: kindSet,
direction: args.data.direction,
senderUsername
})) {
continue
}
const match = findKeywordMatch(message, args.data.query)
if (!match) continue
rawHits.push({
session,
message,
matchedField: match.matchedField,
excerpt: match.excerpt
})
}
if (!result.hasMore) break
}
if (messagesScanned >= MAX_SCAN_GLOBAL) {
truncated = true
break
}
}
rawHits.sort((a, b) => compareMessageCursorDesc(a.message, b.message))
const hits = await Promise.all(rawHits.slice(0, limit).map(async (hit): Promise<McpSearchHit> => ({
session: hit.session,
message: await normalizeMessage(hit.session.sessionId, hit.message, {
includeMediaPaths,
includeRaw
}),
excerpt: hit.excerpt,
matchedField: hit.matchedField
})))
return {
hits,
limit,
sessionsScanned,
messagesScanned,
truncated
}
}
async getSessionContext(rawArgs: GetSessionContextArgs, defaultIncludeMediaPaths: boolean): Promise<McpSessionContextPayload> {
const args = getSessionContextArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid get_session_context arguments.', args.error.message)
}
const { map: sessionMap } = await getSessionCatalog()
const session = resolveSessionRef(args.data.sessionId, sessionMap)
const includeRaw = args.data.includeRaw ?? false
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
if (args.data.mode === 'latest') {
const latestLimit = Math.min(args.data.beforeLimit ?? 30, MAX_CONTEXT_LIMIT)
const result = await chatService.getMessages(args.data.sessionId, 0, latestLimit)
if (!result.success) {
mapChatError(result.error)
}
const messages = await normalizeMessages(args.data.sessionId, result.messages || [], {
includeMediaPaths,
includeRaw
})
return {
session,
mode: 'latest',
items: messages,
hasMoreBefore: Boolean(result.hasMore),
hasMoreAfter: false
}
}
const anchorCursor = args.data.anchorCursor!
const beforeLimit = Math.min(args.data.beforeLimit ?? 20, MAX_CONTEXT_LIMIT)
const afterLimit = Math.min(args.data.afterLimit ?? 20, MAX_CONTEXT_LIMIT)
const [beforeResult, anchorResult, afterResult] = await Promise.all([
chatService.getMessagesBefore(
args.data.sessionId,
anchorCursor.sortSeq,
beforeLimit,
anchorCursor.createTime,
anchorCursor.localId
),
chatService.getMessagesAfter(
args.data.sessionId,
anchorCursor.sortSeq,
1,
anchorCursor.createTime,
anchorCursor.localId - 1
),
chatService.getMessagesAfter(
args.data.sessionId,
anchorCursor.sortSeq,
afterLimit,
anchorCursor.createTime,
anchorCursor.localId
)
])
if (!beforeResult.success) mapChatError(beforeResult.error)
if (!anchorResult.success) mapChatError(anchorResult.error)
if (!afterResult.success) mapChatError(afterResult.error)
const anchorMessage = (anchorResult.messages || []).find((message) => sameCursor(message, anchorCursor))
if (!anchorMessage) {
throw new McpToolError('BAD_REQUEST', 'Anchor cursor was not found in this session.')
}
const [beforeItems, anchorItem, afterItems] = await Promise.all([
normalizeMessages(args.data.sessionId, beforeResult.messages || [], {
includeMediaPaths,
includeRaw
}),
normalizeMessage(args.data.sessionId, anchorMessage, {
includeMediaPaths,
includeRaw
}),
normalizeMessages(args.data.sessionId, afterResult.messages || [], {
includeMediaPaths,
includeRaw
})
])
return {
session,
mode: 'around',
anchor: anchorItem,
items: [...beforeItems, anchorItem, ...afterItems],
hasMoreBefore: Boolean(beforeResult.hasMore),
hasMoreAfter: Boolean(afterResult.hasMore)
}
}
}