feat(mcp): enrich toolset to 33 tools with token compression and dual format

- Expand MCP_TOOL_REGISTRY from 8 to 33 tools by including all
  AGENT_TOOL_REGISTRY tools (analytics, deep search, conversation, etc.)
- Add format=text|json parameter to all MCP tools (default: text for
  most, json for SQL tools) for token-efficient LLM consumption
- Create format.ts: message filtering, consecutive-sender merging,
  content truncation, and compact text output
- Inspired by community chatlab-mcp-server project

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor authored and digua committed 2026-05-22 10:20:39 +08:00
1 parent d022bed48f
commit 68652248fe
4 files changed
+299 -31

No files matched your search

+238
View File
@@ -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<string, unknown>
// 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<string, unknown>, 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, unknown>): 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, unknown>): string {
const sessions = obj.sessions as Array<Record<string, unknown>>
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')
}
+33 -5
View File
@@ -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<string, unknown>, context)
return { content: [{ type: 'text' as const, text: result.content }] }
const toolParams = { ...params } as Record<string, unknown>
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<string, unknown>
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 }] }
})
}
}
+8 -4
View File
@@ -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"
]
}
+20 -22
View File
@@ -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,
]
/**
* 按名称查找工具(在所有注册表中查找)
*/