From 1bfc5cdd62934e60094c7fe72d7247616bce9bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 09:41:27 +0000 Subject: [PATCH 1/6] feat: add MCP Server and REST API for chat analysis Expose ChatLab's chat analysis capabilities as an independent MCP Server (packages/mcp-server/) that runs standalone without the Electron app. Features: - MCP protocol support via stdio and SSE transports - REST API endpoints for programmatic access - 11 MCP tools: list_sessions, search_messages, get_recent_messages, get_members, get_member_stats, get_member_name_history, get_time_stats, get_conversation_between, get_message_context, execute_sql, get_schema - Read-only SQLite access with connection caching - Cross-platform default database directory detection https://claude.ai/code/session_01U2bFTH9Gme7yYN8q4gE6YB --- packages/mcp-server/README.md | 85 +++ packages/mcp-server/package.json | 25 + packages/mcp-server/src/db.ts | 168 ++++++ packages/mcp-server/src/http.ts | 184 ++++++ packages/mcp-server/src/index.ts | 81 +++ packages/mcp-server/src/queries.ts | 684 ++++++++++++++++++++++ packages/mcp-server/src/server.ts | 21 + packages/mcp-server/src/tools/index.ts | 21 + packages/mcp-server/src/tools/members.ts | 104 ++++ packages/mcp-server/src/tools/messages.ts | 138 +++++ packages/mcp-server/src/tools/session.ts | 43 ++ packages/mcp-server/src/tools/sql.ts | 83 +++ packages/mcp-server/src/tools/stats.ts | 76 +++ packages/mcp-server/src/utils/format.ts | 25 + packages/mcp-server/tsconfig.json | 19 + 15 files changed, 1757 insertions(+) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/db.ts create mode 100644 packages/mcp-server/src/http.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/queries.ts create mode 100644 packages/mcp-server/src/server.ts create mode 100644 packages/mcp-server/src/tools/index.ts create mode 100644 packages/mcp-server/src/tools/members.ts create mode 100644 packages/mcp-server/src/tools/messages.ts create mode 100644 packages/mcp-server/src/tools/session.ts create mode 100644 packages/mcp-server/src/tools/sql.ts create mode 100644 packages/mcp-server/src/tools/stats.ts create mode 100644 packages/mcp-server/src/utils/format.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 00000000..9b639d64 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,85 @@ +# @chatlab/mcp-server + +ChatLab MCP Server — Expose ChatLab's chat analysis capabilities via MCP protocol and REST API. + +## Features + +- **MCP Protocol** (stdio + SSE) for AI agent integration (Claude Code, Cursor, etc.) +- **REST API** for programmatic access +- **Read-only** — only queries data, never modifies +- **Standalone** — runs independently without the Electron app + +## Usage + +### Stdio Mode (for Claude Code, Cursor, etc.) + +```bash +node dist/index.js --db-dir /path/to/ChatLab/data/databases +``` + +### HTTP Mode (REST API + SSE) + +```bash +node dist/index.js --http --port 3000 --db-dir /path/to/ChatLab/data/databases +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--db-dir ` | Path to the ChatLab databases directory | +| `--http` | Start HTTP/SSE server instead of stdio | +| `--port ` | HTTP server port (default: 3000) | + +Environment variable `CHATLAB_DB_DIR` can also be used to set the database directory. + +## Claude Code Integration + +Add to your Claude Code MCP configuration: + +```json +{ + "mcpServers": { + "chatlab": { + "command": "node", + "args": ["/path/to/packages/mcp-server/dist/index.js", "--db-dir", "/path/to/databases"] + } + } +} +``` + +## MCP Tools + +| Tool | Description | +|------|-------------| +| `list_sessions` | List all available chat sessions | +| `search_messages` | Search messages by keywords | +| `get_recent_messages` | Get most recent messages | +| `get_members` | Get all members with message counts | +| `get_member_stats` | Member activity ranking | +| `get_member_name_history` | Member historical nicknames | +| `get_time_stats` | Time-based activity statistics (hourly/daily/weekday/monthly) | +| `get_conversation_between` | Get conversation between two members | +| `get_message_context` | Get messages surrounding a specific message | +| `execute_sql` | Execute read-only SQL queries | +| `get_schema` | Get database table structure | + +## REST API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/sessions` | List sessions | +| GET | `/api/v1/sessions/:id/members` | Get members | +| GET | `/api/v1/sessions/:id/messages?keywords=&limit=` | Search messages | +| GET | `/api/v1/sessions/:id/recent?limit=` | Get recent messages | +| GET | `/api/v1/sessions/:id/member-stats?top_n=` | Member activity stats | +| GET | `/api/v1/sessions/:id/stats/:type` | Time stats (hourly/daily/weekday/monthly) | +| POST | `/api/v1/sessions/:id/sql` | Execute SQL `{"sql": "SELECT ..."}` | +| GET | `/api/v1/sessions/:id/schema` | Get database schema | + +## Build + +```bash +npm install +npm run build +``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000..1e817d3d --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@chatlab/mcp-server", + "version": "0.1.0", + "description": "ChatLab MCP Server - Expose chat analysis capabilities via MCP protocol and REST API", + "type": "module", + "bin": { + "chatlab-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "better-sqlite3": "^12.4.6", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.0", + "typescript": "^5.8.0" + }, + "license": "AGPL-3.0" +} diff --git a/packages/mcp-server/src/db.ts b/packages/mcp-server/src/db.ts new file mode 100644 index 00000000..d20e6a92 --- /dev/null +++ b/packages/mcp-server/src/db.ts @@ -0,0 +1,168 @@ +/** + * Database connection management for MCP Server + * Provides read-only SQLite access to ChatLab databases + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +// Database directory +let DB_DIR: string = '' + +// Connection cache +const dbCache = new Map() + +/** + * Initialize the database directory + */ +export function initDbDir(dir: string): void { + DB_DIR = dir +} + +/** + * Get the database directory + */ +export function getDbDir(): string { + return DB_DIR +} + +/** + * Get the database file path for a session + */ +export function getDbPath(sessionId: string): string { + return path.join(DB_DIR, `${sessionId}.db`) +} + +/** + * Open a database connection (read-only, cached) + */ +export function openDatabase(sessionId: string): Database.Database | null { + if (dbCache.has(sessionId)) { + return dbCache.get(sessionId)! + } + + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + return null + } + + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + + dbCache.set(sessionId, db) + return db +} + +/** + * Close a specific database connection + */ +export function closeDatabase(sessionId: string): void { + const db = dbCache.get(sessionId) + if (db) { + db.close() + dbCache.delete(sessionId) + } +} + +/** + * Close all database connections + */ +export function closeAllDatabases(): void { + for (const [sessionId, db] of dbCache.entries()) { + db.close() + dbCache.delete(sessionId) + } +} + +/** + * Session metadata from the meta table + */ +export interface SessionInfo { + sessionId: string + name: string + platform: string + type: string + importedAt: number + messageCount: number + memberCount: number + timeRange: { start: number; end: number } | null +} + +/** + * List all available chat sessions by scanning the database directory + */ +export function listSessions(): SessionInfo[] { + if (!DB_DIR || !fs.existsSync(DB_DIR)) { + return [] + } + + const files = fs.readdirSync(DB_DIR).filter((f) => f.endsWith('.db')) + const sessions: SessionInfo[] = [] + + for (const file of files) { + const sessionId = file.replace('.db', '') + try { + const db = openDatabase(sessionId) + if (!db) continue + + // Read meta + const meta = db.prepare('SELECT name, platform, type, imported_at FROM meta LIMIT 1').get() as { + name: string + platform: string + type: string + imported_at: number + } | undefined + + if (!meta) continue + + // Get message count + const msgCount = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + + // Get member count + const memberCount = db.prepare('SELECT COUNT(*) as count FROM member').get() as { count: number } + + // Get time range + const timeRange = db.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message').get() as { + start: number | null + end: number | null + } + + sessions.push({ + sessionId, + name: meta.name, + platform: meta.platform, + type: meta.type, + importedAt: meta.imported_at, + messageCount: msgCount.count, + memberCount: memberCount.count, + timeRange: timeRange.start !== null && timeRange.end !== null + ? { start: timeRange.start, end: timeRange.end } + : null, + }) + } catch { + // Skip invalid databases + continue + } + } + + return sessions +} + +/** + * Resolve the default ChatLab database directory based on platform + */ +export function getDefaultDbDir(): string { + const platform = os.platform() + + if (platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'ChatLab', 'data', 'databases') + } else if (platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + return path.join(appData, 'ChatLab', 'data', 'databases') + } else { + // Linux and others + return path.join(os.homedir(), '.config', 'ChatLab', 'data', 'databases') + } +} diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts new file mode 100644 index 00000000..36319cb4 --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,184 @@ +/** + * HTTP/SSE transport + REST API + * Provides both MCP protocol over SSE and direct REST API endpoints + */ + +import express from 'express' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' +import { openDatabase, listSessions } from './db.js' +import * as queries from './queries.js' + +/** + * Start the HTTP server with SSE transport and REST API + */ +export async function startHttpServer(server: McpServer, port: number): Promise { + const app = express() + app.use(express.json()) + + // ==================== MCP SSE Transport ==================== + + // Store active transports + const transports = new Map() + + app.get('/sse', async (req, res) => { + const transport = new SSEServerTransport('/messages', res) + const sessionId = transport.sessionId + transports.set(sessionId, transport) + + res.on('close', () => { + transports.delete(sessionId) + }) + + await server.connect(transport) + }) + + app.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId as string + const transport = transports.get(sessionId) + + if (!transport) { + res.status(404).json({ error: 'SSE session not found' }) + return + } + + await transport.handlePostMessage(req, res) + }) + + // ==================== REST API ==================== + + // List all sessions + app.get('/api/v1/sessions', (_req, res) => { + const sessions = listSessions() + res.json({ sessions }) + }) + + // Get session members + app.get('/api/v1/sessions/:id/members', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + const members = queries.getMembers(db) + res.json({ members }) + }) + + // Search messages + app.get('/api/v1/sessions/:id/messages', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const keywords = req.query.keywords + ? String(req.query.keywords).split(',').map((k) => k.trim()).filter(Boolean) + : [] + const limit = req.query.limit ? Number(req.query.limit) : 30 + const senderId = req.query.sender_id ? Number(req.query.sender_id) : undefined + + const result = queries.searchMessages(db, keywords, { senderId, limit }) + res.json(result) + }) + + // Get recent messages + app.get('/api/v1/sessions/:id/recent', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const limit = req.query.limit ? Number(req.query.limit) : 50 + const result = queries.getRecentMessages(db, { limit }) + res.json(result) + }) + + // Get member stats + app.get('/api/v1/sessions/:id/member-stats', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const topN = req.query.top_n ? Number(req.query.top_n) : 10 + const result = queries.getMemberActivity(db) + res.json({ members: result.slice(0, topN), total: result.length }) + }) + + // Get time stats + app.get('/api/v1/sessions/:id/stats/:type', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const { type } = req.params + let data: unknown + + switch (type) { + case 'hourly': + data = queries.getHourlyActivity(db) + break + case 'daily': + data = queries.getDailyActivity(db) + break + case 'weekday': + data = queries.getWeekdayActivity(db) + break + case 'monthly': + data = queries.getMonthlyActivity(db) + break + default: + res.status(400).json({ error: `Unknown stats type: ${type}. Valid: hourly, daily, weekday, monthly` }) + return + } + + res.json({ type, data }) + }) + + // Execute SQL + app.post('/api/v1/sessions/:id/sql', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const { sql } = req.body + if (!sql || typeof sql !== 'string') { + res.status(400).json({ error: 'Missing "sql" in request body' }) + return + } + + try { + const result = queries.executeRawSQL(db, sql) + res.json(result) + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + // Get schema + app.get('/api/v1/sessions/:id/schema', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const schema = queries.getSchema(db) + res.json({ schema }) + }) + + // ==================== Start Server ==================== + + app.listen(port, () => { + console.error(`[ChatLab MCP] HTTP server listening on http://localhost:${port}`) + console.error(`[ChatLab MCP] SSE endpoint: http://localhost:${port}/sse`) + console.error(`[ChatLab MCP] REST API: http://localhost:${port}/api/v1/`) + }) +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 00000000..c3c83148 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +/** + * ChatLab MCP Server entry point + * + * Usage: + * chatlab-mcp [--db-dir ] [--http] [--port ] + * + * Options: + * --db-dir Path to the ChatLab databases directory + * --http Start HTTP/SSE server instead of stdio + * --port HTTP server port (default: 3000) + * + * Environment variables: + * CHATLAB_DB_DIR Alternative way to specify the database directory + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { initDbDir, getDefaultDbDir } from './db.js' +import { createServer } from './server.js' +import { startHttpServer } from './http.js' + +// Parse command line arguments +function parseArgs(): { dbDir: string; http: boolean; port: number } { + const args = process.argv.slice(2) + let dbDir = '' + let http = false + let port = 3000 + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--db-dir': + dbDir = args[++i] || '' + break + case '--http': + http = true + break + case '--port': + port = parseInt(args[++i] || '3000', 10) + break + } + } + + // Fallback to environment variable + if (!dbDir) { + dbDir = process.env.CHATLAB_DB_DIR || '' + } + + // Fallback to default platform path + if (!dbDir) { + dbDir = getDefaultDbDir() + } + + return { dbDir, http, port } +} + +async function main(): Promise { + const { dbDir, http, port } = parseArgs() + + // Initialize database directory + initDbDir(dbDir) + console.error(`[ChatLab MCP] Database directory: ${dbDir}`) + + // Create MCP server + const server = createServer() + + if (http) { + // HTTP/SSE mode + await startHttpServer(server, port) + } else { + // Stdio mode (default) + const transport = new StdioServerTransport() + await server.connect(transport) + console.error('[ChatLab MCP] Server running on stdio') + } +} + +main().catch((error) => { + console.error('[ChatLab MCP] Fatal error:', error) + process.exit(1) +}) diff --git a/packages/mcp-server/src/queries.ts b/packages/mcp-server/src/queries.ts new file mode 100644 index 00000000..7b29d551 --- /dev/null +++ b/packages/mcp-server/src/queries.ts @@ -0,0 +1,684 @@ +/** + * Core SQL query functions for MCP Server + * Extracted from electron/main/worker/query/ modules + * + * All functions receive a Database instance directly (no session management). + */ + +import type Database from 'better-sqlite3' + +// ==================== Types ==================== + +export interface TimeFilter { + startTs?: number + endTs?: number + memberId?: number | null +} + +export interface MessageResult { + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number +} + +export interface MessagesWithTotal { + messages: MessageResult[] + total: number +} + +export interface MemberInfo { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string[] + messageCount: number +} + +export interface MemberActivity { + memberId: number + platformId: string + name: string + messageCount: number + percentage: number +} + +export interface HourlyActivity { + hour: number + messageCount: number +} + +export interface DailyActivity { + date: string + messageCount: number +} + +export interface WeekdayActivity { + weekday: number + messageCount: number +} + +export interface MonthlyActivity { + month: number + messageCount: number +} + +export interface NameHistoryEntry { + nameType: string + name: string + startTs: number + endTs: number | null +} + +export interface SQLResult { + columns: string[] + rows: unknown[][] + rowCount: number + duration: number +} + +export interface TableSchema { + name: string + columns: { + name: string + type: string + notnull: boolean + pk: boolean + }[] +} + +// ==================== Time Filter Utilities ==================== + +function buildTimeFilter( + filter?: TimeFilter, + tableAlias?: string +): { clause: string; params: (number | string)[] } { + const conditions: string[] = [] + const params: (number | string)[] = [] + + const tsColumn = tableAlias ? `${tableAlias}.ts` : 'ts' + const senderIdColumn = tableAlias ? `${tableAlias}.sender_id` : 'sender_id' + + if (filter?.startTs !== undefined) { + conditions.push(`${tsColumn} >= ?`) + params.push(filter.startTs) + } + if (filter?.endTs !== undefined) { + conditions.push(`${tsColumn} <= ?`) + params.push(filter.endTs) + } + if (filter?.memberId !== undefined && filter?.memberId !== null) { + conditions.push(`${senderIdColumn} = ?`) + params.push(filter.memberId) + } + + return { + clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', + params, + } +} + +function buildSystemMessageFilter(existingClause: string): string { + const systemFilter = "COALESCE(m.account_name, '') != '系统消息'" + if (existingClause.includes('WHERE')) { + return existingClause + ' AND ' + systemFilter + } else { + return ' WHERE ' + systemFilter + } +} + +// ==================== Message Row Processing ==================== + +interface DbMessageRow { + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number +} + +function sanitizeMessageRow(row: DbMessageRow): MessageResult { + return { + id: Number(row.id), + senderId: Number(row.senderId), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + } +} + +// Exclude system messages +const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'" + +// Text-only filter +const TEXT_ONLY_FILTER = "AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != ''" + +// ==================== Query Functions ==================== + +/** + * Search messages by keywords + */ +export function searchMessages( + db: Database.Database, + keywords: string[], + options?: { + senderId?: number + limit?: number + offset?: number + timeFilter?: TimeFilter + } +): MessagesWithTotal { + const limit = options?.limit ?? 50 + const offset = options?.offset ?? 0 + + // Build keyword condition + let keywordCondition = '1=1' + const keywordParams: string[] = [] + if (keywords.length > 0) { + keywordCondition = `(${keywords.map(() => `msg.content LIKE ?`).join(' OR ')})` + keywordParams.push(...keywords.map((k) => `%${k}%`)) + } + + // Build time filter + const { clause: timeClause, params: timeParams } = buildTimeFilter(options?.timeFilter, 'msg') + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + // Build sender filter + const senderCondition = options?.senderId !== undefined ? 'AND msg.sender_id = ?' : '' + const senderParams: number[] = options?.senderId !== undefined ? [options.senderId] : [] + + // Count total + const countSql = ` + SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${keywordCondition} + ${timeCondition} + ${senderCondition} + ` + const totalRow = db.prepare(countSql).get(...keywordParams, ...timeParams, ...senderParams) as { total: number } + const total = totalRow?.total || 0 + + // Query messages + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${keywordCondition} + ${timeCondition} + ${senderCondition} + ORDER BY msg.ts DESC + LIMIT ? OFFSET ? + ` + + const rows = db.prepare(sql).all( + ...keywordParams, ...timeParams, ...senderParams, limit, offset + ) as DbMessageRow[] + + return { + messages: rows.map(sanitizeMessageRow), + total, + } +} + +/** + * Get recent messages (text only, for AI analysis) + */ +export function getRecentMessages( + db: Database.Database, + options?: { limit?: number; timeFilter?: TimeFilter } +): MessagesWithTotal { + const limit = options?.limit ?? 100 + + const { clause: timeClause, params: timeParams } = buildTimeFilter(options?.timeFilter, 'msg') + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + // Count total + const countSql = ` + SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 + ${timeCondition} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} + ` + const totalRow = db.prepare(countSql).get(...timeParams) as { total: number } + const total = totalRow?.total || 0 + + // Query recent messages (DESC then reverse for chronological order) + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 + ${timeCondition} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} + ORDER BY msg.ts DESC + LIMIT ? + ` + + const rows = db.prepare(sql).all(...timeParams, limit) as DbMessageRow[] + return { + messages: rows.map(sanitizeMessageRow).reverse(), + total, + } +} + +/** + * Get message context (surrounding messages) + */ +export function getMessageContext( + db: Database.Database, + messageId: number, + contextSize: number = 20 +): MessageResult[] { + const contextIds = new Set() + contextIds.add(messageId) + + // Get messages before + const beforeRows = db.prepare( + 'SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?' + ).all(messageId, contextSize) as { id: number }[] + beforeRows.forEach((row) => contextIds.add(row.id)) + + // Get messages after + const afterRows = db.prepare( + 'SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?' + ).all(messageId, contextSize) as { id: number }[] + afterRows.forEach((row) => contextIds.add(row.id)) + + if (contextIds.size === 0) return [] + + const idList = Array.from(contextIds) + const placeholders = idList.map(() => '?').join(', ') + + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.id IN (${placeholders}) + ORDER BY msg.id ASC + ` + + const rows = db.prepare(sql).all(...idList) as DbMessageRow[] + return rows.map(sanitizeMessageRow) +} + +/** + * Get conversation between two members + */ +export function getConversationBetween( + db: Database.Database, + memberId1: number, + memberId2: number, + options?: { limit?: number; timeFilter?: TimeFilter } +): MessagesWithTotal & { member1Name: string; member2Name: string } { + const limit = options?.limit ?? 100 + + // Get member names + const getMemberName = (id: number) => { + const row = db.prepare( + "SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?" + ).get(id) as { name: string } | undefined + return row?.name || '' + } + + const member1Name = getMemberName(memberId1) + const member2Name = getMemberName(memberId2) + + if (!member1Name || !member2Name) { + return { messages: [], total: 0, member1Name: '', member2Name: '' } + } + + const { clause: timeClause, params: timeParams } = buildTimeFilter(options?.timeFilter, 'msg') + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + // Count + const countSql = ` + SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.sender_id IN (?, ?) + ${timeCondition} + AND msg.content IS NOT NULL AND msg.content != '' + ` + const totalRow = db.prepare(countSql).get(memberId1, memberId2, ...timeParams) as { total: number } + + // Query + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.sender_id IN (?, ?) + ${timeCondition} + AND msg.content IS NOT NULL AND msg.content != '' + ORDER BY msg.ts DESC + LIMIT ? + ` + + const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as DbMessageRow[] + + return { + messages: rows.map(sanitizeMessageRow).reverse(), + total: totalRow?.total || 0, + member1Name, + member2Name, + } +} + +/** + * Get all members with message counts + */ +export function getMembers(db: Database.Database): MemberInfo[] { + const rows = db.prepare(` + SELECT + m.id, + m.platform_id as platformId, + m.account_name as accountName, + m.group_nickname as groupNickname, + CASE WHEN EXISTS(SELECT 1 FROM pragma_table_info('member') WHERE name='aliases') THEN m.aliases ELSE NULL END as aliases, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id + WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息' + GROUP BY m.id + ORDER BY messageCount DESC + `).all() as Array<{ + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + messageCount: number + }> + + return rows.map((row) => ({ + id: row.id, + platformId: row.platformId, + accountName: row.accountName, + groupNickname: row.groupNickname, + aliases: row.aliases ? (() => { try { return JSON.parse(row.aliases!) } catch { return [] } })() : [], + messageCount: row.messageCount, + })) +} + +/** + * Get member activity ranking + */ +export function getMemberActivity( + db: Database.Database, + timeFilter?: TimeFilter +): MemberActivity[] { + const { clause, params } = buildTimeFilter(timeFilter) + const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' + const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" + + const totalClauseWithSystem = buildSystemMessageFilter(clause) + const totalMessages = ( + db.prepare( + `SELECT COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${totalClauseWithSystem}` + ).get(...params) as { count: number } + ).count + + const rows = db.prepare(` + SELECT + m.id as memberId, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} + WHERE COALESCE(m.account_name, '') != '系统消息' + GROUP BY m.id + HAVING messageCount > 0 + ORDER BY messageCount DESC + `).all(...params) as Array<{ + memberId: number + platformId: string + name: string + messageCount: number + }> + + return rows.map((row) => ({ + memberId: row.memberId, + platformId: row.platformId, + name: row.name, + messageCount: row.messageCount, + percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0, + })) +} + +/** + * Get hourly activity distribution + */ +export function getHourlyActivity( + db: Database.Database, + timeFilter?: TimeFilter +): HourlyActivity[] { + const { clause, params } = buildTimeFilter(timeFilter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db.prepare(` + SELECT + CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY hour + ORDER BY hour + `).all(...params) as Array<{ hour: number; messageCount: number }> + + const result: HourlyActivity[] = [] + for (let h = 0; h < 24; h++) { + const found = rows.find((r) => r.hour === h) + result.push({ hour: h, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * Get daily activity trend + */ +export function getDailyActivity( + db: Database.Database, + timeFilter?: TimeFilter +): DailyActivity[] { + const { clause, params } = buildTimeFilter(timeFilter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + return db.prepare(` + SELECT + strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY date + ORDER BY date + `).all(...params) as DailyActivity[] +} + +/** + * Get weekday activity distribution + */ +export function getWeekdayActivity( + db: Database.Database, + timeFilter?: TimeFilter +): WeekdayActivity[] { + const { clause, params } = buildTimeFilter(timeFilter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db.prepare(` + SELECT + CASE + WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7 + ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) + END as weekday, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY weekday + ORDER BY weekday + `).all(...params) as Array<{ weekday: number; messageCount: number }> + + const result: WeekdayActivity[] = [] + for (let w = 1; w <= 7; w++) { + const found = rows.find((r) => r.weekday === w) + result.push({ weekday: w, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * Get monthly activity distribution + */ +export function getMonthlyActivity( + db: Database.Database, + timeFilter?: TimeFilter +): MonthlyActivity[] { + const { clause, params } = buildTimeFilter(timeFilter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db.prepare(` + SELECT + CAST(strftime('%m', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as month, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY month + ORDER BY month + `).all(...params) as Array<{ month: number; messageCount: number }> + + const result: MonthlyActivity[] = [] + for (let m = 1; m <= 12; m++) { + const found = rows.find((r) => r.month === m) + result.push({ month: m, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * Get member name history + */ +export function getMemberNameHistory( + db: Database.Database, + memberId: number +): NameHistoryEntry[] { + return db.prepare(` + SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs + FROM member_name_history + WHERE member_id = ? + ORDER BY start_ts DESC + `).all(memberId) as NameHistoryEntry[] +} + +/** + * Execute a read-only SQL query + */ +export function executeRawSQL(db: Database.Database, sql: string): SQLResult { + const trimmedSQL = sql.trim() + + // Only allow SELECT statements + if (!trimmedSQL.toUpperCase().startsWith('SELECT')) { + throw new Error('Only SELECT queries are allowed') + } + + const startTime = Date.now() + + const stmt = db.prepare(trimmedSQL) + + // Safety: enforce read-only + if (!stmt.readonly) { + throw new Error('Only read-only statements are allowed') + } + + const rows = stmt.all() + const duration = Date.now() - startTime + const columns = stmt.columns().map((col) => col.name) + const rowData = rows.map((row: any) => columns.map((col) => row[col])) + + return { + columns, + rows: rowData, + rowCount: rows.length, + duration, + } +} + +/** + * Get database schema (tables and columns) + */ +export function getSchema(db: Database.Database): TableSchema[] { + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).all() as { name: string }[] + + const schema: TableSchema[] = [] + + for (const table of tables) { + const columns = db.prepare(`PRAGMA table_info('${table.name}')`).all() as Array<{ + cid: number + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + }> + + schema.push({ + name: table.name, + columns: columns.map((col) => ({ + name: col.name, + type: col.type, + notnull: col.notnull === 1, + pk: col.pk === 1, + })), + }) + } + + return schema +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 00000000..f77fb7b5 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,21 @@ +/** + * MCP Server factory + * Creates and configures the McpServer instance with all tools registered + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { registerAllTools } from './tools/index.js' + +/** + * Create a configured MCP Server instance + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: 'ChatLab', + version: '0.1.0', + }) + + registerAllTools(server) + + return server +} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts new file mode 100644 index 00000000..29a66cfb --- /dev/null +++ b/packages/mcp-server/src/tools/index.ts @@ -0,0 +1,21 @@ +/** + * MCP tools registry - aggregates all tool registration functions + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { registerSessionTools } from './session.js' +import { registerMemberTools } from './members.js' +import { registerMessageTools } from './messages.js' +import { registerStatsTools } from './stats.js' +import { registerSqlTools } from './sql.js' + +/** + * Register all MCP tools on the server + */ +export function registerAllTools(server: McpServer): void { + registerSessionTools(server) + registerMemberTools(server) + registerMessageTools(server) + registerStatsTools(server) + registerSqlTools(server) +} diff --git a/packages/mcp-server/src/tools/members.ts b/packages/mcp-server/src/tools/members.ts new file mode 100644 index 00000000..d146b39a --- /dev/null +++ b/packages/mcp-server/src/tools/members.ts @@ -0,0 +1,104 @@ +/** + * Member query MCP tools + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { openDatabase } from '../db.js' +import * as queries from '../queries.js' + +export function registerMemberTools(server: McpServer): void { + server.tool( + 'get_members', + 'Get all members in a chat session with their message counts. Useful for finding member IDs needed by other tools.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + }, + async ({ session_id }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const members = queries.getMembers(db) + + if (members.length === 0) { + return { content: [{ type: 'text', text: 'No members found.' }] } + } + + const lines = members.map((m) => { + const name = m.groupNickname || m.accountName || m.platformId + const aliasStr = m.aliases.length > 0 ? ` (aliases: ${m.aliases.join(', ')})` : '' + return `ID: ${m.id} | ${name}${aliasStr} | ${m.messageCount} messages` + }) + + return { + content: [{ type: 'text', text: `${members.length} members:\n\n${lines.join('\n')}` }], + } + } + ) + + server.tool( + 'get_member_stats', + 'Get member activity ranking - who sends the most messages. Shows top N members with message counts and percentages.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + top_n: z.number().optional().default(10).describe('Number of top members to return (default: 10)'), + }, + async ({ session_id, top_n }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.getMemberActivity(db) + const topMembers = result.slice(0, top_n) + + if (topMembers.length === 0) { + return { content: [{ type: 'text', text: 'No activity data found.' }] } + } + + const lines = topMembers.map( + (m, i) => `${i + 1}. ${m.name} — ${m.messageCount} messages (${m.percentage}%)` + ) + + return { + content: [{ + type: 'text', + text: `Top ${topMembers.length} members (out of ${result.length} total):\n\n${lines.join('\n')}`, + }], + } + } + ) + + server.tool( + 'get_member_name_history', + 'Get the historical nicknames/names of a specific member. Useful for tracking identity changes over time.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + member_id: z.number().describe('The member ID (from get_members)'), + }, + async ({ session_id, member_id }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const history = queries.getMemberNameHistory(db, member_id) + + if (history.length === 0) { + return { content: [{ type: 'text', text: 'No name history found for this member.' }] } + } + + const lines = history.map((h) => { + const start = new Date(h.startTs * 1000).toISOString().split('T')[0] + const end = h.endTs ? new Date(h.endTs * 1000).toISOString().split('T')[0] : 'present' + return `${h.nameType}: "${h.name}" (${start} ~ ${end})` + }) + + return { + content: [{ type: 'text', text: `Name history (${history.length} entries):\n\n${lines.join('\n')}` }], + } + } + ) +} diff --git a/packages/mcp-server/src/tools/messages.ts b/packages/mcp-server/src/tools/messages.ts new file mode 100644 index 00000000..d595891e --- /dev/null +++ b/packages/mcp-server/src/tools/messages.ts @@ -0,0 +1,138 @@ +/** + * Message search and retrieval MCP tools + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { openDatabase } from '../db.js' +import * as queries from '../queries.js' +import { formatTimestamp } from '../utils/format.js' + +function formatMessages(messages: queries.MessageResult[]): string { + return messages + .map((m) => { + const time = formatTimestamp(m.timestamp) + const content = m.content || '[non-text message]' + return `[${time}] ${m.senderName}: ${content}` + }) + .join('\n') +} + +export function registerMessageTools(server: McpServer): void { + server.tool( + 'search_messages', + 'Search chat messages by keywords. Returns matching messages with sender and timestamp. Keywords are matched with OR logic (any keyword matches).', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + keywords: z.array(z.string()).describe('Keywords to search for (OR logic)'), + sender_id: z.number().optional().describe('Optional: filter by sender member ID'), + limit: z.number().optional().default(30).describe('Max results to return (default: 30)'), + }, + async ({ session_id, keywords, sender_id, limit }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.searchMessages(db, keywords, { senderId: sender_id, limit }) + + if (result.messages.length === 0) { + return { content: [{ type: 'text', text: `No messages found matching: ${keywords.join(', ')}` }] } + } + + const text = [ + `Found ${result.total} messages (showing ${result.messages.length}):`, + '', + formatMessages(result.messages), + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) + + server.tool( + 'get_recent_messages', + 'Get the most recent messages in a chat session. Returns text messages in chronological order.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + limit: z.number().optional().default(50).describe('Number of messages to return (default: 50)'), + }, + async ({ session_id, limit }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.getRecentMessages(db, { limit }) + + if (result.messages.length === 0) { + return { content: [{ type: 'text', text: 'No messages found.' }] } + } + + const text = [ + `Recent messages (${result.messages.length} of ${result.total} total):`, + '', + formatMessages(result.messages), + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) + + server.tool( + 'get_message_context', + 'Get messages surrounding a specific message ID. Useful for understanding the context of a conversation.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + message_id: z.number().describe('The message ID to get context for'), + context_size: z.number().optional().default(20).describe('Number of messages before and after (default: 20)'), + }, + async ({ session_id, message_id, context_size }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const messages = queries.getMessageContext(db, message_id, context_size) + + if (messages.length === 0) { + return { content: [{ type: 'text', text: `Message ${message_id} not found.` }] } + } + + return { + content: [{ type: 'text', text: `Context around message ${message_id} (${messages.length} messages):\n\n${formatMessages(messages)}` }], + } + } + ) + + server.tool( + 'get_conversation_between', + 'Get the conversation between two specific members. Shows messages sent by either member in chronological order.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + member_id_1: z.number().describe('First member ID (from get_members)'), + member_id_2: z.number().describe('Second member ID (from get_members)'), + limit: z.number().optional().default(50).describe('Max messages to return (default: 50)'), + }, + async ({ session_id, member_id_1, member_id_2, limit }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.getConversationBetween(db, member_id_1, member_id_2, { limit }) + + if (result.messages.length === 0) { + return { content: [{ type: 'text', text: 'No conversation found between these members.' }] } + } + + const text = [ + `Conversation between ${result.member1Name} and ${result.member2Name} (${result.messages.length} of ${result.total} messages):`, + '', + formatMessages(result.messages), + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) +} diff --git a/packages/mcp-server/src/tools/session.ts b/packages/mcp-server/src/tools/session.ts new file mode 100644 index 00000000..b428e934 --- /dev/null +++ b/packages/mcp-server/src/tools/session.ts @@ -0,0 +1,43 @@ +/** + * Session management MCP tools + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { listSessions } from '../db.js' +import { formatTimestamp } from '../utils/format.js' + +export function registerSessionTools(server: McpServer): void { + server.tool( + 'list_sessions', + 'List all available chat sessions (imported chat histories). Returns session IDs, names, platforms, and basic statistics.', + {}, + async () => { + const sessions = listSessions() + + if (sessions.length === 0) { + return { + content: [{ type: 'text', text: 'No chat sessions found. Make sure the database directory is correct and contains .db files.' }], + } + } + + const lines = sessions.map((s) => { + const timeInfo = s.timeRange + ? `${formatTimestamp(s.timeRange.start)} ~ ${formatTimestamp(s.timeRange.end)}` + : 'N/A' + return [ + `Session ID: ${s.sessionId}`, + ` Name: ${s.name}`, + ` Platform: ${s.platform}`, + ` Type: ${s.type}`, + ` Messages: ${s.messageCount}`, + ` Members: ${s.memberCount}`, + ` Time Range: ${timeInfo}`, + ].join('\n') + }) + + return { + content: [{ type: 'text', text: `Found ${sessions.length} session(s):\n\n${lines.join('\n\n')}` }], + } + } + ) +} diff --git a/packages/mcp-server/src/tools/sql.ts b/packages/mcp-server/src/tools/sql.ts new file mode 100644 index 00000000..9ef77e64 --- /dev/null +++ b/packages/mcp-server/src/tools/sql.ts @@ -0,0 +1,83 @@ +/** + * SQL query and schema MCP tools + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { openDatabase } from '../db.js' +import * as queries from '../queries.js' + +export function registerSqlTools(server: McpServer): void { + server.tool( + 'execute_sql', + 'Execute a read-only SQL query against a chat database. Only SELECT statements are allowed. Use get_schema first to understand the table structure.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + sql: z.string().describe('SQL SELECT query to execute'), + }, + async ({ session_id, sql }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + try { + const result = queries.executeRawSQL(db, sql) + + if (result.rowCount === 0) { + return { content: [{ type: 'text', text: 'Query returned no results.' }] } + } + + // Format as table + const header = result.columns.join(' | ') + const separator = result.columns.map(() => '---').join(' | ') + const rows = result.rows.map((row) => + row.map((cell) => (cell === null ? 'NULL' : String(cell))).join(' | ') + ) + + const text = [ + `${result.rowCount} rows (${result.duration}ms):`, + '', + header, + separator, + ...rows, + ].join('\n') + + return { content: [{ type: 'text', text }] } + } catch (error) { + return { + content: [{ type: 'text', text: `SQL Error: ${error instanceof Error ? error.message : String(error)}` }], + isError: true, + } + } + } + ) + + server.tool( + 'get_schema', + 'Get the database schema (tables and columns) of a chat session. Useful before writing custom SQL queries.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + }, + async ({ session_id }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const schema = queries.getSchema(db) + + const lines = schema.map((table) => { + const cols = table.columns.map((col) => { + const flags = [col.pk ? 'PK' : '', col.notnull ? 'NOT NULL' : ''].filter(Boolean).join(', ') + return ` ${col.name} ${col.type}${flags ? ` (${flags})` : ''}` + }) + return `${table.name}:\n${cols.join('\n')}` + }) + + return { + content: [{ type: 'text', text: `Database schema:\n\n${lines.join('\n\n')}` }], + } + } + ) +} diff --git a/packages/mcp-server/src/tools/stats.ts b/packages/mcp-server/src/tools/stats.ts new file mode 100644 index 00000000..715ec753 --- /dev/null +++ b/packages/mcp-server/src/tools/stats.ts @@ -0,0 +1,76 @@ +/** + * Statistics and analytics MCP tools + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { openDatabase } from '../db.js' +import * as queries from '../queries.js' + +const WEEKDAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] +const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + +export function registerStatsTools(server: McpServer): void { + server.tool( + 'get_time_stats', + 'Get message activity statistics by time dimension: hourly (0-23h distribution), daily (day-by-day trend), weekday (Mon-Sun), or monthly (Jan-Dec).', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + type: z.enum(['hourly', 'daily', 'weekday', 'monthly']).describe('Type of time statistics'), + }, + async ({ session_id, type }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + let text: string + + switch (type) { + case 'hourly': { + const data = queries.getHourlyActivity(db) + const lines = data.map((d) => `${String(d.hour).padStart(2, '0')}:00 — ${d.messageCount} messages`) + const peak = data.reduce((a, b) => (a.messageCount > b.messageCount ? a : b)) + text = `Hourly activity distribution:\n\n${lines.join('\n')}\n\nPeak hour: ${peak.hour}:00 (${peak.messageCount} messages)` + break + } + case 'daily': { + const data = queries.getDailyActivity(db) + if (data.length === 0) { + text = 'No daily activity data.' + break + } + const total = data.reduce((sum, d) => sum + d.messageCount, 0) + const avg = Math.round(total / data.length) + const peak = data.reduce((a, b) => (a.messageCount > b.messageCount ? a : b)) + // Show summary + last 30 days + const recent = data.slice(-30) + const lines = recent.map((d) => `${d.date} — ${d.messageCount} messages`) + text = [ + `Daily activity (${data.length} days total, showing last ${recent.length}):`, + `Average: ${avg} messages/day | Peak: ${peak.date} (${peak.messageCount})`, + '', + ...lines, + ].join('\n') + break + } + case 'weekday': { + const data = queries.getWeekdayActivity(db) + const lines = data.map((d) => `${WEEKDAY_NAMES[d.weekday]} — ${d.messageCount} messages`) + const peak = data.reduce((a, b) => (a.messageCount > b.messageCount ? a : b)) + text = `Weekday activity distribution:\n\n${lines.join('\n')}\n\nMost active: ${WEEKDAY_NAMES[peak.weekday]} (${peak.messageCount} messages)` + break + } + case 'monthly': { + const data = queries.getMonthlyActivity(db) + const lines = data.map((d) => `${MONTH_NAMES[d.month]} — ${d.messageCount} messages`) + const peak = data.reduce((a, b) => (a.messageCount > b.messageCount ? a : b)) + text = `Monthly activity distribution:\n\n${lines.join('\n')}\n\nMost active: ${MONTH_NAMES[peak.month]} (${peak.messageCount} messages)` + break + } + } + + return { content: [{ type: 'text', text }] } + } + ) +} diff --git a/packages/mcp-server/src/utils/format.ts b/packages/mcp-server/src/utils/format.ts new file mode 100644 index 00000000..dc995158 --- /dev/null +++ b/packages/mcp-server/src/utils/format.ts @@ -0,0 +1,25 @@ +/** + * Result formatting utilities for MCP Server + */ + +/** + * Format a unix timestamp to a human-readable date string + */ +export function formatTimestamp(ts: number): string { + return new Date(ts * 1000).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '') +} + +/** + * Format a date range for display + */ +export function formatDateRange(start: number, end: number): string { + return `${formatTimestamp(start)} ~ ${formatTimestamp(end)}` +} + +/** + * Truncate text to a maximum length + */ +export function truncate(text: string, maxLength: number = 200): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + '...' +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000..f4624bd2 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 243223e8d5559fa693169e9f02114cebe1be9fef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 09:42:23 +0000 Subject: [PATCH 2/6] chore: add package-lock.json and .gitignore for mcp-server https://claude.ai/code/session_01U2bFTH9Gme7yYN8q4gE6YB --- packages/mcp-server/.gitignore | 2 + packages/mcp-server/package-lock.json | 1709 +++++++++++++++++++++++++ 2 files changed, 1711 insertions(+) create mode 100644 packages/mcp-server/.gitignore create mode 100644 packages/mcp-server/package-lock.json diff --git a/packages/mcp-server/.gitignore b/packages/mcp-server/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/mcp-server/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json new file mode 100644 index 00000000..f42b6fce --- /dev/null +++ b/packages/mcp-server/package-lock.json @@ -0,0 +1,1709 @@ +{ + "name": "@chatlab/mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@chatlab/mcp-server", + "version": "0.1.0", + "license": "AGPL-3.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "better-sqlite3": "^12.4.6", + "express": "^5.1.0" + }, + "bin": { + "chatlab-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.0", + "typescript": "^5.8.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} From a7fc0bcbe9f23638da42a83ad002df0383bda262 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 14:59:34 +0000 Subject: [PATCH 3/6] feat: add MCP Server settings UI for start/stop and configuration Add settings section in Basic Settings tab to enable, configure, and control the MCP Server from within ChatLab. Supports HTTP/Stdio transport mode selection, port configuration, auto-start on app launch, and displays external tool configuration info for Claude Code/Cursor integration. https://claude.ai/code/session_01U2bFTH9Gme7yYN8q4gE6YB --- electron/main/ipc/mcp.ts | 283 +++++++++++++ electron/main/ipcMain.ts | 4 + electron/preload/apis/utils.ts | 44 ++ electron/preload/index.d.ts | 30 ++ electron/preload/index.ts | 5 +- src/i18n/locales/en-US/settings.json | 20 + src/i18n/locales/ja-JP/settings.json | 20 + src/i18n/locales/zh-CN/settings.json | 20 + src/i18n/locales/zh-TW/settings.json | 20 + .../settings/components/BasicSettingsTab.vue | 4 + .../settings/components/McpServerSection.vue | 388 ++++++++++++++++++ 11 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 electron/main/ipc/mcp.ts create mode 100644 src/pages/settings/components/McpServerSection.vue diff --git a/electron/main/ipc/mcp.ts b/electron/main/ipc/mcp.ts new file mode 100644 index 00000000..25e7461a --- /dev/null +++ b/electron/main/ipc/mcp.ts @@ -0,0 +1,283 @@ +/** + * MCP Server IPC 处理器 + * 管理 MCP Server 子进程的启动、停止和状态查询 + */ + +import { ipcMain } from 'electron' +import { fork, type ChildProcess } from 'child_process' +import * as path from 'path' +import * as fs from 'fs' +import { getDatabaseDir, getSettingsDir, ensureDir } from '../paths' +import type { IpcContext } from './types' + +/** MCP Server 配置 */ +interface McpServerConfig { + /** 是否启用 MCP Server */ + enabled: boolean + /** 传输模式:stdio 或 http */ + transport: 'stdio' | 'http' + /** HTTP 模式端口 */ + port: number + /** 是否随应用启动 */ + autoStart: boolean +} + +/** MCP Server 运行状态 */ +interface McpServerStatus { + running: boolean + pid?: number + transport?: 'stdio' | 'http' + port?: number + uptime?: number + error?: string +} + +const DEFAULT_CONFIG: McpServerConfig = { + enabled: false, + transport: 'http', + port: 3000, + autoStart: false, +} + +const CONFIG_FILE = 'mcp-server.json' + +let mcpProcess: ChildProcess | null = null +let mcpStartTime: number | null = null +let lastConfig: McpServerConfig = { ...DEFAULT_CONFIG } + +/** + * 获取 MCP Server 可执行文件路径 + */ +function getMcpServerEntry(): string { + // 在开发和生产环境下都查找 packages/mcp-server/dist/index.js + const candidates = [ + // 开发环境:从项目根目录 + path.join(__dirname, '..', '..', '..', 'packages', 'mcp-server', 'dist', 'index.js'), + // 生产环境:从 app.asar + path.join(process.resourcesPath || '', 'packages', 'mcp-server', 'dist', 'index.js'), + // 生产环境备选:extraResources + path.join(process.resourcesPath || '', 'mcp-server', 'dist', 'index.js'), + ] + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate + } + } + + throw new Error(`MCP Server entry not found. Searched: ${candidates.join(', ')}`) +} + +/** + * 读取配置 + */ +function loadConfig(): McpServerConfig { + const configPath = path.join(getSettingsDir(), CONFIG_FILE) + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(content) as Partial + lastConfig = { ...DEFAULT_CONFIG, ...parsed } + return lastConfig + } + } catch (error) { + console.error('[MCP] Failed to load config:', error) + } + lastConfig = { ...DEFAULT_CONFIG } + return lastConfig +} + +/** + * 保存配置 + */ +function saveConfig(config: McpServerConfig): void { + const settingsDir = getSettingsDir() + ensureDir(settingsDir) + const configPath = path.join(settingsDir, CONFIG_FILE) + try { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') + lastConfig = config + } catch (error) { + console.error('[MCP] Failed to save config:', error) + throw error + } +} + +/** + * 启动 MCP Server + */ +function startServer(config: McpServerConfig): { success: boolean; error?: string } { + if (mcpProcess && !mcpProcess.killed) { + return { success: false, error: 'MCP Server is already running' } + } + + try { + const entryPath = getMcpServerEntry() + const dbDir = getDatabaseDir() + + const args = ['--db-dir', dbDir] + if (config.transport === 'http') { + args.push('--http', '--port', String(config.port)) + } + + mcpProcess = fork(entryPath, args, { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + CHATLAB_DB_DIR: dbDir, + }, + }) + + mcpStartTime = Date.now() + + mcpProcess.stdout?.on('data', (data: Buffer) => { + console.log('[MCP stdout]', data.toString().trim()) + }) + + mcpProcess.stderr?.on('data', (data: Buffer) => { + console.log('[MCP stderr]', data.toString().trim()) + }) + + mcpProcess.on('error', (err) => { + console.error('[MCP] Process error:', err) + mcpProcess = null + mcpStartTime = null + }) + + mcpProcess.on('exit', (code, signal) => { + console.log(`[MCP] Process exited (code=${code}, signal=${signal})`) + mcpProcess = null + mcpStartTime = null + }) + + console.log(`[MCP] Server started (pid=${mcpProcess.pid}, transport=${config.transport})`) + return { success: true } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + console.error('[MCP] Failed to start server:', msg) + return { success: false, error: msg } + } +} + +/** + * 停止 MCP Server + */ +function stopServer(): { success: boolean; error?: string } { + if (!mcpProcess || mcpProcess.killed) { + mcpProcess = null + mcpStartTime = null + return { success: true } + } + + try { + mcpProcess.kill('SIGTERM') + + // 5 秒后强制 kill + const forceKillTimer = setTimeout(() => { + if (mcpProcess && !mcpProcess.killed) { + console.warn('[MCP] Force killing server...') + mcpProcess.kill('SIGKILL') + } + }, 5000) + + mcpProcess.on('exit', () => { + clearTimeout(forceKillTimer) + }) + + mcpProcess = null + mcpStartTime = null + return { success: true } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + console.error('[MCP] Failed to stop server:', msg) + return { success: false, error: msg } + } +} + +/** + * 获取运行状态 + */ +function getStatus(): McpServerStatus { + if (mcpProcess && !mcpProcess.killed) { + return { + running: true, + pid: mcpProcess.pid, + transport: lastConfig.transport, + port: lastConfig.transport === 'http' ? lastConfig.port : undefined, + uptime: mcpStartTime ? Date.now() - mcpStartTime : undefined, + } + } + return { running: false } +} + +/** + * 注册 MCP Server IPC 处理器 + */ +export function registerMcpHandlers(_context: IpcContext): void { + console.log('[IpcMain] Registering MCP handlers...') + + // 获取配置 + ipcMain.handle('mcp:getConfig', (): McpServerConfig => { + return loadConfig() + }) + + // 保存配置 + ipcMain.handle( + 'mcp:saveConfig', + (_event, config: McpServerConfig): { success: boolean; error?: string } => { + try { + saveConfig(config) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + // 启动服务 + ipcMain.handle('mcp:start', (): { success: boolean; error?: string } => { + const config = loadConfig() + return startServer(config) + }) + + // 停止服务 + ipcMain.handle('mcp:stop', (): { success: boolean; error?: string } => { + return stopServer() + }) + + // 获取状态 + ipcMain.handle('mcp:getStatus', (): McpServerStatus => { + return getStatus() + }) + + // 获取 MCP Server 入口路径(用于外部配置 stdio 模式) + ipcMain.handle('mcp:getServerPath', (): { path: string; dbDir: string } | null => { + try { + return { + path: getMcpServerEntry(), + dbDir: getDatabaseDir(), + } + } catch { + return null + } + }) + + // 自动启动 + const config = loadConfig() + if (config.enabled && config.autoStart) { + console.log('[MCP] Auto-starting server...') + const result = startServer(config) + if (!result.success) { + console.error('[MCP] Auto-start failed:', result.error) + } + } + + console.log('[IpcMain] MCP handlers registered') +} + +/** + * 清理 MCP Server 进程 + */ +export function cleanupMcpServer(): void { + stopServer() +} diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index ca0205f6..a1a8231c 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -15,6 +15,7 @@ import { registerCacheHandlers } from './ipc/cache' import { registerNetworkHandlers } from './ipc/network' import { registerNlpHandlers } from './ipc/nlp' import { registerAnalyticsHandlers } from './analytics' +import { registerMcpHandlers, cleanupMcpServer } from './ipc/mcp' // 导入 Worker 模块(用于异步分析查询和流式导入) import * as worker from './worker/workerManager' @@ -48,6 +49,7 @@ const mainIpcMain = (win: BrowserWindow) => { registerNetworkHandlers(context) registerNlpHandlers(context) registerAnalyticsHandlers() + registerMcpHandlers(context) console.log('[IpcMain] All IPC handlers registered successfully') } @@ -57,6 +59,8 @@ export const cleanup = () => { try { // 关闭 Worker worker.closeWorker() + // 停止 MCP Server + cleanupMcpServer() // 清理临时数据库 cleanupTempDbs() } catch (error) { diff --git a/electron/preload/apis/utils.ts b/electron/preload/apis/utils.ts index edef559a..bd7d643e 100644 --- a/electron/preload/apis/utils.ts +++ b/electron/preload/apis/utils.ts @@ -214,6 +214,50 @@ export const cacheApi = { }, } +// ==================== MCP Server API ==================== + +export interface McpServerConfig { + enabled: boolean + transport: 'stdio' | 'http' + port: number + autoStart: boolean +} + +export interface McpServerStatus { + running: boolean + pid?: number + transport?: 'stdio' | 'http' + port?: number + uptime?: number + error?: string +} + +export const mcpApi = { + getConfig: (): Promise => { + return ipcRenderer.invoke('mcp:getConfig') + }, + + saveConfig: (config: McpServerConfig): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('mcp:saveConfig', config) + }, + + start: (): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('mcp:start') + }, + + stop: (): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('mcp:stop') + }, + + getStatus: (): Promise => { + return ipcRenderer.invoke('mcp:getStatus') + }, + + getServerPath: (): Promise<{ path: string; dbDir: string } | null> => { + return ipcRenderer.invoke('mcp:getServerPath') + }, +} + // ==================== Session API ==================== export const sessionApi = { diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index ca526f8e..72c015b9 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -949,6 +949,32 @@ interface SessionApi { > } +// MCP Server API 类型 +interface McpServerConfig { + enabled: boolean + transport: 'stdio' | 'http' + port: number + autoStart: boolean +} + +interface McpServerStatus { + running: boolean + pid?: number + transport?: 'stdio' | 'http' + port?: number + uptime?: number + error?: string +} + +interface McpApi { + getConfig: () => Promise + saveConfig: (config: McpServerConfig) => Promise<{ success: boolean; error?: string }> + start: () => Promise<{ success: boolean; error?: string }> + stop: () => Promise<{ success: boolean; error?: string }> + getStatus: () => Promise + getServerPath: () => Promise<{ path: string; dbDir: string } | null> +} + declare global { interface Window { electron: ElectronAPI @@ -965,6 +991,7 @@ declare global { networkApi: NetworkApi sessionApi: SessionApi nlpApi: NlpApi + mcpApi: McpApi } } @@ -1020,4 +1047,7 @@ export { SupportedLocale, PosFilterMode, PosTagInfo, + McpApi, + McpServerConfig, + McpServerStatus, } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 26abf4e3..21bca1f0 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -9,7 +9,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { extendedApi } from './apis/core' import { chatApi, mergeApi } from './apis/chat' import { aiApi, llmApi, agentApi, embeddingApi, assistantApi, skillApi } from './apis/ai' -import { nlpApi, networkApi, cacheApi, sessionApi } from './apis/utils' +import { nlpApi, networkApi, cacheApi, sessionApi, mcpApi } from './apis/utils' // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise @@ -30,6 +30,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('networkApi', networkApi) contextBridge.exposeInMainWorld('sessionApi', sessionApi) contextBridge.exposeInMainWorld('nlpApi', nlpApi) + contextBridge.exposeInMainWorld('mcpApi', mcpApi) } catch (error) { console.error(error) } @@ -62,4 +63,6 @@ if (process.contextIsolated) { window.sessionApi = sessionApi // @ts-ignore (define in dts) window.nlpApi = nlpApi + // @ts-ignore (define in dts) + window.mcpApi = mcpApi } diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index a4239095..b3b88aad 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -49,6 +49,26 @@ "invalidProxyUrl": "Please enter a valid proxy address, e.g. http://127.0.0.1:7890", "onlyHttpSupported": "Only http:// or https:// protocols are supported", "saveFailed": "Save failed" + }, + "mcp": { + "title": "MCP Server", + "enable": "Enable MCP Server", + "enableDesc": "Start MCP server to allow external AI tools like Claude Code, Cursor to access chat analysis capabilities", + "statusRunning": "Running", + "statusStopped": "Stopped", + "start": "Start", + "stop": "Stop", + "transport": "Transport", + "transportDesc": "HTTP mode provides REST API and SSE endpoint; Stdio mode is for direct pipe communication", + "port": "HTTP Port", + "invalidPort": "Port must be an integer between 1-65535", + "autoStart": "Start with App", + "autoStartDesc": "When enabled, MCP Server will start automatically when ChatLab launches", + "externalConfig": "External Tool Configuration", + "externalConfigDesc": "Use the following info to configure MCP Server in Claude Code, Cursor, etc. (Stdio mode)", + "serverEntry": "Server Entry", + "dbDirectory": "Database Directory", + "stdioCommand": "Stdio Command" } }, "aiConfig": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index dfcc6506..2c44b0c6 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -49,6 +49,26 @@ "invalidProxyUrl": "有効なプロキシアドレスを入力してください。形式例:http://127.0.0.1:7890", "onlyHttpSupported": "http:// または https:// プロトコルのみ対応", "saveFailed": "保存に失敗しました" + }, + "mcp": { + "title": "MCP Server", + "enable": "MCP Server を有効にする", + "enableDesc": "MCP サーバーを起動し、Claude Code や Cursor などの外部 AI ツールからチャット分析機能にアクセスできるようにします", + "statusRunning": "実行中", + "statusStopped": "停止中", + "start": "起動", + "stop": "停止", + "transport": "トランスポート", + "transportDesc": "HTTP モードは REST API と SSE エンドポイントを提供。Stdio モードはパイプ通信用", + "port": "HTTP ポート", + "invalidPort": "ポート番号は 1〜65535 の整数で入力してください", + "autoStart": "アプリ起動時に開始", + "autoStartDesc": "有効にすると、ChatLab 起動時に MCP Server が自動的に開始されます", + "externalConfig": "外部ツール設定情報", + "externalConfigDesc": "以下の情報を使って Claude Code や Cursor などで MCP Server を設定してください(Stdio モード)", + "serverEntry": "サーバーエントリ", + "dbDirectory": "データベースディレクトリ", + "stdioCommand": "Stdio 起動コマンド" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index e77fd42c..ba397a61 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -49,6 +49,26 @@ "invalidProxyUrl": "请输入有效的代理地址,格式如 http://127.0.0.1:7890", "onlyHttpSupported": "仅支持 http:// 或 https:// 协议", "saveFailed": "保存失败" + }, + "mcp": { + "title": "MCP Server", + "enable": "启用 MCP Server", + "enableDesc": "启动 MCP 服务,允许 Claude Code、Cursor 等外部 AI 工具访问聊天记录分析能力", + "statusRunning": "运行中", + "statusStopped": "已停止", + "start": "启动", + "stop": "停止", + "transport": "传输模式", + "transportDesc": "HTTP 模式提供 REST API 和 SSE 端点;Stdio 模式用于直接管道通信", + "port": "HTTP 端口", + "invalidPort": "端口号需为 1-65535 之间的整数", + "autoStart": "随应用启动", + "autoStartDesc": "开启后,MCP Server 会在 ChatLab 启动时自动运行", + "externalConfig": "外部工具配置信息", + "externalConfigDesc": "使用以下信息在 Claude Code、Cursor 等工具中配置 MCP Server(Stdio 模式)", + "serverEntry": "服务入口", + "dbDirectory": "数据库目录", + "stdioCommand": "Stdio 启动命令" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 563d2505..49526469 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -49,6 +49,26 @@ "invalidProxyUrl": "請輸入有效的代理地址,格式如 http://127.0.0.1:7890", "onlyHttpSupported": "僅支援 http:// 或 https:// 協定", "saveFailed": "儲存失敗" + }, + "mcp": { + "title": "MCP Server", + "enable": "啟用 MCP Server", + "enableDesc": "啟動 MCP 服務,允許 Claude Code、Cursor 等外部 AI 工具存取聊天記錄分析功能", + "statusRunning": "執行中", + "statusStopped": "已停止", + "start": "啟動", + "stop": "停止", + "transport": "傳輸模式", + "transportDesc": "HTTP 模式提供 REST API 和 SSE 端點;Stdio 模式用於直接管道通訊", + "port": "HTTP 連接埠", + "invalidPort": "連接埠需為 1-65535 之間的整數", + "autoStart": "隨應用程式啟動", + "autoStartDesc": "開啟後,MCP Server 會在 ChatLab 啟動時自動執行", + "externalConfig": "外部工具設定資訊", + "externalConfigDesc": "使用以下資訊在 Claude Code、Cursor 等工具中設定 MCP Server(Stdio 模式)", + "serverEntry": "服務入口", + "dbDirectory": "資料庫目錄", + "stdioCommand": "Stdio 啟動指令" } }, "aiConfig": { diff --git a/src/pages/settings/components/BasicSettingsTab.vue b/src/pages/settings/components/BasicSettingsTab.vue index 70288b70..1a0a1b88 100644 --- a/src/pages/settings/components/BasicSettingsTab.vue +++ b/src/pages/settings/components/BasicSettingsTab.vue @@ -7,6 +7,7 @@ import { useSettingsStore } from '@/stores/settings' import { useColorMode } from '@vueuse/core' import { availableLocales, type LocaleType } from '@/i18n' import NetworkSettingsSection from './NetworkSettingsSection.vue' +import McpServerSection from './McpServerSection.vue' import UITabs from '@/components/UI/Tabs.vue' const { t } = useI18n() @@ -119,5 +120,8 @@ watch( + + + diff --git a/src/pages/settings/components/McpServerSection.vue b/src/pages/settings/components/McpServerSection.vue new file mode 100644 index 00000000..b7ae6647 --- /dev/null +++ b/src/pages/settings/components/McpServerSection.vue @@ -0,0 +1,388 @@ + + + From f665cbf592bc0075d1b6d334a4c20169fa050f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 15:04:25 +0000 Subject: [PATCH 4/6] fix: use app.getAppPath() for MCP Server entry path resolution __dirname in electron-vite points to out/main/ which caused incorrect path traversal. app.getAppPath() reliably returns the project root in dev and the app.asar path in production. https://claude.ai/code/session_01U2bFTH9Gme7yYN8q4gE6YB --- electron/main/ipc/mcp.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/electron/main/ipc/mcp.ts b/electron/main/ipc/mcp.ts index 25e7461a..9cc623fb 100644 --- a/electron/main/ipc/mcp.ts +++ b/electron/main/ipc/mcp.ts @@ -3,7 +3,7 @@ * 管理 MCP Server 子进程的启动、停止和状态查询 */ -import { ipcMain } from 'electron' +import { ipcMain, app } from 'electron' import { fork, type ChildProcess } from 'child_process' import * as path from 'path' import * as fs from 'fs' @@ -49,13 +49,13 @@ let lastConfig: McpServerConfig = { ...DEFAULT_CONFIG } * 获取 MCP Server 可执行文件路径 */ function getMcpServerEntry(): string { - // 在开发和生产环境下都查找 packages/mcp-server/dist/index.js + // app.getAppPath() returns the project root in dev, or app.asar in production + const appRoot = app.getAppPath() const candidates = [ - // 开发环境:从项目根目录 - path.join(__dirname, '..', '..', '..', 'packages', 'mcp-server', 'dist', 'index.js'), - // 生产环境:从 app.asar + // 开发环境:项目根目录下的 packages/mcp-server/dist/index.js + path.join(appRoot, 'packages', 'mcp-server', 'dist', 'index.js'), + // 生产环境:extraResources 目录 path.join(process.resourcesPath || '', 'packages', 'mcp-server', 'dist', 'index.js'), - // 生产环境备选:extraResources path.join(process.resourcesPath || '', 'mcp-server', 'dist', 'index.js'), ] From e38630d314cc12b3c1bfeea409348f18b5b66d6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 15:21:08 +0000 Subject: [PATCH 5/6] feat: enhance MCP Server with security, new tools, resources, and API endpoints Security: - Bind HTTP server to 127.0.0.1 only (no external access) - Add API key authentication for REST API endpoints (Bearer token) - Add CORS headers for browser clients New MCP Tools: - get_session_overview: comprehensive session stats (messages, members, types, top users) - export_messages: export filtered messages as text/markdown/json - get_word_frequency: lightweight word frequency analysis (CJK n-gram + whitespace splitting) MCP Resources: - chatlab://sessions: browsable session list - chatlab://sessions/{id}/members: session members - chatlab://sessions/{id}/schema: database table structure New REST API Endpoints: - GET /api/v1/sessions/:id/overview - GET /api/v1/sessions/:id/export - GET /api/v1/sessions/:id/word-frequency Settings UI: - API Key input field for HTTP mode - Security warning when running without API key - REST API URL display when server is running https://claude.ai/code/session_01U2bFTH9Gme7yYN8q4gE6YB --- electron/main/ipc/mcp.ts | 6 + electron/preload/apis/utils.ts | 1 + electron/preload/index.d.ts | 1 + packages/mcp-server/src/http.ts | 99 ++++++++++++- packages/mcp-server/src/index.ts | 29 ++-- packages/mcp-server/src/queries.ts | 131 ++++++++++++++++++ packages/mcp-server/src/server.ts | 70 +++++++++- packages/mcp-server/src/tools/index.ts | 2 + packages/mcp-server/src/tools/messages.ts | 52 +++++-- packages/mcp-server/src/tools/nlp.ts | 41 ++++++ packages/mcp-server/src/tools/session.ts | 47 ++++++- packages/mcp-server/src/utils/format.ts | 28 ++++ src/i18n/locales/en-US/settings.json | 6 +- src/i18n/locales/ja-JP/settings.json | 6 +- src/i18n/locales/zh-CN/settings.json | 6 +- src/i18n/locales/zh-TW/settings.json | 6 +- .../settings/components/McpServerSection.vue | 60 ++++++++ 17 files changed, 561 insertions(+), 30 deletions(-) create mode 100644 packages/mcp-server/src/tools/nlp.ts diff --git a/electron/main/ipc/mcp.ts b/electron/main/ipc/mcp.ts index 9cc623fb..c09bfb9f 100644 --- a/electron/main/ipc/mcp.ts +++ b/electron/main/ipc/mcp.ts @@ -20,6 +20,8 @@ interface McpServerConfig { port: number /** 是否随应用启动 */ autoStart: boolean + /** API Key 认证(仅 HTTP 模式) */ + apiKey: string } /** MCP Server 运行状态 */ @@ -37,6 +39,7 @@ const DEFAULT_CONFIG: McpServerConfig = { transport: 'http', port: 3000, autoStart: false, + apiKey: '', } const CONFIG_FILE = 'mcp-server.json' @@ -118,6 +121,9 @@ function startServer(config: McpServerConfig): { success: boolean; error?: strin const args = ['--db-dir', dbDir] if (config.transport === 'http') { args.push('--http', '--port', String(config.port)) + if (config.apiKey) { + args.push('--api-key', config.apiKey) + } } mcpProcess = fork(entryPath, args, { diff --git a/electron/preload/apis/utils.ts b/electron/preload/apis/utils.ts index bd7d643e..6c164053 100644 --- a/electron/preload/apis/utils.ts +++ b/electron/preload/apis/utils.ts @@ -221,6 +221,7 @@ export interface McpServerConfig { transport: 'stdio' | 'http' port: number autoStart: boolean + apiKey: string } export interface McpServerStatus { diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 72c015b9..4fa9391c 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -955,6 +955,7 @@ interface McpServerConfig { transport: 'stdio' | 'http' port: number autoStart: boolean + apiKey: string } interface McpServerStatus { diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 36319cb4..41e5fa15 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -8,14 +8,46 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' import { openDatabase, listSessions } from './db.js' import * as queries from './queries.js' +import { formatMessagesAsText, formatMessagesAsMarkdown } from './utils/format.js' /** * Start the HTTP server with SSE transport and REST API */ -export async function startHttpServer(server: McpServer, port: number): Promise { +export async function startHttpServer(server: McpServer, port: number, apiKey?: string): Promise { const app = express() app.use(express.json()) + // ==================== CORS ==================== + + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + if (_req.method === 'OPTIONS') { + res.status(204).end() + return + } + next() + }) + + // ==================== API Key Authentication ==================== + + if (apiKey) { + app.use('/api', (req, res, next) => { + const authHeader = req.headers.authorization + const provided = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7) + : (req.query.api_key as string | undefined) + + if (provided !== apiKey) { + res.status(401).json({ error: 'Invalid or missing API key. Use Authorization: Bearer header.' }) + return + } + next() + }) + console.error('[ChatLab MCP] API key authentication enabled for /api/ endpoints') + } + // ==================== MCP SSE Transport ==================== // Store active transports @@ -53,6 +85,17 @@ export async function startHttpServer(server: McpServer, port: number): Promise< res.json({ sessions }) }) + // Get session overview + app.get('/api/v1/sessions/:id/overview', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + const overview = queries.getSessionOverview(db) + res.json(overview) + }) + // Get session members app.get('/api/v1/sessions/:id/members', (req, res) => { const db = openDatabase(req.params.id) @@ -95,6 +138,37 @@ export async function startHttpServer(server: McpServer, port: number): Promise< res.json(result) }) + // Export messages + app.get('/api/v1/sessions/:id/export', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const keywords = req.query.keywords + ? String(req.query.keywords).split(',').map((k) => k.trim()).filter(Boolean) + : [] + const limit = req.query.limit ? Number(req.query.limit) : 200 + const format = (req.query.format as string) || 'text' + const senderId = req.query.sender_id ? Number(req.query.sender_id) : undefined + + const result = keywords.length > 0 + ? queries.searchMessages(db, keywords, { senderId, limit }) + : queries.getRecentMessages(db, { limit }) + + switch (format) { + case 'json': + res.json(result) + break + case 'markdown': + res.type('text/markdown').send(formatMessagesAsMarkdown(result.messages)) + break + default: + res.type('text/plain').send(formatMessagesAsText(result.messages)) + } + }) + // Get member stats app.get('/api/v1/sessions/:id/member-stats', (req, res) => { const db = openDatabase(req.params.id) @@ -140,6 +214,21 @@ export async function startHttpServer(server: McpServer, port: number): Promise< res.json({ type, data }) }) + // Word frequency + app.get('/api/v1/sessions/:id/word-frequency', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const topN = req.query.top_n ? Number(req.query.top_n) : 50 + const minCount = req.query.min_count ? Number(req.query.min_count) : 3 + + const result = queries.getWordFrequency(db, { topN, minCount }) + res.json(result) + }) + // Execute SQL app.post('/api/v1/sessions/:id/sql', (req, res) => { const db = openDatabase(req.params.id) @@ -176,9 +265,9 @@ export async function startHttpServer(server: McpServer, port: number): Promise< // ==================== Start Server ==================== - app.listen(port, () => { - console.error(`[ChatLab MCP] HTTP server listening on http://localhost:${port}`) - console.error(`[ChatLab MCP] SSE endpoint: http://localhost:${port}/sse`) - console.error(`[ChatLab MCP] REST API: http://localhost:${port}/api/v1/`) + app.listen(port, '127.0.0.1', () => { + console.error(`[ChatLab MCP] HTTP server listening on http://127.0.0.1:${port}`) + console.error(`[ChatLab MCP] SSE endpoint: http://127.0.0.1:${port}/sse`) + console.error(`[ChatLab MCP] REST API: http://127.0.0.1:${port}/api/v1/`) }) } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index c3c83148..18ca8ca5 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -4,15 +4,17 @@ * ChatLab MCP Server entry point * * Usage: - * chatlab-mcp [--db-dir ] [--http] [--port ] + * chatlab-mcp [--db-dir ] [--http] [--port ] [--api-key ] * * Options: - * --db-dir Path to the ChatLab databases directory - * --http Start HTTP/SSE server instead of stdio - * --port HTTP server port (default: 3000) + * --db-dir Path to the ChatLab databases directory + * --http Start HTTP/SSE server instead of stdio + * --port HTTP server port (default: 3000) + * --api-key API key for HTTP endpoint authentication * * Environment variables: - * CHATLAB_DB_DIR Alternative way to specify the database directory + * CHATLAB_DB_DIR Alternative way to specify the database directory + * CHATLAB_API_KEY Alternative way to specify the API key */ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' @@ -21,11 +23,12 @@ import { createServer } from './server.js' import { startHttpServer } from './http.js' // Parse command line arguments -function parseArgs(): { dbDir: string; http: boolean; port: number } { +function parseArgs(): { dbDir: string; http: boolean; port: number; apiKey: string } { const args = process.argv.slice(2) let dbDir = '' let http = false let port = 3000 + let apiKey = '' for (let i = 0; i < args.length; i++) { switch (args[i]) { @@ -38,24 +41,30 @@ function parseArgs(): { dbDir: string; http: boolean; port: number } { case '--port': port = parseInt(args[++i] || '3000', 10) break + case '--api-key': + apiKey = args[++i] || '' + break } } - // Fallback to environment variable + // Fallback to environment variables if (!dbDir) { dbDir = process.env.CHATLAB_DB_DIR || '' } + if (!apiKey) { + apiKey = process.env.CHATLAB_API_KEY || '' + } // Fallback to default platform path if (!dbDir) { dbDir = getDefaultDbDir() } - return { dbDir, http, port } + return { dbDir, http, port, apiKey } } async function main(): Promise { - const { dbDir, http, port } = parseArgs() + const { dbDir, http, port, apiKey } = parseArgs() // Initialize database directory initDbDir(dbDir) @@ -66,7 +75,7 @@ async function main(): Promise { if (http) { // HTTP/SSE mode - await startHttpServer(server, port) + await startHttpServer(server, port, apiKey) } else { // Stdio mode (default) const transport = new StdioServerTransport() diff --git a/packages/mcp-server/src/queries.ts b/packages/mcp-server/src/queries.ts index 7b29d551..d6ab828c 100644 --- a/packages/mcp-server/src/queries.ts +++ b/packages/mcp-server/src/queries.ts @@ -649,6 +649,137 @@ export function executeRawSQL(db: Database.Database, sql: string): SQLResult { } } +// ==================== Session Overview ==================== + +const MESSAGE_TYPE_NAMES: Record = { + 0: 'text', 1: 'image', 2: 'voice', 3: 'video', + 4: 'file', 5: 'emoji', 6: 'link', 7: 'system', +} + +export interface SessionOverview { + totalMessages: number + totalMembers: number + timeRange: { start: number; end: number } | null + messageTypes: Array<{ type: number; typeName: string; count: number }> + topMembers: Array<{ name: string; messageCount: number; percentage: number }> +} + +/** + * Get comprehensive session overview + */ +export function getSessionOverview(db: Database.Database): SessionOverview { + const msgCount = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + const memberCount = db.prepare( + "SELECT COUNT(*) as count FROM member WHERE COALESCE(group_nickname, account_name, platform_id) != '系统消息'" + ).get() as { count: number } + const timeRange = db.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message').get() as { + start: number | null; end: number | null + } + + // Message type distribution + const typeRows = db.prepare( + 'SELECT type, COUNT(*) as count FROM message GROUP BY type ORDER BY count DESC' + ).all() as Array<{ type: number; count: number }> + + const messageTypes = typeRows.map((r) => ({ + type: r.type, + typeName: MESSAGE_TYPE_NAMES[r.type] || `type_${r.type}`, + count: r.count, + })) + + // Top 5 members + const activity = getMemberActivity(db) + const topMembers = activity.slice(0, 5).map((m) => ({ + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + + return { + totalMessages: msgCount.count, + totalMembers: memberCount.count, + timeRange: timeRange.start !== null && timeRange.end !== null + ? { start: timeRange.start, end: timeRange.end } + : null, + messageTypes, + topMembers, + } +} + +// ==================== Word Frequency ==================== + +export interface WordFrequencyResult { + words: Array<{ word: string; count: number }> + totalWords: number + uniqueWords: number +} + +// CJK Unicode range detection +const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/ + +/** + * Get word frequency analysis (lightweight, no native NLP dependency) + */ +export function getWordFrequency( + db: Database.Database, + options?: { topN?: number; minCount?: number } +): WordFrequencyResult { + const topN = options?.topN ?? 50 + const minCount = options?.minCount ?? 3 + + // Fetch text messages + const rows = db.prepare( + "SELECT content FROM message WHERE type = 0 AND content IS NOT NULL AND content != '' LIMIT 50000" + ).all() as Array<{ content: string }> + + const wordCounts = new Map() + let totalWords = 0 + + for (const row of rows) { + const text = row.content.trim() + if (!text) continue + + // Check if text is primarily CJK + const hasCJK = CJK_REGEX.test(text) + + if (hasCJK) { + // CJK: extract 2-char and 3-char n-grams (skip punctuation/whitespace) + const clean = text.replace(/[\s\p{P}\p{S}\p{N}]/gu, '') + for (let len = 2; len <= 3; len++) { + for (let i = 0; i <= clean.length - len; i++) { + const gram = clean.slice(i, i + len) + // Only include n-grams where all chars are CJK + if ([...gram].every((ch) => CJK_REGEX.test(ch))) { + wordCounts.set(gram, (wordCounts.get(gram) || 0) + 1) + totalWords++ + } + } + } + } else { + // Non-CJK: split by whitespace/punctuation + const words = text.toLowerCase().split(/[\s\p{P}]+/u).filter((w) => w.length >= 2) + for (const word of words) { + wordCounts.set(word, (wordCounts.get(word) || 0) + 1) + totalWords++ + } + } + } + + // Filter and sort + const sorted = [...wordCounts.entries()] + .filter(([, count]) => count >= minCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, topN) + + return { + words: sorted.map(([word, count]) => ({ word, count })), + totalWords, + uniqueWords: wordCounts.size, + } +} + +// ==================== Schema ==================== + /** * Get database schema (tables and columns) */ diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index f77fb7b5..f42f38fc 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,10 +1,12 @@ /** * MCP Server factory - * Creates and configures the McpServer instance with all tools registered + * Creates and configures the McpServer instance with all tools and resources */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' import { registerAllTools } from './tools/index.js' +import { listSessions, openDatabase } from './db.js' +import * as queries from './queries.js' /** * Create a configured MCP Server instance @@ -15,7 +17,71 @@ export function createServer(): McpServer { version: '0.1.0', }) + // Register tools registerAllTools(server) + // Register resources + registerResources(server) + return server } + +/** + * Register MCP Resources + */ +function registerResources(server: McpServer): void { + // Static resource: all sessions + server.resource( + 'sessions', + 'chatlab://sessions', + { description: 'List of all available chat sessions with metadata' }, + async (uri) => { + const sessions = listSessions() + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(sessions, null, 2), + }], + } + } + ) + + // Dynamic resource: session members + server.resource( + 'session-members', + new ResourceTemplate('chatlab://sessions/{sessionId}/members', { list: undefined }), + { description: 'Members of a specific chat session' }, + async (uri, { sessionId }) => { + const db = openDatabase(sessionId as string) + if (!db) throw new Error(`Session "${sessionId}" not found`) + const members = queries.getMembers(db) + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(members, null, 2), + }], + } + } + ) + + // Dynamic resource: session schema + server.resource( + 'session-schema', + new ResourceTemplate('chatlab://sessions/{sessionId}/schema', { list: undefined }), + { description: 'Database table structure for a chat session' }, + async (uri, { sessionId }) => { + const db = openDatabase(sessionId as string) + if (!db) throw new Error(`Session "${sessionId}" not found`) + const schema = queries.getSchema(db) + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(schema, null, 2), + }], + } + } + ) +} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index 29a66cfb..d6e9af4e 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -8,6 +8,7 @@ import { registerMemberTools } from './members.js' import { registerMessageTools } from './messages.js' import { registerStatsTools } from './stats.js' import { registerSqlTools } from './sql.js' +import { registerNlpTools } from './nlp.js' /** * Register all MCP tools on the server @@ -18,4 +19,5 @@ export function registerAllTools(server: McpServer): void { registerMessageTools(server) registerStatsTools(server) registerSqlTools(server) + registerNlpTools(server) } diff --git a/packages/mcp-server/src/tools/messages.ts b/packages/mcp-server/src/tools/messages.ts index d595891e..14026271 100644 --- a/packages/mcp-server/src/tools/messages.ts +++ b/packages/mcp-server/src/tools/messages.ts @@ -6,16 +6,10 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { openDatabase } from '../db.js' import * as queries from '../queries.js' -import { formatTimestamp } from '../utils/format.js' +import { formatMessagesAsText, formatMessagesAsMarkdown } from '../utils/format.js' function formatMessages(messages: queries.MessageResult[]): string { - return messages - .map((m) => { - const time = formatTimestamp(m.timestamp) - const content = m.content || '[non-text message]' - return `[${time}] ${m.senderName}: ${content}` - }) - .join('\n') + return formatMessagesAsText(messages) } export function registerMessageTools(server: McpServer): void { @@ -135,4 +129,46 @@ export function registerMessageTools(server: McpServer): void { return { content: [{ type: 'text', text }] } } ) + + server.tool( + 'export_messages', + 'Export chat messages matching filters as formatted text. Useful for creating reports or sharing conversation excerpts.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + keywords: z.array(z.string()).optional().describe('Keywords to filter (OR logic)'), + sender_id: z.number().optional().describe('Filter by sender member ID'), + limit: z.number().optional().default(200).describe('Max messages to export (default: 200)'), + format: z.enum(['text', 'markdown', 'json']).optional().default('text').describe('Output format'), + }, + async ({ session_id, keywords, sender_id, limit, format }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = keywords && keywords.length > 0 + ? queries.searchMessages(db, keywords, { senderId: sender_id, limit }) + : queries.getRecentMessages(db, { limit }) + + if (result.messages.length === 0) { + return { content: [{ type: 'text', text: 'No messages found matching the criteria.' }] } + } + + let output: string + switch (format) { + case 'json': + output = JSON.stringify(result.messages, null, 2) + break + case 'markdown': + output = formatMessagesAsMarkdown(result.messages) + break + default: + output = formatMessagesAsText(result.messages) + } + + return { + content: [{ type: 'text', text: `Exported ${result.messages.length} messages (${format} format):\n\n${output}` }], + } + } + ) } diff --git a/packages/mcp-server/src/tools/nlp.ts b/packages/mcp-server/src/tools/nlp.ts new file mode 100644 index 00000000..d6f2201e --- /dev/null +++ b/packages/mcp-server/src/tools/nlp.ts @@ -0,0 +1,41 @@ +/** + * NLP analysis MCP tools + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { openDatabase } from '../db.js' +import * as queries from '../queries.js' + +export function registerNlpTools(server: McpServer): void { + server.tool( + 'get_word_frequency', + 'Get word frequency analysis for a chat session. Shows the most commonly used words or phrases. For CJK languages, extracts 2-3 character n-grams; for others, splits by whitespace.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + top_n: z.number().optional().default(50).describe('Number of top words to return (default: 50)'), + min_count: z.number().optional().default(3).describe('Minimum occurrence count to include (default: 3)'), + }, + async ({ session_id, top_n, min_count }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.getWordFrequency(db, { topN: top_n, minCount: min_count }) + + if (result.words.length === 0) { + return { content: [{ type: 'text', text: 'No words found meeting the criteria.' }] } + } + + const lines = result.words.map((w, i) => `${i + 1}. ${w.word} (${w.count})`) + const text = [ + `Word frequency analysis (top ${result.words.length} of ${result.uniqueWords} unique, ${result.totalWords} total):`, + '', + ...lines, + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) +} diff --git a/packages/mcp-server/src/tools/session.ts b/packages/mcp-server/src/tools/session.ts index b428e934..af3ec4a6 100644 --- a/packages/mcp-server/src/tools/session.ts +++ b/packages/mcp-server/src/tools/session.ts @@ -2,9 +2,11 @@ * Session management MCP tools */ +import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { listSessions } from '../db.js' +import { listSessions, openDatabase } from '../db.js' import { formatTimestamp } from '../utils/format.js' +import * as queries from '../queries.js' export function registerSessionTools(server: McpServer): void { server.tool( @@ -40,4 +42,47 @@ export function registerSessionTools(server: McpServer): void { } } ) + + server.tool( + 'get_session_overview', + 'Get a comprehensive overview of a chat session including total messages, member count, time range, message type distribution, and top active members.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + }, + async ({ session_id }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const overview = queries.getSessionOverview(db) + + const timeInfo = overview.timeRange + ? `${formatTimestamp(overview.timeRange.start)} ~ ${formatTimestamp(overview.timeRange.end)}` + : 'N/A' + + const typeLines = overview.messageTypes.map( + (t) => ` ${t.typeName}: ${t.count}` + ) + + const memberLines = overview.topMembers.map( + (m, i) => ` ${i + 1}. ${m.name} — ${m.messageCount} messages (${m.percentage}%)` + ) + + const text = [ + `Session Overview:`, + ` Total Messages: ${overview.totalMessages}`, + ` Total Members: ${overview.totalMembers}`, + ` Time Range: ${timeInfo}`, + '', + 'Message Types:', + ...typeLines, + '', + 'Top Members:', + ...memberLines, + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) } diff --git a/packages/mcp-server/src/utils/format.ts b/packages/mcp-server/src/utils/format.ts index dc995158..d238f2ca 100644 --- a/packages/mcp-server/src/utils/format.ts +++ b/packages/mcp-server/src/utils/format.ts @@ -2,6 +2,8 @@ * Result formatting utilities for MCP Server */ +import type { MessageResult } from '../queries.js' + /** * Format a unix timestamp to a human-readable date string */ @@ -23,3 +25,29 @@ export function truncate(text: string, maxLength: number = 200): string { if (text.length <= maxLength) return text return text.slice(0, maxLength) + '...' } + +/** + * Format messages as plain text + */ +export function formatMessagesAsText(messages: MessageResult[]): string { + return messages + .map((m) => { + const time = formatTimestamp(m.timestamp) + const content = m.content || '[non-text message]' + return `[${time}] ${m.senderName}: ${content}` + }) + .join('\n') +} + +/** + * Format messages as Markdown + */ +export function formatMessagesAsMarkdown(messages: MessageResult[]): string { + return messages + .map((m) => { + const time = formatTimestamp(m.timestamp) + const content = m.content || '*[non-text message]*' + return `**${m.senderName}** (${time})\n> ${content}` + }) + .join('\n\n') +} diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index b3b88aad..b3ddb7d9 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -68,7 +68,11 @@ "externalConfigDesc": "Use the following info to configure MCP Server in Claude Code, Cursor, etc. (Stdio mode)", "serverEntry": "Server Entry", "dbDirectory": "Database Directory", - "stdioCommand": "Stdio Command" + "stdioCommand": "Stdio Command", + "apiKey": "API Key Authentication", + "apiKeyDesc": "When set, HTTP endpoints require a Bearer token in the Authorization header", + "apiKeyPlaceholder": "Leave empty to disable auth", + "noAuthWarning": "HTTP server has no API key set. Any local program can access your data." } }, "aiConfig": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 2c44b0c6..bf7aa75d 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -68,7 +68,11 @@ "externalConfigDesc": "以下の情報を使って Claude Code や Cursor などで MCP Server を設定してください(Stdio モード)", "serverEntry": "サーバーエントリ", "dbDirectory": "データベースディレクトリ", - "stdioCommand": "Stdio 起動コマンド" + "stdioCommand": "Stdio 起動コマンド", + "apiKey": "API Key 認証", + "apiKeyDesc": "設定すると、HTTP エンドポイントへのアクセスに Bearer トークンが必要になります", + "apiKeyPlaceholder": "空欄の場合、認証は無効", + "noAuthWarning": "HTTP サーバーに API Key が設定されていません。ローカルのすべてのプログラムがデータにアクセスできます。" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ba397a61..69eb8b10 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -68,7 +68,11 @@ "externalConfigDesc": "使用以下信息在 Claude Code、Cursor 等工具中配置 MCP Server(Stdio 模式)", "serverEntry": "服务入口", "dbDirectory": "数据库目录", - "stdioCommand": "Stdio 启动命令" + "stdioCommand": "Stdio 启动命令", + "apiKey": "API Key 认证", + "apiKeyDesc": "设置后,HTTP 接口需要在请求头中携带 Bearer Token 才能访问", + "apiKeyPlaceholder": "留空则不启用认证", + "noAuthWarning": "HTTP 服务未设置 API Key 认证,任何本地程序均可访问数据" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 49526469..a64d5c5f 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -68,7 +68,11 @@ "externalConfigDesc": "使用以下資訊在 Claude Code、Cursor 等工具中設定 MCP Server(Stdio 模式)", "serverEntry": "服務入口", "dbDirectory": "資料庫目錄", - "stdioCommand": "Stdio 啟動指令" + "stdioCommand": "Stdio 啟動指令", + "apiKey": "API Key 認證", + "apiKeyDesc": "設定後,HTTP 介面需要在請求標頭中攜帶 Bearer Token 才能存取", + "apiKeyPlaceholder": "留空則不啟用認證", + "noAuthWarning": "HTTP 服務未設定 API Key 認證,任何本機程式均可存取資料" } }, "aiConfig": { diff --git a/src/pages/settings/components/McpServerSection.vue b/src/pages/settings/components/McpServerSection.vue index b7ae6647..61d43393 100644 --- a/src/pages/settings/components/McpServerSection.vue +++ b/src/pages/settings/components/McpServerSection.vue @@ -9,6 +9,7 @@ const enabled = ref(false) const transport = ref<'stdio' | 'http'>('http') const port = ref(3000) const autoStart = ref(false) +const apiKey = ref('') // 状态 const isRunning = ref(false) @@ -46,6 +47,7 @@ async function loadConfig() { transport.value = config.transport port.value = config.port autoStart.value = config.autoStart + apiKey.value = config.apiKey || '' } catch (error) { console.error('Failed to load MCP config:', error) } @@ -59,6 +61,7 @@ async function saveConfig() { transport: transport.value, port: port.value, autoStart: autoStart.value, + apiKey: apiKey.value, }) } catch (error) { console.error('Failed to save MCP config:', error) @@ -145,6 +148,11 @@ async function handlePortBlur() { } } +// API Key 失焦保存 +async function handleApiKeyBlur() { + await saveConfig() +} + // 加载服务路径信息 async function loadServerPath() { try { @@ -293,6 +301,58 @@ onUnmounted(() => {

{{ portError }}

+ +
+
+
+

+ {{ t('settings.basic.mcp.apiKey') }} +

+

+ {{ t('settings.basic.mcp.apiKeyDesc') }} +

+
+
+ +
+
+
+ + +
+
+
+ +

+ {{ t('settings.basic.mcp.noAuthWarning') }} +

+
+
+
+ + +
+
+ REST API: + + http://127.0.0.1:{{ port }}/api/v1/ + + + + +
+
+
From a420918e6cf1b0407611a002d0fc85f7d128652e Mon Sep 17 00:00:00 2001 From: gamesme Date: Wed, 25 Mar 2026 03:20:51 +0800 Subject: [PATCH 6/6] feat: complete MCP Server integration with security fixes and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Packaging: bundle mcp-server via extraResources; fix ms module missing in asar - Build: add build:mcp, build:mac:zip scripts; fix electron_builder_binaries_mirror - New tools: get_date_range_messages, get_member_profile, get_interaction_frequency - New REST endpoints: /messages/range, /members/:id/profile, /interaction-frequency - Settings UI: enabled toggle starts/stops HTTP server directly (option B) - Settings UI: remove separate Start/Stop buttons and autoStart section - Settings UI: show Claude Desktop config snippet with copy button - Settings UI: auto-start HTTP server when switching transport from stdio→http - Security: extend API key auth to cover /sse and /messages endpoints (not just /api) - Security: include Authorization header in Claude Desktop config snippet when key is set - Fix: message type enum mapping aligned with base.ts (LINK=7, SYSTEM=80, RECALL=81, etc.) - Fix: getInteractionFrequency uses 5-minute window instead of adjacent row numbers - Fix: IPC auto-start uses enabled+transport=http instead of enabled+autoStart - README: add MCP Server section (tools list, HTTP/stdio config) in all 4 languages - i18n: add stdioAbiWarning explaining Electron ABI incompatibility for stdio mode Co-Authored-By: Claude Sonnet 4.6 --- .npmrc | 3 +- README.ja-JP.md | 40 +++ README.md | 40 +++ README.zh-CN.md | 40 +++ README.zh-TW.md | 40 +++ electron-builder.yml | 7 + electron/main/ipc/mcp.ts | 2 +- package.json | 13 +- packages/mcp-server/src/http.ts | 57 ++++- packages/mcp-server/src/queries.ts | 230 +++++++++++++++++- packages/mcp-server/src/tools/members.ts | 74 ++++++ packages/mcp-server/src/tools/messages.ts | 32 +++ pnpm-lock.yaml | 3 + src/i18n/locales/en-US/settings.json | 8 +- src/i18n/locales/ja-JP/settings.json | 8 +- src/i18n/locales/zh-CN/settings.json | 8 +- src/i18n/locales/zh-TW/settings.json | 8 +- .../settings/components/McpServerSection.vue | 185 ++++++++------ 18 files changed, 709 insertions(+), 89 deletions(-) diff --git a/.npmrc b/.npmrc index ab561f0a..7bffc8f2 100644 --- a/.npmrc +++ b/.npmrc @@ -4,7 +4,8 @@ registry=https://registry.npmmirror.com # 常用二进制镜像(本项目核心依赖) electron-mirror=https://npmmirror.com/mirrors/electron/ electron_mirror=https://npmmirror.com/mirrors/electron/ -electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ +# 使用 GitHub 官方地址:npmmirror 缺少 dmg-builder 新版本 +electron_builder_binaries_mirror=https://github.com/electron-userland/electron-builder-binaries/releases/download/ better-sqlite3_binary_host_mirror=https://npmmirror.com/mirrors/better-sqlite3 # 历史项目依赖兼容:保留 hoist 行为,避免安装结构变化带来回归 diff --git a/README.ja-JP.md b/README.ja-JP.md index 6cd3e1b5..f654d12e 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -21,6 +21,7 @@ ChatLab は、チャット履歴を深く理解するためのローカル完結 - 🤖 **実データを扱える AI Agent**:10 以上の Function Calling ツールを備え、文脈に応じて動的に呼び分けながら履歴を掘り下げます。 - 📊 **多面的な可視化**:アクティブ度の推移、時間帯の傾向、メンバーランキングなどを分かりやすく確認できます。 - 🧩 **形式差分を吸収する標準化**:異なるチャットアプリのエクスポート形式を統一モデルに変換し、同じ視点で比較・分析できます。 +- 🔌 **MCP Server**:Model Context Protocol を通じて、Claude Code や Cursor などの外部 AI ツールからチャット分析機能を直接呼び出せます。HTTP と Stdio の 2 つの接続方式をサポートします。 ## ガイド @@ -35,6 +36,45 @@ ChatLab は、チャット履歴を深く理解するためのローカル完結 ![Preview Interface](/public/images/intro_en.png) +## MCP Server + +ChatLab には MCP(Model Context Protocol)サーバーが内蔵されており、Claude Code や Cursor などの外部 AI ツールからチャット履歴の分析機能を直接呼び出せます。 + +**利用可能なツール(計 17 個):** `list_sessions`、`get_session_overview`、`get_members`、`get_member_stats`、`get_member_profile`、`get_member_name_history`、`get_interaction_frequency`、`search_messages`、`get_recent_messages`、`get_date_range_messages`、`get_message_context`、`get_conversation_between`、`export_messages`、`get_time_stats`、`get_word_frequency`、`execute_sql`、`get_schema` + +### HTTP モード + +**設定 → MCP Server** からサーバーを起動し、SSE エンドポイントでクライアントを接続します: + +```json +{ + "mcpServers": { + "chatlab": { + "url": "http://127.0.0.1:3000/sse" + } + } +} +``` + +REST API も利用できます:`http://127.0.0.1:{port}/api/v1/` + +### Stdio モード + +ChatLab 側での起動操作は不要です。**設定 → MCP Server → 外部ツール設定情報** から設定スニペットをコピーし、クライアントの設定ファイル(例:`claude_desktop_config.json`)に貼り付けます: + +```json +{ + "mcpServers": { + "chatlab": { + "command": "node", + "args": ["/path/to/mcp-server/dist/index.js", "--db-dir", "/path/to/databases"] + } + } +} +``` + +プロセスのライフサイクルはクライアントが自動管理します。 + ## システムアーキテクチャ ### 設計原則(Architecture Principles) diff --git a/README.md b/README.md index acb127db..83465cd3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Currently supported: **WhatsApp, LINE, WeChat, QQ, Discord, Instagram, and Teleg - 🤖 **AI that can actually operate on data**: Agent + Function Calling workflows can search, summarize, and analyze chat records with context. - 📊 **Insight-rich visual views**: See trends, time patterns, interaction frequency, rankings, and more in one place. - 🧩 **Cross-platform normalization**: Different export formats are mapped into a unified model so you can analyze them consistently. +- 🔌 **MCP Server**: Expose chat analysis capabilities to Claude Code, Cursor, and other external AI tools via the Model Context Protocol — over HTTP/SSE or stdio. ## Usage Guides @@ -35,6 +36,45 @@ For more previews, please visit the official website: [chatlab.fun](https://chat ![Preview Interface](/public/images/intro_en.png) +## MCP Server + +ChatLab includes a built-in MCP (Model Context Protocol) server that lets external AI tools like Claude Code and Cursor directly query your chat data. + +**Available tools (17 total):** `list_sessions`, `get_session_overview`, `get_members`, `get_member_stats`, `get_member_profile`, `get_member_name_history`, `get_interaction_frequency`, `search_messages`, `get_recent_messages`, `get_date_range_messages`, `get_message_context`, `get_conversation_between`, `export_messages`, `get_time_stats`, `get_word_frequency`, `execute_sql`, `get_schema` + +### HTTP Mode + +Start the server from **Settings → MCP Server**, then connect your client using the SSE endpoint: + +```json +{ + "mcpServers": { + "chatlab": { + "url": "http://127.0.0.1:3000/sse" + } + } +} +``` + +A REST API is also available at `http://127.0.0.1:{port}/api/v1/`. + +### Stdio Mode + +No need to start anything in ChatLab. Copy the config snippet from **Settings → MCP Server → External Tool Configuration** and paste it into your client (e.g. `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "chatlab": { + "command": "node", + "args": ["/path/to/mcp-server/dist/index.js", "--db-dir", "/path/to/databases"] + } + } +} +``` + +The client manages the process lifecycle automatically. + ## System Architecture ### Architecture Principles diff --git a/README.zh-CN.md b/README.zh-CN.md index 3c3fad8f..f196610f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,7 @@ ChatLab 是一个专注于社交记录分析的本地化应用。通过 AI Agent - 🤖 **智能 AI Agent**:集成 10+ Function Calling 工具,支持动态调度,深度挖掘聊天记录中的更多有趣。 - 📊 **多维数据可视化**:提供活跃度趋势、时间规律分布、成员排行等多个维度的直观分析图表。 - 🧩 **格式标准化**:通过强大的数据抽象层,抹平不同聊天软件的格式差异,即使是再小众的聊天软件,也能分析。 +- 🔌 **MCP Server**:通过 Model Context Protocol 将聊天分析能力开放给 Claude Code、Cursor 等外部 AI 工具,支持 HTTP 和 Stdio 两种接入方式。 ## 使用指南 @@ -35,6 +36,45 @@ ChatLab 是一个专注于社交记录分析的本地化应用。通过 AI Agent ![预览界面](/public/images/intro_zh.png) +## MCP Server + +ChatLab 内置了一个 MCP(Model Context Protocol)服务,可以让 Claude Code、Cursor 等外部 AI 工具直接调用聊天记录的分析能力。 + +**可用工具(共 17 个):** `list_sessions`、`get_session_overview`、`get_members`、`get_member_stats`、`get_member_profile`、`get_member_name_history`、`get_interaction_frequency`、`search_messages`、`get_recent_messages`、`get_date_range_messages`、`get_message_context`、`get_conversation_between`、`export_messages`、`get_time_stats`、`get_word_frequency`、`execute_sql`、`get_schema` + +### HTTP 模式 + +在 **设置 → MCP Server** 中启动服务,然后用 SSE 端点接入客户端: + +```json +{ + "mcpServers": { + "chatlab": { + "url": "http://127.0.0.1:3000/sse" + } + } +} +``` + +同时提供 REST API:`http://127.0.0.1:{port}/api/v1/` + +### Stdio 模式 + +无需在 ChatLab 中手动启动。在 **设置 → MCP Server → 外部工具配置信息** 中复制配置片段,粘贴到客户端配置文件(如 `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "chatlab": { + "command": "node", + "args": ["/path/to/mcp-server/dist/index.js", "--db-dir", "/path/to/databases"] + } + } +} +``` + +客户端会自动管理进程的生命周期。 + ## 系统架构 ### 架构原则(Architecture Principles) diff --git a/README.zh-TW.md b/README.zh-TW.md index 148b3203..1cff5dbb 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -21,6 +21,7 @@ ChatLab 是一款專注於社交記錄分析的本機應用。結合 AI Agent - 🤖 **可實際操作資料的 AI Agent**:內建 10+ 個 Function Calling 工具,可依任務動態調度,深入挖掘聊天脈絡與重點。 - 📊 **多維度視覺化分析**:提供活躍度趨勢、時段分布、成員排行等多種圖表與分析視角。 - 🧩 **統一資料格式**:透過穩定的資料抽象層,抹平不同聊天平台的匯出差異,分析流程更一致。 +- 🔌 **MCP Server**:透過 Model Context Protocol 將聊天分析能力開放給 Claude Code、Cursor 等外部 AI 工具,支援 HTTP 與 Stdio 兩種接入方式。 ## 使用指南 @@ -35,6 +36,45 @@ ChatLab 是一款專注於社交記錄分析的本機應用。結合 AI Agent ![預覽畫面](/public/images/intro_zh.png) +## MCP Server + +ChatLab 內建 MCP(Model Context Protocol)服務,可讓 Claude Code、Cursor 等外部 AI 工具直接呼叫聊天記錄的分析能力。 + +**可用工具(共 17 個):** `list_sessions`、`get_session_overview`、`get_members`、`get_member_stats`、`get_member_profile`、`get_member_name_history`、`get_interaction_frequency`、`search_messages`、`get_recent_messages`、`get_date_range_messages`、`get_message_context`、`get_conversation_between`、`export_messages`、`get_time_stats`、`get_word_frequency`、`execute_sql`、`get_schema` + +### HTTP 模式 + +在 **設定 → MCP Server** 中啟動服務,接著用 SSE 端點接入客戶端: + +```json +{ + "mcpServers": { + "chatlab": { + "url": "http://127.0.0.1:3000/sse" + } + } +} +``` + +同時提供 REST API:`http://127.0.0.1:{port}/api/v1/` + +### Stdio 模式 + +無需在 ChatLab 中手動啟動。在 **設定 → MCP Server → 外部工具設定資訊** 中複製設定片段,貼到客戶端設定檔(如 `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "chatlab": { + "command": "node", + "args": ["/path/to/mcp-server/dist/index.js", "--db-dir", "/path/to/databases"] + } + } +} +``` + +客戶端會自動管理行程的生命週期。 + ## 系統架構 ### 架構原則(Architecture Principles) diff --git a/electron-builder.yml b/electron-builder.yml index 91f1f431..a973caba 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,13 @@ files: - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' +# 将 MCP Server 发布包含在应用资源目录中 +extraResources: + - from: packages/mcp-server/dist + to: mcp-server/dist + - from: packages/mcp-server/node_modules + to: mcp-server/node_modules + # 哪些文件将不会被压缩,而是解压到构建目录 asarUnpack: - resources/** diff --git a/electron/main/ipc/mcp.ts b/electron/main/ipc/mcp.ts index c09bfb9f..0a9f848e 100644 --- a/electron/main/ipc/mcp.ts +++ b/electron/main/ipc/mcp.ts @@ -270,7 +270,7 @@ export function registerMcpHandlers(_context: IpcContext): void { // 自动启动 const config = loadConfig() - if (config.enabled && config.autoStart) { + if (config.enabled && config.transport === 'http') { console.log('[MCP] Auto-starting server...') const result = startServer(config) if (!result.success) { diff --git a/package.json b/package.json index fc862014..3224f28d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "pnpm": { "onlyBuiltDependencies": [ "better-sqlite3", - "electron" + "electron", + "electron-winstaller", + "esbuild", + "protobufjs", + "vue-demi" ] }, "scripts": { @@ -20,8 +24,10 @@ "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "build": "electron-vite build", - "build:mac": "npm run build && electron-builder --mac --config electron-builder.yml -p never", - "build:win": "npm run build && electron-builder --win --config electron-builder.yml -p never", + "build:mcp": "pnpm --filter @chatlab/mcp-server build", + "build:mac:zip": "npm run build:mcp && npm run build && electron-rebuild --module-dir packages/mcp-server && electron-builder --mac --target zip --config electron-builder.yml -p never", + "build:mac": "npm run build:mcp && npm run build && electron-rebuild --module-dir packages/mcp-server && ELECTRON_BUILDER_BINARIES_MIRROR=https://github.com/electron-userland/electron-builder-binaries/releases/download/ electron-builder --mac --config electron-builder.yml -p never", + "build:win": "npm run build:mcp && npm run build && electron-rebuild --module-dir packages/mcp-server && electron-builder --win --config electron-builder.yml -p never", "type-check:web": "vue-tsc --noEmit -p tsconfig.web.json", "type-check:node": "tsc --noEmit -p tsconfig.node.json", "type-check:all": "npm run type-check:web && npm run type-check:node", @@ -39,6 +45,7 @@ "@tanstack/vue-virtual": "^3.13.18", "@zumer/snapdom": "^2.0.1", "better-sqlite3": "^12.4.6", + "ms": "^2.1.3", "echarts": "^6.0.0", "echarts-wordcloud": "^2.1.0", "electron-updater": "^6.6.2", diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 41e5fa15..8f03053d 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -33,7 +33,8 @@ export async function startHttpServer(server: McpServer, port: number, apiKey?: // ==================== API Key Authentication ==================== if (apiKey) { - app.use('/api', (req, res, next) => { + app.use((req, res, next) => { + if (req.method === 'OPTIONS') { next(); return } const authHeader = req.headers.authorization const provided = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) @@ -45,7 +46,7 @@ export async function startHttpServer(server: McpServer, port: number, apiKey?: } next() }) - console.error('[ChatLab MCP] API key authentication enabled for /api/ endpoints') + console.error('[ChatLab MCP] API key authentication enabled for all endpoints (SSE + REST)') } // ==================== MCP SSE Transport ==================== @@ -229,6 +230,58 @@ export async function startHttpServer(server: McpServer, port: number, apiKey?: res.json(result) }) + // Get messages by date range + app.get('/api/v1/sessions/:id/messages/range', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const startDate = req.query.start as string + const endDate = req.query.end as string + if (!startDate || !endDate) { + res.status(400).json({ error: 'Missing required query params: start, end (YYYY-MM-DD)' }) + return + } + + const limit = req.query.limit ? Number(req.query.limit) : 200 + const senderId = req.query.sender_id ? Number(req.query.sender_id) : undefined + + const result = queries.getDateRangeMessages(db, startDate, endDate, { senderId, limit }) + res.json(result) + }) + + // Get member profile + app.get('/api/v1/sessions/:id/members/:memberId/profile', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const memberId = Number(req.params.memberId) + const profile = queries.getMemberProfile(db, memberId) + if (!profile) { + res.status(404).json({ error: 'Member not found' }) + return + } + res.json(profile) + }) + + // Get interaction frequency between member pairs + app.get('/api/v1/sessions/:id/interaction-frequency', (req, res) => { + const db = openDatabase(req.params.id) + if (!db) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const topN = req.query.top_n ? Number(req.query.top_n) : 10 + const pairs = queries.getInteractionFrequency(db, topN) + res.json({ pairs, total: pairs.length }) + }) + // Execute SQL app.post('/api/v1/sessions/:id/sql', (req, res) => { const db = openDatabase(req.params.id) diff --git a/packages/mcp-server/src/queries.ts b/packages/mcp-server/src/queries.ts index d6ab828c..7e81fd87 100644 --- a/packages/mcp-server/src/queries.ts +++ b/packages/mcp-server/src/queries.ts @@ -653,7 +653,10 @@ export function executeRawSQL(db: Database.Database, sql: string): SQLResult { const MESSAGE_TYPE_NAMES: Record = { 0: 'text', 1: 'image', 2: 'voice', 3: 'video', - 4: 'file', 5: 'emoji', 6: 'link', 7: 'system', + 4: 'file', 5: 'emoji', 7: 'link', 8: 'location', + 20: 'redPacket', 21: 'transfer', 22: 'poke', 23: 'call', + 24: 'share', 25: 'reply', 26: 'forward', 27: 'contact', + 80: 'system', 81: 'recall', 99: 'other', } export interface SessionOverview { @@ -778,6 +781,231 @@ export function getWordFrequency( } } +// ==================== Date Range Messages ==================== + +export interface DateRangeMessagesResult extends MessagesWithTotal { + startDate: string + endDate: string +} + +/** + * Get messages within a date range (YYYY-MM-DD) + */ +export function getDateRangeMessages( + db: Database.Database, + startDate: string, + endDate: string, + options?: { senderId?: number; limit?: number } +): DateRangeMessagesResult { + const limit = options?.limit ?? 200 + // Convert dates to unix seconds (start of day / end of day local time) + const startTs = Math.floor(new Date(startDate + 'T00:00:00').getTime() / 1000) + const endTs = Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) + + const senderCondition = options?.senderId !== undefined ? 'AND msg.sender_id = ?' : '' + const senderParams: number[] = options?.senderId !== undefined ? [options.senderId] : [] + + const countSql = ` + SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.ts >= ? AND msg.ts <= ? + ${senderCondition} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} + ` + const totalRow = db.prepare(countSql).get(startTs, endTs, ...senderParams) as { total: number } + + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.ts >= ? AND msg.ts <= ? + ${senderCondition} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} + ORDER BY msg.ts ASC + LIMIT ? + ` + + const rows = db.prepare(sql).all(startTs, endTs, ...senderParams, limit) as DbMessageRow[] + + return { + messages: rows.map(sanitizeMessageRow), + total: totalRow?.total || 0, + startDate, + endDate, + } +} + +// ==================== Member Profile ==================== + +export interface MessageTypeCount { + type: number + typeName: string + count: number +} + +export interface MemberProfile { + memberId: number + name: string + totalMessages: number + percentage: number + avgMessageLength: number + messageTypes: MessageTypeCount[] + peakHour: number | null + topWords: Array<{ word: string; count: number }> +} + +const MESSAGE_TYPE_NAMES_MAP = MESSAGE_TYPE_NAMES + +/** + * Get comprehensive profile for a specific member + */ +export function getMemberProfile(db: Database.Database, memberId: number): MemberProfile | null { + // Basic member info + const memberRow = db.prepare( + "SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?" + ).get(memberId) as { name: string } | undefined + + if (!memberRow) return null + + // Total messages and avg length + const statsRow = db.prepare(` + SELECT COUNT(*) as total, AVG(LENGTH(COALESCE(content, ''))) as avgLen + FROM message WHERE sender_id = ? + `).get(memberId) as { total: number; avgLen: number | null } + + // Total messages in session (for percentage) + const totalRow = db.prepare( + "SELECT COUNT(*) as count FROM message JOIN member m ON message.sender_id = m.id WHERE COALESCE(m.account_name, '') != '系统消息'" + ).get() as { count: number } + + // Message type distribution + const typeRows = db.prepare(` + SELECT type, COUNT(*) as count FROM message WHERE sender_id = ? GROUP BY type ORDER BY count DESC + `).all(memberId) as Array<{ type: number; count: number }> + + const messageTypes: MessageTypeCount[] = typeRows.map((r) => ({ + type: r.type, + typeName: MESSAGE_TYPE_NAMES_MAP[r.type] || `type_${r.type}`, + count: r.count, + })) + + // Peak hour + const peakHourRow = db.prepare(` + SELECT CAST(strftime('%H', ts, 'unixepoch', 'localtime') AS INTEGER) as hour, COUNT(*) as cnt + FROM message WHERE sender_id = ? GROUP BY hour ORDER BY cnt DESC LIMIT 1 + `).get(memberId) as { hour: number; cnt: number } | undefined + + // Top words from this member's text messages + const textRows = db.prepare( + "SELECT content FROM message WHERE sender_id = ? AND type = 0 AND content IS NOT NULL AND content != '' LIMIT 20000" + ).all(memberId) as Array<{ content: string }> + + const wordCounts = new Map() + for (const row of textRows) { + const text = row.content.trim() + if (!text) continue + const hasCJK = CJK_REGEX.test(text) + if (hasCJK) { + const clean = text.replace(/[\s\p{P}\p{S}\p{N}]/gu, '') + for (let len = 2; len <= 3; len++) { + for (let i = 0; i <= clean.length - len; i++) { + const gram = clean.slice(i, i + len) + if ([...gram].every((ch) => CJK_REGEX.test(ch))) { + wordCounts.set(gram, (wordCounts.get(gram) || 0) + 1) + } + } + } + } else { + const words = text.toLowerCase().split(/[\s\p{P}]+/u).filter((w) => w.length >= 2) + for (const word of words) { + wordCounts.set(word, (wordCounts.get(word) || 0) + 1) + } + } + } + + const topWords = [...wordCounts.entries()] + .filter(([, count]) => count >= 2) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([word, count]) => ({ word, count })) + + const total = statsRow?.total || 0 + const sessionTotal = totalRow?.count || 0 + + return { + memberId, + name: memberRow.name, + totalMessages: total, + percentage: sessionTotal > 0 ? Math.round((total / sessionTotal) * 10000) / 100 : 0, + avgMessageLength: Math.round((statsRow?.avgLen || 0) * 10) / 10, + messageTypes, + peakHour: peakHourRow?.hour ?? null, + topWords, + } +} + +// ==================== Interaction Frequency ==================== + +export interface InteractionPair { + member1Id: number + member1Name: string + member2Id: number + member2Name: string + exchangeCount: number +} + +/** + * Get interaction frequency between member pairs (consecutive message exchanges) + */ +export function getInteractionFrequency( + db: Database.Database, + topN: number = 10 +): InteractionPair[] { + // Count interactions: A speaks, B responds within 5 minutes (300 seconds), either direction + const rows = db.prepare(` + SELECT + CASE WHEN a.sender_id < b.sender_id THEN a.sender_id ELSE b.sender_id END as member1, + CASE WHEN a.sender_id < b.sender_id THEN b.sender_id ELSE a.sender_id END as member2, + COUNT(*) as exchange_count + FROM message a + JOIN message b + ON b.sender_id != a.sender_id + AND b.ts > a.ts + AND b.ts <= a.ts + 300 + WHERE a.type = 0 AND b.type = 0 + AND a.content IS NOT NULL AND a.content != '' + AND b.content IS NOT NULL AND b.content != '' + GROUP BY member1, member2 + ORDER BY exchange_count DESC + LIMIT ? + `).all(topN) as Array<{ member1: number; member2: number; exchange_count: number }> + + const getName = (id: number): string => { + const row = db.prepare( + "SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?" + ).get(id) as { name: string } | undefined + return row?.name || String(id) + } + + return rows.map((r) => ({ + member1Id: r.member1, + member1Name: getName(r.member1), + member2Id: r.member2, + member2Name: getName(r.member2), + exchangeCount: r.exchange_count, + })) +} + // ==================== Schema ==================== /** diff --git a/packages/mcp-server/src/tools/members.ts b/packages/mcp-server/src/tools/members.ts index d146b39a..1473940e 100644 --- a/packages/mcp-server/src/tools/members.ts +++ b/packages/mcp-server/src/tools/members.ts @@ -71,6 +71,80 @@ export function registerMemberTools(server: McpServer): void { } ) + server.tool( + 'get_member_profile', + 'Get a comprehensive profile for a specific member: message count, activity peak hour, avg message length, message type breakdown, and top words.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + member_id: z.number().describe('The member ID (from get_members)'), + }, + async ({ session_id, member_id }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const profile = queries.getMemberProfile(db, member_id) + if (!profile) { + return { content: [{ type: 'text', text: `Member ${member_id} not found.` }] } + } + + const typeLines = profile.messageTypes + .map((t) => ` ${t.typeName}: ${t.count}`) + .join('\n') + + const wordLines = profile.topWords.length > 0 + ? profile.topWords.map((w) => `${w.word}(${w.count})`).join(', ') + : 'N/A' + + const peakHourStr = profile.peakHour !== null + ? `${profile.peakHour}:00 ~ ${profile.peakHour}:59` + : 'N/A' + + const text = [ + `Member Profile: ${profile.name} (ID: ${profile.memberId})`, + `Total Messages: ${profile.totalMessages} (${profile.percentage}% of session)`, + `Avg Message Length: ${profile.avgMessageLength} chars`, + `Peak Active Hour: ${peakHourStr}`, + `Message Types:\n${typeLines}`, + `Top Words: ${wordLines}`, + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) + + server.tool( + 'get_interaction_frequency', + 'Get the most frequent message exchange pairs between members. Shows which members interact with each other most often based on consecutive message exchanges.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + top_n: z.number().optional().default(10).describe('Number of top pairs to return (default: 10)'), + }, + async ({ session_id, top_n }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const pairs = queries.getInteractionFrequency(db, top_n) + if (pairs.length === 0) { + return { content: [{ type: 'text', text: 'No interaction data found.' }] } + } + + const lines = pairs.map( + (p, i) => `${i + 1}. ${p.member1Name} ↔ ${p.member2Name}: ${p.exchangeCount} exchanges` + ) + + return { + content: [{ + type: 'text', + text: `Top ${pairs.length} interaction pairs:\n\n${lines.join('\n')}`, + }], + } + } + ) + server.tool( 'get_member_name_history', 'Get the historical nicknames/names of a specific member. Useful for tracking identity changes over time.', diff --git a/packages/mcp-server/src/tools/messages.ts b/packages/mcp-server/src/tools/messages.ts index 14026271..6761f853 100644 --- a/packages/mcp-server/src/tools/messages.ts +++ b/packages/mcp-server/src/tools/messages.ts @@ -130,6 +130,38 @@ export function registerMessageTools(server: McpServer): void { } ) + server.tool( + 'get_date_range_messages', + 'Get messages within a specific date range (YYYY-MM-DD). Useful for analyzing what was discussed during a particular period.', + { + session_id: z.string().describe('The session ID (from list_sessions)'), + start_date: z.string().describe('Start date in YYYY-MM-DD format (inclusive)'), + end_date: z.string().describe('End date in YYYY-MM-DD format (inclusive)'), + sender_id: z.number().optional().describe('Optional: filter by sender member ID'), + limit: z.number().optional().default(200).describe('Max messages to return (default: 200)'), + }, + async ({ session_id, start_date, end_date, sender_id, limit }) => { + const db = openDatabase(session_id) + if (!db) { + return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] } + } + + const result = queries.getDateRangeMessages(db, start_date, end_date, { senderId: sender_id, limit }) + + if (result.messages.length === 0) { + return { content: [{ type: 'text', text: `No messages found between ${start_date} and ${end_date}.` }] } + } + + const text = [ + `Messages from ${start_date} to ${end_date} (${result.messages.length} of ${result.total} total):`, + '', + formatMessages(result.messages), + ].join('\n') + + return { content: [{ type: 'text', text }] } + } + ) + server.tool( 'export_messages', 'Export chat messages matching filters as formatted text. Useful for creating reports or sharing conversation excerpts.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 125c25e8..6820589d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: markdown-it: specifier: ^14.1.0 version: 14.1.0 + ms: + specifier: ^2.1.3 + version: 2.1.3 node-machine-id: specifier: ^1.1.12 version: 1.1.12 diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index b3ddb7d9..c032e394 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -53,7 +53,7 @@ "mcp": { "title": "MCP Server", "enable": "Enable MCP Server", - "enableDesc": "Start MCP server to allow external AI tools like Claude Code, Cursor to access chat analysis capabilities", + "enableDesc": "HTTP: toggle to start/stop the server. Stdio: toggle to show/hide external tool configuration.", "statusRunning": "Running", "statusStopped": "Stopped", "start": "Start", @@ -72,7 +72,11 @@ "apiKey": "API Key Authentication", "apiKeyDesc": "When set, HTTP endpoints require a Bearer token in the Authorization header", "apiKeyPlaceholder": "Leave empty to disable auth", - "noAuthWarning": "HTTP server has no API key set. Any local program can access your data." + "noAuthWarning": "HTTP server has no API key set. Any local program can access your data.", + "claudeDesktopConfig": "Claude Desktop Config Snippet", + "claudeDesktopConfigDesc": "Copy this JSON and paste it into Claude Desktop's claude_desktop_config.json", + "startError": "Failed to start", + "stdioAbiWarning": "Stdio mode: the bundled binary is compiled for Electron's Node.js ABI. Running with a different system Node version may fail to load native modules. HTTP mode is recommended for most integrations." } }, "aiConfig": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index bf7aa75d..c4e7101f 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -53,7 +53,7 @@ "mcp": { "title": "MCP Server", "enable": "MCP Server を有効にする", - "enableDesc": "MCP サーバーを起動し、Claude Code や Cursor などの外部 AI ツールからチャット分析機能にアクセスできるようにします", + "enableDesc": "HTTP モード:トグルでサーバーを起動/停止。Stdio モード:トグルで外部ツール設定の表示を切替。", "statusRunning": "実行中", "statusStopped": "停止中", "start": "起動", @@ -72,7 +72,11 @@ "apiKey": "API Key 認証", "apiKeyDesc": "設定すると、HTTP エンドポイントへのアクセスに Bearer トークンが必要になります", "apiKeyPlaceholder": "空欄の場合、認証は無効", - "noAuthWarning": "HTTP サーバーに API Key が設定されていません。ローカルのすべてのプログラムがデータにアクセスできます。" + "noAuthWarning": "HTTP サーバーに API Key が設定されていません。ローカルのすべてのプログラムがデータにアクセスできます。", + "claudeDesktopConfig": "Claude Desktop 設定スニペット", + "claudeDesktopConfigDesc": "以下の JSON をコピーして Claude Desktop の claude_desktop_config.json に貼り付けてください", + "startError": "起動に失敗しました", + "stdioAbiWarning": "Stdio モードの注意:同梱バイナリは Electron の Node.js ABI 向けにビルドされています。バージョンが異なるシステム Node.js を使用すると、ネイティブモジュールの読み込みに失敗する場合があります。通常の連携には HTTP モードを推奨します。" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 69eb8b10..66be90f8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -53,7 +53,7 @@ "mcp": { "title": "MCP Server", "enable": "启用 MCP Server", - "enableDesc": "启动 MCP 服务,允许 Claude Code、Cursor 等外部 AI 工具访问聊天记录分析能力", + "enableDesc": "HTTP 模式:开关直接启动/停止服务。Stdio 模式:开关控制外部工具配置信息的显示。", "statusRunning": "运行中", "statusStopped": "已停止", "start": "启动", @@ -72,7 +72,11 @@ "apiKey": "API Key 认证", "apiKeyDesc": "设置后,HTTP 接口需要在请求头中携带 Bearer Token 才能访问", "apiKeyPlaceholder": "留空则不启用认证", - "noAuthWarning": "HTTP 服务未设置 API Key 认证,任何本地程序均可访问数据" + "noAuthWarning": "HTTP 服务未设置 API Key 认证,任何本地程序均可访问数据", + "claudeDesktopConfig": "Claude Desktop 配置片段", + "claudeDesktopConfigDesc": "复制以下 JSON,粘贴到 Claude Desktop 的 claude_desktop_config.json 文件中", + "startError": "启动失败", + "stdioAbiWarning": "Stdio 模式注意:内置二进制文件已按 Electron 的 Node.js ABI 编译,使用系统 Node.js 运行时,若版本不匹配将无法加载原生模块。建议大多数集成场景使用 HTTP 模式。" } }, "aiConfig": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index a64d5c5f..465f4c8a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -53,7 +53,7 @@ "mcp": { "title": "MCP Server", "enable": "啟用 MCP Server", - "enableDesc": "啟動 MCP 服務,允許 Claude Code、Cursor 等外部 AI 工具存取聊天記錄分析功能", + "enableDesc": "HTTP 模式:開關直接啟動/停止服務。Stdio 模式:開關控制外部工具設定資訊的顯示。", "statusRunning": "執行中", "statusStopped": "已停止", "start": "啟動", @@ -72,7 +72,11 @@ "apiKey": "API Key 認證", "apiKeyDesc": "設定後,HTTP 介面需要在請求標頭中攜帶 Bearer Token 才能存取", "apiKeyPlaceholder": "留空則不啟用認證", - "noAuthWarning": "HTTP 服務未設定 API Key 認證,任何本機程式均可存取資料" + "noAuthWarning": "HTTP 服務未設定 API Key 認證,任何本機程式均可存取資料", + "claudeDesktopConfig": "Claude Desktop 設定片段", + "claudeDesktopConfigDesc": "複製以下 JSON,貼至 Claude Desktop 的 claude_desktop_config.json 檔案中", + "startError": "啟動失敗", + "stdioAbiWarning": "Stdio 模式注意:內建二進位檔案已依 Electron 的 Node.js ABI 編譯,使用系統 Node.js 執行時,若版本不符將無法載入原生模組。建議大多數整合場景使用 HTTP 模式。" } }, "aiConfig": { diff --git a/src/pages/settings/components/McpServerSection.vue b/src/pages/settings/components/McpServerSection.vue index 61d43393..90f03b7d 100644 --- a/src/pages/settings/components/McpServerSection.vue +++ b/src/pages/settings/components/McpServerSection.vue @@ -8,7 +8,6 @@ const { t } = useI18n() const enabled = ref(false) const transport = ref<'stdio' | 'http'>('http') const port = ref(3000) -const autoStart = ref(false) const apiKey = ref('') // 状态 @@ -18,6 +17,7 @@ const serverUptime = ref() const isStarting = ref(false) const isStopping = ref(false) const portError = ref('') +const startError = ref('') const serverPath = ref('') const dbDir = ref('') const showStdioConfig = ref(false) @@ -46,7 +46,6 @@ async function loadConfig() { enabled.value = config.enabled transport.value = config.transport port.value = config.port - autoStart.value = config.autoStart apiKey.value = config.apiKey || '' } catch (error) { console.error('Failed to load MCP config:', error) @@ -60,7 +59,7 @@ async function saveConfig() { enabled: enabled.value, transport: transport.value, port: port.value, - autoStart: autoStart.value, + autoStart: false, apiKey: apiKey.value, }) } catch (error) { @@ -80,29 +79,27 @@ async function refreshStatus() { } } -// 启动服务 -async function handleStart() { - if (portError.value) return - - isStarting.value = true - try { - // 先保存配置 - await saveConfig() - const result = await window.mcpApi.start() - if (!result.success) { - console.error('Failed to start MCP server:', result.error) +// Claude Desktop JSON 配置片段 +const claudeDesktopSnippet = computed(() => { + if (transport.value === 'http') { + const entry: Record = { url: `http://127.0.0.1:${port.value}/sse` } + if (apiKey.value) { + entry.headers = { Authorization: `Bearer ${apiKey.value}` } } - // 短暂延迟后刷新状态 - setTimeout(refreshStatus, 500) - } catch (error) { - console.error('Failed to start MCP server:', error) - } finally { - isStarting.value = false + return JSON.stringify({ mcpServers: { chatlab: entry } }, null, 2) } -} + return JSON.stringify({ + mcpServers: { + chatlab: { + command: 'node', + args: [serverPath.value || '[server-path]', '--db-dir', dbDir.value || '[db-dir]'], + }, + }, + }, null, 2) +}) -// 停止服务 -async function handleStop() { +// 停止服务(内部使用) +async function stopServer() { isStopping.value = true try { await window.mcpApi.stop() @@ -127,18 +124,67 @@ function validatePort(val: number) { async function handleTransportChange(mode: string | number) { transport.value = mode as 'stdio' | 'http' await saveConfig() + if (mode === 'stdio' && isRunning.value) { + // 切到 stdio:停止正在运行的 HTTP server + await stopServer() + } else if (mode === 'http' && enabled.value && !isRunning.value) { + // 切到 HTTP 且已启用:自动启动服务 + startError.value = '' + isStarting.value = true + try { + const result = await window.mcpApi.start() + if (!result.success) { + startError.value = result.error || t('settings.basic.mcp.startError') + } + setTimeout(refreshStatus, 500) + } catch (error) { + startError.value = error instanceof Error ? error.message : t('settings.basic.mcp.startError') + } finally { + isStarting.value = false + } + } } -// 开关切换 +// 开关切换:HTTP 模式直接启动/停止服务,Stdio 模式仅保存配置 async function handleEnabledChange(val: boolean) { enabled.value = val - await saveConfig() -} - -// 自启动切换 -async function handleAutoStartChange(val: boolean) { - autoStart.value = val - await saveConfig() + startError.value = '' + if (transport.value === 'http') { + if (val) { + if (portError.value) { + enabled.value = false + return + } + isStarting.value = true + try { + await saveConfig() + const result = await window.mcpApi.start() + if (!result.success) { + startError.value = result.error || t('settings.basic.mcp.startError') + console.error('Failed to start MCP server:', result.error) + } + setTimeout(refreshStatus, 500) + } catch (error) { + startError.value = error instanceof Error ? error.message : t('settings.basic.mcp.startError') + console.error('Failed to start MCP server:', error) + } finally { + isStarting.value = false + } + } else { + isStopping.value = true + try { + await saveConfig() + await window.mcpApi.stop() + setTimeout(refreshStatus, 500) + } catch (error) { + console.error('Failed to stop MCP server:', error) + } finally { + isStopping.value = false + } + } + } else { + await saveConfig() + } } // 端口失焦保存 @@ -207,12 +253,17 @@ onUnmounted(() => { {{ t('settings.basic.mcp.enableDesc') }}

- +