diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml
index 6f4d6a14..cfab7ee9 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
diff --git a/.gitignore b/.gitignore
index 7607842f..8878e458 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 504099a8..6a88c049 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -182,4 +182,46 @@ 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) | 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).
+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.
+- 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/README.md b/README.md
index f106b6e3..e8d42e13 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; 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 0cf0a7f4..8e03238c 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 加工后的星标;需后端或 Electron/客户端(纯前端模式不显示);设置中开关,无需额外安装 |
| **Release 追踪** | 订阅仓库并在统一时间线查看新版本 |
| **一键下载** | 展开 Release 资产并即时下载 |
| **智能资产过滤** | 按关键词匹配资产 (dmg / mac / arm64 / aarch64) |
diff --git a/electron/main.js b/electron/main.js
new file mode 100644
index 00000000..adc096a1
--- /dev/null
+++ b/electron/main.js
@@ -0,0 +1,426 @@
+const { app, BrowserWindow, Menu, shell, globalShortcut, ipcMain } = require('electron');
+const path = require('path');
+const fs = require('fs');
+const isDev = process.env.NODE_ENV === 'development';
+const { createMcpLocalServer } = require('./mcpLocalServer');
+
+let mainWindow;
+
+function createWindow() {
+ mainWindow = new BrowserWindow({
+ width: 1200,
+ height: 800,
+ minWidth: 800,
+ minHeight: 600,
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ enableRemoteModule: false,
+ // 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'),
+ titleBarStyle: 'default', // 使用默认标题栏,避免重叠问题
+ show: false,
+ autoHideMenuBar: false, // 显示菜单栏,确保编辑快捷键行为一致
+ frame: true, // 保持窗口框架
+ backgroundColor: '#ffffff', // 设置背景色,避免白屏闪烁
+ titleBarOverlay: false, // 禁用标题栏覆盖
+ 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);
+ const fallbackPath = path.join(__dirname, '../dist/index.html');
+ 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);
+ }
+ });
+
+ 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 {
+ // 生产环境:尝试多个可能的路径
+ 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();
+ });
+
+ // 提供稳定的菜单与编辑快捷键(生产环境)
+ 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' };
+ });
+
+ mainWindow.on('closed', () => {
+ mainWindow = null;
+ });
+}
+
+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'
+ });
+ // 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');
+ }
+}
+
+ipcMain.handle('set-proxy', async (event, config) => {
+ saveProxyConfig(config);
+ await applyProxy(config);
+ return { success: true };
+});
+
+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;
+ 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);
+ 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) {
+ if (buffered.length < 2) return;
+ const data = buffered;
+ 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,
+}));
+
+/** Desktop MCP must only bind loopback. */
+function normalizeMcpHost(_rawHost) {
+ return '127.0.0.1';
+}
+
+ipcMain.handle('mcp:setConfig', async (_e, config) => {
+ const previousHost = mcpConfig.host;
+ const previousPort = mcpConfig.port;
+ mcpConfig = {
+ enabled: !!config?.enabled,
+ 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 : '',
+ };
+ const addressChanged = mcpConfig.host !== previousHost || mcpConfig.port !== previousPort;
+ if (!mcpConfig.enabled || addressChanged) {
+ 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();
+ const savedProxy = loadProxyConfig();
+ if (savedProxy.enabled && savedProxy.host && savedProxy.port) {
+ applyProxy(savedProxy);
+ }
+ // 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();
+ }
+});
+
+app.on('will-quit', () => {
+ globalShortcut.unregisterAll();
+ void mcpServer.stop();
+});
+
+app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+});
\ No newline at end of file
diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js
new file mode 100644
index 00000000..afed4420
--- /dev/null
+++ b/electron/mcpLocalServer.js
@@ -0,0 +1,730 @@
+/**
+ * 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 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);
+ }
+}
+
+/**
+ * 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 || '';
+ const baseUrl = String(emb.baseUrl || '').replace(/\/+$/, '');
+ let url;
+ const headers = { 'Content-Type': 'application/json' };
+ let body;
+
+ if (apiType === 'ollama') {
+ // 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') {
+ // 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 {
+ // 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] };
+ }
+
+ 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} (${apiType} ${url}): ${errText.slice(0, 200)}`
+ );
+ }
+ const data = await res.json();
+
+ if (apiType === 'ollama') {
+ // 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') {
+ // 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');
+ 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 = [
+ {
+ 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:
+ 'Semantic vector search over starred repositories (uses the app embedding + Vectorize worker config).',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ query: { type: 'string' },
+ topK: { type: 'number' },
+ threshold: { type: 'number' },
+ },
+ required: ['query'],
+ },
+ });
+ }
+ return tools;
+}
+
+async function callTool(name, args, snapshot) {
+ const repos = Array.isArray(snapshot?.repositories) ? snapshot.repositories : [];
+ const categories = Array.isArray(snapshot?.customCategories) ? snapshot.customCategories : [];
+ const vectorInfo = getVectorAvailability(snapshot);
+
+ 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: 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 || '');
+ 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': {
+ 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 };
+ }
+}
+
+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;
+ /** @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`);
+ }
+
+ async function handleJsonRpc(body, snapshot) {
+ const method = body?.method;
+ const id = body?.id ?? null;
+ const vectorInfo = getVectorAvailability(snapshot);
+
+ 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(vectorInfo.available) },
+ };
+ }
+ if (method === 'tools/call') {
+ const name = body?.params?.name;
+ const args = body?.params?.arguments || {};
+ const result = await 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' };
+ }
+ 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) {
+ 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 host = normalizeHost(state.config?.host);
+ 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}`);
+ 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', 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 = await 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' || 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',
+ })
+ );
+ 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', 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 = await 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() {
+ 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());
+ });
+ server = null;
+ return { success: true };
+ }
+
+ function getStatus() {
+ const state = getState();
+ const cfg = state?.config || {};
+ const host =
+ !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::'
+ ? '127.0.0.1'
+ : cfg.host;
+ return {
+ running: !!server,
+ url: server ? `http://${host}:${cfg.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 00000000..c4a9498d
--- /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 00000000..6f4ec739
--- /dev/null
+++ b/electron/preload.js
@@ -0,0 +1,15 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+contextBridge.exposeInMainWorld('electronAPI', {
+ 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'),
+ 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 bcf2f289..37ab2e0c 100644
--- a/nginx.conf.template
+++ b/nginx.conf.template
@@ -50,6 +50,80 @@ http {
client_max_body_size 100m;
}
+ # 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;
+ 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 8ad5b70e..5145b4d3 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 5658d03e..667b5ae7 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 652bf88c..dd217bd3 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 d7ef9e5c..5b204e08 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 00000000..d7cc62e7
--- /dev/null
+++ b/server/src/mcp/http.ts
@@ -0,0 +1,161 @@
+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' });
+ }
+ }
+}
+
+/** 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.
+ *
+ * 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.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 {
+ 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, (req, res) => {
+ void handleSseMessage(req, res);
+ });
+
+ // Backward-compatible aliases (older docs / clients)
+ app.get('/sse', mcpAuthMiddleware, async (_req, res) => {
+ try {
+ 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, (req, res) => {
+ void handleSseMessage(req, res);
+ });
+
+ 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 00000000..e8e7924c
--- /dev/null
+++ b/server/src/mcp/provider.ts
@@ -0,0 +1,366 @@
+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 || ''),
+ };
+}
+
+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);
+ }
+}
+
+/**
+ * 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
+): 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');
+ }
+ }
+
+ const baseUrl = String(emb.base_url || '').replace(/\/+$/, '');
+ let url: string;
+ const headers: Record = { 'Content-Type': 'application/json' };
+ let body: Record;
+
+ if (apiType === 'ollama') {
+ url = `${baseUrl || 'http://127.0.0.1:11434'}/api/embed`;
+ body = { model, input: texts };
+ } else if (apiType === 'gemini') {
+ 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 if (apiType === 'openai-compatible') {
+ if (!baseUrl) {
+ throw new Error('openai-compatible base_url is required (full embeddings endpoint)');
+ }
+ 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 };
+ }
+
+ const res = await fetchWithTimeout(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} (${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) 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) return embObj.values;
+ throw new Error('Gemini embedding missing');
+ }
+ 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 fetchWithTimeout(`${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 00000000..ddc6a5ea
--- /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 00000000..b0b78f7f
--- /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 00000000..a4c9c672
--- /dev/null
+++ b/server/src/mcp/settings.ts
@@ -0,0 +1,105 @@
+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.
+ * Token is durable in SQLite (encrypted); never rotated here — only resetMcpToken() does.
+ */
+export function ensureMcpToken(): string {
+ const existing = getMcpTokenPlain();
+ if (existing) return existing;
+ const token = generateMcpToken();
+ setMcpTokenPlain(token);
+ return token;
+}
+
+/** Explicit user-initiated rotation only. */
+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 00000000..7b334c9d
--- /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 00000000..f034a51e
--- /dev/null
+++ b/server/src/routes/mcp.ts
@@ -0,0 +1,94 @@
+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.
+ * Mint only when enabled and missing — never rotates an existing token.
+ */
+router.get('/api/mcp/status', (_req, res) => {
+ try {
+ const enabled = isMcpEnabled();
+ // Only mint when already enabled and no token exists yet (first enable path)
+ 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();
+ // 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();
+ }
+
+ 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 55635efa..bf779a66 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