From f5ca04ad51f0e17ad281ebd05a1003eaad10b208 Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Wed, 1 Apr 2026 19:41:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20CipherTalk=20MCP?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .npmrc | 1 + README.md | 39 ++- electron/main.ts | 11 + electron/mcp.ts | 46 +-- electron/preload.ts | 24 ++ electron/services/mcp/bootstrap.ts | 52 +++ electron/services/mcp/result.ts | 52 +++ electron/services/mcp/runtime.ts | 177 ++++++++++ electron/services/mcp/server.ts | 158 +-------- electron/services/mcp/service.ts | 437 ++++++++++++++++++++++++ electron/services/mcp/tools.ts | 74 ++++ electron/services/mcp/types.ts | 104 ++++++ package.json | 11 + scripts/ciphertalk-mcp-bootstrap.cjs | 66 ++++ scripts/ciphertalk-mcp.cmd | 32 ++ scripts/mcp-probe.js | 53 +++ scripts/mcp-runner.js | 21 +- src/App.tsx | 2 + src/components/Sidebar.tsx | 3 +- src/components/ai/AISummarySettings.tsx | 57 ---- src/pages/McpPage.tsx | 384 +++++++++++++++++++++ src/pages/SettingsPage.tsx | 19 -- src/types/electron.d.ts | 6 + 23 files changed, 1543 insertions(+), 286 deletions(-) create mode 100644 electron/services/mcp/bootstrap.ts create mode 100644 electron/services/mcp/result.ts create mode 100644 electron/services/mcp/runtime.ts create mode 100644 electron/services/mcp/service.ts create mode 100644 electron/services/mcp/tools.ts create mode 100644 electron/services/mcp/types.ts create mode 100644 scripts/ciphertalk-mcp-bootstrap.cjs create mode 100644 scripts/ciphertalk-mcp.cmd create mode 100644 scripts/mcp-probe.js create mode 100644 src/pages/McpPage.tsx diff --git a/.npmrc b/.npmrc index a751a1f..abb96b9 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ registry=https://registry.npmmirror.com ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ +legacy-peer-deps=true diff --git a/README.md b/README.md index c9856f4..9d71701 100644 --- a/README.md +++ b/README.md @@ -192,31 +192,49 @@ npm run build:core ## MCP Server -CipherTalk 现已提供基于 `stdio` 的内嵌 MCP Server,可供 Claude Desktop、Codex、Cherry Studio 等 MCP 宿主直接读取本地聊天数据。 +CipherTalk 现已提供基于 `stdio` 的独立 MCP Server,可供 Claude Desktop、Codex、Cherry Studio 等 MCP 宿主直接读取本地聊天数据。 -### 启动 +### 开发态启动 ```bash -npm run build:mcp -node scripts/mcp-runner.js +npm run mcp ``` -### 首批工具 +首次运行若缺少 `dist-electron/mcp.js`,会自动执行 `build:mcp` 后再启动。 + +### 打包态启动 + +安装版会附带 `ciphertalk-mcp.cmd` 伴随启动器,放在安装目录根部,可直接作为宿主的 `command` 使用。 + +### v1 工具 - `health_check` - `get_status` - `list_sessions` - `get_messages` -- `list_contacts` -### 宿主配置示例 +### 宿主配置示例(开发态) ```json { "mcpServers": { "ciphertalk": { - "command": "node", - "args": ["scripts/mcp-runner.js"], + "command": "npm", + "args": ["run", "mcp"], + "cwd": "E:/CipherTalk" + } + } +} +``` + +### 宿主配置示例(打包态) + +```json +{ + "mcpServers": { + "ciphertalk": { + "command": "E:/CipherTalk/ciphertalk-mcp.cmd", + "args": [], "cwd": "E:/CipherTalk" } } @@ -231,7 +249,8 @@ node scripts/mcp-runner.js "arguments": { "sessionId": "wxid_xxx", "limit": 20, - "fields": ["base", "time", "sender", "media"] + "order": "asc", + "includeMediaPaths": true } } ``` diff --git a/electron/main.ts b/electron/main.ts index e38a41e..f4641b4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,7 @@ import { voiceTranscribeServiceWhisper } from './services/voiceTranscribeService import { windowsHelloService, WindowsHelloResult } from './services/windowsHelloService' import { shortcutService } from './services/shortcutService' import { httpApiService } from './services/httpApiService' +import { getMcpLaunchConfig as getMcpLaunchConfigForUi } from './services/mcp/runtime' // 扩展 app 对象类型,添加 isQuitting 标志 declare module 'electron' { @@ -1285,6 +1286,16 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:getMcpLaunchConfig', async () => { + return getMcpLaunchConfigForUi() + }) + + ipcMain.on('app:getMcpLaunchConfig:request', (event, payload: { requestId?: string } | undefined) => { + const requestId = payload?.requestId + if (!requestId) return + event.sender.send(`app:getMcpLaunchConfig:response:${requestId}`, getMcpLaunchConfigForUi()) + }) + ipcMain.handle('app:checkForUpdates', async () => { try { const result = await autoUpdater.checkForUpdates() diff --git a/electron/mcp.ts b/electron/mcp.ts index 8db78cb..8bdf2e6 100644 --- a/electron/mcp.ts +++ b/electron/mcp.ts @@ -1,45 +1,3 @@ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { createCipherTalkMcpServer } from './services/mcp/server' +import { bootstrapCipherTalkMcpServer } from './services/mcp/bootstrap' -let mcpServer: Awaited> | null = null - -async function start() { - mcpServer = createCipherTalkMcpServer() - const transport = new StdioServerTransport() - await mcpServer.connect(transport) - - process.stderr.write('[CipherTalk MCP] stdio server started\n') -} - -async function shutdown(code = 0) { - try { - await mcpServer?.close() - } catch (error) { - process.stderr.write(`[CipherTalk MCP] close error: ${String(error)}\n`) - } finally { - process.exit(code) - } -} - -process.on('SIGINT', () => { - void shutdown(0) -}) - -process.on('SIGTERM', () => { - void shutdown(0) -}) - -process.on('uncaughtException', (error) => { - process.stderr.write(`[CipherTalk MCP] uncaughtException: ${String(error)}\n`) - void shutdown(1) -}) - -process.on('unhandledRejection', (error) => { - process.stderr.write(`[CipherTalk MCP] unhandledRejection: ${String(error)}\n`) - void shutdown(1) -}) - -void start().catch((error) => { - process.stderr.write(`[CipherTalk MCP] startup failed: ${String(error)}\n`) - void shutdown(1) -}) +void bootstrapCipherTalkMcpServer() diff --git a/electron/preload.ts b/electron/preload.ts index d8ca35a..423081c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,28 @@ import { contextBridge, ipcRenderer } from 'electron' +function getMcpLaunchConfigSafe(): Promise<{ + command: string + args: string[] + cwd: string + mode: 'dev' | 'packaged' +} | null> { + return new Promise((resolve) => { + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const responseChannel = `app:getMcpLaunchConfig:response:${requestId}` + const timeout = setTimeout(() => { + ipcRenderer.removeAllListeners(responseChannel) + resolve(null) + }, 600) + + ipcRenderer.once(responseChannel, (_, payload) => { + clearTimeout(timeout) + resolve(payload ?? null) + }) + + ipcRenderer.send('app:getMcpLaunchConfig:request', { requestId }) + }) +} + // 暴露给渲染进程的 API contextBridge.exposeInMainWorld('electronAPI', { // 配置 @@ -47,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', { app: { getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getVersion: () => ipcRenderer.invoke('app:getVersion'), + getMcpLaunchConfig: () => getMcpLaunchConfigSafe(), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), getStartupDbConnected: () => ipcRenderer.invoke('app:getStartupDbConnected'), diff --git a/electron/services/mcp/bootstrap.ts b/electron/services/mcp/bootstrap.ts new file mode 100644 index 0000000..61ed60b --- /dev/null +++ b/electron/services/mcp/bootstrap.ts @@ -0,0 +1,52 @@ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { createCipherTalkMcpServer } from './server' + +let mcpServer: ReturnType | null = null +let isShuttingDown = false + +async function shutdown(code = 0) { + if (isShuttingDown) return + isShuttingDown = true + + try { + await mcpServer?.close?.() + } catch (error) { + process.stderr.write(`[CipherTalk MCP] close error: ${String(error)}\n`) + } finally { + process.exit(code) + } +} + +function installProcessHandlers() { + process.on('SIGINT', () => { + void shutdown(0) + }) + + process.on('SIGTERM', () => { + void shutdown(0) + }) + + process.on('uncaughtException', (error) => { + process.stderr.write(`[CipherTalk MCP] uncaughtException: ${String(error)}\n`) + void shutdown(1) + }) + + process.on('unhandledRejection', (error) => { + process.stderr.write(`[CipherTalk MCP] unhandledRejection: ${String(error)}\n`) + void shutdown(1) + }) +} + +export async function bootstrapCipherTalkMcpServer() { + installProcessHandlers() + + try { + mcpServer = createCipherTalkMcpServer() + const transport = new StdioServerTransport() + await mcpServer.connect(transport) + process.stderr.write('[CipherTalk MCP] stdio server started\n') + } catch (error) { + process.stderr.write(`[CipherTalk MCP] startup failed: ${String(error)}\n`) + await shutdown(1) + } +} diff --git a/electron/services/mcp/result.ts b/electron/services/mcp/result.ts new file mode 100644 index 0000000..6c25cc7 --- /dev/null +++ b/electron/services/mcp/result.ts @@ -0,0 +1,52 @@ +import type { McpErrorCode, McpErrorShape } from './types' + +export class McpToolError extends Error { + code: McpErrorCode + hint?: string + + constructor(code: McpErrorCode, message: string, hint?: string) { + super(message) + this.name = 'McpToolError' + this.code = code + this.hint = hint + } + + toShape(): McpErrorShape { + return { + code: this.code, + message: this.message, + hint: this.hint + } + } +} + +function toStructuredContent(data: unknown): Record { + if (data && typeof data === 'object') { + return data as Record + } + + return { value: data } +} + +export function createToolSuccess(summary: string, data: unknown) { + return { + content: [{ type: 'text' as const, text: summary }], + structuredContent: toStructuredContent(data), + isError: false + } +} + +export function createToolError(error: unknown) { + const payload = error instanceof McpToolError + ? error.toShape() + : { + code: 'INTERNAL_ERROR' as const, + message: String(error) + } + + return { + content: [{ type: 'text' as const, text: payload.message }], + structuredContent: payload, + isError: true + } +} diff --git a/electron/services/mcp/runtime.ts b/electron/services/mcp/runtime.ts new file mode 100644 index 0000000..8f5e804 --- /dev/null +++ b/electron/services/mcp/runtime.ts @@ -0,0 +1,177 @@ +import { dirname, join } from 'path' +import { existsSync } from 'fs' +import { ConfigService } from '../config' +import { getAppPath, getAppVersion, getDocumentsPath, getExePath, isElectronPackaged } from '../runtimePaths' +import type { McpHealthPayload, McpLaunchConfig, McpLauncherMode, McpStatusPayload } from './types' +import { MCP_TOOL_NAMES } from './types' + +const MCP_SERVICE_NAME = 'ciphertalk-mcp' + +function cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + return trimmed +} + +function findAccountDir(baseDir: string, wxid: string): string | null { + if (!existsSync(baseDir)) return null + + const cleanedWxid = cleanAccountDirName(wxid) + const directCandidates = [wxid] + + if (cleanedWxid && cleanedWxid !== wxid) { + directCandidates.push(cleanedWxid) + } + + for (const candidate of directCandidates) { + if (existsSync(join(baseDir, candidate))) { + return candidate + } + } + + try { + const fs = require('fs') as typeof import('fs') + const entries = fs.readdirSync(baseDir, { withFileTypes: true }) + const wxidLower = wxid.toLowerCase() + const cleanedLower = cleanedWxid.toLowerCase() + + for (const entry of entries) { + if (!entry.isDirectory()) continue + const dirName = entry.name + const dirLower = dirName.toLowerCase() + const cleanedDirLower = cleanAccountDirName(dirName).toLowerCase() + + if (dirLower === wxidLower || dirLower === cleanedLower) return dirName + if (dirLower.startsWith(`${wxidLower}_`) || dirLower.startsWith(`${cleanedLower}_`)) return dirName + if (cleanedDirLower === wxidLower || cleanedDirLower === cleanedLower) return dirName + } + } catch { + return null + } + + return null +} + +function getDecryptedDbDir(configService: ConfigService): string { + const cachePath = String(configService.get('cachePath') || '') + if (cachePath) return cachePath + + if (!isElectronPackaged()) { + return join(getDocumentsPath(), 'CipherTalkData') + } + + const installDir = dirname(getExePath()) + const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\') + if (isOnCDrive) { + return join(getDocumentsPath(), 'CipherTalkData') + } + + return join(installDir, 'CipherTalkData') +} + +function getLauncherMode(): McpLauncherMode { + const mode = String(process.env.CIPHERTALK_MCP_LAUNCHER || '').trim() + if (mode === 'dev-runner' || mode === 'packaged-launcher') { + return mode + } + + return 'direct' +} + +function getRuntimeWarnings(config: { mcpEnabled: boolean; dbReady: boolean }): string[] { + const warnings: string[] = [] + + if (!config.mcpEnabled) { + warnings.push('MCP is not marked as enabled in Settings. Calls still work, but hosts should treat this as informational.') + } + + if (!config.dbReady) { + warnings.push('Chat database is not ready yet. Data tools may return DB_NOT_READY until setup is complete.') + } + + return warnings +} + +export function getPackagedLauncherPath(): string { + return join(dirname(getExePath()), 'ciphertalk-mcp.cmd') +} + +export function getMcpLaunchConfig(): McpLaunchConfig { + if (isElectronPackaged()) { + return { + command: getPackagedLauncherPath(), + args: [], + cwd: dirname(getExePath()), + mode: 'packaged' + } + } + + return { + command: 'npm', + args: ['run', 'mcp'], + cwd: getAppPath(), + mode: 'dev' + } +} + +export function getMcpConfigSnapshot() { + const configService = new ConfigService() + try { + const mcpEnabled = Boolean(configService.get('mcpEnabled')) + const mcpExposeMediaPaths = configService.get('mcpExposeMediaPaths') !== false + const myWxid = String(configService.get('myWxid') || '') + const decryptedBaseDir = getDecryptedDbDir(configService) + + let dbReady = false + if (myWxid) { + const accountDir = findAccountDir(decryptedBaseDir, myWxid) + if (accountDir) { + dbReady = existsSync(join(decryptedBaseDir, accountDir, 'session.db')) + } + } + + return { + mcpEnabled, + mcpExposeMediaPaths, + dbReady + } + } finally { + configService.close() + } +} + +export function getMcpHealthPayload(): McpHealthPayload { + const config = getMcpConfigSnapshot() + return { + ok: true, + service: MCP_SERVICE_NAME, + version: getAppVersion(), + warnings: getRuntimeWarnings(config) + } +} + +export function getMcpStatusPayload(): McpStatusPayload { + const config = getMcpConfigSnapshot() + return { + runtime: { + pid: process.pid, + platform: process.platform, + appMode: isElectronPackaged() ? 'packaged' : 'dev', + launcherMode: getLauncherMode() + }, + config, + capabilities: { + tools: [...MCP_TOOL_NAMES] + }, + warnings: getRuntimeWarnings(config) + } +} diff --git a/electron/services/mcp/server.ts b/electron/services/mcp/server.ts index 209ab3e..b5f042a 100644 --- a/electron/services/mcp/server.ts +++ b/electron/services/mcp/server.ts @@ -1,163 +1,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { z } from 'zod' -import { ConfigService } from '../config' -import { - ApiQueryError, - queryContacts, - queryHealth, - queryMessages, - querySessions, - queryStatus -} from '../httpApiFacade' - -function formatToolResult(data: unknown, isError = false) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(data, null, 2) - } - ], - structuredContent: (data && typeof data === 'object' ? data : { value: data }) as Record, - isError - } -} - -function formatToolError(error: unknown) { - if (error instanceof ApiQueryError) { - return formatToolResult(error.toResponse(), true) - } - - return formatToolResult({ - code: 'INTERNAL_ERROR', - message: String(error) - }, true) -} - -function getHttpRuntimeStatus() { - const configService = new ConfigService() - const enabled = Boolean(configService.get('httpApiEnabled')) - const port = Number(configService.get('httpApiPort') || 5031) - const token = String(configService.get('httpApiToken') || '') - configService.close() - - return { - enabled, - running: enabled, - host: '127.0.0.1', - port, - startedAt: Date.now(), - token, - startError: '' - } -} - -function getMcpDefaults() { - const configService = new ConfigService() - const mcpEnabled = Boolean(configService.get('mcpEnabled')) - const mcpExposeMediaPaths = configService.get('mcpExposeMediaPaths') !== false - configService.close() - return { mcpEnabled, mcpExposeMediaPaths } -} +import { getAppVersion } from '../runtimePaths' +import { registerCipherTalkMcpTools } from './tools' export function createCipherTalkMcpServer() { const server = new McpServer({ name: 'ciphertalk-mcp', - version: '1.0.0' - }) - - server.registerTool('health_check', { - title: 'Health Check', - description: 'Return the embedded CipherTalk service health status.' - }, async () => { - return formatToolResult(queryHealth()) - }) - - server.registerTool('get_status', { - title: 'Get Status', - description: 'Return CipherTalk service and configuration status.', - inputSchema: { - verbose: z.boolean().optional().describe('Whether to include verbose debug and app details.') - } - }, async ({ verbose }) => { - try { - return formatToolResult(queryStatus(getHttpRuntimeStatus(), Boolean(verbose))) - } catch (error) { - return formatToolError(error) - } - }) - - server.registerTool('list_sessions', { - title: 'List Sessions', - description: 'List chat sessions with pagination, filtering, and sorting.', - inputSchema: { - q: z.string().optional().describe('Search keyword.'), - type: z.array(z.string()).optional().describe('Session types: friend, group, official, other.'), - unreadOnly: z.boolean().optional().describe('Only include sessions with unread messages.'), - sort: z.string().optional().describe('Sort mode.'), - offset: z.number().int().nonnegative().optional().describe('Pagination offset.'), - limit: z.number().int().positive().optional().describe('Pagination limit.') - } - }, async (args) => { - try { - return formatToolResult(await querySessions(args)) - } catch (error) { - return formatToolError(error) - } - }) - - server.registerTool('get_messages', { - title: 'Get Messages', - description: 'Query messages from a session with filtering, field selection, and optional media path resolution.', - inputSchema: { - sessionId: z.string().describe('Required session identifier / chat username.'), - offset: z.number().int().nonnegative().optional(), - limit: z.number().int().positive().optional(), - sort: z.string().optional(), - keyword: z.string().optional(), - msgType: z.array(z.number().int()).optional(), - messageKind: z.array(z.string()).optional(), - appMsgType: z.array(z.string()).optional(), - startTime: z.number().int().positive().optional().describe('Unix timestamp in seconds or milliseconds.'), - endTime: z.number().int().positive().optional().describe('Unix timestamp in seconds or milliseconds.'), - includeRaw: z.boolean().optional(), - resolveMediaPath: z.boolean().optional(), - resolveVoicePath: z.boolean().optional(), - adaptive: z.boolean().optional(), - maxScan: z.number().int().positive().optional(), - fields: z.array(z.string()).optional().describe('Requested field groups.') - } - }, async (args) => { - try { - const defaults = getMcpDefaults() - const resolveMediaPath = args.resolveMediaPath ?? defaults.mcpExposeMediaPaths - return formatToolResult(await queryMessages({ - ...args, - resolveMediaPath - })) - } catch (error) { - return formatToolError(error) - } - }) - - server.registerTool('list_contacts', { - title: 'List Contacts', - description: 'List contacts with pagination, filtering, and optional avatar fields.', - inputSchema: { - q: z.string().optional().describe('Search keyword.'), - type: z.array(z.string()).optional().describe('Contact types: friend, group, official, former_friend, other.'), - includeAvatar: z.boolean().optional(), - sort: z.string().optional(), - offset: z.number().int().nonnegative().optional(), - limit: z.number().int().positive().optional() - } - }, async (args) => { - try { - return formatToolResult(await queryContacts(args)) - } catch (error) { - return formatToolError(error) - } + version: getAppVersion() }) + registerCipherTalkMcpTools(server) return server } diff --git a/electron/services/mcp/service.ts b/electron/services/mcp/service.ts new file mode 100644 index 0000000..547a4db --- /dev/null +++ b/electron/services/mcp/service.ts @@ -0,0 +1,437 @@ +import { existsSync, mkdirSync } from 'fs' +import { writeFile } from 'fs/promises' +import { join } from 'path' +import { z } from 'zod' +import { chatService } from '../chatService' +import { ConfigService } from '../config' +import { imageDecryptService } from '../imageDecryptService' +import { videoService } from '../videoService' +import { McpToolError } from './result' +import type { McpMessageItem, McpMessagesPayload, McpSessionItem, McpSessionsPayload } from './types' + +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() +}) + +type ListSessionsArgs = z.infer +type GetMessagesArgs = z.infer + +function toTimestampMs(value?: number | null): number | null { + if (!value || !Number.isFinite(value) || value <= 0) return null + return value < 1_000_000_000_000 ? value * 1000 : value +} + +function detectSessionKind(sessionId: string): McpSessionItem['kind'] { + if (sessionId.includes('@chatroom')) return 'group' + if (sessionId.startsWith('gh_')) return 'official' + if (sessionId) return 'friend' + return 'other' +} + +function detectMessageKind(message: Record): string { + const localType = Number(message.localType || 0) + const raw = String(message.rawContent || message.parsedContent || '') + const xmlTypeMatch = raw.match(/\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 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('数据库未连接') + ) { + 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(base: Record): Promise { + const emojiMd5 = base.emojiMd5 as string | undefined + const emojiCdnUrl = base.emojiCdnUrl as string | undefined + + if (!emojiMd5 && !emojiCdnUrl) return null + + try { + const result = await chatService.downloadEmoji( + String(emojiCdnUrl || ''), + emojiMd5, + base.productId as string | undefined, + Number(base.createTime || 0) + ) + + return result.success ? result.cachePath || result.localPath || null : null + } catch { + return null + } +} + +async function getImageLocalPath(sessionId: string, base: Record): Promise { + if (!base.imageMd5 && !base.imageDatName) return null + + try { + const resolved = await imageDecryptService.resolveCachedImage({ + sessionId, + imageMd5: base.imageMd5 as string | undefined, + imageDatName: base.imageDatName as string | undefined + }) + + if (resolved.success && resolved.localPath) { + return resolved.localPath + } + + const decrypted = await imageDecryptService.decryptImage({ + sessionId, + imageMd5: base.imageMd5 as string | undefined, + imageDatName: base.imageDatName as string | undefined, + force: false + }) + + return decrypted.success ? decrypted.localPath || null : null + } catch { + return null + } +} + +function getVideoLocalPath(base: Record): string | null { + if (!base.videoMd5) return null + + try { + const info = videoService.getVideoInfo(String(base.videoMd5)) + return info.exists ? info.videoUrl || null : null + } catch { + return null + } +} + +async function getVoiceLocalPath(sessionId: string, base: Record): Promise { + const localId = Number(base.localId || 0) + const createTime = Number(base.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(base: Record): string | null { + const fileName = String(base.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(base.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 toMcpMessage(sessionId: string, includeMediaPaths: boolean, includeRaw: boolean, message: Record): Promise { + const kind = detectMessageKind(message) + const direction = Number(message.isSend) === 1 ? 'out' : 'in' + const base: McpMessageItem = { + messageId: Number(message.localId || message.serverId || 0), + timestamp: Number(message.createTime || 0), + direction, + kind, + text: String(message.parsedContent || message.rawContent || ''), + sender: { + username: (message.senderUsername as string | null) ?? null, + isSelf: direction === 'out' + } + } + + if (includeRaw) { + base.raw = String(message.rawContent || '') + } + + switch (kind) { + case 'emoji': + base.media = { + type: 'emoji', + md5: (message.emojiMd5 as string | undefined) || null + } + if (includeMediaPaths) { + base.media.localPath = await getEmojiLocalPath(message) + } + break + case 'image': + base.media = { + type: 'image', + md5: (message.imageMd5 as string | undefined) || null, + isLivePhoto: Boolean(message.isLivePhoto) + } + if (includeMediaPaths) { + base.media.localPath = await getImageLocalPath(sessionId, message) + } + break + case 'video': + base.media = { + type: 'video', + md5: (message.videoMd5 as string | undefined) || null, + durationSeconds: Number(message.videoDuration || 0) || null, + isLivePhoto: Boolean(message.isLivePhoto) + } + if (includeMediaPaths) { + base.media.localPath = getVideoLocalPath(message) + } + break + case 'voice': + base.media = { + type: 'voice', + durationSeconds: Number(message.voiceDuration || 0) || null + } + if (includeMediaPaths) { + base.media.localPath = await getVoiceLocalPath(sessionId, message) + } + break + case 'app_file': { + const localPath = includeMediaPaths ? getFileLocalPath(message) : null + base.media = { + type: 'file', + md5: (message.fileMd5 as string | undefined) || null, + fileName: (message.fileName as string | undefined) || null, + fileSize: Number(message.fileSize || 0) || null, + localPath, + exists: localPath ? existsSync(localPath) : null + } + break + } + default: + break + } + + return base +} + +export class McpReadService { + async listSessions(rawArgs: ListSessionsArgs): Promise { + const args = listSessionsArgsSchema.safeParse(rawArgs) + if (!args.success) { + throw new McpToolError('BAD_REQUEST', 'Invalid list_sessions arguments.', args.error.message) + } + + const query = String(args.data.q || '').trim().toLowerCase() + const offset = Math.max(0, args.data.offset ?? 0) + const limit = Math.min(args.data.limit ?? 100, 200) + const unreadOnly = Boolean(args.data.unreadOnly) + + const result = await chatService.getSessions() + if (!result.success) { + mapChatError(result.error) + } + + let sessions = (result.sessions || []).map((session) => ({ + sessionId: session.username, + displayName: session.displayName || session.username, + kind: detectSessionKind(session.username), + lastMessagePreview: session.summary || '', + unreadCount: Number(session.unreadCount || 0), + lastTimestamp: Number(session.lastTimestamp || 0) + } satisfies McpSessionItem)) + + 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) + } + + sessions.sort((a, b) => b.lastTimestamp - a.lastTimestamp) + + const total = sessions.length + const items = sessions.slice(offset, offset + limit) + + return { + items, + total, + offset, + limit, + hasMore: offset + items.length < total + } + } + + async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean): Promise { + 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, 200) + const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths + const keywordQuery = String(keyword || '').trim().toLowerCase() + const startTimeMs = toTimestampMs(args.data.startTime) + const endTimeMs = toTimestampMs(args.data.endTime) + + const matched: Record[] = [] + const batchSize = 200 + const maxScan = 5000 + let scanOffset = 0 + let scanned = 0 + let reachedEnd = false + const targetCount = offset + limit + 1 + + while (scanned < maxScan && matched.length < targetCount) { + const result = await chatService.getMessages(sessionId, scanOffset, batchSize) + if (!result.success) { + mapChatError(result.error) + } + + const part = result.messages || [] + if (part.length === 0) { + reachedEnd = true + break + } + + for (const message of part) { + const timestampMs = toTimestampMs(Number(message.createTime || 0)) || 0 + const parsedContent = String(message.parsedContent || '') + const rawContent = String(message.rawContent || '') + + if (startTimeMs && timestampMs < startTimeMs) continue + if (endTimeMs && timestampMs > endTimeMs) continue + if (keywordQuery && !parsedContent.toLowerCase().includes(keywordQuery) && !rawContent.toLowerCase().includes(keywordQuery)) { + continue + } + + matched.push(message as unknown as Record) + } + + scanOffset += part.length + scanned += part.length + + if (!result.hasMore) { + reachedEnd = true + break + } + } + + matched.sort((a, b) => { + const timeDelta = Number(a.createTime || 0) - Number(b.createTime || 0) + if (timeDelta !== 0) { + return order === 'asc' ? timeDelta : -timeDelta + } + + const idDelta = Number(a.localId || 0) - Number(b.localId || 0) + return order === 'asc' ? idDelta : -idDelta + }) + + const page = matched.slice(offset, offset + limit) + const items = await Promise.all(page.map((message) => toMcpMessage(sessionId, includeMediaPaths, includeRaw, message))) + + return { + items, + offset, + limit, + hasMore: reachedEnd ? matched.length > offset + items.length : true + } + } +} diff --git a/electron/services/mcp/tools.ts b/electron/services/mcp/tools.ts new file mode 100644 index 0000000..72af846 --- /dev/null +++ b/electron/services/mcp/tools.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { createToolError, createToolSuccess } from './result' +import { getMcpConfigSnapshot, getMcpHealthPayload, getMcpStatusPayload } from './runtime' +import { McpReadService } from './service' + +const readService = new McpReadService() + +export function registerCipherTalkMcpTools(server: any) { + server.registerTool('health_check', { + title: 'Health Check', + description: 'Return CipherTalk MCP health information.' + }, async () => { + try { + const payload = getMcpHealthPayload() + return createToolSuccess('CipherTalk MCP health is available.', payload) + } catch (error) { + return createToolError(error) + } + }) + + server.registerTool('get_status', { + title: 'Get Status', + description: 'Return CipherTalk MCP runtime and configuration status.' + }, async () => { + try { + const payload = getMcpStatusPayload() + return createToolSuccess('CipherTalk MCP status loaded.', payload) + } catch (error) { + return createToolError(error) + } + }) + + server.registerTool('list_sessions', { + title: 'List Sessions', + description: 'List chat sessions with search and pagination.', + inputSchema: { + q: z.string().optional().describe('Optional search keyword.'), + offset: z.number().int().nonnegative().optional().describe('Pagination offset.'), + limit: z.number().int().positive().optional().describe('Pagination limit.'), + unreadOnly: z.boolean().optional().describe('Only return sessions with unread messages.') + } + }, async (args: unknown) => { + try { + const payload = await readService.listSessions((args || {}) as any) + return createToolSuccess(`Loaded ${payload.items.length} sessions.`, payload) + } catch (error) { + return createToolError(error) + } + }) + + server.registerTool('get_messages', { + title: 'Get Messages', + description: 'List messages from one chat session with filters and pagination.', + inputSchema: { + sessionId: z.string().trim().min(1).describe('Required session identifier / username.'), + offset: z.number().int().nonnegative().optional().describe('Pagination offset.'), + limit: z.number().int().positive().optional().describe('Pagination limit.'), + order: z.enum(['asc', 'desc']).optional().describe('Message sort order by time.'), + keyword: z.string().optional().describe('Optional content keyword filter.'), + startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'), + endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'), + includeRaw: z.boolean().optional().describe('Include raw message content when true.'), + includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.') + } + }, async (args: unknown) => { + try { + const defaults = getMcpConfigSnapshot() + const payload = await readService.getMessages((args || {}) as any, defaults.mcpExposeMediaPaths) + return createToolSuccess(`Loaded ${payload.items.length} messages.`, payload) + } catch (error) { + return createToolError(error) + } + }) +} diff --git a/electron/services/mcp/types.ts b/electron/services/mcp/types.ts new file mode 100644 index 0000000..8f78d78 --- /dev/null +++ b/electron/services/mcp/types.ts @@ -0,0 +1,104 @@ +export const MCP_TOOL_NAMES = [ + 'health_check', + 'get_status', + 'list_sessions', + 'get_messages' +] as const + +export type McpToolName = (typeof MCP_TOOL_NAMES)[number] + +export type McpLaunchMode = 'dev' | 'packaged' +export type McpLauncherMode = 'dev-runner' | 'packaged-launcher' | 'direct' + +export interface McpLaunchConfig { + command: string + args: string[] + cwd: string + mode: McpLaunchMode +} + +export type McpErrorCode = + | 'BAD_REQUEST' + | 'DB_NOT_READY' + | 'SESSION_NOT_FOUND' + | 'INTERNAL_ERROR' + +export interface McpErrorShape { + code: McpErrorCode + message: string + hint?: string +} + +export interface McpHealthPayload { + ok: boolean + service: string + version: string + warnings: string[] +} + +export interface McpStatusPayload { + runtime: { + pid: number + platform: NodeJS.Platform + appMode: McpLaunchMode + launcherMode: McpLauncherMode + } + config: { + mcpEnabled: boolean + mcpExposeMediaPaths: boolean + dbReady: boolean + } + capabilities: { + tools: McpToolName[] + } + warnings: string[] +} + +export interface McpSessionItem { + sessionId: string + displayName: string + kind: 'friend' | 'group' | 'official' | 'other' + lastMessagePreview: string + unreadCount: number + lastTimestamp: number +} + +export interface McpSessionsPayload { + items: McpSessionItem[] + total: number + offset: number + limit: number + hasMore: boolean +} + +export interface McpMessageMedia { + type: string + localPath?: string | null + md5?: string | null + durationSeconds?: number | null + fileName?: string | null + fileSize?: number | null + exists?: boolean | null + isLivePhoto?: boolean | null +} + +export interface McpMessageItem { + messageId: number + timestamp: number + direction: 'in' | 'out' + kind: string + text: string + sender: { + username: string | null + isSelf: boolean + } + media?: McpMessageMedia + raw?: string +} + +export interface McpMessagesPayload { + items: McpMessageItem[] + offset: number + limit: number + hasMore: boolean +} diff --git a/package.json b/package.json index 7c0eb6c..9c75a17 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:mcp": "tsc && vite build", "build:pro": "node scripts/build-full.js", "mcp": "node scripts/mcp-runner.js", + "mcp:probe": "node scripts/mcp-probe.js", "preview": "vite preview", "electron:dev": "vite --mode electron", "electron:build": "npm run build", @@ -141,6 +142,16 @@ "to": "xinnian.ico" } ], + "extraFiles": [ + { + "from": "scripts/ciphertalk-mcp.cmd", + "to": "ciphertalk-mcp.cmd" + }, + { + "from": "scripts/ciphertalk-mcp-bootstrap.cjs", + "to": "ciphertalk-mcp-bootstrap.cjs" + } + ], "files": [ "dist/**/*", "dist-electron/**/*", diff --git a/scripts/ciphertalk-mcp-bootstrap.cjs b/scripts/ciphertalk-mcp-bootstrap.cjs new file mode 100644 index 0000000..b8ba545 --- /dev/null +++ b/scripts/ciphertalk-mcp-bootstrap.cjs @@ -0,0 +1,66 @@ +"use strict"; + +const os = require("os"); +const path = require("path"); +const Module = require("module"); + +const appDir = path.dirname(process.execPath); +const appAsarPath = path.join(appDir, "resources", "app.asar"); + +function getUserDataPath() { + const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); + return path.join(appData, "ciphertalk"); +} + +function getDocumentsPath() { + return path.join(os.homedir(), "Documents"); +} + +const electronShim = { + app: { + isPackaged: true, + getPath(name) { + switch (name) { + case "userData": + return getUserDataPath(); + case "documents": + return getDocumentsPath(); + case "exe": + return process.execPath; + default: + return appDir; + } + }, + getAppPath() { + return appAsarPath; + }, + getVersion() { + try { + return require(path.join(appAsarPath, "package.json")).version || "0.0.0"; + } catch { + return "0.0.0"; + } + }, + }, + BrowserWindow: { + getAllWindows() { + return []; + }, + }, +}; + +const originalLoad = Module._load; +Module._load = function patchedLoad(request, parent, isMain) { + if (request === "electron") { + return electronShim; + } + return originalLoad.call(this, request, parent, isMain); +}; + +const entry = String(process.env.CIPHERTALK_MCP_ENTRY || "").trim(); +if (!entry) { + process.stderr.write("[CipherTalk MCP Bootstrap] CIPHERTALK_MCP_ENTRY is not set\n"); + process.exit(1); +} + +require(entry); diff --git a/scripts/ciphertalk-mcp.cmd b/scripts/ciphertalk-mcp.cmd new file mode 100644 index 0000000..b43a933 --- /dev/null +++ b/scripts/ciphertalk-mcp.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal + +set "APP_DIR=%~dp0" +set "EXE_PATH=%APP_DIR%CipherTalk.exe" +set "MCP_ARCHIVE=%APP_DIR%resources\app.asar" +set "MCP_ENTRY_UNPACKED=%APP_DIR%resources\app.asar.unpacked\dist-electron\mcp.js" +set "MCP_ENTRY=%MCP_ARCHIVE%\dist-electron\mcp.js" +set "MCP_BOOTSTRAP=%APP_DIR%ciphertalk-mcp-bootstrap.cjs" + +if not exist "%EXE_PATH%" ( + >&2 echo [CipherTalk MCP Launcher] CipherTalk.exe not found at "%EXE_PATH%" + exit /b 1 +) + +if not exist "%MCP_BOOTSTRAP%" ( + >&2 echo [CipherTalk MCP Launcher] MCP bootstrap not found at "%MCP_BOOTSTRAP%" + exit /b 1 +) + +if exist "%MCP_ENTRY_UNPACKED%" ( + set "MCP_ENTRY=%MCP_ENTRY_UNPACKED%" +) else if not exist "%MCP_ARCHIVE%" ( + >&2 echo [CipherTalk MCP Launcher] app.asar not found at "%MCP_ARCHIVE%" + exit /b 1 +) + +set "ELECTRON_RUN_AS_NODE=1" +set "CIPHERTALK_MCP_LAUNCHER=packaged-launcher" +set "CIPHERTALK_MCP_ENTRY=%MCP_ENTRY%" + +"%EXE_PATH%" "%MCP_BOOTSTRAP%" %* diff --git a/scripts/mcp-probe.js b/scripts/mcp-probe.js new file mode 100644 index 0000000..c993ea9 --- /dev/null +++ b/scripts/mcp-probe.js @@ -0,0 +1,53 @@ +const path = require('path') + +async function main() { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js') + + const mode = process.argv[2] || 'dev' + const cwd = process.cwd() + let command + let args + let transportCwd = cwd + + if (mode === 'packaged') { + const launcherPath = process.argv[3] || path.join(cwd, 'ciphertalk-mcp.cmd') + command = launcherPath + args = [] + transportCwd = path.dirname(launcherPath) + } else { + command = process.platform === 'win32' ? 'npm.cmd' : 'npm' + args = ['run', 'mcp'] + } + + const transport = new StdioClientTransport({ + command, + args, + cwd: transportCwd, + stderr: 'pipe' + }) + + const client = new Client({ + name: 'ciphertalk-mcp-probe', + version: '1.0.0' + }) + + try { + await client.connect(transport) + const tools = await client.listTools() + const health = await client.callTool({ name: 'health_check', arguments: {} }) + + console.log(JSON.stringify({ + mode, + tools: (tools.tools || []).map((tool) => tool.name), + health + }, null, 2)) + } finally { + await client.close() + } +} + +main().catch((error) => { + console.error('[CipherTalk MCP Probe] failed:', error) + process.exit(1) +}) diff --git a/scripts/mcp-runner.js b/scripts/mcp-runner.js index e47e6ac..c0a8c0a 100644 --- a/scripts/mcp-runner.js +++ b/scripts/mcp-runner.js @@ -1,15 +1,34 @@ const { spawn } = require('child_process') +const { spawnSync } = require('child_process') +const fs = require('fs') const path = require('path') const electronBinary = require('electron') const rootDir = path.resolve(__dirname, '..') const entry = path.join(rootDir, 'dist-electron', 'mcp.js') +if (!fs.existsSync(entry)) { + process.stderr.write('[CipherTalk MCP Runner] dist-electron/mcp.js not found, running build:mcp...\n') + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const build = spawnSync(npmCmd, ['run', 'build:mcp'], { + cwd: rootDir, + env: process.env, + stdio: 'inherit', + windowsHide: true + }) + + if (build.status !== 0 || !fs.existsSync(entry)) { + process.stderr.write('[CipherTalk MCP Runner] build:mcp failed, cannot start MCP server\n') + process.exit(build.status ?? 1) + } +} + const child = spawn(electronBinary, [entry], { cwd: rootDir, env: { ...process.env, - ELECTRON_RUN_AS_NODE: '1' + ELECTRON_RUN_AS_NODE: '1', + CIPHERTALK_MCP_LAUNCHER: 'dev-runner' }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true diff --git a/src/App.tsx b/src/App.tsx index 56d459c..6f50626 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import OpenApiPage from './pages/OpenApiPage' +import McpPage from './pages/McpPage' import ExportPage from './pages/ExportPage' import ActivationPage from './pages/ActivationPage' import ImageWindow from './pages/ImageWindow' @@ -513,6 +514,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 27fcbbd..492087c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ import ListItemButton from '@mui/material/ListItemButton' import ListItemIcon from '@mui/material/ListItemIcon' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network, Boxes } from 'lucide-react' import { useAppStore } from '../stores/appStore' const DRAWER_WIDTH = 220 @@ -82,6 +82,7 @@ function Sidebar() { { key: 'export', label: '导出数据', icon: , type: 'route', path: '/export' }, { key: 'data-management', label: '数据管理', icon: , type: 'route', path: '/data-management' }, { key: 'open-api', label: '开放接口', icon: , type: 'route', path: '/open-api' }, + { key: 'mcp', label: 'MCP 服务', icon: , type: 'route', path: '/mcp' }, ] const navItemSx = { diff --git a/src/components/ai/AISummarySettings.tsx b/src/components/ai/AISummarySettings.tsx index 62e4a36..854143e 100644 --- a/src/components/ai/AISummarySettings.tsx +++ b/src/components/ai/AISummarySettings.tsx @@ -111,10 +111,6 @@ interface AISummarySettingsProps { setEnableThinking: (val: boolean) => void messageLimit: number setMessageLimit: (val: number) => void - mcpEnabled: boolean - setMcpEnabled: (val: boolean) => void - mcpExposeMediaPaths: boolean - setMcpExposeMediaPaths: (val: boolean) => void showMessage: (text: string, success: boolean) => void } @@ -137,10 +133,6 @@ function AISummarySettings({ setEnableThinking, messageLimit, setMessageLimit, - mcpEnabled, - setMcpEnabled, - mcpExposeMediaPaths, - setMcpExposeMediaPaths, showMessage }: AISummarySettingsProps) { const [showApiKey, setShowApiKey] = useState(false) @@ -758,55 +750,6 @@ function AISummarySettings({ )} -

MCP Server

-
-
- -
-

为 Claude Desktop、Codex、Cherry Studio 等 MCP 宿主暴露 CipherTalk 数据读取能力。

-
-
- -
- -
-

控制 MCP `get_messages` 默认是否解析并返回图片、视频、文件等本地路径。

-
-
- -
- - -
- 首批工具:`health_check`、`get_status`、`list_sessions`、`get_messages`、`list_contacts` -
-
-
-

💡 提示:API 密钥存储在本地,不会上传到任何服务器。摘要内容仅用于本地展示。

diff --git a/src/pages/McpPage.tsx b/src/pages/McpPage.tsx new file mode 100644 index 0000000..8040856 --- /dev/null +++ b/src/pages/McpPage.tsx @@ -0,0 +1,384 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Alert, + Box, + Button, + Card, + CardContent, + CardHeader, + Container, + Snackbar, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material' +import { Check, Copy, Save } from 'lucide-react' +import * as configService from '../services/config' + +type ToastState = { + text: string + success: boolean +} + +type McpLaunchConfig = { + command: string + args: string[] + cwd: string + mode: 'dev' | 'packaged' +} + +function formatCommandPart(value: string) { + if (!value) return value + return /[\s"]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value +} + +const textFieldSx = { + '& .MuiInputLabel-root': { + color: 'var(--text-secondary)', + }, + '& .MuiInputLabel-root.Mui-focused': { + color: 'var(--primary)', + }, + '& .MuiOutlinedInput-root': { + borderRadius: '14px', + color: 'var(--text-primary)', + backgroundColor: 'var(--bg-secondary)', + '& fieldset': { + borderColor: 'var(--border-color)', + }, + '&:hover fieldset': { + borderColor: 'var(--primary)', + }, + '&.Mui-focused fieldset': { + borderColor: 'var(--primary)', + }, + }, + '& .MuiInputBase-input': { + color: 'var(--text-primary)', + }, +} + +const switchSx = { + '& .MuiSwitch-switchBase.Mui-checked': { + color: 'var(--primary)', + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: 'var(--primary)', + }, + '& .MuiSwitch-track': { + backgroundColor: 'var(--text-tertiary)', + }, +} + +const secondaryButtonSx = { + borderRadius: '999px', + minWidth: 120, + textTransform: 'none', + fontWeight: 600, + color: 'var(--text-primary)', + borderColor: 'var(--border-color)', + backgroundColor: 'var(--bg-secondary)', + '&:hover': { + borderColor: 'var(--primary)', + backgroundColor: 'var(--primary-light)', + }, +} + +function McpPage() { + const [mcpEnabled, setMcpEnabled] = useState(false) + const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [toast, setToast] = useState(null) + const [launchConfig, setLaunchConfig] = useState({ + command: 'npm', + args: ['run', 'mcp'], + cwd: 'D:/CipherTalk', + mode: 'dev', + }) + + useEffect(() => { + const load = async () => { + try { + const [enabled, exposeMediaPaths] = await Promise.all([ + configService.getMcpEnabled(), + configService.getMcpExposeMediaPaths(), + ]) + setMcpEnabled(enabled) + setMcpExposeMediaPaths(exposeMediaPaths) + + try { + const mcpLaunchConfig = await window.electronAPI.app.getMcpLaunchConfig() + if (mcpLaunchConfig?.command && Array.isArray(mcpLaunchConfig.args) && mcpLaunchConfig.cwd) { + setLaunchConfig(mcpLaunchConfig) + } + } catch (innerError) { + const message = String(innerError || '') + if (!message.includes("No handler registered for 'app:getMcpLaunchConfig'")) { + console.error('获取 MCP 启动配置失败:', innerError) + } + } + } catch (e) { + console.error('加载 MCP 配置失败:', e) + setToast({ text: '加载 MCP 配置失败', success: false }) + } finally { + setLoading(false) + } + } + void load() + }, []) + + const mcpRunCommand = useMemo(() => { + const parts = [launchConfig.command, ...launchConfig.args].map(formatCommandPart) + return parts.join(' ') + }, [launchConfig]) + + const mcpServerJsonTemplate = useMemo(() => JSON.stringify({ + mcpServers: { + ciphertalk: { + command: launchConfig.command, + args: launchConfig.args, + cwd: launchConfig.cwd + } + } + }, null, 2), [launchConfig]) + + const handleSave = async () => { + setSaving(true) + try { + await Promise.all([ + configService.setMcpEnabled(mcpEnabled), + configService.setMcpExposeMediaPaths(mcpExposeMediaPaths), + ]) + setToast({ text: 'MCP 配置已保存', success: true }) + } catch (e) { + console.error('保存 MCP 配置失败:', e) + setToast({ text: '保存 MCP 配置失败', success: false }) + } finally { + setSaving(false) + } + } + + const copyText = async (text: string, successText: string) => { + try { + await navigator.clipboard.writeText(text) + setToast({ text: successText, success: true }) + } catch (e) { + console.error('复制失败:', e) + setToast({ text: '复制失败,请手动复制', success: false }) + } + } + + return ( + + + + + + MCP Server + + + 使用标准 MCP `stdio` 工具接口为 Claude Desktop、Codex、Cherry Studio 等宿主提供本地聊天数据读取能力。 + + + + + + + + + `mcpEnabled` 现在只作为状态标记和 warning 来源,不阻止宿主拉起 MCP。 + {launchConfig.mode === 'packaged' + ? ' 当前展示的是打包版伴随启动器 `ciphertalk-mcp.cmd`。' + : ' 当前展示的是开发态入口 `npm run mcp`。'} + + + + + MCP 状态标记 + + 仅用于在 `health_check` / `get_status` 中暴露当前配置状态,不会阻止宿主调用工具。 + + + setMcpEnabled(e.target.checked)} + disabled={loading || saving} + sx={switchSx} + /> + + + + + 默认解析媒体本地路径 + + 控制 `get_messages` 默认是否解析并返回图片、视频、语音、文件等本地路径。 + + + setMcpExposeMediaPaths(e.target.checked)} + disabled={loading || saving} + sx={switchSx} + /> + + + + 启动命令 + + + + + + + + + 标准 mcpServers 配置(可直接粘贴) + + + + + + {launchConfig.mode === 'packaged' + ? '`cwd` 已指向安装目录,宿主通常无需额外包一层 shell。' + : '`cwd` 已自动使用当前仓库目录,通常无需修改。'} + + + + + + + v1 工具:`health_check`、`get_status`、`list_sessions`、`get_messages` + + + + + + + + + + + setToast(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + : undefined} + severity={toast?.success ? 'success' : 'error'} + variant="filled" + onClose={() => setToast(null)} + sx={{ + borderRadius: '12px', + color: '#fff', + bgcolor: toast?.success ? 'var(--primary)' : 'var(--danger)', + }} + > + {toast?.text} + + + + ) +} + +export default McpPage diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 9265a4f..6092081 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -140,8 +140,6 @@ function SettingsPage() { const [aiCustomSystemPrompt, setAiCustomSystemPromptState] = useState('') const [aiEnableThinking, setAiEnableThinkingState] = useState(true) const [aiMessageLimit, setAiMessageLimitState] = useState(3000) - const [mcpEnabled, setMcpEnabledState] = useState(false) - const [mcpExposeMediaPaths, setMcpExposeMediaPathsState] = useState(true) // 日志相关状态 const [logFiles, setLogFiles] = useState>([]) @@ -221,8 +219,6 @@ function SettingsPage() { const savedAiCustomSystemPrompt = await configService.getAiCustomSystemPrompt() const savedAiEnableThinking = await configService.getAiEnableThinking() const savedAiMessageLimit = await configService.getAiMessageLimit() - const savedMcpEnabled = await configService.getMcpEnabled() - const savedMcpExposeMediaPaths = await configService.getMcpExposeMediaPaths() setAiProviderState(savedAiProvider) setAiApiKeyState(savedAiApiKey) @@ -233,8 +229,6 @@ function SettingsPage() { setAiCustomSystemPromptState(savedAiCustomSystemPrompt) setAiEnableThinkingState(savedAiEnableThinking) setAiMessageLimitState(savedAiMessageLimit) - setMcpEnabledState(savedMcpEnabled) - setMcpExposeMediaPathsState(savedMcpExposeMediaPaths) // 加载关闭行为配置 const savedCloseToTray = await configService.getCloseToTray() @@ -268,8 +262,6 @@ function SettingsPage() { aiCustomSystemPrompt: savedAiCustomSystemPrompt, aiEnableThinking: savedAiEnableThinking, aiMessageLimit: savedAiMessageLimit, - mcpEnabled: savedMcpEnabled, - mcpExposeMediaPaths: savedMcpExposeMediaPaths, closeToTray: savedCloseToTray }) @@ -318,8 +310,6 @@ function SettingsPage() { aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit, - mcpEnabled, - mcpExposeMediaPaths, closeToTray } @@ -333,7 +323,6 @@ function SettingsPage() { quoteStyle, exportDefaultDateRange, exportDefaultAvatars, aiProvider, aiApiKey, aiModel, aiDefaultTimeRange, aiSummaryDetail, aiSystemPromptPreset, aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit, - mcpEnabled, mcpExposeMediaPaths, closeToTray, initialConfig ]) @@ -859,8 +848,6 @@ function SettingsPage() { await configService.setAiCustomSystemPrompt(aiCustomSystemPrompt) await configService.setAiEnableThinking(aiEnableThinking) await configService.setAiMessageLimit(aiMessageLimit) - await configService.setMcpEnabled(mcpEnabled) - await configService.setMcpExposeMediaPaths(mcpExposeMediaPaths) // 保存关闭行为配置 await configService.setCloseToTray(closeToTray) @@ -900,8 +887,6 @@ function SettingsPage() { aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit, - mcpEnabled, - mcpExposeMediaPaths, closeToTray }) setHasUnsavedChanges(false) @@ -2802,10 +2787,6 @@ function SettingsPage() { setEnableThinking={setAiEnableThinkingState} messageLimit={aiMessageLimit} setMessageLimit={setAiMessageLimitState} - mcpEnabled={mcpEnabled} - setMcpEnabled={setMcpEnabledState} - mcpExposeMediaPaths={mcpExposeMediaPaths} - setMcpExposeMediaPaths={setMcpExposeMediaPathsState} showMessage={showMessage} /> )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4f9c7ee..cea9479 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -75,6 +75,12 @@ export interface ElectronAPI { app: { getDownloadsPath: () => Promise getVersion: () => Promise + getMcpLaunchConfig: () => Promise<{ + command: string + args: string[] + cwd: string + mode: 'dev' | 'packaged' + } | null> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> downloadAndInstall: () => Promise getStartupDbConnected?: () => Promise