diff --git a/packages/mcp-server/src/format.ts b/packages/mcp-server/src/format.ts new file mode 100644 index 00000000..0fad8d45 --- /dev/null +++ b/packages/mcp-server/src/format.ts @@ -0,0 +1,238 @@ +/** + * MCP output formatting for token-efficient LLM consumption + * + * Provides message filtering, merging, truncation, and compact text output. + * Inspired by community chatlab-mcp-server's format.ts. + */ + +const MAX_CONTENT_LENGTH = 200 +const MAX_MERGED_CONTENT_LENGTH = 400 + +const PLACEHOLDER_CONTENTS = new Set([ + '[图片]', + '[语音]', + '[视频]', + '[文件]', + '[表情]', + '[动画表情]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[撤回消息]', + '[分享]', + '[image]', + '[voice]', + '[video]', + '[file]', + '[sticker]', + '[animated sticker]', + '[location]', + '[contact]', + '[red packet]', + '[transfer]', + '[recalled message]', + '[photo]', + '[audio]', + '[gif]', + '[share]', +]) + +const MEANINGLESS_SHORT_EN = new Set([ + 'ok', + 'k', + 'yes', + 'no', + 'ya', + 'yep', + 'nope', + 'lol', + 'haha', + 'hehe', + 'hmm', + 'ah', + 'oh', + 'wow', + 'thx', + 'ty', + 'np', + 'gg', + 'brb', + 'idk', +]) + +const MEANINGFUL_SHORT_ZH = new Set(['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意']) + +const SYSTEM_PATTERNS_ZH = [/^.*邀请.*加入了群聊$/, /^.*退出了群聊$/, /^.*撤回了一条消息$/, /^你撤回了一条消息$/] + +const SYSTEM_PATTERNS_EN = [ + /^.*invited.*to the group$/i, + /^.*left the group$/i, + /^.*recalled a message$/i, + /^.*joined the group$/i, + /^.*has been removed$/i, +] + +const EMOJI_ONLY = /^[\p{Emoji}\s[\]()()]+$/u + +interface FormattableMessage { + senderName: string + content: string | null + timestamp: number +} + +/** + * Check if message content is meaningful (not noise). + */ +export function isValidMessage(content: string): boolean { + const trimmed = content.trim() + if (!trimmed) return false + + if (trimmed.length <= 2 && !MEANINGFUL_SHORT_ZH.has(trimmed)) return false + if (MEANINGLESS_SHORT_EN.has(trimmed.toLowerCase())) return false + if (EMOJI_ONLY.test(trimmed)) return false + if (PLACEHOLDER_CONTENTS.has(trimmed.toLowerCase())) return false + if (SYSTEM_PATTERNS_ZH.some((p) => p.test(trimmed))) return false + if (SYSTEM_PATTERNS_EN.some((p) => p.test(trimmed))) return false + + return true +} + +function truncate(text: string, maxLen: number): string { + return text.length <= maxLen ? text : text.slice(0, maxLen) + '...' +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts * 1000) + const M = d.getMonth() + 1 + const D = d.getDate() + const h = String(d.getHours()).padStart(2, '0') + const m = String(d.getMinutes()).padStart(2, '0') + return `${M}/${D} ${h}:${m}` +} + +/** + * Format messages as compact plain text with consecutive-sender merging. + * Filters out noise messages, merges same-sender runs, truncates long content. + */ +export function formatMessagesCompact(messages: FormattableMessage[]): string { + if (messages.length === 0) return '' + + const valid = messages.filter((m) => m.content && isValidMessage(m.content)) + if (valid.length === 0) return '' + + const lines: string[] = [] + let prevSender = '' + let pendingContents: string[] = [] + let pendingTs = 0 + + const flush = () => { + if (pendingContents.length === 0) return + const combined = pendingContents.join('; ') + const maxLen = pendingContents.length > 1 ? MAX_MERGED_CONTENT_LENGTH : MAX_CONTENT_LENGTH + lines.push(`${formatTimestamp(pendingTs)} ${prevSender}: ${truncate(combined, maxLen)}`) + } + + for (const msg of valid) { + const content = msg.content!.trim() + if (msg.senderName === prevSender) { + pendingContents.push(content) + } else { + flush() + prevSender = msg.senderName + pendingContents = [content] + pendingTs = msg.timestamp + } + } + flush() + + return lines.join('\n') +} + +/** + * Try to parse a tool result's JSON content and convert to compact text. + * Returns null if the content is not suitable for text formatting. + */ +export function formatToolResultAsText(content: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return null + } + + if (!parsed || typeof parsed !== 'object') return null + const obj = parsed as Record + + // If there's a messages array with senderName/content/timestamp, format it + if (Array.isArray(obj.messages) && obj.messages.length > 0) { + const msgs = obj.messages as FormattableMessage[] + if (msgs[0] && 'senderName' in msgs[0] && 'timestamp' in msgs[0]) { + return formatObjectWithMessages(obj, msgs) + } + } + + // If there are ranking/keywords arrays, format as numbered list + if (Array.isArray(obj.ranking) || Array.isArray(obj.keywords)) { + return formatArrayResult(obj) + } + + // If there are sessions, format them compactly + if (Array.isArray(obj.sessions)) { + return formatSessionsList(obj) + } + + return null +} + +function formatObjectWithMessages(obj: Record, msgs: FormattableMessage[]): string { + const lines: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + if (key === 'messages') continue + if (value === undefined || value === null) continue + if (typeof value === 'object' && !Array.isArray(value)) continue + if (Array.isArray(value)) continue + lines.push(`${key}: ${value}`) + } + + const formatted = formatMessagesCompact(msgs) + if (formatted) { + if (lines.length > 0) lines.push('') + lines.push(formatted) + } + + return lines.join('\n') +} + +function formatArrayResult(obj: Record): string { + const lines: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + if (lines.length > 0) lines.push('') + lines.push(...value) + } else if (!Array.isArray(value) && value !== undefined && value !== null) { + lines.push(`${key}: ${value}`) + } + } + + return lines.join('\n') +} + +function formatSessionsList(obj: Record): string { + const sessions = obj.sessions as Array> + const total = obj.total ?? sessions.length + const lines: string[] = [`${total} sessions:`] + + for (const s of sessions) { + const name = s.name ?? s.id + const platform = s.platform ? ` (${s.platform})` : '' + const msgCount = s.totalMessages ?? s.messageCount ?? '' + const members = s.totalMembers ?? s.memberCount ?? '' + const stats = msgCount ? ` — ${msgCount} msgs, ${members} members` : '' + lines.push(`- ${name}${platform}${stats} [${s.id}]`) + } + + return lines.join('\n') +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 3fa085d6..d731d0c6 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -13,25 +13,49 @@ import { MCP_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions' import type { McpDatabaseManager, McpServerOptions } from './types' import { jsonSchemaToZod } from './schema' +import { formatToolResultAsText } from './format' const MCP_TOOL_PREFIX = 'chatlab_' +const FORMAT_PARAM = z + .enum(['text', 'json']) + .optional() + .describe('Output format: "text" for token-efficient plain text (default), "json" for structured JSON') + +/** Tools where JSON is a better default (raw SQL results, schema) */ +const JSON_DEFAULT_TOOLS = new Set(['execute_sql', 'get_schema']) + +function applyFormat(content: string, format: string | undefined, toolName: string): string { + const effectiveFormat = format ?? (JSON_DEFAULT_TOOLS.has(toolName) ? 'json' : 'text') + if (effectiveFormat === 'json') return content + + const textResult = formatToolResultAsText(content) + return textResult ?? content +} + function registerTools(server: McpServer, dbManager: McpDatabaseManager): void { for (const tool of MCP_TOOL_REGISTRY) { const mcpName = `${MCP_TOOL_PREFIX}${tool.name}` if (tool.name === 'list_sessions') { - const zodShape = jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required) + const zodShape = { + ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + format: FORMAT_PARAM, + } server.tool(mcpName, tool.description, zodShape, async (params) => { + const format = params.format as string | undefined const context: SessionListContext = { db: null as any, sessionId: '', listSessionIds: () => dbManager.listSessionIds(), openDb: (id) => dbManager.open(id), } - const result = await tool.handler(params as Record, context) - return { content: [{ type: 'text' as const, text: result.content }] } + const toolParams = { ...params } as Record + delete toolParams.format + const result = await tool.handler(toolParams, context) + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } }) continue } @@ -40,23 +64,27 @@ function registerTools(server: McpServer, dbManager: McpDatabaseManager): void { const zodShape = { session_id: z.string().describe(`Session ID (use ${sessionsToolMcpName} to get available sessions)`), ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + format: FORMAT_PARAM, } server.tool(mcpName, tool.description, zodShape, async (params) => { const sessionId = params.session_id as string + const format = params.format as string | undefined const db = dbManager.open(sessionId) if (!db) { return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: `Session ${sessionId} not found` }) }], + content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], isError: true, } } const toolParams = { ...params } as Record delete toolParams.session_id + delete toolParams.format const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) }) - return { content: [{ type: 'text' as const, text: result.content }] } + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } }) } } diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json index 2adee5d2..ffc2c1b9 100644 --- a/packages/mcp-server/tsconfig.json +++ b/packages/mcp-server/tsconfig.json @@ -2,13 +2,17 @@ "extends": "../../tsconfig.node.json", "compilerOptions": { "composite": true, - "rootDir": "./src", - "outDir": "./dist" + "noEmit": true }, "include": [ "src/**/*.ts", "../../packages/shared-types/**/*", - "../../packages/core/**/*", - "../../packages/tools/**/*" + "../../packages/core/src/**/*.ts", + "../../packages/tools/src/**/*.ts" + ], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts" ] } diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index dc6b486d..cf2a01ff 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -1,17 +1,14 @@ /** * 工具注册表 * - * MCP_TOOL_REGISTRY: MCP Server 专用,包含会话发现等 MCP 特有工具。 * AGENT_TOOL_REGISTRY: Agent 共用(Server Agent / Electron Agent), * 与 Electron TOOL_REGISTRY 保持一致的完整工具集。 + * MCP_TOOL_REGISTRY: MCP Server 使用,包含 Agent 全量工具 + MCP 特有的会话发现工具。 */ import type { ToolDefinition } from './types' import { searchTool } from './definitions/search' -import { memberStatsTool } from './definitions/member-stats' -import { timeStatsTool } from './definitions/time-stats' -import { recentMessagesTool } from './definitions/recent-messages' import { sqlQueryTool, schemaTool } from './definitions/sql-query' import { sessionInfoTool } from './definitions/session-info' import { sessionsListTool } from './definitions/sessions' @@ -22,6 +19,9 @@ import { getMessageContextTool } from './definitions/get-message-context' import { searchSessionsTool } from './definitions/search-sessions' import { getSessionMessagesTool } from './definitions/get-session-messages' import { getMembersTool } from './definitions/get-members' +import { memberStatsTool } from './definitions/member-stats' +import { timeStatsTool } from './definitions/time-stats' +import { recentMessagesTool } from './definitions/recent-messages' import { getMemberNameHistoryTool } from './definitions/get-member-name-history' import { getConversationBetweenTool } from './definitions/get-conversation-between' import { getSessionSummariesTool } from './definitions/get-session-summaries' @@ -29,29 +29,11 @@ import { responseTimeAnalysisTool } from './definitions/response-time-analysis' import { keywordFrequencyTool } from './definitions/keyword-frequency' import { SQL_TOOL_DEFS, createAllSqlToolDefinitions } from './sql' -/** - * MCP Server 专用注册表 - * - * 包含会话发现/管理工具(list_sessions、get_session_info)和简化搜索(search_keyword), - * 这些工具在 Agent 上下文中不适用(Agent 已有选定的会话和更完整的搜索工具)。 - */ -export const MCP_TOOL_REGISTRY: ToolDefinition[] = [ - sessionsListTool, - sessionInfoTool, - searchTool, - memberStatsTool, - timeStatsTool, - recentMessagesTool, - sqlQueryTool, - schemaTool, -] - /** * Agent 共用注册表 * * 与 Electron TOOL_REGISTRY 完全对齐的工具集,在"已选定会话"上下文中使用。 * 不包含 MCP 特有的会话发现工具(list_sessions、get_session_info、search_keyword)。 - * 额外包含 execute_sql 作为通用自定义 SQL 查询能力。 */ export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ // Core @@ -78,6 +60,22 @@ export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ ...createAllSqlToolDefinitions(SQL_TOOL_DEFS), ] +/** + * MCP Server 注册表 + * + * 包含 MCP 特有工具(会话发现、schema)以及完整的 Agent 工具集。 + * MCP 层自动为非 list_sessions 工具注入 session_id 参数。 + */ +export const MCP_TOOL_REGISTRY: ToolDefinition[] = [ + // MCP-specific: session discovery & schema + sessionsListTool, + sessionInfoTool, + searchTool, + schemaTool, + // Full agent toolset + ...AGENT_TOOL_REGISTRY, +] + /** * 按名称查找工具(在所有注册表中查找) */