From a7b587e222ea8f0138cd8f833dd6ab638914184f Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 19:56:59 +0800 Subject: [PATCH 01/13] feat(mcp): embed read-only MCP server for agent access to starred repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Streamable HTTP + legacy SSE MCP endpoints so agents can search AI-enriched stars without a separate CLI. Backend mounts /mcp (token-gated, config read per request); Electron hosts loopback-only local MCP via IPC snapshot. Settings panel enables toggle, viewable token, and one-click agent JSON. Vector tool is listed only when vector search is configured. Additive settings only — no data-loss migrations. Security/audit hardening: restore helmet CSP, no token mint on mount, stateless Streamable HTTP, loopback bind enforcement, log sanitization for mcp tokens, nginx prefix proxy for /mcp paths. --- .gitignore | 2 +- DOCKER.md | 35 +- README.md | 1 + README_zh.md | 1 + electron/main.js | 196 ++++++ electron/mcpLocalServer.js | 434 ++++++++++++ electron/package.json | 8 + electron/preload.js | 15 + nginx.conf.template | 54 ++ scripts/build-desktop.js | 162 +---- server/package-lock.json | 665 ++++++++++++++++++- server/package.json | 16 +- server/src/index.ts | 28 +- server/src/mcp/http.ts | 154 +++++ server/src/mcp/provider.ts | 330 +++++++++ server/src/mcp/repoSearch.ts | 200 ++++++ server/src/mcp/server.ts | 11 + server/src/mcp/settings.ts | 101 +++ server/src/mcp/tools.ts | 170 +++++ server/src/routes/mcp.ts | 86 +++ server/src/services/logSanitizer.ts | 2 + server/tests/mcp/repoSearch.test.ts | 55 ++ server/tests/mcp/routes.test.ts | 171 +++++ src/components/SearchBar.tsx | 202 +----- src/components/SettingsPanel.tsx | 14 +- src/components/settings/McpSettingsPanel.tsx | 496 ++++++++++++++ src/components/settings/index.ts | 1 + src/services/backendAdapter.ts | 35 + src/services/electronProxy.ts | 26 +- src/store/useAppStore.ts | 55 ++ src/types/index.ts | 14 + src/utils/repoSearch.test.ts | 121 ++++ src/utils/repoSearch.ts | 237 +++++++ 33 files changed, 3733 insertions(+), 365 deletions(-) create mode 100644 electron/main.js create mode 100644 electron/mcpLocalServer.js create mode 100644 electron/package.json create mode 100644 electron/preload.js create mode 100644 server/src/mcp/http.ts create mode 100644 server/src/mcp/provider.ts create mode 100644 server/src/mcp/repoSearch.ts create mode 100644 server/src/mcp/server.ts create mode 100644 server/src/mcp/settings.ts create mode 100644 server/src/mcp/tools.ts create mode 100644 server/src/routes/mcp.ts create mode 100644 server/tests/mcp/repoSearch.test.ts create mode 100644 server/tests/mcp/routes.test.ts create mode 100644 src/components/settings/McpSettingsPanel.tsx create mode 100644 src/utils/repoSearch.test.ts create mode 100644 src/utils/repoSearch.ts diff --git a/.gitignore b/.gitignore index 7607842fd..8878e4581 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ dist-ssr *.sw? .env release -electron +# electron/ sources are committed (main, preload, MCP). Do not ignore the whole dir. # Backend server data server/data/*.db diff --git a/DOCKER.md b/DOCKER.md index 504099a82..2924bf6b8 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -182,4 +182,37 @@ docker stop github-stars-backend && docker rm github-stars-backend ## Note on Desktop Packaging -This Docker setup does not affect the existing desktop packaging workflows. The GitHub Actions workflow for building desktop applications remains unchanged and continues to work as before. \ No newline at end of file +This Docker setup does not affect the existing desktop packaging workflows. The GitHub Actions workflow for building desktop applications remains unchanged and continues to work as before. +## MCP Server (Agent access) + +With Docker Compose, the backend MCP endpoints are exposed through nginx (frontend container) so agents on the host do not need a published backend port: + +| Endpoint | URL (default compose) | +|----------|------------------------| +| Streamable HTTP | `http://localhost:8080/mcp` | +| Legacy SSE | `http://localhost:8080/mcp/sse` | + +1. Open the app → **Settings → MCP Server**. +2. Toggle **Enable MCP Server** (requires backend connection). +3. Copy the token (always viewable) and the JSON agent config. +4. Paste into Claude Code / Cursor MCP settings, for example: + +```json +{ + "mcpServers": { + "github-stars-manager": { + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Bearer gsm_mcp_..." + } + } + } +} +``` + +**Notes** + +- MCP uses a **separate token** from `API_SECRET` (backend UI auth). Resetting the MCP token does not break app↔backend sync. +- Pure frontend (no backend) does not show the MCP settings page. +- `gsm_vector_search` appears only when Vector Search is configured and enabled in the app. +- Enabling MCP is additive: existing SQLite data is unchanged; disabling MCP only stops the endpoint. diff --git a/README.md b/README.md index f106b6e30..7d459e7f8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ GitHub Stars Manager automatically syncs your starred repos, uses AI to summariz | **AI Summaries & Categories** | Generate tags, topics, and short README overviews using AI | | **Semantic Search** | Find repos by intent, not exact names | | **Vector Semantic Search** | Embed repo descriptions/READMEs into a Cloudflare Vectorize index; query with natural language for high-precision semantic matching | +| **MCP Server** | Optional Streamable HTTP / SSE endpoint so agents (Claude Code, Cursor, etc.) can search AI-enriched stars; toggle in Settings, no extra install | | **Release Tracking** | Subscribe to repos and see new versions in one unified timeline | | **One‑click Downloads** | Expand release assets and download instantly | | **Smart Asset Filters** | Match assets by keywords (dmg / mac / arm64 / aarch64) | diff --git a/README_zh.md b/README_zh.md index 0cf0a7f40..896250638 100644 --- a/README_zh.md +++ b/README_zh.md @@ -28,6 +28,7 @@ | **AI 摘要与分类** | 使用 AI 生成标签、主题和简短 README 概览 | | **语义搜索** | 按意图而非精确名称查找仓库 | | **向量语义搜索** | 将仓库描述/README 嵌入 Cloudflare Vectorize 向量库,自然语言查询实现高精度语义匹配 | +| **MCP 服务** | 可选 Streamable HTTP / SSE,供 Claude Code、Cursor 等 Agent 检索 AI 加工后的星标;设置中开关,无需额外安装 | | **Release 追踪** | 订阅仓库并在统一时间线查看新版本 | | **一键下载** | 展开 Release 资产并即时下载 | | **智能资产过滤** | 按关键词匹配资产 (dmg / mac / arm64 / aarch64) | diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 000000000..f2f52520d --- /dev/null +++ b/electron/main.js @@ -0,0 +1,196 @@ +const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron'); +const path = require('path'); +const { createMcpLocalServer } = require('./mcpLocalServer'); + +const isDev = process.env.NODE_ENV === 'development'; + +/** @type {BrowserWindow | null} */ +let mainWindow = null; + +// Proxy state (optional; may be extended later) +let proxyConfig = { enabled: false, type: 'http', host: '', port: 7890 }; + +// MCP local state +let mcpConfig = { + enabled: false, + host: '127.0.0.1', + port: 3927, + token: '', +}; +/** @type {object | null} */ +let mcpSnapshot = null; + +const mcpServer = createMcpLocalServer(() => ({ + config: mcpConfig, + snapshot: mcpSnapshot, +})); + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), + // Disable webSecurity for local services (aria2 RPC, etc.) + webSecurity: false, + }, + icon: path.join(__dirname, '../dist/icon.svg'), + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + show: false, + }); + + if (isDev) { + mainWindow.loadURL('http://localhost:5173'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + } + + mainWindow.once('ready-to-show', () => { + mainWindow.show(); + if (process.platform === 'darwin') { + const template = [ + { + label: 'GitHub Stars Manager', + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Window', + submenu: [{ role: 'minimize' }, { role: 'close' }], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + } + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +// IPC — proxy stubs (keep API surface for Network panel) +ipcMain.handle('proxy:set', async (_e, config) => { + proxyConfig = { ...proxyConfig, ...config }; + return { success: true }; +}); +ipcMain.handle('proxy:get', async () => proxyConfig); +ipcMain.handle('proxy:test', async () => ({ + success: false, + error: 'Proxy test not implemented in this build', +})); + +// IPC — MCP +ipcMain.handle('mcp:setConfig', async (_e, config) => { + const rawHost = + typeof config?.host === 'string' && config.host.trim() ? config.host.trim() : '127.0.0.1'; + // Never allow non-loopback binds for the desktop MCP token surface + const host = + rawHost === '127.0.0.1' || rawHost === 'localhost' || rawHost === '::1' + ? rawHost === 'localhost' + ? '127.0.0.1' + : rawHost + : '127.0.0.1'; + mcpConfig = { + enabled: !!config?.enabled, + host, + port: + typeof config?.port === 'number' && config.port >= 1 && config.port <= 65535 + ? config.port + : 3927, + token: typeof config?.token === 'string' ? config.token : '', + }; + if (!mcpConfig.enabled) { + await mcpServer.stop(); + } + return { success: true }; +}); + +ipcMain.handle('mcp:getConfig', async () => mcpConfig); + +ipcMain.handle('mcp:pushSnapshot', async (_e, snapshot) => { + mcpSnapshot = snapshot || null; + return { success: true }; +}); + +ipcMain.handle('mcp:start', async () => { + try { + return await mcpServer.start(); + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +}); + +ipcMain.handle('mcp:stop', async () => mcpServer.stop()); + +ipcMain.handle('mcp:getStatus', async () => mcpServer.getStatus()); + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + void mcpServer.stop(); + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('before-quit', () => { + void mcpServer.stop(); +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } +}); + +app.on('web-contents-created', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); +}); diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js new file mode 100644 index 000000000..55dfe528b --- /dev/null +++ b/electron/mcpLocalServer.js @@ -0,0 +1,434 @@ +/** + * Lightweight local MCP HTTP server for Electron (read-only tools over JSON-RPC). + * Implements a practical subset of Streamable HTTP / JSON responses for local agents. + * Does not depend on @modelcontextprotocol/sdk so the desktop shell stays lean. + */ +const http = require('http'); +const crypto = require('crypto'); + +function performBasicTextSearch(repos, query) { + const normalizedQuery = String(query || '').toLowerCase().trim(); + if (!normalizedQuery) return repos; + const words = normalizedQuery.split(/\s+/).filter(Boolean); + return repos.filter((repo) => { + const text = [ + repo.name, + repo.full_name, + repo.description || '', + repo.custom_description || '', + repo.language || '', + ...(repo.topics || []), + repo.ai_summary || '', + ...(repo.ai_tags || []), + ...(repo.ai_platforms || []), + ...(repo.custom_tags || []), + repo.custom_category || '', + ] + .join(' ') + .toLowerCase(); + return words.every((w) => text.includes(w)); + }); +} + +function projectRepo(repo, max = 400) { + const summary = repo.ai_summary || repo.custom_description || repo.description || null; + const truncated = + typeof summary === 'string' && summary.length > max ? `${summary.slice(0, max)}…` : summary; + return { + id: repo.id, + full_name: repo.full_name, + name: repo.name, + html_url: repo.html_url, + description: repo.description, + language: repo.language, + stargazers_count: repo.stargazers_count, + topics: repo.topics || [], + ai_summary: truncated, + ai_tags: repo.ai_tags || [], + ai_platforms: repo.ai_platforms || [], + custom_description: repo.custom_description, + custom_tags: repo.custom_tags, + custom_category: repo.custom_category, + analyzed_at: repo.analyzed_at, + subscribed_to_releases: !!repo.subscribed_to_releases, + starred_at: repo.starred_at, + updated_at: repo.updated_at, + pushed_at: repo.pushed_at, + }; +} + +function getTools(vectorAvailable) { + const tools = [ + { + name: 'gsm_status', + description: 'Get GithubStarsManager MCP status.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'gsm_search_repos', + description: 'Keyword search over starred repositories with AI fields.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + languages: { type: 'array', items: { type: 'string' } }, + tags: { type: 'array', items: { type: 'string' } }, + category: { type: 'string' }, + minStars: { type: 'number' }, + maxStars: { type: 'number' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + }, + }, + }, + { + name: 'gsm_get_repo', + description: 'Get one repository by id or full_name.', + inputSchema: { + type: 'object', + properties: { idOrFullName: { type: 'string' } }, + required: ['idOrFullName'], + }, + }, + { + name: 'gsm_list_categories', + description: 'List custom categories.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'gsm_list_repos_by_category', + description: 'List repositories by custom_category.', + inputSchema: { + type: 'object', + properties: { + category: { type: 'string' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + }, + required: ['category'], + }, + }, + { + name: 'gsm_stats', + description: 'Aggregate stats over starred repositories.', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + if (vectorAvailable) { + tools.push({ + name: 'gsm_vector_search', + description: + 'Vector search is configured in the app but runs on the backend Worker; in Electron local mode use gsm_search_repos or connect backend MCP.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + }); + } + return tools; +} + +function callTool(name, args, snapshot) { + const repos = Array.isArray(snapshot?.repositories) ? snapshot.repositories : []; + const categories = Array.isArray(snapshot?.customCategories) ? snapshot.customCategories : []; + const vectorEnabled = !!( + snapshot?.vectorSearchConfig?.enabled && + snapshot?.vectorSearchConfig?.workerUrl && + snapshot?.vectorSearchConfig?.embeddingConfigId + ); + + const text = (data) => ({ + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }); + + switch (name) { + case 'gsm_status': + return text({ + name: 'github-stars-manager', + version: '0.7.0', + mode: 'electron-local', + repositoryCount: repos.length, + snapshotAt: snapshot?.snapshotAt || null, + vector: { + available: vectorEnabled, + reason: vectorEnabled + ? 'configured_in_app_use_backend_mcp_for_live_vector' + : 'vector_search_disabled', + }, + }); + case 'gsm_search_repos': { + let list = performBasicTextSearch(repos, args?.query || ''); + if (args?.languages?.length) { + list = list.filter((r) => r.language && args.languages.includes(r.language)); + } + if (args?.tags?.length) { + list = list.filter((r) => { + const tags = [...(r.ai_tags || []), ...(r.topics || []), ...(r.custom_tags || [])]; + return args.tags.some((t) => tags.includes(t)); + }); + } + if (args?.category) { + list = list.filter((r) => r.custom_category === args.category); + } + if (typeof args?.minStars === 'number') { + list = list.filter((r) => (r.stargazers_count || 0) >= args.minStars); + } + if (typeof args?.maxStars === 'number') { + list = list.filter((r) => (r.stargazers_count || 0) <= args.maxStars); + } + list = [...list].sort((a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0)); + const offset = Math.max(0, args?.offset || 0); + const limit = Math.min(100, Math.max(1, args?.limit || 20)); + const items = list.slice(offset, offset + limit).map((r) => projectRepo(r)); + return text({ total: list.length, count: items.length, offset, limit, items }); + } + case 'gsm_get_repo': { + const key = String(args?.idOrFullName || ''); + const repo = repos.find( + (r) => + String(r.id) === key || + (r.full_name && r.full_name.toLowerCase() === key.toLowerCase()) + ); + if (!repo) return text({ error: 'not_found', idOrFullName: key }); + return text(projectRepo(repo, 2000)); + } + case 'gsm_list_categories': + return text({ categories }); + case 'gsm_list_repos_by_category': { + const cat = args?.category; + let list = repos.filter((r) => r.custom_category === cat); + list = [...list].sort((a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0)); + const offset = Math.max(0, args?.offset || 0); + const limit = Math.min(100, Math.max(1, args?.limit || 20)); + const items = list.slice(offset, offset + limit).map((r) => projectRepo(r)); + return text({ total: list.length, count: items.length, items }); + } + case 'gsm_stats': { + const byLanguage = {}; + let analyzed = 0; + let subscribed = 0; + for (const r of repos) { + const lang = r.language || 'Unknown'; + byLanguage[lang] = (byLanguage[lang] || 0) + 1; + if (r.analyzed_at && !r.analysis_failed) analyzed += 1; + if (r.subscribed_to_releases) subscribed += 1; + } + return text({ + totalRepositories: repos.length, + analyzed, + subscribedToReleases: subscribed, + byLanguage, + }); + } + case 'gsm_vector_search': + return text({ + available: false, + reason: + 'Electron local MCP does not call embedding/worker APIs. Use backend-hosted MCP for gsm_vector_search, or gsm_search_repos for keyword search.', + }); + default: + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + } +} + +function timingSafeEqualString(a, b) { + const aBuf = Buffer.from(String(a || '')); + const bBuf = Buffer.from(String(b || '')); + if (aBuf.length !== bBuf.length) return false; + return crypto.timingSafeEqual(aBuf, bBuf); +} + +function createMcpLocalServer(getState) { + /** @type {import('http').Server | null} */ + let server = null; + let lastError = null; + + function handleJsonRpc(body, snapshot) { + const method = body?.method; + const id = body?.id ?? null; + const vectorAvailable = !!( + snapshot?.vectorSearchConfig?.enabled && + snapshot?.vectorSearchConfig?.workerUrl && + snapshot?.vectorSearchConfig?.embeddingConfigId + ); + + if (method === 'initialize') { + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: body?.params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'github-stars-manager', version: '0.7.0' }, + }, + }; + } + if (method === 'notifications/initialized' || method === 'initialized') { + return null; // notification + } + if (method === 'ping') { + return { jsonrpc: '2.0', id, result: {} }; + } + if (method === 'tools/list') { + return { + jsonrpc: '2.0', + id, + result: { tools: getTools(vectorAvailable) }, + }; + } + if (method === 'tools/call') { + const name = body?.params?.name; + const args = body?.params?.arguments || {}; + const result = callTool(name, args, snapshot); + return { jsonrpc: '2.0', id, result }; + } + return { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } + + function authOk(req, token) { + if (!token) return false; + const header = req.headers.authorization || ''; + const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; + const alt = req.headers['x-mcp-token']; + const provided = bearer || (typeof alt === 'string' ? alt.trim() : ''); + return provided && timingSafeEqualString(provided, token); + } + + async function start() { + const state = getState(); + if (!state.config?.enabled) { + return { success: false, error: 'MCP disabled' }; + } + if (server) { + return { success: true, url: `http://${state.config.host}:${state.config.port}/mcp` }; + } + + // Hard-bind loopback only — never listen on 0.0.0.0 for local MCP token auth + const requestedHost = state.config.host || '127.0.0.1'; + const host = + requestedHost === '0.0.0.0' || requestedHost === '::' || requestedHost === '[::]' + ? '127.0.0.1' + : requestedHost; + const port = state.config.port || 3927; + + server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-MCP-Token, Mcp-Session-Id' + ); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE'); + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const current = getState(); + if (!current.config?.enabled) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'MCP disabled', code: 'MCP_DISABLED' })); + return; + } + if (!authOk(req, current.config.token)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized', code: 'MCP_UNAUTHORIZED' })); + return; + } + + const url = new URL(req.url || '/', `http://${host}:${port}`); + if (url.pathname !== '/mcp' && url.pathname !== '/sse') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + if (req.method === 'GET') { + // Minimal health / capability probe + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + status: 'ok', + transport: 'json-rpc-http', + path: '/mcp', + mode: 'electron-local', + }) + ); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + const body = raw ? JSON.parse(raw) : {}; + const messages = Array.isArray(body) ? body : [body]; + const responses = []; + for (const msg of messages) { + const out = handleJsonRpc(msg, current.snapshot); + if (out) responses.push(out); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (Array.isArray(body)) { + res.end(JSON.stringify(responses)); + } else { + res.end(JSON.stringify(responses[0] || { jsonrpc: '2.0', result: {} })); + } + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON-RPC', detail: lastError })); + } + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', (err) => { + lastError = err.message; + server = null; + reject(err); + }); + server.listen(port, host, () => resolve()); + }); + + return { success: true, url: `http://${host}:${port}/mcp` }; + } + + async function stop() { + if (!server) return { success: true }; + await new Promise((resolve) => { + server.close(() => resolve()); + }); + server = null; + return { success: true }; + } + + function getStatus() { + const state = getState(); + const host = + !state.config.host || state.config.host === '0.0.0.0' || state.config.host === '::' + ? '127.0.0.1' + : state.config.host; + return { + running: !!server, + url: server ? `http://${host}:${state.config.port || 3927}/mcp` : undefined, + error: lastError || undefined, + }; + } + + return { start, stop, getStatus }; +} + +module.exports = { createMcpLocalServer }; diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 000000000..c4a9498d2 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,8 @@ +{ + "name": "github-stars-manager-desktop", + "version": "1.0.0", + "description": "GitHub Stars Manager Desktop App", + "main": "main.js", + "author": "GitHub Stars Manager", + "license": "MIT" +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 000000000..7be397199 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,15 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + setProxy: (config) => ipcRenderer.invoke('proxy:set', config), + getProxy: () => ipcRenderer.invoke('proxy:get'), + testProxy: (config) => ipcRenderer.invoke('proxy:test', config), + mcp: { + setConfig: (config) => ipcRenderer.invoke('mcp:setConfig', config), + getConfig: () => ipcRenderer.invoke('mcp:getConfig'), + pushSnapshot: (snapshot) => ipcRenderer.invoke('mcp:pushSnapshot', snapshot), + start: () => ipcRenderer.invoke('mcp:start'), + stop: () => ipcRenderer.invoke('mcp:stop'), + getStatus: () => ipcRenderer.invoke('mcp:getStatus'), + }, +}); diff --git a/nginx.conf.template b/nginx.conf.template index bcf2f289f..f282a28df 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -50,6 +50,60 @@ http { client_max_body_size 100m; } + # MCP Streamable HTTP + nested SSE paths (agents: http://localhost:8080/mcp) + # Use prefix location so /mcp/sse and /mcp/sse/messages are covered too. + location /mcp { + set $backend_upstream ${BACKEND_HOST}; + proxy_pass http://$backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_set_header Mcp-Session-Id $http_mcp_session_id; + proxy_set_header Connection ''; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + client_max_body_size 100m; + } + + # Legacy SSE aliases (older clients) + location = /sse { + set $backend_upstream ${BACKEND_HOST}; + proxy_pass http://$backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding on; + } + + location = /messages { + set $backend_upstream ${BACKEND_HOST}; + proxy_pass http://$backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_buffering off; + proxy_read_timeout 300s; + client_max_body_size 100m; + } + # Handle preflight requests for CORS location / { diff --git a/scripts/build-desktop.js b/scripts/build-desktop.js index 8ad5b70e3..5145b4d39 100644 --- a/scripts/build-desktop.js +++ b/scripts/build-desktop.js @@ -10,158 +10,20 @@ console.log('🚀 开始构建桌面应用...'); console.log('📦 构建Web应用...'); execSync('npm run build', { stdio: 'inherit' }); -// 2. 创建Electron目录和文件 -console.log('⚡ 设置Electron环境...'); +// 2. Electron sources are committed under electron/ (main.js, preload.js, mcpLocalServer.js). +// Do NOT overwrite them with a generated shell — MCP + preload require first-class sources. const electronDir = path.join(__dirname, '../electron'); -if (!fs.existsSync(electronDir)) { - fs.mkdirSync(electronDir, { recursive: true }); -} - -// 3. 创建主进程文件 -const mainJs = ` -const { app, BrowserWindow, Menu, shell } = require('electron'); -const path = require('path'); -const isDev = process.env.NODE_ENV === 'development'; - -let mainWindow; - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - enableRemoteModule: false, - // Disable webSecurity to allow cross-origin requests to local services (aria2 RPC, etc.) - // Safe for desktop app: only loads local files, no arbitrary web content - webSecurity: false - }, - icon: path.join(__dirname, '../dist/icon.svg'), - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', - show: false - }); - - // 加载应用 - if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools(); - } else { - mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); +const required = ['main.js', 'preload.js', 'mcpLocalServer.js', 'package.json']; +for (const file of required) { + const p = path.join(electronDir, file); + if (!fs.existsSync(p)) { + console.error(`❌ Missing required Electron file: electron/${file}`); + process.exit(1); } - - mainWindow.once('ready-to-show', () => { - mainWindow.show(); - - // 设置应用菜单 - if (process.platform === 'darwin') { - const template = [ - { - label: 'GitHub Stars Manager', - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' } - ] - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'selectall' } - ] - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' } - ] - }, - { - label: 'Window', - submenu: [ - { role: 'minimize' }, - { role: 'close' } - ] - } - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - } - }); - - // 处理外部链接 - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); } +console.log('⚡ 使用已提交的 electron/ 源码(含 MCP 与 preload)'); -app.whenReady().then(createWindow); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } -}); - -// 安全设置 -app.on('web-contents-created', (event, contents) => { - contents.on('new-window', (event, navigationUrl) => { - event.preventDefault(); - shell.openExternal(navigationUrl); - }); -}); -`; - -fs.writeFileSync(path.join(electronDir, 'main.js'), mainJs); - -// 4. 创建Electron package.json -const electronPackageJson = { - name: 'github-stars-manager-desktop', - version: '1.0.0', - description: 'GitHub Stars Manager Desktop App', - main: 'main.js', - author: 'GitHub Stars Manager', - license: 'MIT' -}; - -fs.writeFileSync( - path.join(electronDir, 'package.json'), - JSON.stringify(electronPackageJson, null, 2) -); - -// 5. 安装Electron依赖 +// 3. 安装Electron依赖 console.log('📥 安装Electron依赖...'); try { execSync('npm install --save-dev electron electron-builder', { stdio: 'inherit' }); @@ -170,7 +32,7 @@ try { process.exit(1); } -// 6. 构建应用 +// 4. 构建应用 console.log('🔨 构建桌面应用...'); try { execSync('npx electron-builder', { stdio: 'inherit' }); @@ -179,4 +41,4 @@ try { } catch (error) { console.error('构建失败:', error.message); process.exit(1); -} \ No newline at end of file +} diff --git a/server/package-lock.json b/server/package-lock.json index 5658d03ef..667b5ae76 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,13 +8,15 @@ "name": "github-stars-manager-server", "version": "0.1.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "axios": "^1.7.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "express": "^4.21.0", "helmet": "^7.1.0", "morgan": "^1.10.0", - "socks-proxy-agent": "^9.0.0" + "socks-proxy-agent": "^9.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -470,6 +472,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -490,6 +504,392 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1211,6 +1611,39 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "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/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -1561,7 +1994,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1826,6 +2258,27 @@ "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.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -1905,6 +2358,54 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/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/express-rate-limit/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/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-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1912,6 +2413,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "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", @@ -2175,6 +2692,15 @@ "node": ">=16.0.0" } }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "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", @@ -2303,6 +2829,12 @@ "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/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -2320,9 +2852,17 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2330,6 +2870,18 @@ "dev": true, "license": "MIT" }, + "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/local-pkg": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", @@ -2708,7 +3260,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2744,6 +3295,15 @@ "dev": true, "license": "ISC" }, + "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/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2941,6 +3501,15 @@ "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/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2996,6 +3565,55 @@ "fsevents": "~2.3.2" } }, + "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/router/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/router/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/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3089,7 +3707,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3102,21 +3719,20 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "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==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3128,13 +3744,13 @@ } }, "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==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4281,7 +4897,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4328,6 +4943,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/server/package.json b/server/package.json index 652bf88cd..dd217bd3d 100644 --- a/server/package.json +++ b/server/package.json @@ -11,23 +11,25 @@ "test:watch": "vitest" }, "dependencies": { - "express": "^4.21.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "axios": "^1.7.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", + "express": "^4.21.0", "helmet": "^7.1.0", "morgan": "^1.10.0", - "axios": "^1.7.0", - "socks-proxy-agent": "^9.0.0" + "socks-proxy-agent": "^9.0.0", + "zod": "^4.4.3" }, "devDependencies": { - "@types/express": "^4.17.21", "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", + "@types/supertest": "^6.0.2", + "supertest": "^6.3.4", "tsx": "^4.7.0", "typescript": "^5.5.3", - "vitest": "^1.6.0", - "supertest": "^6.3.4", - "@types/supertest": "^6.0.2" + "vitest": "^1.6.0" } } diff --git a/server/src/index.ts b/server/src/index.ts index d7ef9e5cc..5b204e080 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,13 +16,27 @@ import configsRouter from './routes/configs.js'; import syncRouter from './routes/sync.js'; import proxyRouter from './routes/proxy.js'; import logsRouter from './routes/logs.js'; +import mcpAdminRouter from './routes/mcp.js'; +import { mountMcpRoutes } from './mcp/http.js'; export function createApp(): express.Express { const app = express(); - // Middleware + // Keep default helmet (incl. CSP). MCP is a machine API; agents are not browser-CSP clients. app.use(helmet()); - app.use(cors({ exposedHeaders: ['X-Log-Count'] })); + app.use( + cors({ + exposedHeaders: ['X-Log-Count', 'Mcp-Session-Id', 'mcp-session-id'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-MCP-Token', + 'Mcp-Session-Id', + 'mcp-session-id', + 'Last-Event-ID', + ], + }) + ); app.use(morgan('combined', { stream: morganLoggerStream })); app.use(express.json({ limit: '50mb' })); @@ -45,6 +59,13 @@ export function createApp(): express.Express { // Wave 4: Logs route app.use(logsRouter); + // MCP admin API (protected by API_SECRET via /api middleware above) + app.use(mcpAdminRouter); + + // MCP Streamable HTTP + legacy SSE (own token auth; not under /api) + // Mount always; each request is gated on live SQLite settings (no write on mount). + mountMcpRoutes(app); + // Global error handler app.use(errorHandler); @@ -81,7 +102,8 @@ function startServer(): void { } // Only start server when run directly (not imported for tests) -const isMainModule = process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; +const isMainModule = + process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isMainModule) { startServer(); } diff --git a/server/src/mcp/http.ts b/server/src/mcp/http.ts new file mode 100644 index 000000000..678401278 --- /dev/null +++ b/server/src/mcp/http.ts @@ -0,0 +1,154 @@ +import type { Express, Request, Response, NextFunction } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { createMcpServer } from './server.js'; +import { + getMcpTokenPlain, + isMcpEnabled, + timingSafeEqualString, +} from './settings.js'; +import { logger } from '../services/logger.js'; + +/** Legacy SSE transports keyed by session id (cleaned on connection close). */ +const sseSessions = new Map(); + +function extractBearer(req: Request): string | null { + const header = req.headers.authorization; + if (typeof header === 'string' && header.toLowerCase().startsWith('bearer ')) { + return header.slice(7).trim(); + } + const alt = req.headers['x-mcp-token']; + if (typeof alt === 'string' && alt.trim()) return alt.trim(); + return null; +} + +/** + * Live config gate: read enabled + token from SQLite on every request so toggles + * take effect without restart. No module-level server or cached token. + */ +export function mcpAuthMiddleware(req: Request, res: Response, next: NextFunction): void { + if (!isMcpEnabled()) { + // 404 when disabled — do not advertise MCP surface + res.status(404).json({ error: 'not found', code: 'MCP_DISABLED' }); + return; + } + + const expected = getMcpTokenPlain(); + if (!expected) { + res.status(503).json({ error: 'MCP token not configured', code: 'MCP_TOKEN_MISSING' }); + return; + } + + const provided = extractBearer(req); + if (!provided || !timingSafeEqualString(provided, expected)) { + res.status(401).json({ error: 'Unauthorized', code: 'MCP_UNAUTHORIZED' }); + return; + } + + next(); +} + +/** + * Stateless Streamable HTTP: new transport + McpServer per request. + * Avoids unbounded session maps and ensures tools/list reflects live vector config. + */ +async function handleStreamable(req: Request, res: Response): Promise { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on('close', () => { + void transport.close().catch(() => undefined); + }); + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + logger.errorFromError('mcp.streamable', 'Streamable HTTP request failed', err); + if (!res.headersSent) { + res.status(500).json({ error: 'MCP request failed' }); + } + } +} + +/** + * Mount MCP Streamable HTTP (/mcp) and legacy SSE (/mcp/sse + /mcp/sse/messages). + * Auth uses MCP token (not API_SECRET). Config is read per request — no token write on mount. + */ +export function mountMcpRoutes(app: Express): void { + app.all('/mcp', mcpAuthMiddleware, (req, res) => { + void handleStreamable(req, res); + }); + + // Legacy SSE under /mcp/* so a single nginx location /mcp can proxy if needed; + // dedicated /mcp/sse still works with explicit nginx blocks. + app.get('/mcp/sse', mcpAuthMiddleware, async (req, res) => { + try { + const transport = new SSEServerTransport('/mcp/sse/messages', res); + sseSessions.set(transport.sessionId, transport); + res.on('close', () => { + sseSessions.delete(transport.sessionId); + const maybeClose = (transport as { close?: () => Promise }).close; + if (typeof maybeClose === 'function') { + void maybeClose.call(transport).catch(() => undefined); + } + }); + const server = createMcpServer(); + await server.connect(transport); + } catch (err) { + logger.errorFromError('mcp.sse', 'SSE connection failed', err); + if (!res.headersSent) res.status(500).end(); + } + }); + + app.post('/mcp/sse/messages', mcpAuthMiddleware, async (req, res) => { + const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''; + const transport = sseSessions.get(sessionId); + if (!transport) { + res.status(404).json({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' }); + return; + } + try { + await transport.handlePostMessage(req, res, req.body); + } catch (err) { + logger.errorFromError('mcp.sse', 'SSE message failed', err); + if (!res.headersSent) res.status(500).json({ error: 'SSE message failed' }); + } + }); + + // Backward-compatible aliases (older docs / clients) + app.get('/sse', mcpAuthMiddleware, async (req, res) => { + try { + const transport = new SSEServerTransport('/messages', res); + sseSessions.set(transport.sessionId, transport); + res.on('close', () => { + sseSessions.delete(transport.sessionId); + }); + const server = createMcpServer(); + await server.connect(transport); + } catch (err) { + logger.errorFromError('mcp.sse', 'SSE connection failed', err); + if (!res.headersSent) res.status(500).end(); + } + }); + + app.post('/messages', mcpAuthMiddleware, async (req, res) => { + const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''; + const transport = sseSessions.get(sessionId); + if (!transport) { + res.status(404).json({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' }); + return; + } + try { + await transport.handlePostMessage(req, res, req.body); + } catch (err) { + logger.errorFromError('mcp.sse', 'SSE message failed', err); + if (!res.headersSent) res.status(500).json({ error: 'SSE message failed' }); + } + }); + + logger.info( + 'mcp.mount', + 'MCP routes registered at /mcp (Streamable HTTP) and /mcp/sse (legacy); gated by settings' + ); +} diff --git a/server/src/mcp/provider.ts b/server/src/mcp/provider.ts new file mode 100644 index 000000000..016d81142 --- /dev/null +++ b/server/src/mcp/provider.ts @@ -0,0 +1,330 @@ +import { getDb } from '../db/connection.js'; +import { decrypt } from '../services/crypto.js'; +import { config } from '../config.js'; +import { logger } from '../services/logger.js'; +import { + type McpRepository, + type McpSearchFilters, + projectRepoForAgent, + searchRepositories, +} from './repoSearch.js'; + +function parseJsonColumn(value: unknown): unknown[] { + if (typeof value !== 'string' || !value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function transformRepoRow(row: Record): McpRepository { + return { + id: row.id as number, + name: row.name as string, + full_name: row.full_name as string, + description: (row.description as string | null) ?? null, + html_url: row.html_url as string, + stargazers_count: (row.stargazers_count as number) ?? 0, + language: (row.language as string | null) ?? null, + created_at: (row.created_at as string | null) ?? null, + updated_at: (row.updated_at as string | null) ?? null, + pushed_at: (row.pushed_at as string | null) ?? null, + starred_at: (row.starred_at as string | null) ?? null, + owner: { + login: (row.owner_login as string) ?? '', + avatar_url: (row.owner_avatar_url as string) ?? '', + }, + topics: parseJsonColumn(row.topics) as string[], + ai_summary: (row.ai_summary as string | null) ?? null, + ai_tags: parseJsonColumn(row.ai_tags) as string[], + ai_platforms: parseJsonColumn(row.ai_platforms) as string[], + analyzed_at: (row.analyzed_at as string | null) ?? null, + analysis_failed: !!row.analysis_failed, + custom_description: (row.custom_description as string | null) ?? null, + custom_tags: parseJsonColumn(row.custom_tags) as string[], + custom_category: (row.custom_category as string | null) ?? null, + category_locked: !!row.category_locked, + subscribed_to_releases: !!row.subscribed_to_releases, + }; +} + +/** Soft cap to avoid unbounded memory if a DB ever holds extreme row counts. */ +const MAX_REPOS_IN_MEMORY = 50_000; + +export function loadAllRepositories(): McpRepository[] { + const db = getDb(); + const rows = db + .prepare('SELECT * FROM repositories ORDER BY stargazers_count DESC LIMIT ?') + .all(MAX_REPOS_IN_MEMORY) as Record[]; + return rows.map(transformRepoRow); +} + +export function getRepository(idOrFullName: string | number): McpRepository | null { + const db = getDb(); + let row: Record | undefined; + if (typeof idOrFullName === 'number' || /^\d+$/.test(String(idOrFullName))) { + row = db + .prepare('SELECT * FROM repositories WHERE id = ?') + .get(Number(idOrFullName)) as Record | undefined; + } else { + row = db + .prepare('SELECT * FROM repositories WHERE full_name = ? COLLATE NOCASE') + .get(String(idOrFullName)) as Record | undefined; + } + return row ? transformRepoRow(row) : null; +} + +export function listCategories(): Array> { + const db = getDb(); + const rows = db + .prepare('SELECT * FROM categories ORDER BY sort_order ASC, name ASC') + .all() as Record[]; + return rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + keywords: parseJsonColumn(row.keywords), + color: row.color, + sort_order: row.sort_order, + })); +} + +export function searchRepos(filters: McpSearchFilters) { + const all = loadAllRepositories(); + const { items, total } = searchRepositories(all, filters); + return { + total, + count: items.length, + offset: filters.offset ?? 0, + limit: Math.min(100, Math.max(1, filters.limit ?? 20)), + items: items.map((r) => projectRepoForAgent(r)), + }; +} + +export function getStats() { + const repos = loadAllRepositories(); + const byLanguage: Record = {}; + const tagCounts: Record = {}; + let analyzed = 0; + let subscribed = 0; + let failed = 0; + + for (const r of repos) { + const lang = r.language || 'Unknown'; + byLanguage[lang] = (byLanguage[lang] || 0) + 1; + if (r.analyzed_at && !r.analysis_failed) analyzed += 1; + if (r.analyzed_at && r.analysis_failed) failed += 1; + if (r.subscribed_to_releases) subscribed += 1; + for (const tag of [...(r.ai_tags || []), ...(r.custom_tags || [])]) { + tagCounts[tag] = (tagCounts[tag] || 0) + 1; + } + } + + const topTags = Object.entries(tagCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([tag, count]) => ({ tag, count })); + + return { + totalRepositories: repos.length, + analyzed, + analysisFailed: failed, + unanalyzed: repos.length - analyzed - failed, + subscribedToReleases: subscribed, + byLanguage, + topTags, + }; +} + +export interface VectorAvailability { + available: boolean; + reason?: string; + workerUrl?: string; + embeddingModel?: string; +} + +export function getVectorAvailability(): VectorAvailability { + const db = getDb(); + const row = db + .prepare('SELECT * FROM vector_search_configs WHERE id = ?') + .get('default') as Record | undefined; + + if (!row || !row.enabled) { + return { available: false, reason: 'vector_search_disabled' }; + } + const workerUrl = String(row.worker_url || '').trim(); + if (!workerUrl) { + return { available: false, reason: 'worker_url_missing' }; + } + const embeddingId = String(row.embedding_config_id || '').trim(); + if (!embeddingId) { + return { available: false, reason: 'embedding_config_missing' }; + } + const emb = db + .prepare('SELECT * FROM embedding_configs WHERE id = ?') + .get(embeddingId) as Record | undefined; + if (!emb) { + return { available: false, reason: 'embedding_config_not_found' }; + } + const hasKey = !!emb.api_key_encrypted || emb.api_type === 'ollama'; + if (!hasKey && emb.api_type !== 'ollama') { + return { available: false, reason: 'embedding_api_key_missing' }; + } + return { + available: true, + workerUrl, + embeddingModel: String(emb.model || ''), + }; +} + +async function embedQuery( + texts: string[], + emb: Record +): Promise { + const apiType = String(emb.api_type || 'openai'); + const model = String(emb.model || ''); + let apiKey = ''; + if (emb.api_key_encrypted) { + try { + apiKey = decrypt(String(emb.api_key_encrypted), config.encryptionKey); + } catch { + throw new Error('Failed to decrypt embedding API key'); + } + } + + let baseUrl = String(emb.base_url || '').replace(/\/$/, ''); + let url: string; + let headers: Record = { 'Content-Type': 'application/json' }; + let body: Record; + + if (apiType === 'ollama') { + url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embeddings`; + body = { model, prompt: texts[0] }; + } else if (apiType === 'gemini') { + const keyQ = apiKey ? `?key=${encodeURIComponent(apiKey)}` : ''; + url = `${baseUrl || 'https://generativelanguage.googleapis.com'}/v1beta/models/${model}:embedContent${keyQ}`; + body = { content: { parts: [{ text: texts[0] }] } }; + } else if (apiType === 'cohere') { + url = `${baseUrl || 'https://api.cohere.com'}/v1/embed`; + headers.Authorization = `Bearer ${apiKey}`; + body = { model, texts, input_type: 'search_query' }; + } else { + // openai / openai-compatible / siliconflow + if (!baseUrl) { + baseUrl = + apiType === 'siliconflow' + ? 'https://api.siliconflow.cn/v1' + : 'https://api.openai.com/v1'; + } + url = `${baseUrl}/embeddings`; + headers.Authorization = `Bearer ${apiKey}`; + body = { model, input: texts }; + } + + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Embedding API error ${res.status}: ${text.slice(0, 200)}`); + } + const data = (await res.json()) as Record; + + if (apiType === 'ollama') { + const embVec = data.embedding as number[] | undefined; + if (!embVec) throw new Error('Ollama embedding missing'); + return embVec; + } + if (apiType === 'gemini') { + const embObj = data.embedding as { values?: number[] } | undefined; + if (!embObj?.values) throw new Error('Gemini embedding missing'); + return embObj.values; + } + if (apiType === 'cohere') { + const embeddings = data.embeddings as number[][] | undefined; + if (!embeddings?.[0]) throw new Error('Cohere embedding missing'); + return embeddings[0]; + } + const list = data.data as Array<{ embedding: number[] }> | undefined; + if (!list?.[0]?.embedding) throw new Error('OpenAI-compatible embedding missing'); + return list[0].embedding; +} + +export async function vectorSearch( + query: string, + opts: { topK?: number; threshold?: number } = {} +): Promise<{ available: false; reason: string } | { available: true; matches: Array> }> { + const availability = getVectorAvailability(); + if (!availability.available) { + return { available: false, reason: availability.reason || 'unavailable' }; + } + + const db = getDb(); + const vs = db + .prepare('SELECT * FROM vector_search_configs WHERE id = ?') + .get('default') as Record; + const emb = db + .prepare('SELECT * FROM embedding_configs WHERE id = ?') + .get(String(vs.embedding_config_id)) as Record; + + let workerToken = ''; + if (vs.auth_token_encrypted) { + try { + workerToken = decrypt(String(vs.auth_token_encrypted), config.encryptionKey); + } catch (err) { + logger.warn('mcp.vector', 'Failed to decrypt worker auth token'); + return { available: false, reason: 'worker_token_decrypt_failed' }; + } + } + + const topK = Math.min(50, Math.max(1, opts.topK ?? 20)); + const threshold = opts.threshold ?? 0.35; + + let vector: number[]; + try { + vector = await embedQuery([query], emb); + } catch (err) { + logger.warn('mcp.vector', 'Embedding failed', { + error: err instanceof Error ? err.message : String(err), + }); + return { available: false, reason: `embedding_failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + const workerUrl = String(vs.worker_url).replace(/\/$/, ''); + const res = await fetch(`${workerUrl}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(workerToken ? { Authorization: `Bearer ${workerToken}` } : {}), + }, + body: JSON.stringify({ vector, topK, threshold }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + return { available: false, reason: `worker_query_failed: ${res.status} ${text.slice(0, 120)}` }; + } + const data = (await res.json()) as { + matches?: Array<{ id: string; score: number; metadata?: Record }>; + }; + const matches = data.matches || []; + const repos = loadAllRepositories(); + const byId = new Map(repos.map((r) => [String(r.id), r])); + + const enriched = matches + .map((m) => { + const repo = byId.get(String(m.id)); + if (!repo) return null; + return { + score: m.score, + ...projectRepoForAgent(repo), + }; + }) + .filter(Boolean) as Array>; + + return { available: true, matches: enriched }; +} diff --git a/server/src/mcp/repoSearch.ts b/server/src/mcp/repoSearch.ts new file mode 100644 index 000000000..ddc6a5ea8 --- /dev/null +++ b/server/src/mcp/repoSearch.ts @@ -0,0 +1,200 @@ +/** + * Pure repo search helpers for MCP (mirrors src/utils/repoSearch.ts). + * Kept server-local to avoid coupling the Express package to the Vite app tree. + */ + +export interface McpRepository { + id: number; + name: string; + full_name: string; + description: string | null; + html_url: string; + stargazers_count: number; + language: string | null; + created_at?: string | null; + updated_at?: string | null; + pushed_at?: string | null; + starred_at?: string | null; + topics: string[]; + ai_summary?: string | null; + ai_tags?: string[]; + ai_platforms?: string[]; + analyzed_at?: string | null; + analysis_failed?: boolean; + custom_description?: string | null; + custom_tags?: string[]; + custom_category?: string | null; + category_locked?: boolean; + subscribed_to_releases?: boolean; + owner?: { login: string; avatar_url?: string }; +} + +export interface McpSearchFilters { + query?: string; + tags?: string[]; + languages?: string[]; + platforms?: string[]; + sortBy?: 'stars' | 'updated' | 'name' | 'starred'; + sortOrder?: 'desc' | 'asc'; + minStars?: number; + maxStars?: number; + isAnalyzed?: boolean; + isSubscribed?: boolean; + isCategoryLocked?: boolean; + analysisFailed?: boolean; + category?: string; + limit?: number; + offset?: number; +} + +export function performBasicTextSearch(repos: T[], query: string): T[] { + const normalizedQuery = query.toLowerCase().trim(); + if (!normalizedQuery) return repos; + const queryWords = normalizedQuery.split(/\s+/).filter(Boolean); + + return repos.filter((repo) => { + const searchableText = [ + repo.name, + repo.full_name, + repo.description || '', + repo.custom_description || '', + repo.language || '', + ...(repo.topics || []), + repo.ai_summary || '', + ...(repo.ai_tags || []), + ...(repo.ai_platforms || []), + ...(repo.custom_tags || []), + repo.custom_category || '', + ] + .join(' ') + .toLowerCase(); + return queryWords.every((word) => searchableText.includes(word)); + }); +} + +function getSortValue(repo: McpRepository, sortBy: McpSearchFilters['sortBy']): number | string { + switch (sortBy) { + case 'stars': + return repo.stargazers_count ?? 0; + case 'updated': + return new Date(repo.pushed_at || repo.updated_at || 0).getTime(); + case 'name': + return repo.name.toLowerCase(); + case 'starred': + return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; + default: + return new Date(repo.pushed_at || repo.updated_at || 0).getTime(); + } +} + +export function applyRepoFilters( + repos: T[], + filters: McpSearchFilters +): T[] { + let filtered: T[] = repos; + + if (filters.languages?.length) { + filtered = filtered.filter((r) => r.language && filters.languages!.includes(r.language)); + } + if (filters.tags?.length) { + filtered = filtered.filter((r) => { + const tags = [...(r.ai_tags || []), ...(r.topics || []), ...(r.custom_tags || [])]; + return filters.tags!.some((t) => tags.includes(t)); + }); + } + if (filters.platforms?.length) { + filtered = filtered.filter((r) => { + const platforms = r.ai_platforms || []; + return filters.platforms!.some((p) => platforms.includes(p)); + }); + } + if (filters.isAnalyzed !== undefined && filters.analysisFailed === undefined) { + filtered = filtered.filter((r) => + filters.isAnalyzed ? !!r.analyzed_at && !r.analysis_failed : !r.analyzed_at + ); + } + if (filters.isSubscribed !== undefined) { + filtered = filtered.filter((r) => + filters.isSubscribed ? !!r.subscribed_to_releases : !r.subscribed_to_releases + ); + } + if (filters.isCategoryLocked !== undefined) { + filtered = filtered.filter((r) => + filters.isCategoryLocked ? !!r.category_locked : !r.category_locked + ); + } + if (filters.analysisFailed !== undefined && filters.isAnalyzed === undefined) { + filtered = filtered.filter((r) => { + const hasFailed = !!(r.analyzed_at && r.analysis_failed); + return filters.analysisFailed ? hasFailed : !hasFailed; + }); + } + if (filters.minStars !== undefined) { + filtered = filtered.filter((r) => (r.stargazers_count ?? 0) >= filters.minStars!); + } + if (filters.maxStars !== undefined) { + filtered = filtered.filter((r) => (r.stargazers_count ?? 0) <= filters.maxStars!); + } + if (filters.category && filters.category !== 'all') { + filtered = filtered.filter((r) => r.custom_category === filters.category); + } + + const sortBy = filters.sortBy ?? 'stars'; + const sortOrder = filters.sortOrder ?? 'desc'; + const sorted = [...filtered]; + sorted.sort((a, b) => { + const aValue = getSortValue(a, sortBy); + const bValue = getSortValue(b, sortBy); + if (aValue < bValue) return sortOrder === 'desc' ? 1 : -1; + if (aValue > bValue) return sortOrder === 'desc' ? -1 : 1; + return 0; + }); + return sorted; +} + +export function searchRepositories( + repos: T[], + filters: McpSearchFilters +): { items: T[]; total: number } { + let result = repos; + if (filters.query?.trim()) { + result = performBasicTextSearch(result, filters.query); + } + result = applyRepoFilters(result, filters); + const total = result.length; + const offset = Math.max(0, filters.offset ?? 0); + const limit = Math.min(100, Math.max(1, filters.limit ?? 20)); + return { items: result.slice(offset, offset + limit), total }; +} + +export function projectRepoForAgent( + repo: McpRepository, + opts: { summaryMaxChars?: number } = {} +): Record { + const max = opts.summaryMaxChars ?? 400; + const summary = repo.ai_summary || repo.custom_description || repo.description || null; + const truncated = + typeof summary === 'string' && summary.length > max ? `${summary.slice(0, max)}…` : summary; + + return { + id: repo.id, + full_name: repo.full_name, + name: repo.name, + html_url: repo.html_url, + description: repo.description, + language: repo.language, + stargazers_count: repo.stargazers_count, + topics: repo.topics ?? [], + ai_summary: truncated, + ai_tags: repo.ai_tags ?? [], + ai_platforms: repo.ai_platforms ?? [], + custom_description: repo.custom_description, + custom_tags: repo.custom_tags, + custom_category: repo.custom_category, + analyzed_at: repo.analyzed_at, + subscribed_to_releases: !!repo.subscribed_to_releases, + starred_at: repo.starred_at, + updated_at: repo.updated_at, + pushed_at: repo.pushed_at, + }; +} diff --git a/server/src/mcp/server.ts b/server/src/mcp/server.ts new file mode 100644 index 000000000..b0b78f7ff --- /dev/null +++ b/server/src/mcp/server.ts @@ -0,0 +1,11 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerMcpTools } from './tools.js'; + +export function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'github-stars-manager', + version: '0.7.0', + }); + registerMcpTools(server); + return server; +} diff --git a/server/src/mcp/settings.ts b/server/src/mcp/settings.ts new file mode 100644 index 000000000..07b5cbeab --- /dev/null +++ b/server/src/mcp/settings.ts @@ -0,0 +1,101 @@ +import crypto from 'node:crypto'; +import { getDb } from '../db/connection.js'; +import { encrypt, decrypt } from '../services/crypto.js'; +import { config } from '../config.js'; +import { logger } from '../services/logger.js'; + +export const MCP_SETTING_ENABLED = 'mcp_enabled'; +export const MCP_SETTING_TOKEN = 'mcp_token'; + +/** AES-GCM ciphertext format used by services/crypto: iv:ciphertext:tag */ +function looksEncrypted(value: string): boolean { + const parts = value.split(':'); + return parts.length === 3 && parts.every((p) => p.length > 0); +} + +export function generateMcpToken(): string { + return `gsm_mcp_${crypto.randomBytes(24).toString('base64url')}`; +} + +export function getSettingRaw(key: string): string | null { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as + | { value: string | null } + | undefined; + return row?.value ?? null; +} + +export function setSettingRaw(key: string, value: string | null): void { + const db = getDb(); + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); +} + +export function isMcpEnabled(): boolean { + const raw = getSettingRaw(MCP_SETTING_ENABLED); + return raw === '1' || raw === 'true'; +} + +export function setMcpEnabled(enabled: boolean): void { + setSettingRaw(MCP_SETTING_ENABLED, enabled ? '1' : '0'); +} + +/** Returns plaintext MCP token, or null if unset / undecryptable. */ +export function getMcpTokenPlain(): string | null { + const stored = getSettingRaw(MCP_SETTING_TOKEN); + if (!stored) return null; + try { + if (looksEncrypted(stored)) { + return decrypt(stored, config.encryptionKey); + } + // Legacy / resilience: accept plaintext only if it looks like our token prefix + if (stored.startsWith('gsm_mcp_')) { + return stored; + } + logger.warn('mcp.token', 'Ignoring mcp_token with unexpected format'); + return null; + } catch (err) { + logger.warn('mcp.token', 'Failed to decrypt mcp_token', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +export function setMcpTokenPlain(token: string): void { + if (!token || typeof token !== 'string') { + throw new Error('MCP token must be a non-empty string'); + } + const encrypted = encrypt(token, config.encryptionKey); + setSettingRaw(MCP_SETTING_TOKEN, encrypted); +} + +/** Create token only if missing. Does not enable MCP. */ +export function ensureMcpToken(): string { + const existing = getMcpTokenPlain(); + if (existing) return existing; + const token = generateMcpToken(); + setMcpTokenPlain(token); + return token; +} + +export function resetMcpToken(): string { + const token = generateMcpToken(); + setMcpTokenPlain(token); + return token; +} + +/** + * Constant-time string compare. Different lengths always return false without + * short-circuiting the comparison on shared prefix length of the shorter buffer. + */ +export function timingSafeEqualString(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) { + // Compare against self-sized zero buffer to keep roughly constant work for wrong length + const dummy = Buffer.alloc(aBuf.length); + crypto.timingSafeEqual(aBuf, dummy); + return false; + } + return crypto.timingSafeEqual(aBuf, bBuf); +} diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts new file mode 100644 index 000000000..7b334c9d7 --- /dev/null +++ b/server/src/mcp/tools.ts @@ -0,0 +1,170 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + getRepository, + getStats, + getVectorAvailability, + listCategories, + loadAllRepositories, + searchRepos, + vectorSearch, +} from './provider.js'; +import { projectRepoForAgent } from './repoSearch.js'; + +function textResult(data: unknown) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], + }; +} + +export function registerMcpTools(server: McpServer): void { + server.registerTool( + 'gsm_status', + { + description: + 'Get GithubStarsManager MCP status: repo count, vector availability, and version.', + }, + async () => { + const repos = loadAllRepositories(); + const vector = getVectorAvailability(); + return textResult({ + name: 'github-stars-manager', + version: '0.7.0', + mode: 'backend-sqlite', + repositoryCount: repos.length, + vector: { + available: vector.available, + reason: vector.reason, + embeddingModel: vector.embeddingModel, + }, + toolsNote: vector.available + ? 'gsm_vector_search is available' + : 'gsm_vector_search is not listed until vector search is configured and enabled', + }); + } + ); + + server.registerTool( + 'gsm_search_repos', + { + description: + 'Keyword search over starred repositories including AI summaries/tags and custom fields. Supports filters and pagination.', + inputSchema: { + query: z.string().optional().describe('Keyword query (AND of words)'), + languages: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + platforms: z.array(z.string()).optional(), + category: z.string().optional().describe('custom_category exact match'), + minStars: z.number().optional(), + maxStars: z.number().optional(), + isAnalyzed: z.boolean().optional(), + isSubscribed: z.boolean().optional(), + sortBy: z.enum(['stars', 'updated', 'name', 'starred']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + limit: z.number().min(1).max(100).optional(), + offset: z.number().min(0).optional(), + }, + }, + async (args) => { + const result = searchRepos({ + query: args.query, + languages: args.languages, + tags: args.tags, + platforms: args.platforms, + category: args.category, + minStars: args.minStars, + maxStars: args.maxStars, + isAnalyzed: args.isAnalyzed, + isSubscribed: args.isSubscribed, + sortBy: args.sortBy, + sortOrder: args.sortOrder, + limit: args.limit, + offset: args.offset, + }); + return textResult(result); + } + ); + + server.registerTool( + 'gsm_get_repo', + { + description: + 'Get one repository by numeric id or full_name (e.g. owner/repo). Returns processed AI fields.', + inputSchema: { + idOrFullName: z.string().describe('Repository id or full_name'), + }, + }, + async (args) => { + const repo = getRepository(args.idOrFullName); + if (!repo) { + return textResult({ error: 'not_found', idOrFullName: args.idOrFullName }); + } + return textResult(projectRepoForAgent(repo, { summaryMaxChars: 2000 })); + } + ); + + server.registerTool( + 'gsm_list_categories', + { + description: 'List custom categories stored in GithubStarsManager.', + }, + async () => textResult({ categories: listCategories() }) + ); + + server.registerTool( + 'gsm_list_repos_by_category', + { + description: 'List repositories in a custom_category with pagination.', + inputSchema: { + category: z.string().describe('custom_category value'), + limit: z.number().min(1).max(100).optional(), + offset: z.number().min(0).optional(), + sortBy: z.enum(['stars', 'updated', 'name', 'starred']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + }, + }, + async (args) => { + const result = searchRepos({ + category: args.category, + limit: args.limit, + offset: args.offset, + sortBy: args.sortBy, + sortOrder: args.sortOrder, + }); + return textResult(result); + } + ); + + server.registerTool( + 'gsm_stats', + { + description: + 'Aggregate stats over starred repositories (language, analysis, tags).', + }, + async () => textResult(getStats()) + ); + + // Only list vector tool when vector search is fully configured + const vector = getVectorAvailability(); + if (vector.available) { + server.registerTool( + 'gsm_vector_search', + { + description: + 'Semantic vector search over indexed stars (requires vector search configured in the app).', + inputSchema: { + query: z.string().min(1).describe('Natural language query'), + topK: z.number().min(1).max(50).optional(), + threshold: z.number().min(0).max(1).optional(), + }, + }, + async (args) => { + const result = await vectorSearch(args.query, { + topK: args.topK, + threshold: args.threshold, + }); + return textResult(result); + } + ); + } +} diff --git a/server/src/routes/mcp.ts b/server/src/routes/mcp.ts new file mode 100644 index 000000000..5ac75c34b --- /dev/null +++ b/server/src/routes/mcp.ts @@ -0,0 +1,86 @@ +import { Router } from 'express'; +import { + ensureMcpToken, + getMcpTokenPlain, + isMcpEnabled, + resetMcpToken, + setMcpEnabled, +} from '../mcp/settings.js'; +import { getVectorAvailability } from '../mcp/provider.js'; +import { logger } from '../services/logger.js'; + +const router = Router(); + +/** + * GET /api/mcp/status + * Protected by existing API_SECRET auth (via /api middleware). + * Token is returned in full for owner UI (viewable anytime) when present. + * Does not create a token until MCP has been enabled at least once. + */ +router.get('/api/mcp/status', (_req, res) => { + try { + const enabled = isMcpEnabled(); + // Only mint a token when already enabled (or one already exists) + let token = getMcpTokenPlain() || ''; + if (enabled && !token) { + token = ensureMcpToken(); + } + const vector = getVectorAvailability(); + res.json({ + enabled, + token, + endpoints: { + streamableHttp: '/mcp', + sse: '/mcp/sse', + messages: '/mcp/sse/messages', + }, + vectorAvailable: vector.available, + vectorReason: vector.reason ?? null, + }); + } catch (err) { + logger.errorFromError('mcp.status', 'GET /api/mcp/status failed', err); + res.status(500).json({ error: 'Failed to get MCP status', code: 'MCP_STATUS_FAILED' }); + } +}); + +/** + * PUT /api/mcp/config + * body: { enabled?: boolean, resetToken?: boolean } + */ +router.put('/api/mcp/config', (req, res) => { + try { + const body = (req.body && typeof req.body === 'object' ? req.body : {}) as { + enabled?: boolean; + resetToken?: boolean; + }; + + if (typeof body.enabled === 'boolean') { + setMcpEnabled(body.enabled); + if (body.enabled) { + ensureMcpToken(); + } + } + + let token = getMcpTokenPlain(); + if (body.resetToken) { + token = resetMcpToken(); + } else if (isMcpEnabled() && !token) { + token = ensureMcpToken(); + } + + res.json({ + enabled: isMcpEnabled(), + token: token || '', + endpoints: { + streamableHttp: '/mcp', + sse: '/mcp/sse', + messages: '/mcp/sse/messages', + }, + }); + } catch (err) { + logger.errorFromError('mcp.config', 'PUT /api/mcp/config failed', err); + res.status(500).json({ error: 'Failed to update MCP config', code: 'MCP_CONFIG_FAILED' }); + } +}); + +export default router; diff --git a/server/src/services/logSanitizer.ts b/server/src/services/logSanitizer.ts index 55635efa4..bf779a667 100644 --- a/server/src/services/logSanitizer.ts +++ b/server/src/services/logSanitizer.ts @@ -8,6 +8,7 @@ const SENSITIVE_FIELD_NAMES = new Set([ 'apiKey', 'api_key', 'api_key_encrypted', 'password', 'password_encrypted', 'secret', 'token', 'githubToken', 'accessToken', 'authorization', 'x-api-key', 'credentials', 'passwd', 'pwd', 'backendApiSecret', + 'mcp_token', 'mcpToken', 'authToken', 'auth_token_encrypted', ]); // URL query param keys to redact @@ -85,6 +86,7 @@ export function sanitizeForLog(input: unknown, seen: WeakSet = new WeakS function sanitizeString(value: string): string { if (isGitHubToken(value)) return maskSecret(value); + if (value.startsWith('gsm_mcp_')) return maskSecret(value); if (looksLikeSecret(value)) return maskSecret(value); if (EMAIL_RE.test(value)) return maskEmail(value); if (value.startsWith('http://') || value.startsWith('https://')) return redactUrl(value); diff --git a/server/tests/mcp/repoSearch.test.ts b/server/tests/mcp/repoSearch.test.ts new file mode 100644 index 000000000..70b972e18 --- /dev/null +++ b/server/tests/mcp/repoSearch.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + performBasicTextSearch, + searchRepositories, + projectRepoForAgent, + type McpRepository, +} from '../../src/mcp/repoSearch.js'; + +function repo(partial: Partial & Pick): McpRepository { + return { + description: null, + html_url: `https://github.com/${partial.full_name}`, + stargazers_count: 10, + language: 'TS', + topics: [], + ...partial, + }; +} + +const sample = [ + repo({ + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + ai_summary: 'CRDT offline sync', + ai_tags: ['crdt'], + stargazers_count: 100, + }), + repo({ + id: 2, + name: 'beta', + full_name: 'acme/beta', + description: 'webdav', + custom_category: 'tools', + stargazers_count: 5, + }), +]; + +describe('mcp repoSearch', () => { + it('searches AI summary keywords', () => { + const hits = performBasicTextSearch(sample, 'crdt offline'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('paginates and filters category', () => { + const { items, total } = searchRepositories(sample, { category: 'tools', limit: 10 }); + expect(total).toBe(1); + expect(items[0].full_name).toBe('acme/beta'); + }); + + it('projects compact agent payload', () => { + const p = projectRepoForAgent(sample[0], { summaryMaxChars: 10 }); + expect(String(p.ai_summary).length).toBeLessThanOrEqual(11); + }); +}); diff --git a/server/tests/mcp/routes.test.ts b/server/tests/mcp/routes.test.ts new file mode 100644 index 000000000..367f5b980 --- /dev/null +++ b/server/tests/mcp/routes.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// Isolate DB before importing app modules that call getDb() +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-mcp-')); +process.env.DB_PATH = path.join(tmpDir, 'test.db'); +process.env.ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +process.env.API_SECRET = 'test-api-secret'; + +async function canOpenSqlite(): Promise { + try { + const Database = (await import('better-sqlite3')).default; + const db = new Database(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} + +const dbAvailable = await canOpenSqlite(); +const describeIfDb = dbAvailable ? describe : describe.skip; + +describeIfDb('MCP admin + transport auth', () => { + let request: typeof import('supertest').default; + let app: import('express').Express; + let closeDb: () => void; + let getMcpTokenPlain: () => string | null; + let setMcpEnabled: (v: boolean) => void; + let ensureMcpToken: () => string; + let isMcpEnabled: () => boolean; + + beforeAll(async () => { + const conn = await import('../../src/db/connection.js'); + const migrations = await import('../../src/db/migrations.js'); + const index = await import('../../src/index.js'); + const settings = await import('../../src/mcp/settings.js'); + const supertest = await import('supertest'); + + request = supertest.default; + closeDb = conn.closeDb; + getMcpTokenPlain = settings.getMcpTokenPlain; + setMcpEnabled = settings.setMcpEnabled; + ensureMcpToken = settings.ensureMcpToken; + isMcpEnabled = settings.isMcpEnabled; + + const db = conn.getDb(); + migrations.runMigrations(db); + db.prepare( + `INSERT INTO repositories (id, name, full_name, description, html_url, stargazers_count, language, owner_login, topics, ai_summary, ai_tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 1, + 'alpha', + 'acme/alpha', + 'offline CRDT', + 'https://github.com/acme/alpha', + 42, + 'Rust', + 'acme', + '[]', + 'A CRDT library', + JSON.stringify(['crdt']) + ); + // Default: MCP off — mount must not mint tokens + setMcpEnabled(false); + app = index.createApp(); + }); + + afterAll(() => { + try { + closeDb(); + } catch { + /* ignore */ + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('does not create token on app mount when MCP disabled', () => { + expect(isMcpEnabled()).toBe(false); + // token may be absent + expect(getMcpTokenPlain()).toBeNull(); + }); + + it('GET /api/mcp/status requires API secret', async () => { + const res = await request(app).get('/api/mcp/status'); + expect(res.status).toBe(401); + }); + + it('returns disabled status without minting token', async () => { + const res = await request(app) + .get('/api/mcp/status') + .set('Authorization', 'Bearer test-api-secret'); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(false); + expect(res.body.token).toBe(''); + }); + + it('rejects /mcp with 404 when disabled (no surface leak)', async () => { + const res = await request(app).post('/mcp').send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + expect(res.status).toBe(404); + }); + + it('enables MCP, mints token, and accepts initialize', async () => { + const enable = await request(app) + .put('/api/mcp/config') + .set('Authorization', 'Bearer test-api-secret') + .send({ enabled: true }); + expect(enable.status).toBe(200); + expect(enable.body.enabled).toBe(true); + expect(String(enable.body.token)).toMatch(/^gsm_mcp_/); + + const token = enable.body.token as string; + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json, text/event-stream') + .set('Content-Type', 'application/json') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }); + expect([200, 202]).toContain(res.status); + }); + + it('rejects wrong MCP token with 401 when enabled', async () => { + ensureMcpToken(); + setMcpEnabled(true); + const res = await request(app) + .post('/mcp') + .set('Authorization', 'Bearer gsm_mcp_wrong_token_value_here_xxx') + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); + expect(res.status).toBe(401); + }); + + it('PUT /api/mcp/config can reset token', async () => { + setMcpEnabled(true); + const before = ensureMcpToken(); + const res = await request(app) + .put('/api/mcp/config') + .set('Authorization', 'Bearer test-api-secret') + .send({ resetToken: true, enabled: true }); + expect(res.status).toBe(200); + expect(res.body.token).toBeTruthy(); + expect(res.body.token).not.toBe(before); + }); +}); + +describe('MCP pure units always run', () => { + it('generateMcpToken format (no db)', async () => { + const { generateMcpToken, timingSafeEqualString } = await import('../../src/mcp/settings.js'); + const t = generateMcpToken(); + expect(t.startsWith('gsm_mcp_')).toBe(true); + expect(timingSafeEqualString(t, t)).toBe(true); + expect(timingSafeEqualString(t, t + 'x')).toBe(false); + }); + + it('notes when sqlite native binding unavailable', () => { + // Environment limitation (e.g. Node 26 without rebuildable better-sqlite3) + expect(typeof dbAvailable).toBe('boolean'); + }); +}); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 777e2ce73..a0b4a417a 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -5,10 +5,10 @@ import { AIService } from '../services/aiService'; import { EmbeddingClient, VectorSearchService } from '../services/vectorSearchService'; import { GitHubApiService } from '../services/githubApi'; import { forceSyncToBackend } from '../services/autoSync'; -import { Repository } from '../types'; import { useSearchShortcuts } from '../hooks/useSearchShortcuts'; import { useDialog } from '../hooks/useDialog'; import { isRepoCustomized } from '../utils/repoUtils'; +import { applyRepoFilters, performBasicTextSearch as basicTextSearch, sortRepositories } from '../utils/repoSearch'; import { NumberInput } from './ui/NumberInput'; type SortBy = 'stars' | 'updated' | 'name' | 'starred'; @@ -312,196 +312,30 @@ export const SearchBar: React.FC = () => { setSearchResults(filtered); }; - const performBasicTextSearch = (repos: typeof repositories, query: string) => { - const normalizedQuery = query.toLowerCase(); - - return repos.filter(repo => { - const searchableText = [ - repo.name, - repo.full_name, - repo.description || '', - repo.custom_description || '', - repo.language || '', - ...(repo.topics || []), - repo.ai_summary || '', - ...(repo.ai_tags || []), - ...(repo.ai_platforms || []), - ...(repo.custom_tags || []), - ].join(' ').toLowerCase(); - - const queryWords = normalizedQuery.split(/\s+/); - return queryWords.every(word => searchableText.includes(word)); - }); - }; + const performBasicTextSearch = (repos: typeof repositories, query: string) => + basicTextSearch(repos, query); const applyFilters = (repos: typeof repositories) => { - let filtered = repos; - - // Language filter - if (searchFilters.languages.length > 0) { - filtered = filtered.filter(repo => - repo.language && searchFilters.languages.includes(repo.language) - ); - } - - // Tag filter - 包含AI标签、GitHub topics和用户自定义标签 - if (searchFilters.tags.length > 0) { - filtered = filtered.filter(repo => { - const repoTags = [ - ...(repo.ai_tags || []), - ...(repo.topics || []), - ...(repo.custom_tags || []) - ]; - return searchFilters.tags.some(tag => repoTags.includes(tag)); - }); - } - - // Platform filter - if (searchFilters.platforms.length > 0) { - filtered = filtered.filter(repo => { - const repoPlatforms = repo.ai_platforms || []; - return searchFilters.platforms.some(platform => repoPlatforms.includes(platform)); - }); - } - - // AI analyzed filter - 与 analysisFailed 互斥 - if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { - filtered = filtered.filter(repo => - searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at - ); - } - - // Release subscription filter - if (searchFilters.isSubscribed !== undefined) { - filtered = filtered.filter(repo => - searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id) - ); - } - - // 自定义筛选 - if (searchFilters.isEdited !== undefined) { - filtered = filtered.filter(repo => - searchFilters.isEdited ? isRepoCustomized(repo, allCategories) : !isRepoCustomized(repo, allCategories) - ); - } - - // Category locked filter - 检查分类是否被锁定 - if (searchFilters.isCategoryLocked !== undefined) { - filtered = filtered.filter(repo => { - const isLocked = !!repo.category_locked; - return searchFilters.isCategoryLocked ? isLocked : !isLocked; - }); - } - - // Analysis failed filter - 检查分析是否失败(需要有分析记录且标记为失败),与 isAnalyzed 互斥 - if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { - filtered = filtered.filter(repo => { - const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); - return searchFilters.analysisFailed ? hasFailed : !hasFailed; - }); - } - - // Star count filter - if (searchFilters.minStars !== undefined) { - filtered = filtered.filter(repo => repo.stargazers_count >= searchFilters.minStars!); - } - if (searchFilters.maxStars !== undefined) { - filtered = filtered.filter(repo => repo.stargazers_count <= searchFilters.maxStars!); - } - - // Sort - const getSortValue = (repo: Repository): number | string => { - switch (searchFilters.sortBy) { - case 'stars': - return repo.stargazers_count; - case 'updated': - return new Date(repo.pushed_at || repo.updated_at).getTime(); - case 'name': - return repo.name.toLowerCase(); - case 'starred': - return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; - default: - return new Date(repo.pushed_at || repo.updated_at).getTime(); - } - }; - - filtered.sort((a, b) => { - const aValue = getSortValue(a); - const bValue = getSortValue(b); - if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1; - if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1; - return 0; + const filtered = applyRepoFilters(repos, searchFilters, { + releaseSubscriptions, + allCategories, }); - // 如果分类锁定筛选导致结果为0,自动清除该筛选条件 + // 如果分类锁定筛选导致结果为0,自动清除该筛选条件(UI 侧副作用保留在此) if (searchFilters.isCategoryLocked !== undefined && filtered.length === 0) { - // 检查是否是分类锁定筛选导致的结果为空 - const filteredWithoutCategoryLock = repos.filter(repo => { - // 复制当前的筛选条件,但排除分类锁定 - let tempFiltered = true; - - // Language filter - if (searchFilters.languages.length > 0) { - tempFiltered = tempFiltered && !!(repo.language && searchFilters.languages.includes(repo.language)); - } - - // Tag filter - if (searchFilters.tags.length > 0) { - const repoTags = [...(repo.ai_tags || []), ...(repo.topics || []), ...(repo.custom_tags || [])]; - tempFiltered = tempFiltered && searchFilters.tags.some(tag => repoTags.includes(tag)); - } - - // Platform filter - if (searchFilters.platforms.length > 0) { - const repoPlatforms = repo.ai_platforms || []; - tempFiltered = tempFiltered && searchFilters.platforms.some(platform => repoPlatforms.includes(platform)); - } - - // AI analyzed filter - if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { - tempFiltered = tempFiltered && (searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at); - } - - // Release subscription filter - if (searchFilters.isSubscribed !== undefined) { - tempFiltered = tempFiltered && (searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id)); - } - - // Edited filter - if (searchFilters.isEdited !== undefined) { - const customized = isRepoCustomized(repo, allCategories); - tempFiltered = tempFiltered && (searchFilters.isEdited ? customized : !customized); - } - - // Analysis failed filter - if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { - const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); - tempFiltered = tempFiltered && (searchFilters.analysisFailed ? hasFailed : !hasFailed); - } - - // Star count filter - if (searchFilters.minStars !== undefined) { - tempFiltered = tempFiltered && repo.stargazers_count >= searchFilters.minStars; - } - if (searchFilters.maxStars !== undefined) { - tempFiltered = tempFiltered && repo.stargazers_count <= searchFilters.maxStars; - } - - return tempFiltered; - }); - - // 如果去掉分类锁定筛选后有结果,说明是分类锁定导致的结果为空,自动清除 - if (filteredWithoutCategoryLock.length > 0) { + const withoutLock = applyRepoFilters( + repos, + { ...searchFilters, isCategoryLocked: undefined }, + { releaseSubscriptions, allCategories } + ); + if (withoutLock.length > 0) { console.log('分类锁定筛选导致结果为空,自动清除该筛选条件'); setSearchFilters({ isCategoryLocked: undefined }); - // 返回去掉分类锁定筛选的结果 - return filteredWithoutCategoryLock.sort((a, b) => { - const aValue = getSortValue(a); - const bValue = getSortValue(b); - if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1; - if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1; - return 0; - }); + return sortRepositories( + withoutLock, + searchFilters.sortBy, + searchFilters.sortOrder + ); } } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index e73640b9c..84fb15a16 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -13,6 +13,7 @@ import { ScrollText, Layout, Search, + Cable, } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; import { isElectron } from '../services/electronProxy'; @@ -29,9 +30,10 @@ import { DiagnosticLogsPanel, MenuManagementPanel, VectorSearchSettings, + McpSettingsPanel, } from './settings'; -type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network' | 'vectorSearch'; +type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network' | 'vectorSearch' | 'mcp'; interface SettingsTabItem { id: SettingsTab; @@ -258,7 +260,7 @@ export const SettingsPanel: React.FC = ({ // Valid SettingsTab values for runtime validation const VALID_TABS: ReadonlySet = useMemo( - () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network', 'vectorSearch']), + () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network', 'vectorSearch', 'mcp']), [] ); @@ -361,6 +363,12 @@ export const SettingsPanel: React.FC = ({ label: t('向量搜索', 'Vector Search'), icon: , }, + // MCP requires a long-lived process: backend or Electron main. Hide for pure SPA. + ...((isElectron() || backend.isAvailable) ? [{ + id: 'mcp' as SettingsTab, + label: t('MCP服务', 'MCP Server'), + icon: , + }] : []), ]; const renderTabContent = () => { @@ -388,6 +396,8 @@ export const SettingsPanel: React.FC = ({ return ; case 'vectorSearch': return ; + case 'mcp': + return ; default: return null; } diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx new file mode 100644 index 000000000..ccfaa68be --- /dev/null +++ b/src/components/settings/McpSettingsPanel.tsx @@ -0,0 +1,496 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Cable, + CheckCircle, + Copy, + Eye, + EyeOff, + Loader2, + RefreshCw, + AlertCircle, +} from 'lucide-react'; +import { useAppStore } from '../../store/useAppStore'; +import { backend } from '../../services/backendAdapter'; +import { isElectron } from '../../services/electronProxy'; +import { useDialog } from '../../hooks/useDialog'; + +interface McpSettingsPanelProps { + t: (zh: string, en: string) => string; +} + +function generateLocalToken(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + const b64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `gsm_mcp_${b64}`; +} + +export const McpSettingsPanel: React.FC = ({ t }) => { + const { mcpConfig, setMcpConfig, language } = useAppStore(); + const { toast, confirm } = useDialog(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showToken, setShowToken] = useState(false); + const [backendMode, setBackendMode] = useState(false); + const [vectorAvailable, setVectorAvailable] = useState(null); + const [endpoints, setEndpoints] = useState({ + streamableHttp: '/mcp', + sse: '/sse', + messages: '/messages', + }); + const [copiedKey, setCopiedKey] = useState(null); + + const isElectronApp = isElectron(); + + const baseUrl = useMemo(() => { + // Electron local MCP always listens on loopback + if (isElectronApp && !backendMode) { + const host = + mcpConfig.host === '0.0.0.0' || !mcpConfig.host ? '127.0.0.1' : mcpConfig.host; + return `http://${host}:${mcpConfig.port || 3927}`; + } + // Backend / Docker: agents should hit the same origin nginx proxies (/mcp) + if (backendMode) { + return window.location.origin; + } + return window.location.origin; + }, [backendMode, isElectronApp, mcpConfig.host, mcpConfig.port]); + + const mcpHttpUrl = `${baseUrl}${endpoints.streamableHttp}`; + const mcpSseUrl = `${baseUrl}${endpoints.sse}`; + + const agentConfigJson = useMemo(() => { + const config = { + mcpServers: { + 'github-stars-manager': { + url: mcpHttpUrl, + headers: { + Authorization: `Bearer ${mcpConfig.token || ''}`, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); + }, [mcpHttpUrl, mcpConfig.token]); + + const refreshFromBackend = useCallback(async () => { + if (!backend.isAvailable) { + setBackendMode(false); + return; + } + setLoading(true); + setError(null); + try { + const status = await backend.getMcpStatus(); + setBackendMode(true); + setMcpConfig({ + enabled: status.enabled, + token: status.token, + }); + setEndpoints(status.endpoints); + setVectorAvailable(status.vectorAvailable); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [setMcpConfig]); + + useEffect(() => { + void refreshFromBackend(); + }, [refreshFromBackend]); + + // Electron: ensure local token exists when enabling without backend + useEffect(() => { + if (!backendMode && isElectronApp && mcpConfig.enabled && !mcpConfig.token) { + const token = generateLocalToken(); + setMcpConfig({ token }); + } + }, [backendMode, isElectronApp, mcpConfig.enabled, mcpConfig.token, setMcpConfig]); + + // Push snapshot / control to Electron main when local MCP is used (debounced) + useEffect(() => { + if (backendMode || !isElectronApp || !window.electronAPI?.mcp) return; + const api = window.electronAPI.mcp; + const host = + mcpConfig.host === '0.0.0.0' || !mcpConfig.host ? '127.0.0.1' : mcpConfig.host; + + const timer = window.setTimeout(() => { + void api.setConfig({ + enabled: mcpConfig.enabled, + host, + port: mcpConfig.port, + token: mcpConfig.token, + }); + if (mcpConfig.enabled) { + const state = useAppStore.getState(); + void api.pushSnapshot({ + repositories: state.repositories, + customCategories: state.customCategories, + vectorSearchConfig: { + enabled: state.vectorSearchConfig.enabled, + workerUrl: state.vectorSearchConfig.workerUrl, + embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, + }, + snapshotAt: new Date().toISOString(), + }); + void api.start(); + } else { + void api.stop(); + } + }, 300); + + return () => window.clearTimeout(timer); + }, [ + backendMode, + isElectronApp, + mcpConfig.enabled, + mcpConfig.host, + mcpConfig.port, + mcpConfig.token, + ]); + + const copyText = async (key: string, text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedKey(key); + toast(t('已复制', 'Copied'), 'success'); + setTimeout(() => setCopiedKey(null), 1500); + } catch { + toast(t('复制失败', 'Copy failed'), 'error'); + } + }; + + const handleToggle = async (enabled: boolean) => { + setSaving(true); + setError(null); + try { + if (backendMode && backend.isAvailable) { + const result = await backend.updateMcpConfig({ enabled }); + setMcpConfig({ enabled: result.enabled, token: result.token }); + setEndpoints(result.endpoints); + toast( + enabled + ? t('MCP 服务已开启', 'MCP server enabled') + : t('MCP 服务已关闭', 'MCP server disabled'), + 'success' + ); + } else if (isElectronApp) { + let token = mcpConfig.token; + if (enabled && !token) token = generateLocalToken(); + setMcpConfig({ enabled, token }); + toast( + enabled + ? t('MCP 服务已开启(本地)', 'MCP server enabled (local)') + : t('MCP 服务已关闭', 'MCP server disabled'), + 'success' + ); + } else { + toast(t('需要后端或客户端才能使用 MCP', 'Backend or desktop client required for MCP'), 'error'); + } + } catch (err) { + setError((err as Error).message); + toast(t('操作失败', 'Operation failed'), 'error'); + } finally { + setSaving(false); + } + }; + + const handleResetToken = async () => { + const ok = await confirm( + t('重置 MCP Token', 'Reset MCP Token'), + t( + '重置后旧 Token 立即失效,需要更新 Agent 配置。是否继续?', + 'The old token will stop working immediately. Update your agent config. Continue?' + ) + ); + if (!ok) return; + + setSaving(true); + try { + if (backendMode && backend.isAvailable) { + const result = await backend.updateMcpConfig({ resetToken: true, enabled: mcpConfig.enabled }); + setMcpConfig({ token: result.token, enabled: result.enabled }); + } else { + setMcpConfig({ token: generateLocalToken() }); + } + toast(t('Token 已重置', 'Token reset'), 'success'); + } catch (err) { + setError((err as Error).message); + toast(t('重置失败', 'Reset failed'), 'error'); + } finally { + setSaving(false); + } + }; + + const statusLabel = mcpConfig.enabled + ? t('运行中', 'Running') + : t('已停止', 'Stopped'); + + return ( +
+
+ +

+ {t('MCP 服务', 'MCP Server')} +

+
+ +

+ {t( + '让 Claude Code / Cursor 等 Agent 通过 Streamable HTTP 读取本应用中的星标仓库、AI 摘要与标签。默认关闭;开启后无需安装额外软件。', + 'Let agents (Claude Code, Cursor, etc.) read your starred repos, AI summaries, and tags via Streamable HTTP. Off by default; no extra install when enabled.' + )} +

+ + {error && ( +
+ + {error} +
+ )} + + {/* Enable + status */} +
+
+
+

+ {t('启用 MCP 服务', 'Enable MCP Server')} +

+

+ {backendMode + ? t('后端模式:挂载于 /mcp', 'Backend mode: mounted at /mcp') + : isElectronApp + ? t('客户端本地模式:127.0.0.1', 'Desktop local mode: 127.0.0.1') + : t('需要后端连接', 'Requires backend connection')} +

+
+ +
+ +
+ {loading ? ( + + ) : mcpConfig.enabled ? ( + + ) : ( + + )} + + {t('状态', 'Status')}: {statusLabel} + + {backendMode && ( + + )} +
+ + {vectorAvailable === false && ( +

+ {t( + '向量搜索未配置:Agent 不会看到 gsm_vector_search 工具。可在「向量搜索」中配置。', + 'Vector search not configured: gsm_vector_search will not be listed. Configure under Vector Search.' + )} +

+ )} + {vectorAvailable === true && ( +

+ {t('向量搜索已启用,将暴露 gsm_vector_search。', 'Vector search enabled; gsm_vector_search is listed.')} +

+ )} +
+ + {/* Electron local port */} + {isElectronApp && !backendMode && ( +
+

+ {t('本地监听', 'Local Listen')} +

+
+ + +
+

+ {t('默认仅绑定 127.0.0.1,仅本机 Agent 可访问。', 'Binds to 127.0.0.1 by default; local agents only.')} +

+
+ )} + + {/* Token */} +
+
+

+ {t('访问 Token', 'Access Token')} +

+ +
+

+ {t( + 'Token 可随时查看与复制(非一次性)。请勿泄露;重置后旧配置失效。', + 'Token is always viewable and copyable (not one-time). Do not share it; reset invalidates old configs.' + )} +

+
+ + + +
+
+ + {/* URLs + copy config */} +
+

+ {t('连接信息', 'Connection')} +

+
+
+ + Streamable HTTP + + + {mcpHttpUrl} + + +
+
+ + SSE ({t('兼容', 'legacy')}) + + + {mcpSseUrl} + + +
+
+ +
+
+ + {t('一键复制 Agent 配置 (JSON)', 'Copy agent config (JSON)')} + + +
+
+            {agentConfigJson}
+          
+

+ {language === 'zh' + ? '粘贴到 Claude Code / Cursor 的 MCP 配置中。Header 使用上面的 Token。' + : 'Paste into Claude Code / Cursor MCP settings. Use the token above in the Authorization header.'} +

+
+
+ +
+

+ {t( + '只读工具:gsm_status / gsm_search_repos / gsm_get_repo / gsm_list_categories / gsm_list_repos_by_category / gsm_stats', + 'Read-only tools: gsm_status / gsm_search_repos / gsm_get_repo / gsm_list_categories / gsm_list_repos_by_category / gsm_stats' + )} +

+

+ {t( + '可选:gsm_vector_search(需已配置向量搜索)', + 'Optional: gsm_vector_search (when vector search is configured)' + )} +

+
+
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 8a08ccc47..7fbd1a91f 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -9,3 +9,4 @@ export { NetworkPanel } from './NetworkPanel'; export { DiagnosticLogsPanel } from './DiagnosticLogsPanel'; export { MenuManagementPanel } from './MenuManagementPanel'; export { VectorSearchSettings } from './VectorSearchSettings'; +export { McpSettingsPanel } from './McpSettingsPanel'; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index a0f38c140..853ee97f8 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -764,6 +764,41 @@ class BackendAdapter { followers: number; }> }>; } + + // === MCP admin (backend-hosted Streamable HTTP / SSE) === + + async getMcpStatus(): Promise<{ + enabled: boolean; + token: string; + endpoints: { streamableHttp: string; sse: string; messages: string }; + vectorAvailable: boolean; + vectorReason: string | null; + }> { + if (!this._backendUrl) throw new Error('Backend not available'); + const res = await this.fetchWithTimeout(`${this._backendUrl}/mcp/status`, { + headers: this.getAuthHeaders(), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch MCP status error'); + return res.json(); + } + + async updateMcpConfig(body: { + enabled?: boolean; + resetToken?: boolean; + }): Promise<{ + enabled: boolean; + token: string; + endpoints: { streamableHttp: string; sse: string; messages: string }; + }> { + if (!this._backendUrl) throw new Error('Backend not available'); + const res = await this.fetchWithTimeout(`${this._backendUrl}/mcp/config`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(body), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Update MCP config error'); + return res.json(); + } } export const backend = new BackendAdapter(); diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts index 3421691dc..293f955f6 100644 --- a/src/services/electronProxy.ts +++ b/src/services/electronProxy.ts @@ -1,9 +1,33 @@ -import type { ProxyConfig } from '../types'; +import type { ProxyConfig, Repository, Category, VectorSearchConfig } from '../types'; + +export interface McpLocalConfig { + enabled: boolean; + host: string; + port: number; + token: string; +} + +export interface McpDataSnapshot { + repositories: Repository[]; + customCategories: Category[]; + vectorSearchConfig: Pick; + snapshotAt: string; +} + +export interface McpElectronAPI { + setConfig: (config: McpLocalConfig) => Promise<{ success: boolean; error?: string }>; + getConfig: () => Promise; + pushSnapshot: (snapshot: McpDataSnapshot) => Promise<{ success: boolean }>; + start: () => Promise<{ success: boolean; error?: string; url?: string }>; + stop: () => Promise<{ success: boolean }>; + getStatus: () => Promise<{ running: boolean; url?: string; error?: string }>; +} interface ElectronAPI { setProxy: (config: ProxyConfig) => Promise<{ success: boolean }>; getProxy: () => Promise; testProxy: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }>; + mcp?: McpElectronAPI; } declare global { diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 0b1098009..f50627a88 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -13,6 +13,7 @@ import { EmbeddingConfig, VectorSearchConfig, VectorSearchStatus, + McpServiceConfig, VectorIndexingState, ProxyConfig, RpcDownloadConfig, @@ -331,6 +332,9 @@ interface AppActions { setVectorSearchStatus: (status: VectorSearchStatus | undefined) => void; setVectorIndexingState: (state: Partial) => void; + // MCP service (local prefs; backend SQLite is source of truth when connected) + setMcpConfig: (config: Partial) => void; + // Similar repositories view actions enterSimilarView: (repos: Repository[], anchor: Repository) => void; resetSimilarView: () => void; @@ -580,6 +584,41 @@ const defaultVectorSearchConfig: VectorSearchConfig = { embeddingFormatVersion: EMBEDDING_FORMAT_VERSION, }; +export const defaultMcpConfig: McpServiceConfig = { + enabled: false, + host: '127.0.0.1', + port: 3927, + token: '', +}; + +const normalizeMcpHost = (raw: unknown): string => { + if (typeof raw !== 'string' || !raw.trim()) return defaultMcpConfig.host; + const host = raw.trim(); + // Desktop MCP must stay loopback-only + if (host === '0.0.0.0' || host === '::' || host === '[::]') return '127.0.0.1'; + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { + return host === 'localhost' ? '127.0.0.1' : host; + } + return '127.0.0.1'; +}; + +const normalizeMcpConfig = (raw: unknown): McpServiceConfig => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ...defaultMcpConfig }; + } + const config = raw as Record; + const port = + typeof config.port === 'number' && Number.isInteger(config.port) && config.port >= 1 && config.port <= 65535 + ? config.port + : defaultMcpConfig.port; + return { + enabled: config.enabled === true, + host: normalizeMcpHost(config.host), + port, + token: typeof config.token === 'string' ? config.token : '', + }; +}; + // 持久化历史配置缺失 embeddingFormatVersion 时的回退版本:旧值为 1,确保旧用户触发一次重建 export const LEGACY_EMBEDDING_FORMAT_VERSION = 1; @@ -743,6 +782,7 @@ export const normalizePersistedState = ( safePersisted.vectorSearchConfig, safePersisted.embeddingConfigs ), + mcpConfig: normalizeMcpConfig((safePersisted as Record).mcpConfig), customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1141,6 +1181,7 @@ export const useAppStore = create()( vectorSearchConfig: { ...defaultVectorSearchConfig }, vectorSearchStatus: { connected: false, vectorCount: 0, dimensions: 0 }, vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, + mcpConfig: { ...defaultMcpConfig }, similarView: null, webdavConfigs: [], activeWebDAVConfig: null, @@ -1500,6 +1541,10 @@ export const useAppStore = create()( vectorSearchConfig: mergeVectorSearchConfig(state.vectorSearchConfig, config) })), setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), + setMcpConfig: (config) => + set((state) => ({ + mcpConfig: normalizeMcpConfig({ ...state.mcpConfig, ...config }), + })), setVectorIndexingState: (indexingState) => set((state) => ({ vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } })), @@ -2195,6 +2240,9 @@ export const useAppStore = create()( // 持久化向量搜索状态(vectorCount 等,跨重启保留) vectorSearchStatus: state.vectorSearchStatus, + // 持久化 MCP 本地偏好(token 可查看/复制;后端连接时以服务端为准) + mcpConfig: state.mcpConfig, + // 持久化WebDAV配置 webdavConfigs: state.webdavConfigs, activeWebDAVConfig: state.activeWebDAVConfig, @@ -2321,6 +2369,12 @@ export const useAppStore = create()( state.vectorSearchStatus = { connected: false, vectorCount: 0, dimensions: 0 }; } + // Additive: MCP config defaults when missing (upgrade). Old builds ignore this key on downgrade. + if (state) { + const stateRecord = state as Record; + stateRecord.mcpConfig = normalizeMcpConfig(stateRecord.mcpConfig); + } + // 迁移仓库数据中的旧标记 if (state && Array.isArray(state.repositories)) { let migratedCount = 0; @@ -2454,6 +2508,7 @@ export const useAppStore = create()( stateRecord.vectorSearchConfig, stateRecord.embeddingConfigs ); + stateRecord.mcpConfig = normalizeMcpConfig(stateRecord.mcpConfig); } return state as PersistedAppState; diff --git a/src/types/index.ts b/src/types/index.ts index 06a92c7eb..ca25533d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -260,6 +260,17 @@ export interface VectorIndexingState { result: { indexed: number; skipped: number; errors: number; error?: string } | null; } +/** Local (Electron) / client-side MCP service preferences. Server is source of truth when backend is on. */ +export interface McpServiceConfig { + enabled: boolean; + /** Local bind host for Electron standalone MCP (default 127.0.0.1) */ + host: string; + /** Local bind port for Electron standalone MCP (default 3927) */ + port: number; + /** Plaintext MCP bearer token — viewable anytime; regenerate via reset */ + token: string; +} + // 相似仓库视图状态:进入"查找相似仓库"后保存当前上下文,重置时恢复 export interface SimilarViewState { active: boolean; @@ -450,6 +461,9 @@ export interface AppState { // Backend backendApiSecret: string | null; + // MCP (local Electron prefs; backend uses SQLite settings when available) + mcpConfig: McpServiceConfig; + // Network Proxy proxyConfig: ProxyConfig; rpcDownloadConfig: RpcDownloadConfig; diff --git a/src/utils/repoSearch.test.ts b/src/utils/repoSearch.test.ts new file mode 100644 index 000000000..841696f3d --- /dev/null +++ b/src/utils/repoSearch.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import type { Repository } from '../types'; +import { + performBasicTextSearch, + applyRepoFilters, + searchRepositories, + projectRepoForAgent, +} from './repoSearch'; + +function makeRepo(partial: Partial & Pick): Repository { + return { + description: null, + html_url: `https://github.com/${partial.full_name}`, + stargazers_count: 100, + forks_count: 10, + forks: 10, + language: 'TypeScript', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', + pushed_at: '2024-06-01T00:00:00Z', + owner: { login: 'owner', avatar_url: '' }, + topics: [], + ...partial, + }; +} + +const sample: Repository[] = [ + makeRepo({ + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + description: 'offline first CRDT', + language: 'Rust', + stargazers_count: 500, + ai_summary: 'A CRDT library for sync', + ai_tags: ['crdt', 'sync'], + ai_platforms: ['cli'], + topics: ['database'], + }), + makeRepo({ + id: 2, + name: 'beta', + full_name: 'acme/beta', + description: 'webdav client', + language: 'TypeScript', + stargazers_count: 50, + ai_tags: ['webdav'], + custom_category: 'tools', + }), + makeRepo({ + id: 3, + name: 'gamma', + full_name: 'acme/gamma', + description: 'ssrf helper', + language: 'Go', + stargazers_count: 200, + analyzed_at: '2024-01-01', + analysis_failed: true, + }), +]; + +describe('performBasicTextSearch', () => { + it('matches AND of words across AI fields', () => { + const hits = performBasicTextSearch(sample, 'crdt sync'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('matches custom tags and topics', () => { + const hits = performBasicTextSearch(sample, 'database'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('returns all for empty query', () => { + expect(performBasicTextSearch(sample, ' ').length).toBe(3); + }); +}); + +describe('applyRepoFilters', () => { + it('filters by language and min stars', () => { + const hits = applyRepoFilters(sample, { languages: ['Rust'], minStars: 100 }); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('filters by tags across ai/topics/custom', () => { + const hits = applyRepoFilters(sample, { tags: ['webdav'] }); + expect(hits.map((r) => r.id)).toEqual([2]); + }); + + it('sorts by stars desc by default', () => { + const hits = applyRepoFilters(sample, { sortBy: 'stars', sortOrder: 'desc' }); + expect(hits.map((r) => r.id)).toEqual([1, 3, 2]); + }); +}); + +describe('searchRepositories', () => { + it('paginates and reports total', () => { + const { items, total } = searchRepositories(sample, { query: '', limit: 1, offset: 1, sortBy: 'stars', sortOrder: 'desc' }); + expect(total).toBe(3); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(3); + }); + + it('filters by custom_category', () => { + const { items, total } = searchRepositories(sample, { category: 'tools' }); + expect(total).toBe(1); + expect(items[0].full_name).toBe('acme/beta'); + }); +}); + +describe('projectRepoForAgent', () => { + it('truncates long summaries', () => { + const long = makeRepo({ + id: 9, + name: 'x', + full_name: 'a/x', + ai_summary: 'y'.repeat(500), + }); + const projected = projectRepoForAgent(long, { summaryMaxChars: 50 }); + expect(String(projected.ai_summary).length).toBeLessThanOrEqual(51); + }); +}); diff --git a/src/utils/repoSearch.ts b/src/utils/repoSearch.ts new file mode 100644 index 000000000..54affbf83 --- /dev/null +++ b/src/utils/repoSearch.ts @@ -0,0 +1,237 @@ +import type { Category, Repository, SearchFilters } from '../types'; +import { isRepoCustomized } from './repoUtils'; + +/** Partial filters used by MCP and UI search (all fields optional except when provided). */ +export type RepoSearchFilterInput = Partial & { + category?: string; + limit?: number; + offset?: number; +}; + +export function performBasicTextSearch(repos: T[], query: string): T[] { + const normalizedQuery = query.toLowerCase().trim(); + if (!normalizedQuery) return repos; + + const queryWords = normalizedQuery.split(/\s+/).filter(Boolean); + + return repos.filter((repo) => { + const searchableText = [ + repo.name, + repo.full_name, + repo.description || '', + repo.custom_description || '', + repo.language || '', + ...(repo.topics || []), + repo.ai_summary || '', + ...(repo.ai_tags || []), + ...(repo.ai_platforms || []), + ...(repo.custom_tags || []), + repo.custom_category || '', + ] + .join(' ') + .toLowerCase(); + + return queryWords.every((word) => searchableText.includes(word)); + }); +} + +function getSortValue(repo: Repository, sortBy: SearchFilters['sortBy']): number | string { + switch (sortBy) { + case 'stars': + return repo.stargazers_count; + case 'updated': + return new Date(repo.pushed_at || repo.updated_at).getTime(); + case 'name': + return repo.name.toLowerCase(); + case 'starred': + return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; + default: + return new Date(repo.pushed_at || repo.updated_at).getTime(); + } +} + +export function sortRepositories( + repos: T[], + sortBy: SearchFilters['sortBy'] = 'stars', + sortOrder: SearchFilters['sortOrder'] = 'desc' +): T[] { + const sorted = [...repos]; + sorted.sort((a, b) => { + const aValue = getSortValue(a, sortBy); + const bValue = getSortValue(b, sortBy); + if (aValue < bValue) return sortOrder === 'desc' ? 1 : -1; + if (aValue > bValue) return sortOrder === 'desc' ? -1 : 1; + return 0; + }); + return sorted; +} + +export interface ApplyFiltersOptions { + releaseSubscriptions?: Set | number[]; + allCategories?: Category[]; + /** When true, skip isEdited filter that needs categories */ + skipEditedFilter?: boolean; +} + +/** + * Apply facet filters and sort. Does NOT auto-clear category-lock UI state + * (that side effect stays in SearchBar). + */ +export function applyRepoFilters( + repos: T[], + searchFilters: Partial, + options: ApplyFiltersOptions = {} +): T[] { + let filtered: T[] = repos; + const releaseSubscriptions = options.releaseSubscriptions + ? options.releaseSubscriptions instanceof Set + ? options.releaseSubscriptions + : new Set(options.releaseSubscriptions) + : new Set(); + const allCategories = options.allCategories ?? []; + + const languages = searchFilters.languages ?? []; + if (languages.length > 0) { + filtered = filtered.filter( + (repo) => repo.language && languages.includes(repo.language) + ); + } + + const tags = searchFilters.tags ?? []; + if (tags.length > 0) { + filtered = filtered.filter((repo) => { + const repoTags = [ + ...(repo.ai_tags || []), + ...(repo.topics || []), + ...(repo.custom_tags || []), + ]; + return tags.some((tag) => repoTags.includes(tag)); + }); + } + + const platforms = searchFilters.platforms ?? []; + if (platforms.length > 0) { + filtered = filtered.filter((repo) => { + const repoPlatforms = repo.ai_platforms || []; + return platforms.some((platform) => repoPlatforms.includes(platform)); + }); + } + + if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { + filtered = filtered.filter((repo) => + searchFilters.isAnalyzed + ? !!repo.analyzed_at && !repo.analysis_failed + : !repo.analyzed_at + ); + } + + if (searchFilters.isSubscribed !== undefined) { + filtered = filtered.filter((repo) => + searchFilters.isSubscribed + ? releaseSubscriptions.has(repo.id) + : !releaseSubscriptions.has(repo.id) + ); + } + + if ( + searchFilters.isEdited !== undefined && + !options.skipEditedFilter && + allCategories.length >= 0 + ) { + filtered = filtered.filter((repo) => { + const customized = isRepoCustomized(repo, allCategories); + return searchFilters.isEdited ? customized : !customized; + }); + } + + if (searchFilters.isCategoryLocked !== undefined) { + filtered = filtered.filter((repo) => { + const isLocked = !!repo.category_locked; + return searchFilters.isCategoryLocked ? isLocked : !isLocked; + }); + } + + if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { + filtered = filtered.filter((repo) => { + const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); + return searchFilters.analysisFailed ? hasFailed : !hasFailed; + }); + } + + if (searchFilters.minStars !== undefined) { + filtered = filtered.filter((repo) => repo.stargazers_count >= searchFilters.minStars!); + } + if (searchFilters.maxStars !== undefined) { + filtered = filtered.filter((repo) => repo.stargazers_count <= searchFilters.maxStars!); + } + + const sortBy = searchFilters.sortBy ?? 'stars'; + const sortOrder = searchFilters.sortOrder ?? 'desc'; + return sortRepositories(filtered, sortBy, sortOrder); +} + +/** Full text search + filters + optional category + pagination for MCP/API use. */ +export function searchRepositories( + repos: T[], + input: RepoSearchFilterInput, + options: ApplyFiltersOptions = {} +): { items: T[]; total: number } { + let result = repos; + + if (input.query?.trim()) { + result = performBasicTextSearch(result, input.query); + } + + if (input.category && input.category !== 'all') { + const cat = input.category; + result = result.filter((repo) => { + const custom = repo.custom_category; + if (custom) return custom === cat; + // loose match on custom_category only when no AI category resolution available + return false; + }); + } + + result = applyRepoFilters(result, input, options); + + const total = result.length; + const offset = Math.max(0, input.offset ?? 0); + const limit = Math.min(100, Math.max(1, input.limit ?? 20)); + const items = result.slice(offset, offset + limit); + return { items, total }; +} + +/** Compact projection for agent context economy. */ +export function projectRepoForAgent( + repo: Repository, + opts: { summaryMaxChars?: number } = {} +): Record { + const max = opts.summaryMaxChars ?? 400; + const summary = repo.ai_summary || repo.custom_description || repo.description || null; + const truncated = + typeof summary === 'string' && summary.length > max + ? `${summary.slice(0, max)}…` + : summary; + + return { + id: repo.id, + full_name: repo.full_name, + name: repo.name, + html_url: repo.html_url, + description: repo.description, + language: repo.language, + stargazers_count: repo.stargazers_count, + topics: repo.topics ?? [], + ai_summary: truncated, + ai_tags: repo.ai_tags ?? [], + ai_platforms: repo.ai_platforms ?? [], + custom_description: repo.custom_description, + custom_tags: repo.custom_tags, + custom_category: repo.custom_category, + analyzed_at: repo.analyzed_at, + subscribed_to_releases: !!repo.subscribed_to_releases, + starred_at: repo.starred_at, + updated_at: repo.updated_at, + pushed_at: repo.pushed_at, + }; +} From 4fbf95b75cc09eb5589a038b2ce276fa251d8474 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 20:44:15 +0800 Subject: [PATCH 02/13] fix(mcp): address CodeRabbit review on PR #250 - Electron: stop MCP server when bind host/port changes so start rebinds - Electron getStatus: null-safe config access - nginx: exact /mcp + ^~ /mcp/ only (no /mcpxyz leak) - SSE: shared connectSse helper closes transport on disconnect for all aliases - provider: 15s timeout on embedding and vector worker fetch - App-level mcpElectronBridge keeps snapshots live after leaving settings tab - Reuse McpServiceConfig type; drop dead allCategories.length >= 0 guard - README: document MCP requires backend or Electron --- README.md | 2 +- README_zh.md | 2 +- electron/main.js | 6 +- electron/mcpLocalServer.js | 7 +- nginx.conf.template | 26 ++++- server/src/mcp/http.ts | 86 +++++++-------- server/src/mcp/provider.ts | 20 +++- src/App.tsx | 8 ++ src/components/settings/McpSettingsPanel.tsx | 45 +------- src/services/electronProxy.ts | 18 ++-- src/services/mcpElectronBridge.ts | 108 +++++++++++++++++++ src/utils/repoSearch.ts | 6 +- 12 files changed, 219 insertions(+), 115 deletions(-) create mode 100644 src/services/mcpElectronBridge.ts diff --git a/README.md b/README.md index 7d459e7f8..e8d42e138 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ GitHub Stars Manager automatically syncs your starred repos, uses AI to summariz | **AI Summaries & Categories** | Generate tags, topics, and short README overviews using AI | | **Semantic Search** | Find repos by intent, not exact names | | **Vector Semantic Search** | Embed repo descriptions/READMEs into a Cloudflare Vectorize index; query with natural language for high-precision semantic matching | -| **MCP Server** | Optional Streamable HTTP / SSE endpoint so agents (Claude Code, Cursor, etc.) can search AI-enriched stars; toggle in Settings, no extra install | +| **MCP Server** | Optional Streamable HTTP / SSE endpoint so agents (Claude Code, Cursor, etc.) can search AI-enriched stars; requires backend or Electron/desktop (hidden on pure frontend); toggle in Settings, no extra install | | **Release Tracking** | Subscribe to repos and see new versions in one unified timeline | | **One‑click Downloads** | Expand release assets and download instantly | | **Smart Asset Filters** | Match assets by keywords (dmg / mac / arm64 / aarch64) | diff --git a/README_zh.md b/README_zh.md index 896250638..8e03238c2 100644 --- a/README_zh.md +++ b/README_zh.md @@ -28,7 +28,7 @@ | **AI 摘要与分类** | 使用 AI 生成标签、主题和简短 README 概览 | | **语义搜索** | 按意图而非精确名称查找仓库 | | **向量语义搜索** | 将仓库描述/README 嵌入 Cloudflare Vectorize 向量库,自然语言查询实现高精度语义匹配 | -| **MCP 服务** | 可选 Streamable HTTP / SSE,供 Claude Code、Cursor 等 Agent 检索 AI 加工后的星标;设置中开关,无需额外安装 | +| **MCP 服务** | 可选 Streamable HTTP / SSE,供 Claude Code、Cursor 等 Agent 检索 AI 加工后的星标;需后端或 Electron/客户端(纯前端模式不显示);设置中开关,无需额外安装 | | **Release 追踪** | 订阅仓库并在统一时间线查看新版本 | | **一键下载** | 展开 Release 资产并即时下载 | | **智能资产过滤** | 按关键词匹配资产 (dmg / mac / arm64 / aarch64) | diff --git a/electron/main.js b/electron/main.js index f2f52520d..b7db16c73 100644 --- a/electron/main.js +++ b/electron/main.js @@ -126,6 +126,8 @@ ipcMain.handle('proxy:test', async () => ({ // IPC — MCP ipcMain.handle('mcp:setConfig', async (_e, config) => { + const previousHost = mcpConfig.host; + const previousPort = mcpConfig.port; const rawHost = typeof config?.host === 'string' && config.host.trim() ? config.host.trim() : '127.0.0.1'; // Never allow non-loopback binds for the desktop MCP token surface @@ -144,7 +146,9 @@ ipcMain.handle('mcp:setConfig', async (_e, config) => { : 3927, token: typeof config?.token === 'string' ? config.token : '', }; - if (!mcpConfig.enabled) { + // Stop on disable OR bind address change so the next start() rebinds (not early-return on old listener) + const addressChanged = mcpConfig.host !== previousHost || mcpConfig.port !== previousPort; + if (!mcpConfig.enabled || addressChanged) { await mcpServer.stop(); } return { success: true }; diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 55dfe528b..062ca93cf 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -417,13 +417,14 @@ function createMcpLocalServer(getState) { function getStatus() { const state = getState(); + const cfg = state?.config || {}; const host = - !state.config.host || state.config.host === '0.0.0.0' || state.config.host === '::' + !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' - : state.config.host; + : cfg.host; return { running: !!server, - url: server ? `http://${host}:${state.config.port || 3927}/mcp` : undefined, + url: server ? `http://${host}:${cfg.port || 3927}/mcp` : undefined, error: lastError || undefined, }; } diff --git a/nginx.conf.template b/nginx.conf.template index f282a28df..37ab2e0ce 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -50,9 +50,29 @@ http { client_max_body_size 100m; } - # MCP Streamable HTTP + nested SSE paths (agents: http://localhost:8080/mcp) - # Use prefix location so /mcp/sse and /mcp/sse/messages are covered too. - location /mcp { + # MCP Streamable HTTP (exact) + nested SSE under /mcp/ only + # Avoid bare `location /mcp` which also matches /mcpxyz, /mcp-foo, etc. + location = /mcp { + set $backend_upstream ${BACKEND_HOST}; + proxy_pass http://$backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_set_header Mcp-Session-Id $http_mcp_session_id; + proxy_set_header Connection ''; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + client_max_body_size 100m; + } + + location ^~ /mcp/ { set $backend_upstream ${BACKEND_HOST}; proxy_pass http://$backend_upstream; proxy_http_version 1.1; diff --git a/server/src/mcp/http.ts b/server/src/mcp/http.ts index 678401278..41664b18d 100644 --- a/server/src/mcp/http.ts +++ b/server/src/mcp/http.ts @@ -71,6 +71,36 @@ async function handleStreamable(req: Request, res: Response): Promise { } } +/** Shared SSE connect + cleanup so all SSE entrypoints close transport on disconnect. */ +async function connectSse(messagesPath: string, res: Response): Promise { + const transport = new SSEServerTransport(messagesPath, res); + sseSessions.set(transport.sessionId, transport); + res.on('close', () => { + sseSessions.delete(transport.sessionId); + const maybeClose = (transport as { close?: () => Promise }).close; + if (typeof maybeClose === 'function') { + void maybeClose.call(transport).catch(() => undefined); + } + }); + const server = createMcpServer(); + await server.connect(transport); +} + +async function handleSseMessage(req: Request, res: Response): Promise { + const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''; + const transport = sseSessions.get(sessionId); + if (!transport) { + res.status(404).json({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' }); + return; + } + try { + await transport.handlePostMessage(req, res, req.body); + } catch (err) { + logger.errorFromError('mcp.sse', 'SSE message failed', err); + if (!res.headersSent) res.status(500).json({ error: 'SSE message failed' }); + } +} + /** * Mount MCP Streamable HTTP (/mcp) and legacy SSE (/mcp/sse + /mcp/sse/messages). * Auth uses MCP token (not API_SECRET). Config is read per request — no token write on mount. @@ -80,71 +110,31 @@ export function mountMcpRoutes(app: Express): void { void handleStreamable(req, res); }); - // Legacy SSE under /mcp/* so a single nginx location /mcp can proxy if needed; - // dedicated /mcp/sse still works with explicit nginx blocks. - app.get('/mcp/sse', mcpAuthMiddleware, async (req, res) => { + app.get('/mcp/sse', mcpAuthMiddleware, async (_req, res) => { try { - const transport = new SSEServerTransport('/mcp/sse/messages', res); - sseSessions.set(transport.sessionId, transport); - res.on('close', () => { - sseSessions.delete(transport.sessionId); - const maybeClose = (transport as { close?: () => Promise }).close; - if (typeof maybeClose === 'function') { - void maybeClose.call(transport).catch(() => undefined); - } - }); - const server = createMcpServer(); - await server.connect(transport); + await connectSse('/mcp/sse/messages', res); } catch (err) { logger.errorFromError('mcp.sse', 'SSE connection failed', err); if (!res.headersSent) res.status(500).end(); } }); - app.post('/mcp/sse/messages', mcpAuthMiddleware, async (req, res) => { - const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''; - const transport = sseSessions.get(sessionId); - if (!transport) { - res.status(404).json({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' }); - return; - } - try { - await transport.handlePostMessage(req, res, req.body); - } catch (err) { - logger.errorFromError('mcp.sse', 'SSE message failed', err); - if (!res.headersSent) res.status(500).json({ error: 'SSE message failed' }); - } + app.post('/mcp/sse/messages', mcpAuthMiddleware, (req, res) => { + void handleSseMessage(req, res); }); // Backward-compatible aliases (older docs / clients) - app.get('/sse', mcpAuthMiddleware, async (req, res) => { + app.get('/sse', mcpAuthMiddleware, async (_req, res) => { try { - const transport = new SSEServerTransport('/messages', res); - sseSessions.set(transport.sessionId, transport); - res.on('close', () => { - sseSessions.delete(transport.sessionId); - }); - const server = createMcpServer(); - await server.connect(transport); + await connectSse('/messages', res); } catch (err) { logger.errorFromError('mcp.sse', 'SSE connection failed', err); if (!res.headersSent) res.status(500).end(); } }); - app.post('/messages', mcpAuthMiddleware, async (req, res) => { - const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''; - const transport = sseSessions.get(sessionId); - if (!transport) { - res.status(404).json({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' }); - return; - } - try { - await transport.handlePostMessage(req, res, req.body); - } catch (err) { - logger.errorFromError('mcp.sse', 'SSE message failed', err); - if (!res.headersSent) res.status(500).json({ error: 'SSE message failed' }); - } + app.post('/messages', mcpAuthMiddleware, (req, res) => { + void handleSseMessage(req, res); }); logger.info( diff --git a/server/src/mcp/provider.ts b/server/src/mcp/provider.ts index 016d81142..6a784f20e 100644 --- a/server/src/mcp/provider.ts +++ b/server/src/mcp/provider.ts @@ -180,6 +180,22 @@ export function getVectorAvailability(): VectorAvailability { }; } +const FETCH_TIMEOUT_MS = 15_000; + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = FETCH_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + async function embedQuery( texts: string[], emb: Record @@ -224,7 +240,7 @@ async function embedQuery( body = { model, input: texts }; } - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { method: 'POST', headers, body: JSON.stringify(body), @@ -296,7 +312,7 @@ export async function vectorSearch( } const workerUrl = String(vs.worker_url).replace(/\/$/, ''); - const res = await fetch(`${workerUrl}/query`, { + const res = await fetchWithTimeout(`${workerUrl}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/App.tsx b/src/App.tsx index c2b0565c7..72481c651 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { logger } from './services/logger'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; +import { startMcpElectronBridge, stopMcpElectronBridge } from './services/mcpElectronBridge'; import type { AppState, SearchFilters } from './types'; /** @@ -129,6 +130,13 @@ function App() { } }, []); + // Electron local MCP: keep start/stop + snapshots for the whole session + // (not only while MCP settings panel is mounted). + useEffect(() => { + startMcpElectronBridge(); + return () => stopMcpElectronBridge(); + }, []); + useEffect(() => { if (theme === 'dark') { document.documentElement.classList.add('dark'); diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index ccfaa68be..5fbe4e084 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -106,7 +106,8 @@ export const McpSettingsPanel: React.FC = ({ t }) => { void refreshFromBackend(); }, [refreshFromBackend]); - // Electron: ensure local token exists when enabling without backend + // Electron: ensure local token exists when enabling without backend. + // Lifecycle (start/stop/snapshot) lives in mcpElectronBridge (App session). useEffect(() => { if (!backendMode && isElectronApp && mcpConfig.enabled && !mcpConfig.token) { const token = generateLocalToken(); @@ -114,48 +115,6 @@ export const McpSettingsPanel: React.FC = ({ t }) => { } }, [backendMode, isElectronApp, mcpConfig.enabled, mcpConfig.token, setMcpConfig]); - // Push snapshot / control to Electron main when local MCP is used (debounced) - useEffect(() => { - if (backendMode || !isElectronApp || !window.electronAPI?.mcp) return; - const api = window.electronAPI.mcp; - const host = - mcpConfig.host === '0.0.0.0' || !mcpConfig.host ? '127.0.0.1' : mcpConfig.host; - - const timer = window.setTimeout(() => { - void api.setConfig({ - enabled: mcpConfig.enabled, - host, - port: mcpConfig.port, - token: mcpConfig.token, - }); - if (mcpConfig.enabled) { - const state = useAppStore.getState(); - void api.pushSnapshot({ - repositories: state.repositories, - customCategories: state.customCategories, - vectorSearchConfig: { - enabled: state.vectorSearchConfig.enabled, - workerUrl: state.vectorSearchConfig.workerUrl, - embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, - }, - snapshotAt: new Date().toISOString(), - }); - void api.start(); - } else { - void api.stop(); - } - }, 300); - - return () => window.clearTimeout(timer); - }, [ - backendMode, - isElectronApp, - mcpConfig.enabled, - mcpConfig.host, - mcpConfig.port, - mcpConfig.token, - ]); - const copyText = async (key: string, text: string) => { try { await navigator.clipboard.writeText(text); diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts index 293f955f6..5b57f8bac 100644 --- a/src/services/electronProxy.ts +++ b/src/services/electronProxy.ts @@ -1,11 +1,13 @@ -import type { ProxyConfig, Repository, Category, VectorSearchConfig } from '../types'; - -export interface McpLocalConfig { - enabled: boolean; - host: string; - port: number; - token: string; -} +import type { + ProxyConfig, + Repository, + Category, + VectorSearchConfig, + McpServiceConfig, +} from '../types'; + +/** Alias of persisted MCP prefs — keep identical to McpServiceConfig to avoid drift. */ +export type McpLocalConfig = McpServiceConfig; export interface McpDataSnapshot { repositories: Repository[]; diff --git a/src/services/mcpElectronBridge.ts b/src/services/mcpElectronBridge.ts new file mode 100644 index 000000000..1b3ce9c69 --- /dev/null +++ b/src/services/mcpElectronBridge.ts @@ -0,0 +1,108 @@ +/** + * Long-lived Electron MCP lifecycle bridge. + * Keeps local MCP start/stop + data snapshots in sync for the whole app session, + * not only while the MCP settings panel is mounted. + */ +import { useAppStore } from '../store/useAppStore'; +import { isElectron } from './electronProxy'; +import { backend } from './backendAdapter'; + +let started = false; +let unsub: (() => void) | null = null; +let debounceTimer: ReturnType | null = null; + +function clearDebounce(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } +} + +function pushLifecycle(): void { + if (!isElectron() || !window.electronAPI?.mcp) return; + // When backend is available, agents should use backend /mcp; stop local server. + if (backend.isAvailable) { + void window.electronAPI.mcp.stop(); + return; + } + + const state = useAppStore.getState(); + const { mcpConfig } = state; + const host = + !mcpConfig.host || mcpConfig.host === '0.0.0.0' || mcpConfig.host === '::' + ? '127.0.0.1' + : mcpConfig.host; + + const api = window.electronAPI.mcp; + void api.setConfig({ + enabled: mcpConfig.enabled, + host, + port: mcpConfig.port, + token: mcpConfig.token, + }); + + if (!mcpConfig.enabled) { + void api.stop(); + return; + } + + void api.pushSnapshot({ + repositories: state.repositories, + customCategories: state.customCategories, + vectorSearchConfig: { + enabled: state.vectorSearchConfig.enabled, + workerUrl: state.vectorSearchConfig.workerUrl, + embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, + }, + snapshotAt: new Date().toISOString(), + }); + void api.start(); +} + +function schedulePush(): void { + clearDebounce(); + debounceTimer = setTimeout(() => { + debounceTimer = null; + pushLifecycle(); + }, 300); +} + +/** + * Subscribe once for the app session. Safe to call repeatedly. + */ +export function startMcpElectronBridge(): void { + if (started || typeof window === 'undefined') return; + if (!isElectron() || !window.electronAPI?.mcp) return; + started = true; + + // Initial sync + schedulePush(); + + unsub = useAppStore.subscribe((state, prev) => { + const cfgChanged = + state.mcpConfig.enabled !== prev.mcpConfig.enabled || + state.mcpConfig.host !== prev.mcpConfig.host || + state.mcpConfig.port !== prev.mcpConfig.port || + state.mcpConfig.token !== prev.mcpConfig.token; + const dataChanged = + state.repositories !== prev.repositories || + state.customCategories !== prev.customCategories || + state.vectorSearchConfig !== prev.vectorSearchConfig; + + if (cfgChanged || dataChanged) { + schedulePush(); + } + }); +} + +export function stopMcpElectronBridge(): void { + clearDebounce(); + if (unsub) { + unsub(); + unsub = null; + } + if (isElectron() && window.electronAPI?.mcp) { + void window.electronAPI.mcp.stop(); + } + started = false; +} diff --git a/src/utils/repoSearch.ts b/src/utils/repoSearch.ts index 54affbf83..64eeafb64 100644 --- a/src/utils/repoSearch.ts +++ b/src/utils/repoSearch.ts @@ -133,11 +133,7 @@ export function applyRepoFilters( ); } - if ( - searchFilters.isEdited !== undefined && - !options.skipEditedFilter && - allCategories.length >= 0 - ) { + if (searchFilters.isEdited !== undefined && !options.skipEditedFilter) { filtered = filtered.filter((repo) => { const customized = isRepoCustomized(repo, allCategories); return searchFilters.isEdited ? customized : !customized; From 9dcb6246b837247da58be23c7fe894a3285cce55 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 20:52:21 +0800 Subject: [PATCH 03/13] fix(mcp): share host normalizer for display and bind endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify MCP host handling for unset / 0.0.0.0 / :: / [::] → 127.0.0.1 across settings UI copy URL, Electron bridge, and store persistence so agents never see invalid http://:::port URLs. --- src/components/settings/McpSettingsPanel.tsx | 11 ++++----- src/services/mcpElectronBridge.ts | 6 ++--- src/store/useAppStore.ts | 16 +++---------- src/utils/mcpHost.test.ts | 24 ++++++++++++++++++++ src/utils/mcpHost.ts | 19 ++++++++++++++++ 5 files changed, 52 insertions(+), 24 deletions(-) create mode 100644 src/utils/mcpHost.test.ts create mode 100644 src/utils/mcpHost.ts diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index 5fbe4e084..45b28ac3c 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -13,6 +13,7 @@ import { useAppStore } from '../../store/useAppStore'; import { backend } from '../../services/backendAdapter'; import { isElectron } from '../../services/electronProxy'; import { useDialog } from '../../hooks/useDialog'; +import { MCP_DEFAULT_PORT, normalizeMcpHost } from '../../utils/mcpHost'; interface McpSettingsPanelProps { t: (zh: string, en: string) => string; @@ -49,16 +50,12 @@ export const McpSettingsPanel: React.FC = ({ t }) => { const isElectronApp = isElectron(); const baseUrl = useMemo(() => { - // Electron local MCP always listens on loopback + // Electron local MCP always listens on loopback (shared host normalizer) if (isElectronApp && !backendMode) { - const host = - mcpConfig.host === '0.0.0.0' || !mcpConfig.host ? '127.0.0.1' : mcpConfig.host; - return `http://${host}:${mcpConfig.port || 3927}`; + const host = normalizeMcpHost(mcpConfig.host); + return `http://${host}:${mcpConfig.port || MCP_DEFAULT_PORT}`; } // Backend / Docker: agents should hit the same origin nginx proxies (/mcp) - if (backendMode) { - return window.location.origin; - } return window.location.origin; }, [backendMode, isElectronApp, mcpConfig.host, mcpConfig.port]); diff --git a/src/services/mcpElectronBridge.ts b/src/services/mcpElectronBridge.ts index 1b3ce9c69..615013bce 100644 --- a/src/services/mcpElectronBridge.ts +++ b/src/services/mcpElectronBridge.ts @@ -4,6 +4,7 @@ * not only while the MCP settings panel is mounted. */ import { useAppStore } from '../store/useAppStore'; +import { normalizeMcpHost } from '../utils/mcpHost'; import { isElectron } from './electronProxy'; import { backend } from './backendAdapter'; @@ -28,10 +29,7 @@ function pushLifecycle(): void { const state = useAppStore.getState(); const { mcpConfig } = state; - const host = - !mcpConfig.host || mcpConfig.host === '0.0.0.0' || mcpConfig.host === '::' - ? '127.0.0.1' - : mcpConfig.host; + const host = normalizeMcpHost(mcpConfig.host); const api = window.electronAPI.mcp; void api.setConfig({ diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index f50627a88..93952d92c 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -44,6 +44,7 @@ import { } from '../types'; import { indexedDBStorage } from '../services/indexedDbStorage'; import { EMBEDDING_FORMAT_VERSION } from '../services/vectorSearchService'; +import { MCP_DEFAULT_HOST, MCP_DEFAULT_PORT, normalizeMcpHost } from '../utils/mcpHost'; import { WATCH_CUSTOM_RELEASE_SOURCE_ID, normalizeReleaseSourceSettings, @@ -586,22 +587,11 @@ const defaultVectorSearchConfig: VectorSearchConfig = { export const defaultMcpConfig: McpServiceConfig = { enabled: false, - host: '127.0.0.1', - port: 3927, + host: MCP_DEFAULT_HOST, + port: MCP_DEFAULT_PORT, token: '', }; -const normalizeMcpHost = (raw: unknown): string => { - if (typeof raw !== 'string' || !raw.trim()) return defaultMcpConfig.host; - const host = raw.trim(); - // Desktop MCP must stay loopback-only - if (host === '0.0.0.0' || host === '::' || host === '[::]') return '127.0.0.1'; - if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { - return host === 'localhost' ? '127.0.0.1' : host; - } - return '127.0.0.1'; -}; - const normalizeMcpConfig = (raw: unknown): McpServiceConfig => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { ...defaultMcpConfig }; diff --git a/src/utils/mcpHost.test.ts b/src/utils/mcpHost.test.ts new file mode 100644 index 000000000..83fcd9ad9 --- /dev/null +++ b/src/utils/mcpHost.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { MCP_DEFAULT_HOST, normalizeMcpHost } from './mcpHost'; + +describe('normalizeMcpHost', () => { + it('maps empty / wildcards / IPv6 any to 127.0.0.1', () => { + expect(normalizeMcpHost(undefined)).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost(' ')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('0.0.0.0')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('::')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('[::]')).toBe(MCP_DEFAULT_HOST); + }); + + it('normalizes localhost aliases', () => { + expect(normalizeMcpHost('localhost')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('127.0.0.1')).toBe('127.0.0.1'); + expect(normalizeMcpHost('::1')).toBe('::1'); + }); + + it('forces non-loopback hosts to loopback', () => { + expect(normalizeMcpHost('192.168.1.1')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('example.com')).toBe(MCP_DEFAULT_HOST); + }); +}); diff --git a/src/utils/mcpHost.ts b/src/utils/mcpHost.ts new file mode 100644 index 000000000..b3b722cdc --- /dev/null +++ b/src/utils/mcpHost.ts @@ -0,0 +1,19 @@ +/** Default loopback bind for desktop MCP. */ +export const MCP_DEFAULT_HOST = '127.0.0.1'; +export const MCP_DEFAULT_PORT = 3927; + +/** + * Normalize MCP listen host for display and bind. + * Maps unset / wildcard hosts to 127.0.0.1; non-loopback hosts are forced to loopback + * (desktop MCP must not expose the token surface on 0.0.0.0 / public interfaces). + */ +export function normalizeMcpHost(raw: unknown): string { + if (typeof raw !== 'string' || !raw.trim()) return MCP_DEFAULT_HOST; + const host = raw.trim(); + if (host === '0.0.0.0' || host === '::' || host === '[::]') return MCP_DEFAULT_HOST; + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { + return host === 'localhost' ? MCP_DEFAULT_HOST : host; + } + // Force loopback for any other value + return MCP_DEFAULT_HOST; +} From 474d6336d47611c5cfe0df32aa1ac7a876b312c7 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 20:56:36 +0800 Subject: [PATCH 04/13] fix(mcp): normalize ::1 to 127.0.0.1 for valid local URLs IPv6 loopback was preserved as ::1, producing invalid unbracketed http://::1:port URLs in the settings panel. Map ::1/[::1] to 127.0.0.1. --- src/utils/mcpHost.test.ts | 5 +++-- src/utils/mcpHost.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils/mcpHost.test.ts b/src/utils/mcpHost.test.ts index 83fcd9ad9..6183dae0e 100644 --- a/src/utils/mcpHost.test.ts +++ b/src/utils/mcpHost.test.ts @@ -11,10 +11,11 @@ describe('normalizeMcpHost', () => { expect(normalizeMcpHost('[::]')).toBe(MCP_DEFAULT_HOST); }); - it('normalizes localhost aliases', () => { + it('normalizes localhost / IPv6 loopback aliases to 127.0.0.1', () => { expect(normalizeMcpHost('localhost')).toBe(MCP_DEFAULT_HOST); expect(normalizeMcpHost('127.0.0.1')).toBe('127.0.0.1'); - expect(normalizeMcpHost('::1')).toBe('::1'); + expect(normalizeMcpHost('::1')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('[::1]')).toBe(MCP_DEFAULT_HOST); }); it('forces non-loopback hosts to loopback', () => { diff --git a/src/utils/mcpHost.ts b/src/utils/mcpHost.ts index b3b722cdc..f59d16cf8 100644 --- a/src/utils/mcpHost.ts +++ b/src/utils/mcpHost.ts @@ -10,10 +10,19 @@ export const MCP_DEFAULT_PORT = 3927; export function normalizeMcpHost(raw: unknown): string { if (typeof raw !== 'string' || !raw.trim()) return MCP_DEFAULT_HOST; const host = raw.trim(); - if (host === '0.0.0.0' || host === '::' || host === '[::]') return MCP_DEFAULT_HOST; - if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { - return host === 'localhost' ? MCP_DEFAULT_HOST : host; + // Wildcards and IPv6 loopback (::1) → 127.0.0.1 so URL construction never + // emits unbracketed IPv6 hosts (invalid in http://host:port). + if ( + host === '0.0.0.0' || + host === '::' || + host === '[::]' || + host === '::1' || + host === '[::1]' || + host === 'localhost' + ) { + return MCP_DEFAULT_HOST; } + if (host === '127.0.0.1') return host; // Force loopback for any other value return MCP_DEFAULT_HOST; } From 0626b9d9046a58d0157ad124de87d578cfd668bc Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 21:23:12 +0800 Subject: [PATCH 05/13] fix(desktop): wire MCP into production Electron main/preload Installed client had mcpLocalServer.js but main.js/preload never exposed IPC or started the listener, so Settings could show a token while nothing bound 127.0.0.1:3927. Merge production proxy/menu shell with MCP setConfig/start/stop/pushSnapshot handlers and preload electronAPI.mcp. --- electron/main.js | 426 +++++++++++++++++++++++++++++++++----------- electron/preload.js | 6 +- 2 files changed, 321 insertions(+), 111 deletions(-) diff --git a/electron/main.js b/electron/main.js index b7db16c73..14ae49670 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,29 +1,10 @@ -const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron'); +const { app, BrowserWindow, Menu, shell, globalShortcut, ipcMain } = require('electron'); const path = require('path'); -const { createMcpLocalServer } = require('./mcpLocalServer'); - +const fs = require('fs'); const isDev = process.env.NODE_ENV === 'development'; +const { createMcpLocalServer } = require('./mcpLocalServer'); -/** @type {BrowserWindow | null} */ -let mainWindow = null; - -// Proxy state (optional; may be extended later) -let proxyConfig = { enabled: false, type: 'http', host: '', port: 7890 }; - -// MCP local state -let mcpConfig = { - enabled: false, - host: '127.0.0.1', - port: 3927, - token: '', -}; -/** @type {object | null} */ -let mcpSnapshot = null; - -const mcpServer = createMcpLocalServer(() => ({ - config: mcpConfig, - snapshot: mcpSnapshot, -})); +let mainWindow; function createWindow() { mainWindow = new BrowserWindow({ @@ -34,75 +15,181 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: path.join(__dirname, 'preload.js'), - // Disable webSecurity for local services (aria2 RPC, etc.) + enableRemoteModule: false, webSecurity: false, + allowRunningInsecureContent: true, + devTools: true, // 生产环境也允许 DevTools 便于排障 + preload: path.join(__dirname, 'preload.js') }, - icon: path.join(__dirname, '../dist/icon.svg'), - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + icon: path.join(__dirname, '../build/icon.png'), + titleBarStyle: 'default', // 使用默认标题栏,避免重叠问题 show: false, + autoHideMenuBar: false, // 显示菜单栏,确保编辑快捷键行为一致 + frame: true, // 保持窗口框架 + backgroundColor: '#ffffff', // 设置背景色,避免白屏闪烁 + titleBarOverlay: false, // 禁用标题栏覆盖 + trafficLightPosition: { x: 20, y: 20 } // macOS 交通灯按钮位置 + }); + + // 添加错误处理和加载事件 + mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { + console.error('Failed to load:', errorCode, errorDescription, validatedURL); + // 如果主页面加载失败,尝试加载 fallback 页面 + const fallbackPath = path.join(__dirname, '../dist/index.html'); + if (fs.existsSync(fallbackPath)) { + console.log('Loading fallback page:', fallbackPath); + mainWindow.loadFile(fallbackPath); + } + }); + + mainWindow.webContents.on('dom-ready', () => { + if (isDev) console.log('DOM ready'); + // 注入一些基础样式,防止白屏 + mainWindow.webContents.insertCSS('body { background-color: #ffffff; }'); + }); + + mainWindow.webContents.on('did-finish-load', () => { + if (isDev) console.log('Page finished loading'); + // 页面加载完成后显示窗口 + if (!mainWindow.isVisible()) { + mainWindow.show(); + } }); if (isDev) { mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { - mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + // 生产环境:尝试多个可能的路径 + const possiblePaths = [ + path.join(__dirname, '../dist/index.html'), + path.join(process.resourcesPath, 'app.asar/dist/index.html'), + path.join(process.resourcesPath, 'app/dist/index.html'), + path.join(process.resourcesPath, 'dist/index.html'), + path.join(__dirname, '../build/index.html') + ]; + + let indexPath = null; + for (const testPath of possiblePaths) { + try { + if (fs.existsSync(testPath)) { + indexPath = testPath; + break; + } + } catch (error) { + // 忽略文件系统错误,继续尝试下一个路径 + continue; + } + } + + if (indexPath) { + console.log('Loading application from:', indexPath); + mainWindow.loadFile(indexPath).catch(error => { + console.error('Failed to load file:', error); + // 加载失败时显示错误页面 + mainWindow.loadURL('data:text/html,

Application Load Error

Could not load the main application. Please restart the app.

'); + }); + } else { + console.error('Could not find index.html in any expected location'); + console.log('Checked paths:', possiblePaths); + console.log('Current directory:', __dirname); + console.log('Process resources path:', process.resourcesPath); + // 显示详细的错误信息 + const errorHtml = '

Application Not Found

Could not locate the application files.

Please reinstall the application.

'; + mainWindow.loadURL('data:text/html,' + encodeURIComponent(errorHtml)); + } } mainWindow.once('ready-to-show', () => { mainWindow.show(); - if (process.platform === 'darwin') { - const template = [ - { - label: 'GitHub Stars Manager', - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' }, - ], - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'selectAll' }, - ], - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - ], - }, - { - label: 'Window', - submenu: [{ role: 'minimize' }, { role: 'close' }], - }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - } }); + // 提供稳定的菜单与编辑快捷键(生产环境) + const menuTemplate = process.platform === 'darwin' ? [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' } + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { type: 'separator' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'close' } + ] + } + ] : [ + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' } + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { type: 'separator' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'close' } + ] + } + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate)); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; @@ -113,40 +200,159 @@ function createWindow() { }); } -// IPC — proxy stubs (keep API surface for Network panel) -ipcMain.handle('proxy:set', async (_e, config) => { - proxyConfig = { ...proxyConfig, ...config }; +const PROXY_CONFIG_PATH = path.join(app.getPath('userData'), 'proxy-config.json'); + +function loadProxyConfig() { + try { + if (fs.existsSync(PROXY_CONFIG_PATH)) { + return JSON.parse(fs.readFileSync(PROXY_CONFIG_PATH, 'utf-8')); + } + } catch (e) { console.error('Failed to load proxy config:', e); } + return { enabled: false, type: 'http', host: '', port: 7890 }; +} + +function saveProxyConfig(config) { + fs.writeFileSync(PROXY_CONFIG_PATH, JSON.stringify(config, null, 2)); +} + +async function applyProxy(config) { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (config.enabled && config.host && config.port) { + let auth = ''; + if (config.username) { + auth = config.password + ? encodeURIComponent(config.username) + ':' + encodeURIComponent(config.password) + '@' + : encodeURIComponent(config.username) + '@'; + } + const proxyUrl = config.type === 'socks5' + ? 'socks5://' + auth + config.host + ':' + config.port + : 'http://' + auth + config.host + ':' + config.port; + await mainWindow.webContents.session.setProxy({ + proxyRules: proxyUrl, + proxyBypassRules: ';localhost;127.0.0.1' + }); + console.log('[Proxy] Applied:', proxyUrl); + } else { + await mainWindow.webContents.session.setProxy({ proxyRules: 'direct://' }); + console.log('[Proxy] Disabled, using direct connection'); + } +} + +ipcMain.handle('set-proxy', async (event, config) => { + saveProxyConfig(config); + await applyProxy(config); return { success: true }; }); -ipcMain.handle('proxy:get', async () => proxyConfig); -ipcMain.handle('proxy:test', async () => ({ - success: false, - error: 'Proxy test not implemented in this build', + +ipcMain.handle('get-proxy', () => { + return loadProxyConfig(); +}); + +ipcMain.handle('test-proxy', async (event, config) => { + const net = require('net'); + const connectToProxy = () => new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.setTimeout(5000); + socket.on('connect', () => resolve(socket)); + socket.on('timeout', () => { socket.destroy(); reject(new Error('Connection timeout')); }); + socket.on('error', (err) => reject(err)); + socket.connect(config.port, config.host); + }); + try { + if (config.type === 'socks5') { + const socket = await connectToProxy(); + return await new Promise((resolve) => { + const greeting = config.username + ? Buffer.from([0x05, 0x02, 0x00, 0x02]) + : Buffer.from([0x05, 0x01, 0x00]); + socket.setTimeout(5000); + socket.write(greeting); + let step = 0; + socket.on('data', (data) => { + if (step === 0) { + if (data[0] !== 0x05) { socket.destroy(); resolve({ success: false, error: 'Invalid SOCKS5 version' }); return; } + if (data[1] === 0xFF) { socket.destroy(); resolve({ success: false, error: 'No acceptable auth method' }); return; } + if (data[1] === 0x02 && config.username && config.password) { + step = 1; + const userBuf = Buffer.from(config.username, 'utf8'); + const passBuf = Buffer.from(config.password, 'utf8'); + const authReq = Buffer.alloc(3 + userBuf.length + passBuf.length); + authReq[0] = 0x01; authReq[1] = userBuf.length; + userBuf.copy(authReq, 2); + authReq[2 + userBuf.length] = passBuf.length; + passBuf.copy(authReq, 3 + userBuf.length); + socket.write(authReq); + } else { socket.destroy(); resolve({ success: true }); } + } else if (step === 1) { + socket.destroy(); + resolve(data[0] === 0x01 && data[1] === 0x00 + ? { success: true } + : { success: false, error: 'SOCKS5 authentication failed' }); + } + }); + socket.on('timeout', () => { socket.destroy(); resolve({ success: false, error: 'SOCKS5 handshake timeout' }); }); + socket.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } else { + const socket = await connectToProxy(); + return await new Promise((resolve) => { + socket.setTimeout(5000); + const authHeader = config.username && config.password + ? 'Proxy-Authorization: Basic ' + Buffer.from(config.username + ':' + config.password).toString('base64') + '\r\n' + : ''; + socket.write('CONNECT httpbin.org:443 HTTP/1.1\r\nHost: httpbin.org:443\r\n' + authHeader + '\r\n'); + let responseData = ''; + socket.on('data', (data) => { + responseData += data.toString(); + if (responseData.includes('\r\n\r\n')) { + socket.destroy(); + if (responseData.includes('200')) resolve({ success: true }); + else if (responseData.includes('407')) resolve({ success: false, error: 'Proxy authentication required' }); + else resolve({ success: false, error: 'Proxy rejected: ' + (responseData.split('\r\n')[0] || 'Unknown') }); + } + }); + socket.on('timeout', () => { socket.destroy(); resolve({ success: false, error: 'HTTP proxy handshake timeout' }); }); + socket.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } + } catch (e) { return { success: false, error: e.message }; } +}); + + +// ── MCP local server (read-only tools for agents) ── +let mcpConfig = { + enabled: false, + host: '127.0.0.1', + port: 3927, + token: '', +}; +let mcpSnapshot = null; +const mcpServer = createMcpLocalServer(() => ({ + config: mcpConfig, + snapshot: mcpSnapshot, })); -// IPC — MCP +function normalizeMcpHost(rawHost) { + const host = typeof rawHost === 'string' && rawHost.trim() ? rawHost.trim() : '127.0.0.1'; + if (host === '0.0.0.0' || host === '::' || host === '[::]' || host === '::1' || host === '[::1]' || host === 'localhost') { + return '127.0.0.1'; + } + if (host === '127.0.0.1') return host; + return '127.0.0.1'; +} + ipcMain.handle('mcp:setConfig', async (_e, config) => { const previousHost = mcpConfig.host; const previousPort = mcpConfig.port; - const rawHost = - typeof config?.host === 'string' && config.host.trim() ? config.host.trim() : '127.0.0.1'; - // Never allow non-loopback binds for the desktop MCP token surface - const host = - rawHost === '127.0.0.1' || rawHost === 'localhost' || rawHost === '::1' - ? rawHost === 'localhost' - ? '127.0.0.1' - : rawHost - : '127.0.0.1'; mcpConfig = { enabled: !!config?.enabled, - host, + host: normalizeMcpHost(config?.host), port: typeof config?.port === 'number' && config.port >= 1 && config.port <= 65535 ? config.port : 3927, token: typeof config?.token === 'string' ? config.token : '', }; - // Stop on disable OR bind address change so the next start() rebinds (not early-return on old listener) const addressChanged = mcpConfig.host !== previousHost || mcpConfig.port !== previousPort; if (!mcpConfig.enabled || addressChanged) { await mcpServer.stop(); @@ -173,28 +379,32 @@ ipcMain.handle('mcp:stop', async () => mcpServer.stop()); ipcMain.handle('mcp:getStatus', async () => mcpServer.getStatus()); -app.whenReady().then(createWindow); +app.whenReady().then(() => { + createWindow(); + const savedProxy = loadProxyConfig(); + if (savedProxy.enabled && savedProxy.host && savedProxy.port) { + applyProxy(savedProxy); + } + globalShortcut.register('CommandOrControl+Shift+I', () => { + const focused = BrowserWindow.getFocusedWindow(); + if (focused && !focused.isDestroyed()) { + focused.webContents.toggleDevTools(); + } + }); +}); app.on('window-all-closed', () => { - void mcpServer.stop(); if (process.platform !== 'darwin') { app.quit(); } }); -app.on('before-quit', () => { - void mcpServer.stop(); +app.on('will-quit', () => { + globalShortcut.unregisterAll(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } -}); - -app.on('web-contents-created', (_event, contents) => { - contents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; - }); -}); +}); \ No newline at end of file diff --git a/electron/preload.js b/electron/preload.js index 7be397199..6f4ec7395 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,9 +1,9 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { - setProxy: (config) => ipcRenderer.invoke('proxy:set', config), - getProxy: () => ipcRenderer.invoke('proxy:get'), - testProxy: (config) => ipcRenderer.invoke('proxy:test', config), + setProxy: (config) => ipcRenderer.invoke('set-proxy', config), + getProxy: () => ipcRenderer.invoke('get-proxy'), + testProxy: (config) => ipcRenderer.invoke('test-proxy', config), mcp: { setConfig: (config) => ipcRenderer.invoke('mcp:setConfig', config), getConfig: () => ipcRenderer.invoke('mcp:getConfig'), From e61437c22849a815080c5802311c7168d377b3fd Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 21:28:09 +0800 Subject: [PATCH 06/13] feat(desktop): implement legacy MCP SSE transport for local server Electron mcpLocalServer previously only accepted /sse as a path alias and returned JSON health on GET. Add real HTTP+SSE: GET /sse (or /mcp/sse) emits endpoint event; POST /messages?sessionId= returns 202 and writes JSON-RPC results on the open stream. Settings UI shows SSE copy helper; docs list desktop and docker SSE URLs. --- DOCKER.md | 16 +++- electron/mcpLocalServer.js | 93 +++++++++++++++++++- src/components/settings/McpSettingsPanel.tsx | 37 +++++++- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 2924bf6b8..d84fd9497 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -187,10 +187,18 @@ This Docker setup does not affect the existing desktop packaging workflows. The With Docker Compose, the backend MCP endpoints are exposed through nginx (frontend container) so agents on the host do not need a published backend port: -| Endpoint | URL (default compose) | -|----------|------------------------| -| Streamable HTTP | `http://localhost:8080/mcp` | -| Legacy SSE | `http://localhost:8080/mcp/sse` | +| Endpoint | URL (default compose) | Notes | +|----------|------------------------|--------| +| Streamable HTTP | `http://localhost:8080/mcp` | Preferred for Claude Code / modern clients | +| Legacy SSE | `http://localhost:8080/mcp/sse` | GET opens `text/event-stream`; client then POSTs to `/mcp/sse/messages?sessionId=…` | +| Legacy SSE (alias) | `http://localhost:8080/sse` | Same protocol; messages at `/messages?sessionId=…` | + +**Desktop (Electron)** after enabling MCP in Settings: + +| Endpoint | URL | +|----------|-----| +| Streamable HTTP | `http://127.0.0.1:3927/mcp` | +| Legacy SSE | `http://127.0.0.1:3927/sse` (messages: `/messages?sessionId=…`) | 1. Open the app → **Settings → MCP Server**. 2. Toggle **Enable MCP Server** (requires backend connection). diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 062ca93cf..799d83ae7 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -243,6 +243,14 @@ function createMcpLocalServer(getState) { /** @type {import('http').Server | null} */ let server = null; let lastError = null; + /** @type {Map} */ + const sseStreams = new Map(); + + function writeSse(res, event, data) { + if (res.writableEnded) return; + if (event) res.write(`event: ${event}\n`); + res.write(`data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`); + } function handleJsonRpc(body, snapshot) { const method = body?.method; @@ -342,20 +350,91 @@ function createMcpLocalServer(getState) { } const url = new URL(req.url || '/', `http://${host}:${port}`); - if (url.pathname !== '/mcp' && url.pathname !== '/sse') { + const pathname = url.pathname; + + // ── Legacy SSE transport (MCP HTTP+SSE): GET open stream, POST messages ── + // Paths aligned with backend: /sse + /messages (also /mcp/sse + /mcp/sse/messages) + const isSseOpen = + (req.method === 'GET' || req.method === 'HEAD') && + (pathname === '/sse' || pathname === '/mcp/sse'); + const isSseMessage = + req.method === 'POST' && + (pathname === '/messages' || pathname === '/mcp/sse/messages'); + + if (isSseOpen) { + const sessionId = crypto.randomUUID(); + const messagesPath = + pathname === '/mcp/sse' ? '/mcp/sse/messages' : '/messages'; + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + // MCP SSE handshake: first event points client to the POST endpoint + writeSse(res, 'endpoint', `${messagesPath}?sessionId=${sessionId}`); + sseStreams.set(sessionId, res); + // Keepalive comments so proxies don't drop the stream + const keepAlive = setInterval(() => { + if (!res.writableEnded) res.write(': ping\n\n'); + }, 15000); + const cleanup = () => { + clearInterval(keepAlive); + sseStreams.delete(sessionId); + }; + res.on('close', cleanup); + res.on('error', cleanup); + req.on('close', cleanup); + return; + } + + if (isSseMessage) { + const sessionId = url.searchParams.get('sessionId') || ''; + const stream = sseStreams.get(sessionId); + if (!stream || stream.writableEnded) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unknown session', code: 'MCP_UNKNOWN_SESSION' })); + return; + } + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + const body = raw ? JSON.parse(raw) : {}; + const messages = Array.isArray(body) ? body : [body]; + for (const msg of messages) { + const out = handleJsonRpc(msg, current.snapshot); + // notifications have no response + if (out) writeSse(stream, 'message', out); + } + // MCP SSE: acknowledge POST with 202; actual result rides the SSE stream + res.writeHead(202).end(); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON-RPC', detail: lastError })); + } + }); + return; + } + + // ── Streamable HTTP (primary): JSON-RPC over POST /mcp ── + if (pathname !== '/mcp') { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; } - if (req.method === 'GET') { - // Minimal health / capability probe + if (req.method === 'GET' || req.method === 'HEAD') { + // Health / capability probe (not SSE — use /sse for that) res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ status: 'ok', transport: 'json-rpc-http', path: '/mcp', + sse: '/sse', mode: 'electron-local', }) ); @@ -407,6 +486,14 @@ function createMcpLocalServer(getState) { } async function stop() { + for (const [, stream] of sseStreams) { + try { + if (!stream.writableEnded) stream.end(); + } catch { + /* ignore */ + } + } + sseStreams.clear(); if (!server) return { success: true }; await new Promise((resolve) => { server.close(() => resolve()); diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index 45b28ac3c..35bfcd1a9 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -40,6 +40,7 @@ export const McpSettingsPanel: React.FC = ({ t }) => { const [showToken, setShowToken] = useState(false); const [backendMode, setBackendMode] = useState(false); const [vectorAvailable, setVectorAvailable] = useState(null); + // Defaults for Electron local; backend overwrites via /api/mcp/status const [endpoints, setEndpoints] = useState({ streamableHttp: '/mcp', sse: '/sse', @@ -62,6 +63,7 @@ export const McpSettingsPanel: React.FC = ({ t }) => { const mcpHttpUrl = `${baseUrl}${endpoints.streamableHttp}`; const mcpSseUrl = `${baseUrl}${endpoints.sse}`; + // Streamable HTTP is primary. Legacy SSE config is shown separately for clients that still need it. const agentConfigJson = useMemo(() => { const config = { mcpServers: { @@ -76,6 +78,21 @@ export const McpSettingsPanel: React.FC = ({ t }) => { return JSON.stringify(config, null, 2); }, [mcpHttpUrl, mcpConfig.token]); + const agentSseConfigJson = useMemo(() => { + const config = { + mcpServers: { + 'github-stars-manager': { + // Some older MCP clients expect the SSE GET URL (not Streamable HTTP) + url: mcpSseUrl, + headers: { + Authorization: `Bearer ${mcpConfig.token || ''}`, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); + }, [mcpSseUrl, mcpConfig.token]); + const refreshFromBackend = useCallback(async () => { if (!backend.isAvailable) { setBackendMode(false); @@ -427,9 +444,25 @@ export const McpSettingsPanel: React.FC = ({ t }) => {

{language === 'zh' - ? '粘贴到 Claude Code / Cursor 的 MCP 配置中。Header 使用上面的 Token。' - : 'Paste into Claude Code / Cursor MCP settings. Use the token above in the Authorization header.'} + ? '优先使用 Streamable HTTP(上面 JSON)。若客户端只支持旧版 SSE,用下方 SSE URL:GET 打开流后 POST 到 messages。' + : 'Prefer Streamable HTTP (JSON above). If the client only supports legacy SSE, use the SSE URL below: GET opens the stream, then POST to messages.'}

+
+ + {t('SSE 兼容配置 (JSON)', 'SSE-compatible config (JSON)')} + + +
+
+            {agentSseConfigJson}
+          
From 48cfc65cf3251036824238a3153ba56c797c6add Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 21:31:21 +0800 Subject: [PATCH 07/13] fix(desktop): harden Electron main per CodeRabbit audit - webSecurity true, no mixed content, DevTools only in isDev - did-fail-load fallback only once (no reload loop) - redact proxy credentials in console logs - buffer SOCKS5 handshake TCP fragments before parse - stop MCP server on quit; simplify loopback host normalizer --- electron/main.js | 56 +++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/electron/main.js b/electron/main.js index 14ae49670..adc096a15 100644 --- a/electron/main.js +++ b/electron/main.js @@ -16,9 +16,11 @@ function createWindow() { nodeIntegration: false, contextIsolation: true, enableRemoteModule: false, - webSecurity: false, - allowRunningInsecureContent: true, - devTools: true, // 生产环境也允许 DevTools 便于排障 + // Production: keep same-origin + block mixed content. Local files load via loadFile. + // Dev may relax for Vite HMR / local services if needed later — keep secure by default. + webSecurity: true, + allowRunningInsecureContent: false, + devTools: isDev, preload: path.join(__dirname, 'preload.js') }, icon: path.join(__dirname, '../build/icon.png'), @@ -31,12 +33,16 @@ function createWindow() { trafficLightPosition: { x: 20, y: 20 } // macOS 交通灯按钮位置 }); - // 添加错误处理和加载事件 + // 添加错误处理和加载事件(fallback 只尝试一次,避免 did-fail-load 死循环) + let fallbackAttempted = false; mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { console.error('Failed to load:', errorCode, errorDescription, validatedURL); - // 如果主页面加载失败,尝试加载 fallback 页面 const fallbackPath = path.join(__dirname, '../dist/index.html'); - if (fs.existsSync(fallbackPath)) { + const alreadyOnFallback = + typeof validatedURL === 'string' && + (validatedURL.includes('/dist/index.html') || validatedURL.endsWith('dist/index.html')); + if (!fallbackAttempted && !alreadyOnFallback && fs.existsSync(fallbackPath)) { + fallbackAttempted = true; console.log('Loading fallback page:', fallbackPath); mainWindow.loadFile(fallbackPath); } @@ -231,7 +237,9 @@ async function applyProxy(config) { proxyRules: proxyUrl, proxyBypassRules: ';localhost;127.0.0.1' }); - console.log('[Proxy] Applied:', proxyUrl); + // Never log credentials embedded in proxy URLs + const redactedProxyUrl = proxyUrl.replace(/\/\/[^@/]+@/, '//***:***@'); + console.log('[Proxy] Applied:', redactedProxyUrl); } else { await mainWindow.webContents.session.setProxy({ proxyRules: 'direct://' }); console.log('[Proxy] Disabled, using direct connection'); @@ -268,12 +276,17 @@ ipcMain.handle('test-proxy', async (event, config) => { socket.setTimeout(5000); socket.write(greeting); let step = 0; - socket.on('data', (data) => { + let buffered = Buffer.alloc(0); + socket.on('data', (chunk) => { + buffered = Buffer.concat([buffered, chunk]); if (step === 0) { + if (buffered.length < 2) return; + const data = buffered; if (data[0] !== 0x05) { socket.destroy(); resolve({ success: false, error: 'Invalid SOCKS5 version' }); return; } if (data[1] === 0xFF) { socket.destroy(); resolve({ success: false, error: 'No acceptable auth method' }); return; } if (data[1] === 0x02 && config.username && config.password) { step = 1; + buffered = Buffer.alloc(0); const userBuf = Buffer.from(config.username, 'utf8'); const passBuf = Buffer.from(config.password, 'utf8'); const authReq = Buffer.alloc(3 + userBuf.length + passBuf.length); @@ -284,6 +297,8 @@ ipcMain.handle('test-proxy', async (event, config) => { socket.write(authReq); } else { socket.destroy(); resolve({ success: true }); } } else if (step === 1) { + if (buffered.length < 2) return; + const data = buffered; socket.destroy(); resolve(data[0] === 0x01 && data[1] === 0x00 ? { success: true } @@ -332,12 +347,8 @@ const mcpServer = createMcpLocalServer(() => ({ snapshot: mcpSnapshot, })); -function normalizeMcpHost(rawHost) { - const host = typeof rawHost === 'string' && rawHost.trim() ? rawHost.trim() : '127.0.0.1'; - if (host === '0.0.0.0' || host === '::' || host === '[::]' || host === '::1' || host === '[::1]' || host === 'localhost') { - return '127.0.0.1'; - } - if (host === '127.0.0.1') return host; +/** Desktop MCP must only bind loopback. */ +function normalizeMcpHost(_rawHost) { return '127.0.0.1'; } @@ -385,15 +396,19 @@ app.whenReady().then(() => { if (savedProxy.enabled && savedProxy.host && savedProxy.port) { applyProxy(savedProxy); } - globalShortcut.register('CommandOrControl+Shift+I', () => { - const focused = BrowserWindow.getFocusedWindow(); - if (focused && !focused.isDestroyed()) { - focused.webContents.toggleDevTools(); - } - }); + // DevTools shortcut only in development + if (isDev) { + globalShortcut.register('CommandOrControl+Shift+I', () => { + const focused = BrowserWindow.getFocusedWindow(); + if (focused && !focused.isDestroyed()) { + focused.webContents.toggleDevTools(); + } + }); + } }); app.on('window-all-closed', () => { + void mcpServer.stop(); if (process.platform !== 'darwin') { app.quit(); } @@ -401,6 +416,7 @@ app.on('window-all-closed', () => { app.on('will-quit', () => { globalShortcut.unregisterAll(); + void mcpServer.stop(); }); app.on('activate', () => { From b80f7e1b32fb3619694792ff2f65aa807e62133f Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 21:42:34 +0800 Subject: [PATCH 08/13] fix(ci): stop overwriting electron sources in desktop build build-desktop.yml regenerated electron/main.js and preload.js without MCP IPC, so workflow packages never listened on :3927 despite UI tokens. Verify committed electron/* (main/preload/mcpLocalServer) instead. --- .github/workflows/build-desktop.yml | 389 +++------------------------- 1 file changed, 30 insertions(+), 359 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6f4d6a140..cfab7ee96 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -176,367 +176,38 @@ jobs: # Install Windows SDK components if needed echo "Setting up Windows build environment" - - name: Create Electron main process + - name: Verify committed Electron sources shell: bash run: | - node -e " - const fs = require('fs'); - const path = require('path'); - - if (!fs.existsSync('electron')) { - fs.mkdirSync('electron', { recursive: true }); - } - - const mainJsContent = 'const { app, BrowserWindow, Menu, shell, globalShortcut, ipcMain } = require(\\'electron\\');\\n' + - 'const path = require(\\'path\\');\\n' + - 'const fs = require(\\'fs\\');\\n' + - 'const isDev = process.env.NODE_ENV === \\'development\\';\\n\\n' + - 'let mainWindow;\\n\\n' + - 'function createWindow() {\\n' + - ' mainWindow = new BrowserWindow({\\n' + - ' width: 1200,\\n' + - ' height: 800,\\n' + - ' minWidth: 800,\\n' + - ' minHeight: 600,\\n' + - ' webPreferences: {\\n' + - ' nodeIntegration: false,\\n' + - ' contextIsolation: true,\\n' + - ' enableRemoteModule: false,\\n' + - ' webSecurity: false,\\n' + - ' allowRunningInsecureContent: true,\\n' + - ' devTools: true, // 生产环境也允许 DevTools 便于排障\\n' + - ' preload: path.join(__dirname, \\'preload.js\\')\\n' + - ' },\\n' + - ' icon: path.join(__dirname, \\'../build/icon.png\\'),\\n' + - ' titleBarStyle: \\'default\\', // 使用默认标题栏,避免重叠问题\\n' + - ' show: false,\\n' + - ' autoHideMenuBar: false, // 显示菜单栏,确保编辑快捷键行为一致\\n' + - ' frame: true, // 保持窗口框架\\n' + - ' backgroundColor: \\'#ffffff\\', // 设置背景色,避免白屏闪烁\\n' + - ' titleBarOverlay: false, // 禁用标题栏覆盖\\n' + - ' trafficLightPosition: { x: 20, y: 20 } // macOS 交通灯按钮位置\\n' + - ' });\\n\\n' + - ' // 添加错误处理和加载事件\\n' + - ' mainWindow.webContents.on(\\'did-fail-load\\', (event, errorCode, errorDescription, validatedURL) => {\\n' + - ' console.error(\\'Failed to load:\\', errorCode, errorDescription, validatedURL);\\n' + - ' // 如果主页面加载失败,尝试加载 fallback 页面\\n' + - ' const fallbackPath = path.join(__dirname, \\'../dist/index.html\\');\\n' + - ' if (fs.existsSync(fallbackPath)) {\\n' + - ' console.log(\\'Loading fallback page:\\', fallbackPath);\\n' + - ' mainWindow.loadFile(fallbackPath);\\n' + - ' }\\n' + - ' });\\n\\n' + - ' mainWindow.webContents.on(\\'dom-ready\\', () => {\\n' + - ' if (isDev) console.log(\\'DOM ready\\');\\n' + - ' // 注入一些基础样式,防止白屏\\n' + - ' mainWindow.webContents.insertCSS(\\'body { background-color: #ffffff; }\\');\\n' + - ' });\\n\\n' + - ' mainWindow.webContents.on(\\'did-finish-load\\', () => {\\n' + - ' if (isDev) console.log(\\'Page finished loading\\');\\n' + - ' // 页面加载完成后显示窗口\\n' + - ' if (!mainWindow.isVisible()) {\\n' + - ' mainWindow.show();\\n' + - ' }\\n' + - ' });\\n\\n' + - ' if (isDev) {\\n' + - ' mainWindow.loadURL(\\'http://localhost:5173\\');\\n' + - ' mainWindow.webContents.openDevTools();\\n' + - ' } else {\\n' + - ' // 生产环境:尝试多个可能的路径\\n' + - ' const possiblePaths = [\\n' + - ' path.join(__dirname, \\'../dist/index.html\\'),\\n' + - ' path.join(process.resourcesPath, \\'app.asar/dist/index.html\\'),\\n' + - ' path.join(process.resourcesPath, \\'app/dist/index.html\\'),\\n' + - ' path.join(process.resourcesPath, \\'dist/index.html\\'),\\n' + - ' path.join(__dirname, \\'../build/index.html\\')\\n' + - ' ];\\n\\n' + - ' let indexPath = null;\\n' + - ' for (const testPath of possiblePaths) {\\n' + - ' try {\\n' + - ' if (fs.existsSync(testPath)) {\\n' + - ' indexPath = testPath;\\n' + - ' break;\\n' + - ' }\\n' + - ' } catch (error) {\\n' + - ' // 忽略文件系统错误,继续尝试下一个路径\\n' + - ' continue;\\n' + - ' }\\n' + - ' }\\n\\n' + - ' if (indexPath) {\\n' + - ' console.log(\\'Loading application from:\\', indexPath);\\n' + - ' mainWindow.loadFile(indexPath).catch(error => {\\n' + - ' console.error(\\'Failed to load file:\\', error);\\n' + - ' // 加载失败时显示错误页面\\n' + - ' mainWindow.loadURL(\\'data:text/html,

Application Load Error

Could not load the main application. Please restart the app.

\\');\\n' + - ' });\\n' + - ' } else {\\n' + - ' console.error(\\'Could not find index.html in any expected location\\');\\n' + - ' console.log(\\'Checked paths:\\', possiblePaths);\\n' + - ' console.log(\\'Current directory:\\', __dirname);\\n' + - ' console.log(\\'Process resources path:\\', process.resourcesPath);\\n' + - ' // 显示详细的错误信息\\n' + - ' const errorHtml = \\'

Application Not Found

Could not locate the application files.

Please reinstall the application.

\\';\\n' + - ' mainWindow.loadURL(\\'data:text/html,\\' + encodeURIComponent(errorHtml));\\n' + - ' }\\n' + - ' }\\n\\n' + - ' mainWindow.once(\\'ready-to-show\\', () => {\\n' + - ' mainWindow.show();\\n' + - ' });\\n\\n' + - ' // 提供稳定的菜单与编辑快捷键(生产环境)\n' + - ' const menuTemplate = process.platform === \'darwin\' ? [\n' + - ' {\n' + - ' label: app.name,\n' + - ' submenu: [\n' + - ' { role: \'about\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'services\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'hide\' },\n' + - ' { role: \'hideOthers\' },\n' + - ' { role: \'unhide\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'quit\' }\n' + - ' ]\n' + - ' },\n' + - ' {\n' + - ' label: \'Edit\',\n' + - ' submenu: [\n' + - ' { role: \'undo\' },\n' + - ' { role: \'redo\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'cut\' },\n' + - ' { role: \'copy\' },\n' + - ' { role: \'paste\' },\n' + - ' { role: \'selectAll\' }\n' + - ' ]\n' + - ' },\n' + - ' {\n' + - ' label: \'View\',\n' + - ' submenu: [\n' + - ' { role: \'reload\' },\n' + - ' { role: \'forceReload\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'toggleDevTools\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'resetZoom\' },\n' + - ' { role: \'zoomIn\' },\n' + - ' { role: \'zoomOut\' },\n' + - ' { role: \'togglefullscreen\' }\n' + - ' ]\n' + - ' },\n' + - ' {\n' + - ' label: \'Window\',\n' + - ' submenu: [\n' + - ' { role: \'minimize\' },\n' + - ' { role: \'close\' }\n' + - ' ]\n' + - ' }\n' + - ' ] : [\n' + - ' {\n' + - ' label: \'Edit\',\n' + - ' submenu: [\n' + - ' { role: \'undo\' },\n' + - ' { role: \'redo\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'cut\' },\n' + - ' { role: \'copy\' },\n' + - ' { role: \'paste\' },\n' + - ' { role: \'selectAll\' }\n' + - ' ]\n' + - ' },\n' + - ' {\n' + - ' label: \'View\',\n' + - ' submenu: [\n' + - ' { role: \'reload\' },\n' + - ' { role: \'forceReload\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'toggleDevTools\' },\n' + - ' { type: \'separator\' },\n' + - ' { role: \'resetZoom\' },\n' + - ' { role: \'zoomIn\' },\n' + - ' { role: \'zoomOut\' },\n' + - ' { role: \'togglefullscreen\' }\n' + - ' ]\n' + - ' },\n' + - ' {\n' + - ' label: \'Window\',\n' + - ' submenu: [\n' + - ' { role: \'minimize\' },\n' + - ' { role: \'close\' }\n' + - ' ]\n' + - ' }\n' + - ' ];\n' + - ' Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));\n' + - '\n' + - ' mainWindow.webContents.setWindowOpenHandler(({ url }) => {\\n' + - ' shell.openExternal(url);\\n' + - ' return { action: \\'deny\\' };\\n' + - ' });\\n\\n' + - ' mainWindow.on(\\'closed\\', () => {\\n' + - ' mainWindow = null;\\n' + - ' });\\n' + - '}\\n\\n' + - 'const PROXY_CONFIG_PATH = path.join(app.getPath(\\'userData\\'), \\'proxy-config.json\\');\\n\\n' + - 'function loadProxyConfig() {\\n' + - ' try {\\n' + - ' if (fs.existsSync(PROXY_CONFIG_PATH)) {\\n' + - ' return JSON.parse(fs.readFileSync(PROXY_CONFIG_PATH, \\'utf-8\\'));\\n' + - ' }\\n' + - ' } catch (e) { console.error(\\'Failed to load proxy config:\\', e); }\\n' + - ' return { enabled: false, type: \\'http\\', host: \\'\\', port: 7890 };\\n' + - '}\\n\\n' + - 'function saveProxyConfig(config) {\\n' + - ' fs.writeFileSync(PROXY_CONFIG_PATH, JSON.stringify(config, null, 2));\\n' + - '}\\n\\n' + - 'async function applyProxy(config) {\\n' + - ' if (!mainWindow || mainWindow.isDestroyed()) return;\\n' + - ' if (config.enabled && config.host && config.port) {\\n' + - ' let auth = \\'\\';\\n' + - ' if (config.username) {\\n' + - ' auth = config.password\\n' + - ' ? encodeURIComponent(config.username) + \\':\\' + encodeURIComponent(config.password) + \\'@\\'\\n' + - ' : encodeURIComponent(config.username) + \\'@\\';\\n' + - ' }\\n' + - ' const proxyUrl = config.type === \\'socks5\\'\\n' + - ' ? \\'socks5://\\' + auth + config.host + \\':\\' + config.port\\n' + - ' : \\'http://\\' + auth + config.host + \\':\\' + config.port;\\n' + - ' await mainWindow.webContents.session.setProxy({\\n' + - ' proxyRules: proxyUrl,\\n' + - ' proxyBypassRules: \\';localhost;127.0.0.1\\'\\n' + - ' });\\n' + - ' console.log(\\'[Proxy] Applied:\\', proxyUrl);\\n' + - ' } else {\\n' + - ' await mainWindow.webContents.session.setProxy({ proxyRules: \\'direct://\\' });\\n' + - ' console.log(\\'[Proxy] Disabled, using direct connection\\');\\n' + - ' }\\n' + - '}\\n\\n' + - 'ipcMain.handle(\\'set-proxy\\', async (event, config) => {\\n' + - ' saveProxyConfig(config);\\n' + - ' await applyProxy(config);\\n' + - ' return { success: true };\\n' + - '});\\n\\n' + - 'ipcMain.handle(\\'get-proxy\\', () => {\\n' + - ' return loadProxyConfig();\\n' + - '});\\n\\n' + - 'ipcMain.handle(\\'test-proxy\\', async (event, config) => {\\n' + - ' const net = require(\\'net\\');\\n' + - ' const connectToProxy = () => new Promise((resolve, reject) => {\\n' + - ' const socket = new net.Socket();\\n' + - ' socket.setTimeout(5000);\\n' + - ' socket.on(\\'connect\\', () => resolve(socket));\\n' + - ' socket.on(\\'timeout\\', () => { socket.destroy(); reject(new Error(\\'Connection timeout\\')); });\\n' + - ' socket.on(\\'error\\', (err) => reject(err));\\n' + - ' socket.connect(config.port, config.host);\\n' + - ' });\\n' + - ' try {\\n' + - ' if (config.type === \\'socks5\\') {\\n' + - ' const socket = await connectToProxy();\\n' + - ' return await new Promise((resolve) => {\\n' + - ' const greeting = config.username\\n' + - ' ? Buffer.from([0x05, 0x02, 0x00, 0x02])\\n' + - ' : Buffer.from([0x05, 0x01, 0x00]);\\n' + - ' socket.setTimeout(5000);\\n' + - ' socket.write(greeting);\\n' + - ' let step = 0;\\n' + - ' socket.on(\\'data\\', (data) => {\\n' + - ' if (step === 0) {\\n' + - ' if (data[0] !== 0x05) { socket.destroy(); resolve({ success: false, error: \\'Invalid SOCKS5 version\\' }); return; }\\n' + - ' if (data[1] === 0xFF) { socket.destroy(); resolve({ success: false, error: \\'No acceptable auth method\\' }); return; }\\n' + - ' if (data[1] === 0x02 && config.username && config.password) {\\n' + - ' step = 1;\\n' + - ' const userBuf = Buffer.from(config.username, \\'utf8\\');\\n' + - ' const passBuf = Buffer.from(config.password, \\'utf8\\');\\n' + - ' const authReq = Buffer.alloc(3 + userBuf.length + passBuf.length);\\n' + - ' authReq[0] = 0x01; authReq[1] = userBuf.length;\\n' + - ' userBuf.copy(authReq, 2);\\n' + - ' authReq[2 + userBuf.length] = passBuf.length;\\n' + - ' passBuf.copy(authReq, 3 + userBuf.length);\\n' + - ' socket.write(authReq);\\n' + - ' } else { socket.destroy(); resolve({ success: true }); }\\n' + - ' } else if (step === 1) {\\n' + - ' socket.destroy();\\n' + - ' resolve(data[0] === 0x01 && data[1] === 0x00\\n' + - ' ? { success: true }\\n' + - ' : { success: false, error: \\'SOCKS5 authentication failed\\' });\\n' + - ' }\\n' + - ' });\\n' + - ' socket.on(\\'timeout\\', () => { socket.destroy(); resolve({ success: false, error: \\'SOCKS5 handshake timeout\\' }); });\\n' + - ' socket.on(\\'error\\', (err) => resolve({ success: false, error: err.message }));\\n' + - ' });\\n' + - ' } else {\\n' + - ' const socket = await connectToProxy();\\n' + - ' return await new Promise((resolve) => {\\n' + - ' socket.setTimeout(5000);\\n' + - ' const authHeader = config.username && config.password\\n' + - ' ? \\'Proxy-Authorization: Basic \\' + Buffer.from(config.username + \\':\\' + config.password).toString(\\'base64\\') + \\'\\\\r\\\\n\\'\\n' + - ' : \\'\\';\\n' + - ' socket.write(\\'CONNECT httpbin.org:443 HTTP/1.1\\\\r\\\\nHost: httpbin.org:443\\\\r\\\\n\\' + authHeader + \\'\\\\r\\\\n\\');\\n' + - ' let responseData = \\'\\';\\n' + - ' socket.on(\\'data\\', (data) => {\\n' + - ' responseData += data.toString();\\n' + - ' if (responseData.includes(\\'\\\\r\\\\n\\\\r\\\\n\\')) {\\n' + - ' socket.destroy();\\n' + - ' if (responseData.includes(\\'200\\')) resolve({ success: true });\\n' + - ' else if (responseData.includes(\\'407\\')) resolve({ success: false, error: \\'Proxy authentication required\\' });\\n' + - ' else resolve({ success: false, error: \\'Proxy rejected: \\' + (responseData.split(\\'\\\\r\\\\n\\')[0] || \\'Unknown\\') });\\n' + - ' }\\n' + - ' });\\n' + - ' socket.on(\\'timeout\\', () => { socket.destroy(); resolve({ success: false, error: \\'HTTP proxy handshake timeout\\' }); });\\n' + - ' socket.on(\\'error\\', (err) => resolve({ success: false, error: err.message }));\\n' + - ' });\\n' + - ' }\\n' + - ' } catch (e) { return { success: false, error: e.message }; }\\n' + - '});\\n\\n' + - 'app.whenReady().then(() => {\\n' + - ' createWindow();\\n' + - ' const savedProxy = loadProxyConfig();\\n' + - ' if (savedProxy.enabled && savedProxy.host && savedProxy.port) {\\n' + - ' applyProxy(savedProxy);\\n' + - ' }\\n' + - ' globalShortcut.register(\\'CommandOrControl+Shift+I\\', () => {\\n' + - ' const focused = BrowserWindow.getFocusedWindow();\\n' + - ' if (focused && !focused.isDestroyed()) {\\n' + - ' focused.webContents.toggleDevTools();\\n' + - ' }\\n' + - ' });\\n' + - '});\\n\\n' + - 'app.on(\\'window-all-closed\\', () => {\\n' + - ' if (process.platform !== \\'darwin\\') {\\n' + - ' app.quit();\\n' + - ' }\\n' + - '});\\n\\n' + - 'app.on(\\'will-quit\\', () => {\\n' + - ' globalShortcut.unregisterAll();\\n' + - '});\\n\\n' + - 'app.on(\\'activate\\', () => {\\n' + - ' if (BrowserWindow.getAllWindows().length === 0) {\\n' + - ' createWindow();\\n' + - ' }\\n' + - '});'; - - fs.writeFileSync('electron/main.js', mainJsContent); - - const preloadJsContent = 'const { contextBridge, ipcRenderer } = require(\\'electron\\');\\n' + - '\\n' + - 'contextBridge.exposeInMainWorld(\\'electronAPI\\', {\\n' + - ' setProxy: (config) => ipcRenderer.invoke(\\'set-proxy\\', config),\\n' + - ' getProxy: () => ipcRenderer.invoke(\\'get-proxy\\'),\\n' + - ' testProxy: (config) => ipcRenderer.invoke(\\'test-proxy\\', config),\\n' + - '});\\n'; - fs.writeFileSync('electron/preload.js', preloadJsContent); - - const electronPackageJson = { - name: 'github-stars-manager-desktop', - version: '1.0.0', - description: 'GitHub Stars Manager Desktop App', - main: 'main.js', - author: 'GitHub Stars Manager', - license: 'MIT' - }; - - fs.writeFileSync('electron/package.json', JSON.stringify(electronPackageJson, null, 2)); - console.log('Electron files created successfully'); - " + # Do NOT regenerate electron/main.js — committed sources include MCP IPC + local server. + # Older CI overwrote this directory and stripped MCP, leaving Settings token with no listener. + set -euo pipefail + required=( + electron/main.js + electron/preload.js + electron/mcpLocalServer.js + electron/package.json + ) + for f in "${required[@]}"; do + if [ ! -f "$f" ]; then + echo "Missing required Electron file: $f" + exit 1 + fi + done + if ! grep -q "createMcpLocalServer" electron/main.js; then + echo "electron/main.js is missing MCP wiring (createMcpLocalServer)" + exit 1 + fi + if ! grep -q "mcp:setConfig" electron/main.js; then + echo "electron/main.js is missing mcp:setConfig IPC handler" + exit 1 + fi + if ! grep -q "mcp:" electron/preload.js; then + echo "electron/preload.js is missing electronAPI.mcp bridge" + exit 1 + fi + echo "Electron sources OK (MCP wired):" + ls -la electron/ - name: Update main package.json for Electron shell: bash From 0d01f3b591df58f73b5192872e082c56327d0f7c Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 22:37:16 +0800 Subject: [PATCH 09/13] fix(mcp): address full CodeRabbit review (token, POST-only, bridge) - Streamable /mcp is POST-only; GET/DELETE return 405 (legacy SSE on /mcp/sse) - Do not mint/reset MCP token while disabled (API + UI) - Start Electron MCP bridge after backend.init; serialize IPC lifecycle - Persist MCP prefs without token; keep desktop token in sessionStorage - Redact gsm_mcp_* in frontend logs and backendAdapter debug bodies - URL copy buttons get aria-labels; normalize already-running MCP URL --- electron/mcpLocalServer.js | 27 ++++-- server/src/mcp/http.ts | 19 +++- server/src/routes/mcp.ts | 8 ++ src/App.tsx | 17 +++- src/components/settings/McpSettingsPanel.tsx | 17 +++- src/services/backendAdapter.ts | 3 +- src/services/mcpElectronBridge.ts | 97 ++++++++++++++------ src/store/useAppStore.ts | 50 ++++++++-- src/utils/logSanitizer.ts | 4 + 9 files changed, 192 insertions(+), 50 deletions(-) diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 799d83ae7..456ebb121 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -312,17 +312,30 @@ function createMcpLocalServer(getState) { if (!state.config?.enabled) { return { success: false, error: 'MCP disabled' }; } + const normalizeHost = (raw) => { + const h = typeof raw === 'string' && raw.trim() ? raw.trim() : '127.0.0.1'; + if ( + h === '0.0.0.0' || + h === '::' || + h === '[::]' || + h === '::1' || + h === '[::1]' || + h === 'localhost' + ) { + return '127.0.0.1'; + } + return h === '127.0.0.1' ? h : '127.0.0.1'; + }; + if (server) { - return { success: true, url: `http://${state.config.host}:${state.config.port}/mcp` }; + const h = normalizeHost(state.config?.host); + const p = state.config?.port || 3927; + return { success: true, url: `http://${h}:${p}/mcp` }; } // Hard-bind loopback only — never listen on 0.0.0.0 for local MCP token auth - const requestedHost = state.config.host || '127.0.0.1'; - const host = - requestedHost === '0.0.0.0' || requestedHost === '::' || requestedHost === '[::]' - ? '127.0.0.1' - : requestedHost; - const port = state.config.port || 3927; + const host = normalizeHost(state.config?.host); + const port = state.config?.port || 3927; server = http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); diff --git a/server/src/mcp/http.ts b/server/src/mcp/http.ts index 41664b18d..d7cc62e7a 100644 --- a/server/src/mcp/http.ts +++ b/server/src/mcp/http.ts @@ -104,11 +104,28 @@ async function handleSseMessage(req: Request, res: Response): Promise { /** * Mount MCP Streamable HTTP (/mcp) and legacy SSE (/mcp/sse + /mcp/sse/messages). * Auth uses MCP token (not API_SECRET). Config is read per request — no token write on mount. + * + * Stateless Streamable HTTP is POST-only (SDK guidance): GET/DELETE return 405 so clients + * do not open orphan SSE streams on /mcp. Use /mcp/sse for legacy SSE. */ export function mountMcpRoutes(app: Express): void { - app.all('/mcp', mcpAuthMiddleware, (req, res) => { + app.post('/mcp', mcpAuthMiddleware, (req, res) => { void handleStreamable(req, res); }); + app.get('/mcp', mcpAuthMiddleware, (_req, res) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed. Use POST for Streamable HTTP or GET /mcp/sse for legacy SSE.' }, + id: null, + }); + }); + app.delete('/mcp', mcpAuthMiddleware, (_req, res) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }); + }); app.get('/mcp/sse', mcpAuthMiddleware, async (_req, res) => { try { diff --git a/server/src/routes/mcp.ts b/server/src/routes/mcp.ts index 5ac75c34b..088d68cf5 100644 --- a/server/src/routes/mcp.ts +++ b/server/src/routes/mcp.ts @@ -62,7 +62,15 @@ router.put('/api/mcp/config', (req, res) => { } let token = getMcpTokenPlain(); + // Only mint/reset when MCP is (or remains) enabled — never create tokens while disabled if (body.resetToken) { + if (!isMcpEnabled()) { + res.status(400).json({ + error: 'Cannot reset token while MCP is disabled', + code: 'MCP_DISABLED', + }); + return; + } token = resetMcpToken(); } else if (isMcpEnabled() && !token) { token = ensureMcpToken(); diff --git a/src/App.tsx b/src/App.tsx index 72481c651..629134999 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,11 @@ import { logger } from './services/logger'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; -import { startMcpElectronBridge, stopMcpElectronBridge } from './services/mcpElectronBridge'; +import { + startMcpElectronBridge, + stopMcpElectronBridge, + refreshMcpElectronBridge, +} from './services/mcpElectronBridge'; import type { AppState, SearchFilters } from './types'; /** @@ -130,10 +134,9 @@ function App() { } }, []); - // Electron local MCP: keep start/stop + snapshots for the whole session - // (not only while MCP settings panel is mounted). + // Electron local MCP: start after backend discovery so we don't bind :3927 + // when agents should use backend /mcp instead. useEffect(() => { - startMcpElectronBridge(); return () => stopMcpElectronBridge(); }, []); @@ -160,6 +163,12 @@ function App() { } } catch (err) { console.error('Failed to initialize backend:', err); + } finally { + // After backend probe (success or not), wire desktop MCP lifecycle + if (!cancelled) { + startMcpElectronBridge(); + refreshMcpElectronBridge(); + } } }; diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index 35bfcd1a9..3f4c3fe8a 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -120,12 +120,11 @@ export const McpSettingsPanel: React.FC = ({ t }) => { void refreshFromBackend(); }, [refreshFromBackend]); - // Electron: ensure local token exists when enabling without backend. + // Electron: mint local token only when MCP is enabled (never while disabled). // Lifecycle (start/stop/snapshot) lives in mcpElectronBridge (App session). useEffect(() => { if (!backendMode && isElectronApp && mcpConfig.enabled && !mcpConfig.token) { - const token = generateLocalToken(); - setMcpConfig({ token }); + setMcpConfig({ token: generateLocalToken() }); } }, [backendMode, isElectronApp, mcpConfig.enabled, mcpConfig.token, setMcpConfig]); @@ -176,6 +175,13 @@ export const McpSettingsPanel: React.FC = ({ t }) => { }; const handleResetToken = async () => { + if (!mcpConfig.enabled) { + toast( + t('请先开启 MCP 服务再重置 Token', 'Enable MCP before resetting the token'), + 'error' + ); + return; + } const ok = await confirm( t('重置 MCP Token', 'Reset MCP Token'), t( @@ -188,7 +194,8 @@ export const McpSettingsPanel: React.FC = ({ t }) => { setSaving(true); try { if (backendMode && backend.isAvailable) { - const result = await backend.updateMcpConfig({ resetToken: true, enabled: mcpConfig.enabled }); + const result = await backend.updateMcpConfig({ resetToken: true, enabled: true }); + // Backend token is session/UI only — store still holds local electron prefs separately setMcpConfig({ token: result.token, enabled: result.enabled }); } else { setMcpConfig({ token: generateLocalToken() }); @@ -404,6 +411,7 @@ export const McpSettingsPanel: React.FC = ({ t }) => { type="button" onClick={() => void copyText('http', mcpHttpUrl)} className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10" + aria-label={t('复制 Streamable HTTP 地址', 'Copy Streamable HTTP URL')} > @@ -419,6 +427,7 @@ export const McpSettingsPanel: React.FC = ({ t }) => { type="button" onClick={() => void copyText('sse', mcpSseUrl)} className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10" + aria-label={t('复制 SSE 地址', 'Copy SSE URL')} > diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 853ee97f8..155df9ead 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -121,7 +121,8 @@ class BackendAdapter { const parsed = JSON.parse(options.body); // Mask any apiKey/password fields recursively requestBody = JSON.stringify(parsed, (key, val) => { - if (/api[_-]?key|password|secret|token|authorization/i.test(key)) return '***'; + if (/api[_-]?key|password|secret|token|authorization|mcp/i.test(key)) return '***'; + if (typeof val === 'string' && val.startsWith('gsm_mcp_')) return '***'; return val; }, 2); } catch { diff --git a/src/services/mcpElectronBridge.ts b/src/services/mcpElectronBridge.ts index 615013bce..e44e80308 100644 --- a/src/services/mcpElectronBridge.ts +++ b/src/services/mcpElectronBridge.ts @@ -7,10 +7,13 @@ import { useAppStore } from '../store/useAppStore'; import { normalizeMcpHost } from '../utils/mcpHost'; import { isElectron } from './electronProxy'; import { backend } from './backendAdapter'; +import { logger } from './logger'; let started = false; let unsub: (() => void) | null = null; let debounceTimer: ReturnType | null = null; +/** Serialize setConfig → pushSnapshot → start/stop so concurrent store updates don't race. */ +let chain: Promise = Promise.resolve(); function clearDebounce(): void { if (debounceTimer) { @@ -19,11 +22,27 @@ function clearDebounce(): void { } } -function pushLifecycle(): void { +function enqueue(task: () => Promise): void { + chain = chain.then(task).catch((err) => { + logger.warn('mcp.bridge', 'Electron MCP lifecycle step failed', { + error: err instanceof Error ? err.message : String(err), + }); + }); +} + +async function pushLifecycle(): Promise { if (!isElectron() || !window.electronAPI?.mcp) return; + const api = window.electronAPI.mcp; + // When backend is available, agents should use backend /mcp; stop local server. if (backend.isAvailable) { - void window.electronAPI.mcp.stop(); + try { + await api.stop(); + } catch (err) { + logger.warn('mcp.bridge', 'Failed to stop local MCP (backend mode)', { + error: err instanceof Error ? err.message : String(err), + }); + } return; } @@ -31,42 +50,53 @@ function pushLifecycle(): void { const { mcpConfig } = state; const host = normalizeMcpHost(mcpConfig.host); - const api = window.electronAPI.mcp; - void api.setConfig({ - enabled: mcpConfig.enabled, - host, - port: mcpConfig.port, - token: mcpConfig.token, - }); + try { + await api.setConfig({ + enabled: mcpConfig.enabled, + host, + port: mcpConfig.port, + token: mcpConfig.token, + }); - if (!mcpConfig.enabled) { - void api.stop(); - return; - } + if (!mcpConfig.enabled) { + await api.stop(); + return; + } - void api.pushSnapshot({ - repositories: state.repositories, - customCategories: state.customCategories, - vectorSearchConfig: { - enabled: state.vectorSearchConfig.enabled, - workerUrl: state.vectorSearchConfig.workerUrl, - embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, - }, - snapshotAt: new Date().toISOString(), - }); - void api.start(); + await api.pushSnapshot({ + repositories: state.repositories, + customCategories: state.customCategories, + vectorSearchConfig: { + enabled: state.vectorSearchConfig.enabled, + workerUrl: state.vectorSearchConfig.workerUrl, + embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, + }, + snapshotAt: new Date().toISOString(), + }); + const startResult = await api.start(); + if (startResult && startResult.success === false) { + logger.warn('mcp.bridge', 'MCP start returned failure', { + error: startResult.error, + }); + } + } catch (err) { + logger.warn('mcp.bridge', 'Electron MCP lifecycle failed', { + error: err instanceof Error ? err.message : String(err), + }); + } } function schedulePush(): void { clearDebounce(); debounceTimer = setTimeout(() => { debounceTimer = null; - pushLifecycle(); + enqueue(() => pushLifecycle()); }, 300); } /** * Subscribe once for the app session. Safe to call repeatedly. + * Prefer calling after backend.init() so backend.isAvailable is accurate. */ export function startMcpElectronBridge(): void { if (started || typeof window === 'undefined') return; @@ -100,7 +130,22 @@ export function stopMcpElectronBridge(): void { unsub = null; } if (isElectron() && window.electronAPI?.mcp) { - void window.electronAPI.mcp.stop(); + enqueue(async () => { + try { + await window.electronAPI!.mcp!.stop(); + } catch { + /* ignore */ + } + }); } started = false; } + +/** Re-evaluate local vs backend after backend.init completes. */ +export function refreshMcpElectronBridge(): void { + if (!started) { + startMcpElectronBridge(); + return; + } + schedulePush(); +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 93952d92c..290abe31f 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -54,6 +54,8 @@ import { logger } from '../services/logger'; import { PRESET_FILTERS } from '../constants/presetFilters'; const BACKEND_SECRET_SESSION_KEY = 'github-stars-manager-backend-secret'; +/** Desktop-only MCP bearer; session-scoped so it never lands in IndexedDB. */ +const MCP_TOKEN_SESSION_KEY = 'github-stars-manager-mcp-token'; /** Menu IDs that must always remain visible — enforced at store level */ const REQUIRED_HEADER_MENU_IDS: ReadonlySet = new Set(['repositories', 'settings']); @@ -220,6 +222,20 @@ const writeSessionBackendSecret = (secret: string | null): void => { } }; +const readSessionMcpToken = (): string => { + if (typeof window === 'undefined') return ''; + return window.sessionStorage.getItem(MCP_TOKEN_SESSION_KEY) || ''; +}; + +const writeSessionMcpToken = (token: string): void => { + if (typeof window === 'undefined') return; + if (token) { + window.sessionStorage.setItem(MCP_TOKEN_SESSION_KEY, token); + } else { + window.sessionStorage.removeItem(MCP_TOKEN_SESSION_KEY); + } +}; + const areRepositoryRecordsEqual = (a: Repository, b: Repository): boolean => { if (a === b) return true; @@ -772,7 +788,12 @@ export const normalizePersistedState = ( safePersisted.vectorSearchConfig, safePersisted.embeddingConfigs ), - mcpConfig: normalizeMcpConfig((safePersisted as Record).mcpConfig), + mcpConfig: (() => { + const base = normalizeMcpConfig((safePersisted as Record).mcpConfig); + // Token never comes from IndexedDB; restore desktop token from session only + const sessionToken = readSessionMcpToken(); + return sessionToken ? { ...base, token: sessionToken } : base; + })(), customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1171,7 +1192,10 @@ export const useAppStore = create()( vectorSearchConfig: { ...defaultVectorSearchConfig }, vectorSearchStatus: { connected: false, vectorCount: 0, dimensions: 0 }, vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, - mcpConfig: { ...defaultMcpConfig }, + mcpConfig: { + ...defaultMcpConfig, + token: readSessionMcpToken(), + }, similarView: null, webdavConfigs: [], activeWebDAVConfig: null, @@ -1532,9 +1556,14 @@ export const useAppStore = create()( })), setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), setMcpConfig: (config) => - set((state) => ({ - mcpConfig: normalizeMcpConfig({ ...state.mcpConfig, ...config }), - })), + set((state) => { + const next = normalizeMcpConfig({ ...state.mcpConfig, ...config }); + // Keep desktop token in sessionStorage only (never IndexedDB partialize) + if (Object.prototype.hasOwnProperty.call(config, 'token')) { + writeSessionMcpToken(next.token || ''); + } + return { mcpConfig: next }; + }), setVectorIndexingState: (indexingState) => set((state) => ({ vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } })), @@ -2230,8 +2259,15 @@ export const useAppStore = create()( // 持久化向量搜索状态(vectorCount 等,跨重启保留) vectorSearchStatus: state.vectorSearchStatus, - // 持久化 MCP 本地偏好(token 可查看/复制;后端连接时以服务端为准) - mcpConfig: state.mcpConfig, + // MCP: persist enable/bind prefs only. Token is desktop-local secret kept + // in memory + Electron main; backend tokens must not land in IndexedDB. + // Local token is re-issued on enable if missing (settings / bridge). + mcpConfig: { + enabled: state.mcpConfig.enabled, + host: state.mcpConfig.host, + port: state.mcpConfig.port, + token: '', + }, // 持久化WebDAV配置 webdavConfigs: state.webdavConfigs, diff --git a/src/utils/logSanitizer.ts b/src/utils/logSanitizer.ts index 2e009df12..ce4a1e165 100644 --- a/src/utils/logSanitizer.ts +++ b/src/utils/logSanitizer.ts @@ -8,6 +8,7 @@ const SENSITIVE_FIELD_NAMES = new Set([ 'apiKey', 'api_key', 'api_key_encrypted', 'password', 'password_encrypted', 'secret', 'token', 'githubToken', 'accessToken', 'authorization', 'x-api-key', 'credentials', 'passwd', 'pwd', 'backendApiSecret', + 'mcp_token', 'mcpToken', 'authToken', 'auth_token', ]); // URL query param keys to redact @@ -122,6 +123,9 @@ function sanitizeString(value: string): string { // GitHub token pattern if (isGitHubToken(value)) return maskSecret(value); + // MCP bearer tokens (gsm_mcp_…) + if (value.startsWith('gsm_mcp_')) return maskSecret(value); + // Plain-string secrets (e.g., sk-..., long base64-like strings) if (looksLikeSecret(value)) return maskSecret(value); From f601464367cd885a455e51e252fbe987e7e045cf Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 22:54:13 +0800 Subject: [PATCH 10/13] fix(mcp): complete remaining review items on token reset and debug logs - Disable Reset Token button while MCP is off (handler + API already guard) - Redact token fields and gsm_mcp_* values in backendAdapter responseBody debug previews so /mcp/status and /mcp/config cannot leak into logs --- src/components/settings/McpSettingsPanel.tsx | 9 +++++++-- src/services/backendAdapter.ts | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index 3f4c3fe8a..92e48b5a4 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -350,8 +350,13 @@ export const McpSettingsPanel: React.FC = ({ t }) => { diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 155df9ead..d14a12ab9 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -143,7 +143,20 @@ class BackendAdapter { const cloned = response.clone(); const text = await cloned.text(); if (text.length > 0) { - responseBody = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + const preview = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + // Redact secrets inside JSON (e.g. /mcp/status returns { token: "gsm_mcp_…" }) + try { + const parsed = JSON.parse(preview.endsWith('...[truncated]') ? text.slice(0, 4000) : preview); + responseBody = JSON.stringify(parsed, (key, val) => { + if (/api[_-]?key|password|secret|token|authorization|mcp/i.test(key)) return '***'; + if (typeof val === 'string' && val.startsWith('gsm_mcp_')) return '***'; + return val; + }, 2); + if (text.length > 4000) responseBody += '\n...[truncated]'; + } catch { + // Non-JSON: strip gsm_mcp_ tokens if present + responseBody = preview.replace(/gsm_mcp_[A-Za-z0-9_-]+/g, 'gsm_mcp_***'); + } } } catch { /* body not readable */ } logger.debug('backendAdapter', 'Backend request', { From 5c4d8f8f0694fc09b5ff0caca93eb436928af5e7 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 23:16:09 +0800 Subject: [PATCH 11/13] feat(desktop): enable gsm_vector_search on local Electron MCP Local MCP previously always returned "vector search not supported". Pass embedding + worker runtime config via IPC snapshot and implement embed + Vectorize query in mcpLocalServer when Vector Search is enabled in the app. tools/list only exposes gsm_vector_search when ready. --- electron/mcpLocalServer.js | 225 ++++++++++++++++++++++++++---- src/services/electronProxy.ts | 16 ++- src/services/mcpElectronBridge.ts | 24 +++- 3 files changed, 231 insertions(+), 34 deletions(-) diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 456ebb121..6599d3b36 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -57,6 +57,168 @@ function projectRepo(repo, max = 400) { }; } +function getVectorAvailability(snapshot) { + const vs = snapshot?.vectorSearchConfig; + if (!vs || !vs.enabled) { + return { available: false, reason: 'vector_search_disabled' }; + } + const workerUrl = String(vs.workerUrl || '').trim(); + if (!workerUrl) { + return { available: false, reason: 'worker_url_missing' }; + } + const emb = vs.embedding; + if (!emb || !emb.model) { + return { available: false, reason: 'embedding_config_missing' }; + } + const apiType = emb.apiType || 'openai'; + if (apiType !== 'ollama' && !emb.apiKey) { + return { available: false, reason: 'embedding_api_key_missing' }; + } + return { + available: true, + reason: null, + embeddingModel: emb.model, + workerUrl, + }; +} + +const FETCH_TIMEOUT_MS = 15_000; + +async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function embedQuery(text, emb) { + const apiType = emb.apiType || 'openai'; + const model = emb.model || ''; + const apiKey = emb.apiKey || ''; + let baseUrl = String(emb.baseUrl || '').replace(/\/$/, ''); + let url; + const headers = { 'Content-Type': 'application/json' }; + let body; + + if (apiType === 'ollama') { + url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embeddings`; + body = { model, prompt: text }; + } else if (apiType === 'gemini') { + const keyQ = apiKey ? `?key=${encodeURIComponent(apiKey)}` : ''; + url = `${baseUrl || 'https://generativelanguage.googleapis.com'}/v1beta/models/${model}:embedContent${keyQ}`; + body = { content: { parts: [{ text }] } }; + } else if (apiType === 'cohere') { + url = `${baseUrl || 'https://api.cohere.com'}/v1/embed`; + headers.Authorization = `Bearer ${apiKey}`; + body = { model, texts: [text], input_type: 'search_query' }; + } else { + if (!baseUrl) { + baseUrl = + apiType === 'siliconflow' ? 'https://api.siliconflow.cn/v1' : 'https://api.openai.com/v1'; + } + url = `${baseUrl}/embeddings`; + headers.Authorization = `Bearer ${apiKey}`; + body = { model, input: [text] }; + } + + const res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ''); + throw new Error(`Embedding API error ${res.status}: ${errText.slice(0, 200)}`); + } + const data = await res.json(); + + if (apiType === 'ollama') { + if (!Array.isArray(data.embedding)) throw new Error('Ollama embedding missing'); + return data.embedding; + } + if (apiType === 'gemini') { + if (!data.embedding?.values) throw new Error('Gemini embedding missing'); + return data.embedding.values; + } + if (apiType === 'cohere') { + if (!data.embeddings?.[0]) throw new Error('Cohere embedding missing'); + return data.embeddings[0]; + } + if (!data.data?.[0]?.embedding) throw new Error('OpenAI-compatible embedding missing'); + return data.data[0].embedding; +} + +async function runVectorSearch(query, args, snapshot) { + const availability = getVectorAvailability(snapshot); + if (!availability.available) { + return { available: false, reason: availability.reason || 'unavailable' }; + } + + const vs = snapshot.vectorSearchConfig; + const emb = vs.embedding; + const topK = Math.min(50, Math.max(1, Number(args?.topK) || vs.searchTopK || 20)); + const threshold = + typeof args?.threshold === 'number' + ? args.threshold + : typeof vs.searchThreshold === 'number' + ? vs.searchThreshold + : 0.35; + + let vector; + try { + vector = await embedQuery(String(query || ''), emb); + } catch (err) { + return { + available: false, + reason: `embedding_failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + const workerUrl = String(vs.workerUrl).replace(/\/$/, ''); + const workerToken = vs.authToken || ''; + let res; + try { + res = await fetchWithTimeout(`${workerUrl}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(workerToken ? { Authorization: `Bearer ${workerToken}` } : {}), + }, + body: JSON.stringify({ vector, topK, threshold }), + }); + } catch (err) { + return { + available: false, + reason: `worker_query_failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + if (!res.ok) { + const errText = await res.text().catch(() => ''); + return { + available: false, + reason: `worker_query_failed: ${res.status} ${errText.slice(0, 120)}`, + }; + } + + const data = await res.json(); + const matches = Array.isArray(data.matches) ? data.matches : []; + const repos = Array.isArray(snapshot?.repositories) ? snapshot.repositories : []; + const byId = new Map(repos.map((r) => [String(r.id), r])); + + const enriched = matches + .map((m) => { + const repo = byId.get(String(m.id)); + if (!repo) return null; + return { score: m.score, ...projectRepo(repo) }; + }) + .filter(Boolean); + + return { available: true, total: enriched.length, matches: enriched }; +} + function getTools(vectorAvailable) { const tools = [ { @@ -118,10 +280,14 @@ function getTools(vectorAvailable) { tools.push({ name: 'gsm_vector_search', description: - 'Vector search is configured in the app but runs on the backend Worker; in Electron local mode use gsm_search_repos or connect backend MCP.', + 'Semantic vector search over starred repositories (uses the app embedding + Vectorize worker config).', inputSchema: { type: 'object', - properties: { query: { type: 'string' } }, + properties: { + query: { type: 'string' }, + topK: { type: 'number' }, + threshold: { type: 'number' }, + }, required: ['query'], }, }); @@ -129,14 +295,10 @@ function getTools(vectorAvailable) { return tools; } -function callTool(name, args, snapshot) { +async function callTool(name, args, snapshot) { const repos = Array.isArray(snapshot?.repositories) ? snapshot.repositories : []; const categories = Array.isArray(snapshot?.customCategories) ? snapshot.customCategories : []; - const vectorEnabled = !!( - snapshot?.vectorSearchConfig?.enabled && - snapshot?.vectorSearchConfig?.workerUrl && - snapshot?.vectorSearchConfig?.embeddingConfigId - ); + const vectorInfo = getVectorAvailability(snapshot); const text = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], @@ -151,11 +313,13 @@ function callTool(name, args, snapshot) { repositoryCount: repos.length, snapshotAt: snapshot?.snapshotAt || null, vector: { - available: vectorEnabled, - reason: vectorEnabled - ? 'configured_in_app_use_backend_mcp_for_live_vector' - : 'vector_search_disabled', + available: vectorInfo.available, + reason: vectorInfo.reason, + embeddingModel: vectorInfo.embeddingModel || null, }, + toolsNote: vectorInfo.available + ? 'gsm_vector_search is available' + : 'gsm_vector_search is not listed until vector search is configured and enabled in the app', }); case 'gsm_search_repos': { let list = performBasicTextSearch(repos, args?.query || ''); @@ -221,12 +385,17 @@ function callTool(name, args, snapshot) { byLanguage, }); } - case 'gsm_vector_search': - return text({ - available: false, - reason: - 'Electron local MCP does not call embedding/worker APIs. Use backend-hosted MCP for gsm_vector_search, or gsm_search_repos for keyword search.', - }); + case 'gsm_vector_search': { + if (!vectorInfo.available) { + return text({ + available: false, + reason: vectorInfo.reason || 'vector_search_disabled', + hint: 'Enable Vector Search in Settings and ensure embedding + worker are configured, then retry.', + }); + } + const result = await runVectorSearch(args?.query || '', args, snapshot); + return text(result); + } default: return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; } @@ -252,14 +421,10 @@ function createMcpLocalServer(getState) { res.write(`data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`); } - function handleJsonRpc(body, snapshot) { + async function handleJsonRpc(body, snapshot) { const method = body?.method; const id = body?.id ?? null; - const vectorAvailable = !!( - snapshot?.vectorSearchConfig?.enabled && - snapshot?.vectorSearchConfig?.workerUrl && - snapshot?.vectorSearchConfig?.embeddingConfigId - ); + const vectorInfo = getVectorAvailability(snapshot); if (method === 'initialize') { return { @@ -282,13 +447,13 @@ function createMcpLocalServer(getState) { return { jsonrpc: '2.0', id, - result: { tools: getTools(vectorAvailable) }, + result: { tools: getTools(vectorInfo.available) }, }; } if (method === 'tools/call') { const name = body?.params?.name; const args = body?.params?.arguments || {}; - const result = callTool(name, args, snapshot); + const result = await callTool(name, args, snapshot); return { jsonrpc: '2.0', id, result }; } return { @@ -411,13 +576,13 @@ function createMcpLocalServer(getState) { } const chunks = []; req.on('data', (c) => chunks.push(c)); - req.on('end', () => { + req.on('end', async () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const body = raw ? JSON.parse(raw) : {}; const messages = Array.isArray(body) ? body : [body]; for (const msg of messages) { - const out = handleJsonRpc(msg, current.snapshot); + const out = await handleJsonRpc(msg, current.snapshot); // notifications have no response if (out) writeSse(stream, 'message', out); } @@ -462,14 +627,14 @@ function createMcpLocalServer(getState) { const chunks = []; req.on('data', (c) => chunks.push(c)); - req.on('end', () => { + req.on('end', async () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const body = raw ? JSON.parse(raw) : {}; const messages = Array.isArray(body) ? body : [body]; const responses = []; for (const msg of messages) { - const out = handleJsonRpc(msg, current.snapshot); + const out = await handleJsonRpc(msg, current.snapshot); if (out) responses.push(out); } res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts index 5b57f8bac..141133489 100644 --- a/src/services/electronProxy.ts +++ b/src/services/electronProxy.ts @@ -3,16 +3,30 @@ import type { Repository, Category, VectorSearchConfig, + EmbeddingConfig, McpServiceConfig, } from '../types'; /** Alias of persisted MCP prefs — keep identical to McpServiceConfig to avoid drift. */ export type McpLocalConfig = McpServiceConfig; +/** Secrets stay in main-process memory only (IPC snapshot); not written to disk by MCP server. */ +export interface McpVectorRuntimeConfig { + enabled: boolean; + workerUrl: string; + authToken: string; + searchThreshold?: number; + searchTopK?: number; + embedding: Pick< + EmbeddingConfig, + 'apiType' | 'baseUrl' | 'apiKey' | 'model' | 'dimensions' + > | null; +} + export interface McpDataSnapshot { repositories: Repository[]; customCategories: Category[]; - vectorSearchConfig: Pick; + vectorSearchConfig: McpVectorRuntimeConfig; snapshotAt: string; } diff --git a/src/services/mcpElectronBridge.ts b/src/services/mcpElectronBridge.ts index e44e80308..0b8d04f4f 100644 --- a/src/services/mcpElectronBridge.ts +++ b/src/services/mcpElectronBridge.ts @@ -63,13 +63,31 @@ async function pushLifecycle(): Promise { return; } + const vs = state.vectorSearchConfig; + const emb = + state.embeddingConfigs.find((c) => c.id === vs.embeddingConfigId) || + state.embeddingConfigs.find((c) => c.id === state.activeEmbeddingConfig) || + null; + await api.pushSnapshot({ repositories: state.repositories, customCategories: state.customCategories, + // Pass runtime secrets only over IPC to main (never logged / never disk-persisted by MCP) vectorSearchConfig: { - enabled: state.vectorSearchConfig.enabled, - workerUrl: state.vectorSearchConfig.workerUrl, - embeddingConfigId: state.vectorSearchConfig.embeddingConfigId, + enabled: !!vs.enabled, + workerUrl: vs.workerUrl || '', + authToken: vs.authToken || '', + searchThreshold: vs.searchThreshold, + searchTopK: vs.searchTopK, + embedding: emb + ? { + apiType: emb.apiType, + baseUrl: emb.baseUrl || '', + apiKey: emb.apiKey || '', + model: emb.model || '', + dimensions: emb.dimensions, + } + : null, }, snapshotAt: new Date().toISOString(), }); From c8e67db427506bc4075ee40631f405dea0b9d7f8 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 23 Jul 2026 23:56:28 +0800 Subject: [PATCH 12/13] fix(mcp): align embedding URLs with app EmbeddingClient SiliconFlow/OpenAI baseUrl in the app is the host (e.g. api.siliconflow.cn); the client appends /v1/embeddings. Local MCP was appending only /embeddings (or wrongly defaulting base to .../v1), producing 404 page not found and breaking gsm_vector_search. Match vectorSearchService.ts for all providers. --- electron/mcpLocalServer.js | 64 ++++++++++++++++++++++++++++---------- server/src/mcp/provider.ts | 60 +++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 6599d3b36..afed44201 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -94,33 +94,57 @@ async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) { } } +/** + * Build embedding request URL/body to match app EmbeddingClient + * (src/services/vectorSearchService.ts). Critical: siliconflow/openai use + * `${baseUrl}/v1/embeddings` where baseUrl is typically host WITHOUT trailing /v1 + * (e.g. https://api.siliconflow.cn → .../v1/embeddings). + */ async function embedQuery(text, emb) { const apiType = emb.apiType || 'openai'; const model = emb.model || ''; const apiKey = emb.apiKey || ''; - let baseUrl = String(emb.baseUrl || '').replace(/\/$/, ''); + const baseUrl = String(emb.baseUrl || '').replace(/\/+$/, ''); let url; const headers = { 'Content-Type': 'application/json' }; let body; if (apiType === 'ollama') { - url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embeddings`; - body = { model, prompt: text }; + // App: POST /api/embed with { model, input } + url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embed`; + body = { model, input: [text] }; } else if (apiType === 'gemini') { - const keyQ = apiKey ? `?key=${encodeURIComponent(apiKey)}` : ''; - url = `${baseUrl || 'https://generativelanguage.googleapis.com'}/v1beta/models/${model}:embedContent${keyQ}`; - body = { content: { parts: [{ text }] } }; + // App: batchEmbedContents for query purpose + const root = baseUrl || 'https://generativelanguage.googleapis.com'; + url = `${root}/v1beta/models/${model}:batchEmbedContents?key=${encodeURIComponent(apiKey)}`; + body = { + requests: [ + { + model: `models/${model}`, + content: { parts: [{ text }] }, + taskType: 'RETRIEVAL_QUERY', + }, + ], + }; } else if (apiType === 'cohere') { + // App: POST ${baseUrl}/v1/embed url = `${baseUrl || 'https://api.cohere.com'}/v1/embed`; headers.Authorization = `Bearer ${apiKey}`; body = { model, texts: [text], input_type: 'search_query' }; + } else if (apiType === 'openai-compatible') { + // App: use baseUrl as full embeddings endpoint URL + if (!baseUrl) throw new Error('openai-compatible baseUrl is required (full embeddings endpoint)'); + url = baseUrl; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + body = { model, input: [text] }; } else { - if (!baseUrl) { - baseUrl = - apiType === 'siliconflow' ? 'https://api.siliconflow.cn/v1' : 'https://api.openai.com/v1'; - } - url = `${baseUrl}/embeddings`; - headers.Authorization = `Bearer ${apiKey}`; + // openai / siliconflow — App appends /v1/embeddings to host baseUrl + const root = + baseUrl || + (apiType === 'siliconflow' ? 'https://api.siliconflow.cn' : 'https://api.openai.com'); + // Avoid double /v1 if user already stored .../v1 + url = /\/v1$/i.test(root) ? `${root}/embeddings` : `${root}/v1/embeddings`; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; body = { model, input: [text] }; } @@ -131,17 +155,23 @@ async function embedQuery(text, emb) { }); if (!res.ok) { const errText = await res.text().catch(() => ''); - throw new Error(`Embedding API error ${res.status}: ${errText.slice(0, 200)}`); + throw new Error( + `Embedding API error ${res.status} (${apiType} ${url}): ${errText.slice(0, 200)}` + ); } const data = await res.json(); if (apiType === 'ollama') { - if (!Array.isArray(data.embedding)) throw new Error('Ollama embedding missing'); - return data.embedding; + // App: { embeddings: [[...]] } + if (Array.isArray(data.embeddings?.[0])) return data.embeddings[0]; + if (Array.isArray(data.embedding)) return data.embedding; + throw new Error('Ollama embedding missing'); } if (apiType === 'gemini') { - if (!data.embedding?.values) throw new Error('Gemini embedding missing'); - return data.embedding.values; + // batchEmbedContents: { embeddings: [{ values: [...] }] } + if (Array.isArray(data.embeddings?.[0]?.values)) return data.embeddings[0].values; + if (Array.isArray(data.embedding?.values)) return data.embedding.values; + throw new Error('Gemini embedding missing'); } if (apiType === 'cohere') { if (!data.embeddings?.[0]) throw new Error('Cohere embedding missing'); diff --git a/server/src/mcp/provider.ts b/server/src/mcp/provider.ts index 6a784f20e..e8e7924c5 100644 --- a/server/src/mcp/provider.ts +++ b/server/src/mcp/provider.ts @@ -196,6 +196,12 @@ async function fetchWithTimeout( } } +/** + * Match app EmbeddingClient URL rules in vectorSearchService.ts: + * - openai / siliconflow: `${baseUrl}/v1/embeddings` (baseUrl usually host without /v1) + * - openai-compatible: baseUrl is the full embeddings endpoint + * - ollama: `${baseUrl}/api/embed` with { model, input } + */ async function embedQuery( texts: string[], emb: Record @@ -211,32 +217,42 @@ async function embedQuery( } } - let baseUrl = String(emb.base_url || '').replace(/\/$/, ''); + const baseUrl = String(emb.base_url || '').replace(/\/+$/, ''); let url: string; - let headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json' }; let body: Record; if (apiType === 'ollama') { - url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embeddings`; - body = { model, prompt: texts[0] }; + url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embed`; + body = { model, input: texts }; } else if (apiType === 'gemini') { - const keyQ = apiKey ? `?key=${encodeURIComponent(apiKey)}` : ''; - url = `${baseUrl || 'https://generativelanguage.googleapis.com'}/v1beta/models/${model}:embedContent${keyQ}`; - body = { content: { parts: [{ text: texts[0] }] } }; + const root = baseUrl || 'https://generativelanguage.googleapis.com'; + url = `${root}/v1beta/models/${model}:batchEmbedContents?key=${encodeURIComponent(apiKey)}`; + body = { + requests: texts.map((t) => ({ + model: `models/${model}`, + content: { parts: [{ text: t }] }, + taskType: 'RETRIEVAL_QUERY', + })), + }; } else if (apiType === 'cohere') { url = `${baseUrl || 'https://api.cohere.com'}/v1/embed`; headers.Authorization = `Bearer ${apiKey}`; body = { model, texts, input_type: 'search_query' }; - } else { - // openai / openai-compatible / siliconflow + } else if (apiType === 'openai-compatible') { if (!baseUrl) { - baseUrl = - apiType === 'siliconflow' - ? 'https://api.siliconflow.cn/v1' - : 'https://api.openai.com/v1'; + throw new Error('openai-compatible base_url is required (full embeddings endpoint)'); } - url = `${baseUrl}/embeddings`; - headers.Authorization = `Bearer ${apiKey}`; + url = baseUrl; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + body = { model, input: texts }; + } else { + // openai / siliconflow + const root = + baseUrl || + (apiType === 'siliconflow' ? 'https://api.siliconflow.cn' : 'https://api.openai.com'); + url = /\/v1$/i.test(root) ? `${root}/embeddings` : `${root}/v1/embeddings`; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; body = { model, input: texts }; } @@ -247,19 +263,23 @@ async function embedQuery( }); if (!res.ok) { const text = await res.text().catch(() => ''); - throw new Error(`Embedding API error ${res.status}: ${text.slice(0, 200)}`); + throw new Error(`Embedding API error ${res.status} (${apiType} ${url}): ${text.slice(0, 200)}`); } const data = (await res.json()) as Record; if (apiType === 'ollama') { + const embeddings = data.embeddings as number[][] | undefined; + if (embeddings?.[0]) return embeddings[0]; const embVec = data.embedding as number[] | undefined; - if (!embVec) throw new Error('Ollama embedding missing'); - return embVec; + if (embVec) return embVec; + throw new Error('Ollama embedding missing'); } if (apiType === 'gemini') { + const batch = data.embeddings as Array<{ values?: number[] }> | undefined; + if (batch?.[0]?.values) return batch[0].values; const embObj = data.embedding as { values?: number[] } | undefined; - if (!embObj?.values) throw new Error('Gemini embedding missing'); - return embObj.values; + if (embObj?.values) return embObj.values; + throw new Error('Gemini embedding missing'); } if (apiType === 'cohere') { const embeddings = data.embeddings as number[][] | undefined; From 511a72e23be14db43d10249825ef2accab3c00cb Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 24 Jul 2026 00:18:49 +0800 Subject: [PATCH 13/13] fix(mcp): persist durable token in IndexedDB / SQLite until user reset Desktop MCP tokens were session-only and auto-regenerated after restart, forcing users to update agent configs silently. Persist full mcpConfig (including token) via Zustand IndexedDB partialize. Backend already stores encrypted mcp_token in SQLite; ensure paths only mint when missing and document that tokens change solely on explicit Reset Token. --- DOCKER.md | 1 + server/src/mcp/settings.ts | 6 ++- server/src/routes/mcp.ts | 4 +- src/components/settings/McpSettingsPanel.tsx | 7 +-- src/store/useAppStore.ts | 52 ++++---------------- 5 files changed, 21 insertions(+), 49 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index d84fd9497..6a88c049c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -221,6 +221,7 @@ With Docker Compose, the backend MCP endpoints are exposed through nginx (fronte **Notes** - MCP uses a **separate token** from `API_SECRET` (backend UI auth). Resetting the MCP token does not break app↔backend sync. +- The MCP bearer is **stable**: stored encrypted in SQLite (`mcp_token`) on the backend, and in IndexedDB with other app state on desktop. It is created once when you first enable MCP and **only changes if you click Reset Token**. - Pure frontend (no backend) does not show the MCP settings page. - `gsm_vector_search` appears only when Vector Search is configured and enabled in the app. - Enabling MCP is additive: existing SQLite data is unchanged; disabling MCP only stops the endpoint. diff --git a/server/src/mcp/settings.ts b/server/src/mcp/settings.ts index 07b5cbeab..a4c9c6723 100644 --- a/server/src/mcp/settings.ts +++ b/server/src/mcp/settings.ts @@ -69,7 +69,10 @@ export function setMcpTokenPlain(token: string): void { setSettingRaw(MCP_SETTING_TOKEN, encrypted); } -/** Create token only if missing. Does not enable MCP. */ +/** + * Create token only if missing. Does not enable MCP. + * Token is durable in SQLite (encrypted); never rotated here — only resetMcpToken() does. + */ export function ensureMcpToken(): string { const existing = getMcpTokenPlain(); if (existing) return existing; @@ -78,6 +81,7 @@ export function ensureMcpToken(): string { return token; } +/** Explicit user-initiated rotation only. */ export function resetMcpToken(): string { const token = generateMcpToken(); setMcpTokenPlain(token); diff --git a/server/src/routes/mcp.ts b/server/src/routes/mcp.ts index 088d68cf5..f034a51ee 100644 --- a/server/src/routes/mcp.ts +++ b/server/src/routes/mcp.ts @@ -15,12 +15,12 @@ const router = Router(); * GET /api/mcp/status * Protected by existing API_SECRET auth (via /api middleware). * Token is returned in full for owner UI (viewable anytime) when present. - * Does not create a token until MCP has been enabled at least once. + * Mint only when enabled and missing — never rotates an existing token. */ router.get('/api/mcp/status', (_req, res) => { try { const enabled = isMcpEnabled(); - // Only mint a token when already enabled (or one already exists) + // Only mint when already enabled and no token exists yet (first enable path) let token = getMcpTokenPlain() || ''; if (enabled && !token) { token = ensureMcpToken(); diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx index 92e48b5a4..a8347d1d3 100644 --- a/src/components/settings/McpSettingsPanel.tsx +++ b/src/components/settings/McpSettingsPanel.tsx @@ -120,7 +120,8 @@ export const McpSettingsPanel: React.FC = ({ t }) => { void refreshFromBackend(); }, [refreshFromBackend]); - // Electron: mint local token only when MCP is enabled (never while disabled). + // Mint a durable local token only when enabling without one (persisted in IndexedDB). + // Never rotate automatically — only user "Reset Token" replaces it. // Lifecycle (start/stop/snapshot) lives in mcpElectronBridge (App session). useEffect(() => { if (!backendMode && isElectronApp && mcpConfig.enabled && !mcpConfig.token) { @@ -363,8 +364,8 @@ export const McpSettingsPanel: React.FC = ({ t }) => {

{t( - 'Token 可随时查看与复制(非一次性)。请勿泄露;重置后旧配置失效。', - 'Token is always viewable and copyable (not one-time). Do not share it; reset invalidates old configs.' + 'Token 会固定保存,重启后不变,可随时查看与复制。仅当你点击「重置 Token」时才会更换,旧配置会失效。请勿泄露。', + 'Token is stored permanently and stays the same across restarts. It only changes when you click Reset Token (old agent configs then stop working). Do not share it.' )}

diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 290abe31f..282e8e6c1 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -54,8 +54,6 @@ import { logger } from '../services/logger'; import { PRESET_FILTERS } from '../constants/presetFilters'; const BACKEND_SECRET_SESSION_KEY = 'github-stars-manager-backend-secret'; -/** Desktop-only MCP bearer; session-scoped so it never lands in IndexedDB. */ -const MCP_TOKEN_SESSION_KEY = 'github-stars-manager-mcp-token'; /** Menu IDs that must always remain visible — enforced at store level */ const REQUIRED_HEADER_MENU_IDS: ReadonlySet = new Set(['repositories', 'settings']); @@ -222,20 +220,6 @@ const writeSessionBackendSecret = (secret: string | null): void => { } }; -const readSessionMcpToken = (): string => { - if (typeof window === 'undefined') return ''; - return window.sessionStorage.getItem(MCP_TOKEN_SESSION_KEY) || ''; -}; - -const writeSessionMcpToken = (token: string): void => { - if (typeof window === 'undefined') return; - if (token) { - window.sessionStorage.setItem(MCP_TOKEN_SESSION_KEY, token); - } else { - window.sessionStorage.removeItem(MCP_TOKEN_SESSION_KEY); - } -}; - const areRepositoryRecordsEqual = (a: Repository, b: Repository): boolean => { if (a === b) return true; @@ -788,12 +772,9 @@ export const normalizePersistedState = ( safePersisted.vectorSearchConfig, safePersisted.embeddingConfigs ), - mcpConfig: (() => { - const base = normalizeMcpConfig((safePersisted as Record).mcpConfig); - // Token never comes from IndexedDB; restore desktop token from session only - const sessionToken = readSessionMcpToken(); - return sessionToken ? { ...base, token: sessionToken } : base; - })(), +// Persist full mcpConfig including token so Agent configs stay stable across restarts + // unless the user explicitly resets the token. + mcpConfig: normalizeMcpConfig((safePersisted as Record).mcpConfig), customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1192,10 +1173,7 @@ export const useAppStore = create()( vectorSearchConfig: { ...defaultVectorSearchConfig }, vectorSearchStatus: { connected: false, vectorCount: 0, dimensions: 0 }, vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, - mcpConfig: { - ...defaultMcpConfig, - token: readSessionMcpToken(), - }, + mcpConfig: { ...defaultMcpConfig }, similarView: null, webdavConfigs: [], activeWebDAVConfig: null, @@ -1556,14 +1534,9 @@ export const useAppStore = create()( })), setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), setMcpConfig: (config) => - set((state) => { - const next = normalizeMcpConfig({ ...state.mcpConfig, ...config }); - // Keep desktop token in sessionStorage only (never IndexedDB partialize) - if (Object.prototype.hasOwnProperty.call(config, 'token')) { - writeSessionMcpToken(next.token || ''); - } - return { mcpConfig: next }; - }), + set((state) => ({ + mcpConfig: normalizeMcpConfig({ ...state.mcpConfig, ...config }), + })), setVectorIndexingState: (indexingState) => set((state) => ({ vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } })), @@ -2259,15 +2232,8 @@ export const useAppStore = create()( // 持久化向量搜索状态(vectorCount 等,跨重启保留) vectorSearchStatus: state.vectorSearchStatus, - // MCP: persist enable/bind prefs only. Token is desktop-local secret kept - // in memory + Electron main; backend tokens must not land in IndexedDB. - // Local token is re-issued on enable if missing (settings / bridge). - mcpConfig: { - enabled: state.mcpConfig.enabled, - host: state.mcpConfig.host, - port: state.mcpConfig.port, - token: '', - }, + // MCP prefs + bearer token (stable across restarts; only changes on user reset) + mcpConfig: state.mcpConfig, // 持久化WebDAV配置 webdavConfigs: state.webdavConfigs,