refactor(mcp): extract MCP core into shared chatlab-mcp package

Move MCP Server logic (tool registration, resource registration, schema
conversion) from apps/cli/src/mcp.ts to packages/mcp-server so both CLI
and Desktop can share the same MCP implementation.

- New package: chatlab-mcp (packages/mcp-server) with McpDatabaseManager
  interface abstraction
- CLI mcp.ts slimmed to thin entry (~37 lines) calling startMcpServer()
- Desktop helper prototype at apps/desktop/helper/mcp.ts for dev-time
  MCP without GUI
- All 8 MCP tools and 3 resources unchanged

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 e3370b52c2
commit d022bed48f
17 files changed
+377 -198

No files matched your search

+1
View File
@@ -74,6 +74,7 @@
"@openchatlab/sync": "workspace:*",
"@openchatlab/tools": "workspace:*",
"@types/better-sqlite3": "^7.6.13",
"chatlab-mcp": "workspace:*",
"tsup": "^8.5.0",
"tsx": "^4.21.0"
}
+2 -2
View File
@@ -269,8 +269,8 @@ program
.command('mcp')
.description('Start MCP Server (stdio transport, for Claude Desktop / Cursor / AI agents)')
.action(async () => {
const { startMcpServer } = await import('./mcp')
await startMcpServer()
const { startCliMcpServer } = await import('./mcp')
await startCliMcpServer()
})
program
+8 -189
View File
@@ -1,14 +1,9 @@
/**
* ChatLab MCP Server
* CLI MCP Server entry point
*
* 通过 stdio 传输协议与 AI 代理(Claude Desktop、Cursor 等)通信。
* 注册 @openchatlab/tools 中的工具为 MCP tools,
* 会话列表作为 MCP resources 暴露。
* Thin wrapper: initializes Node.js runtime, then delegates to chatlab-mcp.
*/
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { loadConfig } from '@openchatlab/config'
import {
NodePathProvider,
@@ -16,16 +11,10 @@ import {
hasPendingElectronDataWarning,
verifyCliDataPath,
} from '@openchatlab/node-runtime'
import { getSessionMeta, getSessionOverview, getDatabaseSchema } from '@openchatlab/core'
import { MCP_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools'
import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions'
import { startMcpServer } from 'chatlab-mcp'
import { getVersion } from './version'
const MCP_TOOL_PREFIX = 'chatlab_'
let dbManager: DatabaseManager
function initMcpRuntime() {
function initMcpRuntime(): DatabaseManager {
const config = loadConfig()
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
@@ -37,180 +26,10 @@ function initMcpRuntime() {
process.exit(1)
}
dbManager = new DatabaseManager(pathProvider)
return { config, dbManager }
return new DatabaseManager(pathProvider)
}
/**
* 将简单的 JSON Schema 属性转为 Zod schema 对象
*/
function jsonSchemaToZod(
properties: Record<string, { type: string; description?: string; default?: unknown; enum?: unknown[] }>,
required?: string[]
): Record<string, z.ZodTypeAny> {
const shape: Record<string, z.ZodTypeAny> = {}
const requiredSet = new Set(required ?? [])
for (const [key, prop] of Object.entries(properties)) {
let zodType: z.ZodTypeAny
switch (prop.type) {
case 'number':
zodType = z.number().describe(prop.description ?? '')
break
case 'boolean':
zodType = z.boolean().describe(prop.description ?? '')
break
case 'string':
default:
if (prop.enum) {
zodType = z.enum(prop.enum as [string, ...string[]]).describe(prop.description ?? '')
} else {
zodType = z.string().describe(prop.description ?? '')
}
break
}
if (!requiredSet.has(key)) {
zodType = zodType.optional()
}
shape[key] = zodType
}
return shape
}
export async function startMcpServer(): Promise<void> {
initMcpRuntime()
const server = new McpServer({
name: 'chatlab',
version: getVersion(),
})
// --- 注册 Tools ---
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)
server.tool(mcpName, tool.description, zodShape, async (params) => {
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 }] }
})
continue
}
const sessionsToolMcpName = `${MCP_TOOL_PREFIX}list_sessions`
const zodShape = {
session_id: z.string().describe(`Session ID (use ${sessionsToolMcpName} to get available sessions)`),
...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required),
}
server.tool(mcpName, tool.description, zodShape, async (params) => {
const sessionId = params.session_id as string
const db = dbManager.open(sessionId)
if (!db) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Session ${sessionId} not found` }) }],
isError: true,
}
}
const toolParams = { ...params } as Record<string, unknown>
delete toolParams.session_id
const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) })
return { content: [{ type: 'text' as const, text: result.content }] }
})
}
// --- 注册 Resources ---
server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => {
const sessionIds = dbManager.listSessionIds()
const sessions = sessionIds
.map((id) => {
const db = dbManager.open(id)
if (!db) return null
const meta = getSessionMeta(db)
if (!meta) return null
return { id, name: meta.name, platform: meta.platform, type: meta.type }
})
.filter(Boolean)
return {
contents: [
{
uri: 'chatlab://sessions',
text: JSON.stringify(sessions, null, 2),
mimeType: 'application/json',
},
],
}
})
server.resource(
'session-meta',
new ResourceTemplate('chatlab://sessions/{sessionId}/meta', { list: undefined }),
{ description: '会话元信息(名称、平台、消息数等)' },
async (uri, params) => {
const sessionId = params.sessionId as string
const db = dbManager.open(sessionId)
if (!db) {
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] }
}
const meta = getSessionMeta(db)
const overview = getSessionOverview(db)
return {
contents: [
{
uri: uri.href,
text: JSON.stringify({ ...meta, ...overview }, null, 2),
mimeType: 'application/json',
},
],
}
}
)
server.resource(
'session-schema',
new ResourceTemplate('chatlab://sessions/{sessionId}/schema', { list: undefined }),
{ description: '会话数据库的表结构' },
async (uri, params) => {
const sessionId = params.sessionId as string
const db = dbManager.open(sessionId)
if (!db) {
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] }
}
const schema = getDatabaseSchema(db)
return {
contents: [
{
uri: uri.href,
text: JSON.stringify(schema, null, 2),
mimeType: 'application/json',
},
],
}
}
)
// --- 启动 stdio 传输 ---
const transport = new StdioServerTransport()
await server.connect(transport)
export async function startCliMcpServer(): Promise<void> {
const dbManager = initMcpRuntime()
await startMcpServer({ version: getVersion(), dbManager })
}
+2 -1
View File
@@ -12,6 +12,7 @@
"../../packages/config/**/*",
"../../packages/parser/**/*",
"../../packages/tools/**/*",
"../../packages/sync/**/*"
"../../packages/sync/**/*",
"../../packages/mcp-server/**/*"
]
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
clean: true,
target: 'node20',
platform: 'node',
noExternal: [/^@openchatlab\//, 'stream-json'],
noExternal: [/^@openchatlab\//, 'chatlab-mcp', 'stream-json'],
external: ['better-sqlite3', '@node-rs/jieba'],
banner: {
js: [
+24
View File
@@ -0,0 +1,24 @@
/**
* Desktop MCP Helper entry point
*
* Standalone stdio MCP server that shares the same data directory as
* the desktop app. Intended to be invoked by MCP clients (Claude Desktop,
* Cursor, etc.) without launching the GUI.
*
* Dev usage:
* pnpm --filter @openchatlab/desktop run mcp
*/
import { loadConfig } from '@openchatlab/config'
import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime'
import { startMcpServer } from 'chatlab-mcp'
const config = loadConfig()
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const dbManager = new DatabaseManager(pathProvider)
const version = process.env.npm_package_version ?? '0.0.0-dev'
startMcpServer({ version, dbManager })
+5 -2
View File
@@ -11,7 +11,8 @@
"preview": "electron-vite preview",
"build": "electron-vite build",
"build:mac": "pnpm run build && electron-builder --mac --config electron-builder.yml -p never",
"build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never"
"build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never",
"mcp": "tsx helper/mcp.ts"
},
"dependencies": {
"@aptabase/electron": "^0.3.1",
@@ -26,8 +27,10 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/rebuild": "^4.0.4",
"@types/better-sqlite3": "^7.6.13",
"chatlab-mcp": "workspace:*",
"electron": "35.7.5",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0"
"electron-vite": "^5.0.0",
"tsx": "^4.21.0"
}
}
+5 -2
View File
@@ -4,6 +4,7 @@
"electron.vite.config.*",
"main/**/*",
"preload/**/*",
"helper/**/*",
"shared/**/*",
"../../src/types/**/*",
"../../packages/shared-types/**/*",
@@ -13,7 +14,8 @@
"../../packages/parser/**/*",
"../../apps/cli/**/*",
"../../packages/tools/**/*",
"../../packages/sync/**/*"
"../../packages/sync/**/*",
"../../packages/mcp-server/**/*"
],
"compilerOptions": {
"composite": true,
@@ -23,7 +25,8 @@
"paths": {
"@/*": ["../../src/*"],
"@electron/*": ["./*"],
"@openchatlab/*": ["../../packages/*"]
"@openchatlab/*": ["../../packages/*"],
"chatlab-mcp": ["../../packages/mcp-server/src"]
}
}
}