mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
d022bed48f
commit
68652248fe
4 files changed
+299
-31
No files matched your search
@@ -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')
|
||||
}
|
||||
@@ -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 }] }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
/**
|
||||
* 按名称查找工具(在所有注册表中查找)
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user