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 = new WeakS function sanitizeString(value: string): string { if (isGitHubToken(value)) return maskSecret(value); + if (value.startsWith('gsm_mcp_')) return maskSecret(value); if (looksLikeSecret(value)) return maskSecret(value); if (EMAIL_RE.test(value)) return maskEmail(value); if (value.startsWith('http://') || value.startsWith('https://')) return redactUrl(value); diff --git a/server/tests/mcp/repoSearch.test.ts b/server/tests/mcp/repoSearch.test.ts new file mode 100644 index 00000000..70b972e1 --- /dev/null +++ b/server/tests/mcp/repoSearch.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + performBasicTextSearch, + searchRepositories, + projectRepoForAgent, + type McpRepository, +} from '../../src/mcp/repoSearch.js'; + +function repo(partial: Partial & Pick): McpRepository { + return { + description: null, + html_url: `https://github.com/${partial.full_name}`, + stargazers_count: 10, + language: 'TS', + topics: [], + ...partial, + }; +} + +const sample = [ + repo({ + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + ai_summary: 'CRDT offline sync', + ai_tags: ['crdt'], + stargazers_count: 100, + }), + repo({ + id: 2, + name: 'beta', + full_name: 'acme/beta', + description: 'webdav', + custom_category: 'tools', + stargazers_count: 5, + }), +]; + +describe('mcp repoSearch', () => { + it('searches AI summary keywords', () => { + const hits = performBasicTextSearch(sample, 'crdt offline'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('paginates and filters category', () => { + const { items, total } = searchRepositories(sample, { category: 'tools', limit: 10 }); + expect(total).toBe(1); + expect(items[0].full_name).toBe('acme/beta'); + }); + + it('projects compact agent payload', () => { + const p = projectRepoForAgent(sample[0], { summaryMaxChars: 10 }); + expect(String(p.ai_summary).length).toBeLessThanOrEqual(11); + }); +}); diff --git a/server/tests/mcp/routes.test.ts b/server/tests/mcp/routes.test.ts new file mode 100644 index 00000000..367f5b98 --- /dev/null +++ b/server/tests/mcp/routes.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// Isolate DB before importing app modules that call getDb() +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-mcp-')); +process.env.DB_PATH = path.join(tmpDir, 'test.db'); +process.env.ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +process.env.API_SECRET = 'test-api-secret'; + +async function canOpenSqlite(): Promise { + try { + const Database = (await import('better-sqlite3')).default; + const db = new Database(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} + +const dbAvailable = await canOpenSqlite(); +const describeIfDb = dbAvailable ? describe : describe.skip; + +describeIfDb('MCP admin + transport auth', () => { + let request: typeof import('supertest').default; + let app: import('express').Express; + let closeDb: () => void; + let getMcpTokenPlain: () => string | null; + let setMcpEnabled: (v: boolean) => void; + let ensureMcpToken: () => string; + let isMcpEnabled: () => boolean; + + beforeAll(async () => { + const conn = await import('../../src/db/connection.js'); + const migrations = await import('../../src/db/migrations.js'); + const index = await import('../../src/index.js'); + const settings = await import('../../src/mcp/settings.js'); + const supertest = await import('supertest'); + + request = supertest.default; + closeDb = conn.closeDb; + getMcpTokenPlain = settings.getMcpTokenPlain; + setMcpEnabled = settings.setMcpEnabled; + ensureMcpToken = settings.ensureMcpToken; + isMcpEnabled = settings.isMcpEnabled; + + const db = conn.getDb(); + migrations.runMigrations(db); + db.prepare( + `INSERT INTO repositories (id, name, full_name, description, html_url, stargazers_count, language, owner_login, topics, ai_summary, ai_tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 1, + 'alpha', + 'acme/alpha', + 'offline CRDT', + 'https://github.com/acme/alpha', + 42, + 'Rust', + 'acme', + '[]', + 'A CRDT library', + JSON.stringify(['crdt']) + ); + // Default: MCP off — mount must not mint tokens + setMcpEnabled(false); + app = index.createApp(); + }); + + afterAll(() => { + try { + closeDb(); + } catch { + /* ignore */ + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('does not create token on app mount when MCP disabled', () => { + expect(isMcpEnabled()).toBe(false); + // token may be absent + expect(getMcpTokenPlain()).toBeNull(); + }); + + it('GET /api/mcp/status requires API secret', async () => { + const res = await request(app).get('/api/mcp/status'); + expect(res.status).toBe(401); + }); + + it('returns disabled status without minting token', async () => { + const res = await request(app) + .get('/api/mcp/status') + .set('Authorization', 'Bearer test-api-secret'); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(false); + expect(res.body.token).toBe(''); + }); + + it('rejects /mcp with 404 when disabled (no surface leak)', async () => { + const res = await request(app).post('/mcp').send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + expect(res.status).toBe(404); + }); + + it('enables MCP, mints token, and accepts initialize', async () => { + const enable = await request(app) + .put('/api/mcp/config') + .set('Authorization', 'Bearer test-api-secret') + .send({ enabled: true }); + expect(enable.status).toBe(200); + expect(enable.body.enabled).toBe(true); + expect(String(enable.body.token)).toMatch(/^gsm_mcp_/); + + const token = enable.body.token as string; + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json, text/event-stream') + .set('Content-Type', 'application/json') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }); + expect([200, 202]).toContain(res.status); + }); + + it('rejects wrong MCP token with 401 when enabled', async () => { + ensureMcpToken(); + setMcpEnabled(true); + const res = await request(app) + .post('/mcp') + .set('Authorization', 'Bearer gsm_mcp_wrong_token_value_here_xxx') + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); + expect(res.status).toBe(401); + }); + + it('PUT /api/mcp/config can reset token', async () => { + setMcpEnabled(true); + const before = ensureMcpToken(); + const res = await request(app) + .put('/api/mcp/config') + .set('Authorization', 'Bearer test-api-secret') + .send({ resetToken: true, enabled: true }); + expect(res.status).toBe(200); + expect(res.body.token).toBeTruthy(); + expect(res.body.token).not.toBe(before); + }); +}); + +describe('MCP pure units always run', () => { + it('generateMcpToken format (no db)', async () => { + const { generateMcpToken, timingSafeEqualString } = await import('../../src/mcp/settings.js'); + const t = generateMcpToken(); + expect(t.startsWith('gsm_mcp_')).toBe(true); + expect(timingSafeEqualString(t, t)).toBe(true); + expect(timingSafeEqualString(t, t + 'x')).toBe(false); + }); + + it('notes when sqlite native binding unavailable', () => { + // Environment limitation (e.g. Node 26 without rebuildable better-sqlite3) + expect(typeof dbAvailable).toBe('boolean'); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index c2b0565c..62913499 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,11 @@ import { logger } from './services/logger'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; +import { + startMcpElectronBridge, + stopMcpElectronBridge, + refreshMcpElectronBridge, +} from './services/mcpElectronBridge'; import type { AppState, SearchFilters } from './types'; /** @@ -129,6 +134,12 @@ function App() { } }, []); + // Electron local MCP: start after backend discovery so we don't bind :3927 + // when agents should use backend /mcp instead. + useEffect(() => { + return () => stopMcpElectronBridge(); + }, []); + useEffect(() => { if (theme === 'dark') { document.documentElement.classList.add('dark'); @@ -152,6 +163,12 @@ function App() { } } catch (err) { console.error('Failed to initialize backend:', err); + } finally { + // After backend probe (success or not), wire desktop MCP lifecycle + if (!cancelled) { + startMcpElectronBridge(); + refreshMcpElectronBridge(); + } } }; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 777e2ce7..a0b4a417 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -5,10 +5,10 @@ import { AIService } from '../services/aiService'; import { EmbeddingClient, VectorSearchService } from '../services/vectorSearchService'; import { GitHubApiService } from '../services/githubApi'; import { forceSyncToBackend } from '../services/autoSync'; -import { Repository } from '../types'; import { useSearchShortcuts } from '../hooks/useSearchShortcuts'; import { useDialog } from '../hooks/useDialog'; import { isRepoCustomized } from '../utils/repoUtils'; +import { applyRepoFilters, performBasicTextSearch as basicTextSearch, sortRepositories } from '../utils/repoSearch'; import { NumberInput } from './ui/NumberInput'; type SortBy = 'stars' | 'updated' | 'name' | 'starred'; @@ -312,196 +312,30 @@ export const SearchBar: React.FC = () => { setSearchResults(filtered); }; - const performBasicTextSearch = (repos: typeof repositories, query: string) => { - const normalizedQuery = query.toLowerCase(); - - return repos.filter(repo => { - const searchableText = [ - repo.name, - repo.full_name, - repo.description || '', - repo.custom_description || '', - repo.language || '', - ...(repo.topics || []), - repo.ai_summary || '', - ...(repo.ai_tags || []), - ...(repo.ai_platforms || []), - ...(repo.custom_tags || []), - ].join(' ').toLowerCase(); - - const queryWords = normalizedQuery.split(/\s+/); - return queryWords.every(word => searchableText.includes(word)); - }); - }; + const performBasicTextSearch = (repos: typeof repositories, query: string) => + basicTextSearch(repos, query); const applyFilters = (repos: typeof repositories) => { - let filtered = repos; - - // Language filter - if (searchFilters.languages.length > 0) { - filtered = filtered.filter(repo => - repo.language && searchFilters.languages.includes(repo.language) - ); - } - - // Tag filter - 包含AI标签、GitHub topics和用户自定义标签 - if (searchFilters.tags.length > 0) { - filtered = filtered.filter(repo => { - const repoTags = [ - ...(repo.ai_tags || []), - ...(repo.topics || []), - ...(repo.custom_tags || []) - ]; - return searchFilters.tags.some(tag => repoTags.includes(tag)); - }); - } - - // Platform filter - if (searchFilters.platforms.length > 0) { - filtered = filtered.filter(repo => { - const repoPlatforms = repo.ai_platforms || []; - return searchFilters.platforms.some(platform => repoPlatforms.includes(platform)); - }); - } - - // AI analyzed filter - 与 analysisFailed 互斥 - if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { - filtered = filtered.filter(repo => - searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at - ); - } - - // Release subscription filter - if (searchFilters.isSubscribed !== undefined) { - filtered = filtered.filter(repo => - searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id) - ); - } - - // 自定义筛选 - if (searchFilters.isEdited !== undefined) { - filtered = filtered.filter(repo => - searchFilters.isEdited ? isRepoCustomized(repo, allCategories) : !isRepoCustomized(repo, allCategories) - ); - } - - // Category locked filter - 检查分类是否被锁定 - if (searchFilters.isCategoryLocked !== undefined) { - filtered = filtered.filter(repo => { - const isLocked = !!repo.category_locked; - return searchFilters.isCategoryLocked ? isLocked : !isLocked; - }); - } - - // Analysis failed filter - 检查分析是否失败(需要有分析记录且标记为失败),与 isAnalyzed 互斥 - if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { - filtered = filtered.filter(repo => { - const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); - return searchFilters.analysisFailed ? hasFailed : !hasFailed; - }); - } - - // Star count filter - if (searchFilters.minStars !== undefined) { - filtered = filtered.filter(repo => repo.stargazers_count >= searchFilters.minStars!); - } - if (searchFilters.maxStars !== undefined) { - filtered = filtered.filter(repo => repo.stargazers_count <= searchFilters.maxStars!); - } - - // Sort - const getSortValue = (repo: Repository): number | string => { - switch (searchFilters.sortBy) { - case 'stars': - return repo.stargazers_count; - case 'updated': - return new Date(repo.pushed_at || repo.updated_at).getTime(); - case 'name': - return repo.name.toLowerCase(); - case 'starred': - return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; - default: - return new Date(repo.pushed_at || repo.updated_at).getTime(); - } - }; - - filtered.sort((a, b) => { - const aValue = getSortValue(a); - const bValue = getSortValue(b); - if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1; - if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1; - return 0; + const filtered = applyRepoFilters(repos, searchFilters, { + releaseSubscriptions, + allCategories, }); - // 如果分类锁定筛选导致结果为0,自动清除该筛选条件 + // 如果分类锁定筛选导致结果为0,自动清除该筛选条件(UI 侧副作用保留在此) if (searchFilters.isCategoryLocked !== undefined && filtered.length === 0) { - // 检查是否是分类锁定筛选导致的结果为空 - const filteredWithoutCategoryLock = repos.filter(repo => { - // 复制当前的筛选条件,但排除分类锁定 - let tempFiltered = true; - - // Language filter - if (searchFilters.languages.length > 0) { - tempFiltered = tempFiltered && !!(repo.language && searchFilters.languages.includes(repo.language)); - } - - // Tag filter - if (searchFilters.tags.length > 0) { - const repoTags = [...(repo.ai_tags || []), ...(repo.topics || []), ...(repo.custom_tags || [])]; - tempFiltered = tempFiltered && searchFilters.tags.some(tag => repoTags.includes(tag)); - } - - // Platform filter - if (searchFilters.platforms.length > 0) { - const repoPlatforms = repo.ai_platforms || []; - tempFiltered = tempFiltered && searchFilters.platforms.some(platform => repoPlatforms.includes(platform)); - } - - // AI analyzed filter - if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { - tempFiltered = tempFiltered && (searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at); - } - - // Release subscription filter - if (searchFilters.isSubscribed !== undefined) { - tempFiltered = tempFiltered && (searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id)); - } - - // Edited filter - if (searchFilters.isEdited !== undefined) { - const customized = isRepoCustomized(repo, allCategories); - tempFiltered = tempFiltered && (searchFilters.isEdited ? customized : !customized); - } - - // Analysis failed filter - if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { - const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); - tempFiltered = tempFiltered && (searchFilters.analysisFailed ? hasFailed : !hasFailed); - } - - // Star count filter - if (searchFilters.minStars !== undefined) { - tempFiltered = tempFiltered && repo.stargazers_count >= searchFilters.minStars; - } - if (searchFilters.maxStars !== undefined) { - tempFiltered = tempFiltered && repo.stargazers_count <= searchFilters.maxStars; - } - - return tempFiltered; - }); - - // 如果去掉分类锁定筛选后有结果,说明是分类锁定导致的结果为空,自动清除 - if (filteredWithoutCategoryLock.length > 0) { + const withoutLock = applyRepoFilters( + repos, + { ...searchFilters, isCategoryLocked: undefined }, + { releaseSubscriptions, allCategories } + ); + if (withoutLock.length > 0) { console.log('分类锁定筛选导致结果为空,自动清除该筛选条件'); setSearchFilters({ isCategoryLocked: undefined }); - // 返回去掉分类锁定筛选的结果 - return filteredWithoutCategoryLock.sort((a, b) => { - const aValue = getSortValue(a); - const bValue = getSortValue(b); - if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1; - if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1; - return 0; - }); + return sortRepositories( + withoutLock, + searchFilters.sortBy, + searchFilters.sortOrder + ); } } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index e73640b9..84fb15a1 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -13,6 +13,7 @@ import { ScrollText, Layout, Search, + Cable, } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; import { isElectron } from '../services/electronProxy'; @@ -29,9 +30,10 @@ import { DiagnosticLogsPanel, MenuManagementPanel, VectorSearchSettings, + McpSettingsPanel, } from './settings'; -type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network' | 'vectorSearch'; +type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network' | 'vectorSearch' | 'mcp'; interface SettingsTabItem { id: SettingsTab; @@ -258,7 +260,7 @@ export const SettingsPanel: React.FC = ({ // Valid SettingsTab values for runtime validation const VALID_TABS: ReadonlySet = useMemo( - () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network', 'vectorSearch']), + () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network', 'vectorSearch', 'mcp']), [] ); @@ -361,6 +363,12 @@ export const SettingsPanel: React.FC = ({ label: t('向量搜索', 'Vector Search'), icon: , }, + // MCP requires a long-lived process: backend or Electron main. Hide for pure SPA. + ...((isElectron() || backend.isAvailable) ? [{ + id: 'mcp' as SettingsTab, + label: t('MCP服务', 'MCP Server'), + icon: , + }] : []), ]; const renderTabContent = () => { @@ -388,6 +396,8 @@ export const SettingsPanel: React.FC = ({ return ; case 'vectorSearch': return ; + case 'mcp': + return ; default: return null; } diff --git a/src/components/settings/McpSettingsPanel.tsx b/src/components/settings/McpSettingsPanel.tsx new file mode 100644 index 00000000..a8347d1d --- /dev/null +++ b/src/components/settings/McpSettingsPanel.tsx @@ -0,0 +1,500 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Cable, + CheckCircle, + Copy, + Eye, + EyeOff, + Loader2, + RefreshCw, + AlertCircle, +} from 'lucide-react'; +import { useAppStore } from '../../store/useAppStore'; +import { backend } from '../../services/backendAdapter'; +import { isElectron } from '../../services/electronProxy'; +import { useDialog } from '../../hooks/useDialog'; +import { MCP_DEFAULT_PORT, normalizeMcpHost } from '../../utils/mcpHost'; + +interface McpSettingsPanelProps { + t: (zh: string, en: string) => string; +} + +function generateLocalToken(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + const b64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `gsm_mcp_${b64}`; +} + +export const McpSettingsPanel: React.FC = ({ t }) => { + const { mcpConfig, setMcpConfig, language } = useAppStore(); + const { toast, confirm } = useDialog(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showToken, setShowToken] = useState(false); + const [backendMode, setBackendMode] = useState(false); + const [vectorAvailable, setVectorAvailable] = useState(null); + // Defaults for Electron local; backend overwrites via /api/mcp/status + const [endpoints, setEndpoints] = useState({ + streamableHttp: '/mcp', + sse: '/sse', + messages: '/messages', + }); + const [copiedKey, setCopiedKey] = useState(null); + + const isElectronApp = isElectron(); + + const baseUrl = useMemo(() => { + // Electron local MCP always listens on loopback (shared host normalizer) + if (isElectronApp && !backendMode) { + const host = normalizeMcpHost(mcpConfig.host); + return `http://${host}:${mcpConfig.port || MCP_DEFAULT_PORT}`; + } + // Backend / Docker: agents should hit the same origin nginx proxies (/mcp) + return window.location.origin; + }, [backendMode, isElectronApp, mcpConfig.host, mcpConfig.port]); + + const mcpHttpUrl = `${baseUrl}${endpoints.streamableHttp}`; + const mcpSseUrl = `${baseUrl}${endpoints.sse}`; + + // Streamable HTTP is primary. Legacy SSE config is shown separately for clients that still need it. + const agentConfigJson = useMemo(() => { + const config = { + mcpServers: { + 'github-stars-manager': { + url: mcpHttpUrl, + headers: { + Authorization: `Bearer ${mcpConfig.token || ''}`, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); + }, [mcpHttpUrl, mcpConfig.token]); + + const agentSseConfigJson = useMemo(() => { + const config = { + mcpServers: { + 'github-stars-manager': { + // Some older MCP clients expect the SSE GET URL (not Streamable HTTP) + url: mcpSseUrl, + headers: { + Authorization: `Bearer ${mcpConfig.token || ''}`, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); + }, [mcpSseUrl, mcpConfig.token]); + + const refreshFromBackend = useCallback(async () => { + if (!backend.isAvailable) { + setBackendMode(false); + return; + } + setLoading(true); + setError(null); + try { + const status = await backend.getMcpStatus(); + setBackendMode(true); + setMcpConfig({ + enabled: status.enabled, + token: status.token, + }); + setEndpoints(status.endpoints); + setVectorAvailable(status.vectorAvailable); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [setMcpConfig]); + + useEffect(() => { + void refreshFromBackend(); + }, [refreshFromBackend]); + + // Mint a durable local token only when enabling without one (persisted in IndexedDB). + // Never rotate automatically — only user "Reset Token" replaces it. + // Lifecycle (start/stop/snapshot) lives in mcpElectronBridge (App session). + useEffect(() => { + if (!backendMode && isElectronApp && mcpConfig.enabled && !mcpConfig.token) { + setMcpConfig({ token: generateLocalToken() }); + } + }, [backendMode, isElectronApp, mcpConfig.enabled, mcpConfig.token, setMcpConfig]); + + const copyText = async (key: string, text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedKey(key); + toast(t('已复制', 'Copied'), 'success'); + setTimeout(() => setCopiedKey(null), 1500); + } catch { + toast(t('复制失败', 'Copy failed'), 'error'); + } + }; + + const handleToggle = async (enabled: boolean) => { + setSaving(true); + setError(null); + try { + if (backendMode && backend.isAvailable) { + const result = await backend.updateMcpConfig({ enabled }); + setMcpConfig({ enabled: result.enabled, token: result.token }); + setEndpoints(result.endpoints); + toast( + enabled + ? t('MCP 服务已开启', 'MCP server enabled') + : t('MCP 服务已关闭', 'MCP server disabled'), + 'success' + ); + } else if (isElectronApp) { + let token = mcpConfig.token; + if (enabled && !token) token = generateLocalToken(); + setMcpConfig({ enabled, token }); + toast( + enabled + ? t('MCP 服务已开启(本地)', 'MCP server enabled (local)') + : t('MCP 服务已关闭', 'MCP server disabled'), + 'success' + ); + } else { + toast(t('需要后端或客户端才能使用 MCP', 'Backend or desktop client required for MCP'), 'error'); + } + } catch (err) { + setError((err as Error).message); + toast(t('操作失败', 'Operation failed'), 'error'); + } finally { + setSaving(false); + } + }; + + const handleResetToken = async () => { + if (!mcpConfig.enabled) { + toast( + t('请先开启 MCP 服务再重置 Token', 'Enable MCP before resetting the token'), + 'error' + ); + return; + } + const ok = await confirm( + t('重置 MCP Token', 'Reset MCP Token'), + t( + '重置后旧 Token 立即失效,需要更新 Agent 配置。是否继续?', + 'The old token will stop working immediately. Update your agent config. Continue?' + ) + ); + if (!ok) return; + + setSaving(true); + try { + if (backendMode && backend.isAvailable) { + const result = await backend.updateMcpConfig({ resetToken: true, enabled: true }); + // Backend token is session/UI only — store still holds local electron prefs separately + setMcpConfig({ token: result.token, enabled: result.enabled }); + } else { + setMcpConfig({ token: generateLocalToken() }); + } + toast(t('Token 已重置', 'Token reset'), 'success'); + } catch (err) { + setError((err as Error).message); + toast(t('重置失败', 'Reset failed'), 'error'); + } finally { + setSaving(false); + } + }; + + const statusLabel = mcpConfig.enabled + ? t('运行中', 'Running') + : t('已停止', 'Stopped'); + + return ( +
+
+ +

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

+
+ +

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

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

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

+

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

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

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

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

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

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

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

+
+ + +
+

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

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

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

+ +
+

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

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

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

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

+ {language === 'zh' + ? '优先使用 Streamable HTTP(上面 JSON)。若客户端只支持旧版 SSE,用下方 SSE URL:GET 打开流后 POST 到 messages。' + : 'Prefer Streamable HTTP (JSON above). If the client only supports legacy SSE, use the SSE URL below: GET opens the stream, then POST to messages.'} +

+
+ + {t('SSE 兼容配置 (JSON)', 'SSE-compatible config (JSON)')} + + +
+
+            {agentSseConfigJson}
+          
+
+
+ +
+

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

+

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

+
+
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 8a08ccc4..7fbd1a91 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -9,3 +9,4 @@ export { NetworkPanel } from './NetworkPanel'; export { DiagnosticLogsPanel } from './DiagnosticLogsPanel'; export { MenuManagementPanel } from './MenuManagementPanel'; export { VectorSearchSettings } from './VectorSearchSettings'; +export { McpSettingsPanel } from './McpSettingsPanel'; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index a0f38c14..d14a12ab 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -121,7 +121,8 @@ class BackendAdapter { const parsed = JSON.parse(options.body); // Mask any apiKey/password fields recursively requestBody = JSON.stringify(parsed, (key, val) => { - if (/api[_-]?key|password|secret|token|authorization/i.test(key)) return '***'; + if (/api[_-]?key|password|secret|token|authorization|mcp/i.test(key)) return '***'; + if (typeof val === 'string' && val.startsWith('gsm_mcp_')) return '***'; return val; }, 2); } catch { @@ -142,7 +143,20 @@ class BackendAdapter { const cloned = response.clone(); const text = await cloned.text(); if (text.length > 0) { - responseBody = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + const preview = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + // Redact secrets inside JSON (e.g. /mcp/status returns { token: "gsm_mcp_…" }) + try { + const parsed = JSON.parse(preview.endsWith('...[truncated]') ? text.slice(0, 4000) : preview); + responseBody = JSON.stringify(parsed, (key, val) => { + if (/api[_-]?key|password|secret|token|authorization|mcp/i.test(key)) return '***'; + if (typeof val === 'string' && val.startsWith('gsm_mcp_')) return '***'; + return val; + }, 2); + if (text.length > 4000) responseBody += '\n...[truncated]'; + } catch { + // Non-JSON: strip gsm_mcp_ tokens if present + responseBody = preview.replace(/gsm_mcp_[A-Za-z0-9_-]+/g, 'gsm_mcp_***'); + } } } catch { /* body not readable */ } logger.debug('backendAdapter', 'Backend request', { @@ -764,6 +778,41 @@ class BackendAdapter { followers: number; }> }>; } + + // === MCP admin (backend-hosted Streamable HTTP / SSE) === + + async getMcpStatus(): Promise<{ + enabled: boolean; + token: string; + endpoints: { streamableHttp: string; sse: string; messages: string }; + vectorAvailable: boolean; + vectorReason: string | null; + }> { + if (!this._backendUrl) throw new Error('Backend not available'); + const res = await this.fetchWithTimeout(`${this._backendUrl}/mcp/status`, { + headers: this.getAuthHeaders(), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch MCP status error'); + return res.json(); + } + + async updateMcpConfig(body: { + enabled?: boolean; + resetToken?: boolean; + }): Promise<{ + enabled: boolean; + token: string; + endpoints: { streamableHttp: string; sse: string; messages: string }; + }> { + if (!this._backendUrl) throw new Error('Backend not available'); + const res = await this.fetchWithTimeout(`${this._backendUrl}/mcp/config`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(body), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Update MCP config error'); + return res.json(); + } } export const backend = new BackendAdapter(); diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts index 3421691d..14113348 100644 --- a/src/services/electronProxy.ts +++ b/src/services/electronProxy.ts @@ -1,9 +1,49 @@ -import type { ProxyConfig } from '../types'; +import type { + ProxyConfig, + Repository, + Category, + VectorSearchConfig, + EmbeddingConfig, + McpServiceConfig, +} from '../types'; + +/** Alias of persisted MCP prefs — keep identical to McpServiceConfig to avoid drift. */ +export type McpLocalConfig = McpServiceConfig; + +/** Secrets stay in main-process memory only (IPC snapshot); not written to disk by MCP server. */ +export interface McpVectorRuntimeConfig { + enabled: boolean; + workerUrl: string; + authToken: string; + searchThreshold?: number; + searchTopK?: number; + embedding: Pick< + EmbeddingConfig, + 'apiType' | 'baseUrl' | 'apiKey' | 'model' | 'dimensions' + > | null; +} + +export interface McpDataSnapshot { + repositories: Repository[]; + customCategories: Category[]; + vectorSearchConfig: McpVectorRuntimeConfig; + snapshotAt: string; +} + +export interface McpElectronAPI { + setConfig: (config: McpLocalConfig) => Promise<{ success: boolean; error?: string }>; + getConfig: () => Promise; + pushSnapshot: (snapshot: McpDataSnapshot) => Promise<{ success: boolean }>; + start: () => Promise<{ success: boolean; error?: string; url?: string }>; + stop: () => Promise<{ success: boolean }>; + getStatus: () => Promise<{ running: boolean; url?: string; error?: string }>; +} interface ElectronAPI { setProxy: (config: ProxyConfig) => Promise<{ success: boolean }>; getProxy: () => Promise; testProxy: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }>; + mcp?: McpElectronAPI; } declare global { diff --git a/src/services/mcpElectronBridge.ts b/src/services/mcpElectronBridge.ts new file mode 100644 index 00000000..0b8d04f4 --- /dev/null +++ b/src/services/mcpElectronBridge.ts @@ -0,0 +1,169 @@ +/** + * Long-lived Electron MCP lifecycle bridge. + * Keeps local MCP start/stop + data snapshots in sync for the whole app session, + * not only while the MCP settings panel is mounted. + */ +import { useAppStore } from '../store/useAppStore'; +import { normalizeMcpHost } from '../utils/mcpHost'; +import { isElectron } from './electronProxy'; +import { backend } from './backendAdapter'; +import { logger } from './logger'; + +let started = false; +let unsub: (() => void) | null = null; +let debounceTimer: ReturnType | null = null; +/** Serialize setConfig → pushSnapshot → start/stop so concurrent store updates don't race. */ +let chain: Promise = Promise.resolve(); + +function clearDebounce(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } +} + +function enqueue(task: () => Promise): void { + chain = chain.then(task).catch((err) => { + logger.warn('mcp.bridge', 'Electron MCP lifecycle step failed', { + error: err instanceof Error ? err.message : String(err), + }); + }); +} + +async function pushLifecycle(): Promise { + if (!isElectron() || !window.electronAPI?.mcp) return; + const api = window.electronAPI.mcp; + + // When backend is available, agents should use backend /mcp; stop local server. + if (backend.isAvailable) { + try { + await api.stop(); + } catch (err) { + logger.warn('mcp.bridge', 'Failed to stop local MCP (backend mode)', { + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } + + const state = useAppStore.getState(); + const { mcpConfig } = state; + const host = normalizeMcpHost(mcpConfig.host); + + try { + await api.setConfig({ + enabled: mcpConfig.enabled, + host, + port: mcpConfig.port, + token: mcpConfig.token, + }); + + if (!mcpConfig.enabled) { + await api.stop(); + return; + } + + const vs = state.vectorSearchConfig; + const emb = + state.embeddingConfigs.find((c) => c.id === vs.embeddingConfigId) || + state.embeddingConfigs.find((c) => c.id === state.activeEmbeddingConfig) || + null; + + await api.pushSnapshot({ + repositories: state.repositories, + customCategories: state.customCategories, + // Pass runtime secrets only over IPC to main (never logged / never disk-persisted by MCP) + vectorSearchConfig: { + enabled: !!vs.enabled, + workerUrl: vs.workerUrl || '', + authToken: vs.authToken || '', + searchThreshold: vs.searchThreshold, + searchTopK: vs.searchTopK, + embedding: emb + ? { + apiType: emb.apiType, + baseUrl: emb.baseUrl || '', + apiKey: emb.apiKey || '', + model: emb.model || '', + dimensions: emb.dimensions, + } + : null, + }, + snapshotAt: new Date().toISOString(), + }); + const startResult = await api.start(); + if (startResult && startResult.success === false) { + logger.warn('mcp.bridge', 'MCP start returned failure', { + error: startResult.error, + }); + } + } catch (err) { + logger.warn('mcp.bridge', 'Electron MCP lifecycle failed', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function schedulePush(): void { + clearDebounce(); + debounceTimer = setTimeout(() => { + debounceTimer = null; + enqueue(() => pushLifecycle()); + }, 300); +} + +/** + * Subscribe once for the app session. Safe to call repeatedly. + * Prefer calling after backend.init() so backend.isAvailable is accurate. + */ +export function startMcpElectronBridge(): void { + if (started || typeof window === 'undefined') return; + if (!isElectron() || !window.electronAPI?.mcp) return; + started = true; + + // Initial sync + schedulePush(); + + unsub = useAppStore.subscribe((state, prev) => { + const cfgChanged = + state.mcpConfig.enabled !== prev.mcpConfig.enabled || + state.mcpConfig.host !== prev.mcpConfig.host || + state.mcpConfig.port !== prev.mcpConfig.port || + state.mcpConfig.token !== prev.mcpConfig.token; + const dataChanged = + state.repositories !== prev.repositories || + state.customCategories !== prev.customCategories || + state.vectorSearchConfig !== prev.vectorSearchConfig; + + if (cfgChanged || dataChanged) { + schedulePush(); + } + }); +} + +export function stopMcpElectronBridge(): void { + clearDebounce(); + if (unsub) { + unsub(); + unsub = null; + } + if (isElectron() && window.electronAPI?.mcp) { + enqueue(async () => { + try { + await window.electronAPI!.mcp!.stop(); + } catch { + /* ignore */ + } + }); + } + started = false; +} + +/** Re-evaluate local vs backend after backend.init completes. */ +export function refreshMcpElectronBridge(): void { + if (!started) { + startMcpElectronBridge(); + return; + } + schedulePush(); +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 0b109800..282e8e6c 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -13,6 +13,7 @@ import { EmbeddingConfig, VectorSearchConfig, VectorSearchStatus, + McpServiceConfig, VectorIndexingState, ProxyConfig, RpcDownloadConfig, @@ -43,6 +44,7 @@ import { } from '../types'; import { indexedDBStorage } from '../services/indexedDbStorage'; import { EMBEDDING_FORMAT_VERSION } from '../services/vectorSearchService'; +import { MCP_DEFAULT_HOST, MCP_DEFAULT_PORT, normalizeMcpHost } from '../utils/mcpHost'; import { WATCH_CUSTOM_RELEASE_SOURCE_ID, normalizeReleaseSourceSettings, @@ -331,6 +333,9 @@ interface AppActions { setVectorSearchStatus: (status: VectorSearchStatus | undefined) => void; setVectorIndexingState: (state: Partial) => void; + // MCP service (local prefs; backend SQLite is source of truth when connected) + setMcpConfig: (config: Partial) => void; + // Similar repositories view actions enterSimilarView: (repos: Repository[], anchor: Repository) => void; resetSimilarView: () => void; @@ -580,6 +585,30 @@ const defaultVectorSearchConfig: VectorSearchConfig = { embeddingFormatVersion: EMBEDDING_FORMAT_VERSION, }; +export const defaultMcpConfig: McpServiceConfig = { + enabled: false, + host: MCP_DEFAULT_HOST, + port: MCP_DEFAULT_PORT, + token: '', +}; + +const normalizeMcpConfig = (raw: unknown): McpServiceConfig => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ...defaultMcpConfig }; + } + const config = raw as Record; + const port = + typeof config.port === 'number' && Number.isInteger(config.port) && config.port >= 1 && config.port <= 65535 + ? config.port + : defaultMcpConfig.port; + return { + enabled: config.enabled === true, + host: normalizeMcpHost(config.host), + port, + token: typeof config.token === 'string' ? config.token : '', + }; +}; + // 持久化历史配置缺失 embeddingFormatVersion 时的回退版本:旧值为 1,确保旧用户触发一次重建 export const LEGACY_EMBEDDING_FORMAT_VERSION = 1; @@ -743,6 +772,9 @@ export const normalizePersistedState = ( safePersisted.vectorSearchConfig, safePersisted.embeddingConfigs ), +// Persist full mcpConfig including token so Agent configs stay stable across restarts + // unless the user explicitly resets the token. + mcpConfig: normalizeMcpConfig((safePersisted as Record).mcpConfig), customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1141,6 +1173,7 @@ export const useAppStore = create()( vectorSearchConfig: { ...defaultVectorSearchConfig }, vectorSearchStatus: { connected: false, vectorCount: 0, dimensions: 0 }, vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, + mcpConfig: { ...defaultMcpConfig }, similarView: null, webdavConfigs: [], activeWebDAVConfig: null, @@ -1500,6 +1533,10 @@ export const useAppStore = create()( vectorSearchConfig: mergeVectorSearchConfig(state.vectorSearchConfig, config) })), setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), + setMcpConfig: (config) => + set((state) => ({ + mcpConfig: normalizeMcpConfig({ ...state.mcpConfig, ...config }), + })), setVectorIndexingState: (indexingState) => set((state) => ({ vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } })), @@ -2195,6 +2232,9 @@ export const useAppStore = create()( // 持久化向量搜索状态(vectorCount 等,跨重启保留) vectorSearchStatus: state.vectorSearchStatus, + // MCP prefs + bearer token (stable across restarts; only changes on user reset) + mcpConfig: state.mcpConfig, + // 持久化WebDAV配置 webdavConfigs: state.webdavConfigs, activeWebDAVConfig: state.activeWebDAVConfig, @@ -2321,6 +2361,12 @@ export const useAppStore = create()( state.vectorSearchStatus = { connected: false, vectorCount: 0, dimensions: 0 }; } + // Additive: MCP config defaults when missing (upgrade). Old builds ignore this key on downgrade. + if (state) { + const stateRecord = state as Record; + stateRecord.mcpConfig = normalizeMcpConfig(stateRecord.mcpConfig); + } + // 迁移仓库数据中的旧标记 if (state && Array.isArray(state.repositories)) { let migratedCount = 0; @@ -2454,6 +2500,7 @@ export const useAppStore = create()( stateRecord.vectorSearchConfig, stateRecord.embeddingConfigs ); + stateRecord.mcpConfig = normalizeMcpConfig(stateRecord.mcpConfig); } return state as PersistedAppState; diff --git a/src/types/index.ts b/src/types/index.ts index 06a92c7e..ca25533d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -260,6 +260,17 @@ export interface VectorIndexingState { result: { indexed: number; skipped: number; errors: number; error?: string } | null; } +/** Local (Electron) / client-side MCP service preferences. Server is source of truth when backend is on. */ +export interface McpServiceConfig { + enabled: boolean; + /** Local bind host for Electron standalone MCP (default 127.0.0.1) */ + host: string; + /** Local bind port for Electron standalone MCP (default 3927) */ + port: number; + /** Plaintext MCP bearer token — viewable anytime; regenerate via reset */ + token: string; +} + // 相似仓库视图状态:进入"查找相似仓库"后保存当前上下文,重置时恢复 export interface SimilarViewState { active: boolean; @@ -450,6 +461,9 @@ export interface AppState { // Backend backendApiSecret: string | null; + // MCP (local Electron prefs; backend uses SQLite settings when available) + mcpConfig: McpServiceConfig; + // Network Proxy proxyConfig: ProxyConfig; rpcDownloadConfig: RpcDownloadConfig; diff --git a/src/utils/logSanitizer.ts b/src/utils/logSanitizer.ts index 2e009df1..ce4a1e16 100644 --- a/src/utils/logSanitizer.ts +++ b/src/utils/logSanitizer.ts @@ -8,6 +8,7 @@ const SENSITIVE_FIELD_NAMES = new Set([ 'apiKey', 'api_key', 'api_key_encrypted', 'password', 'password_encrypted', 'secret', 'token', 'githubToken', 'accessToken', 'authorization', 'x-api-key', 'credentials', 'passwd', 'pwd', 'backendApiSecret', + 'mcp_token', 'mcpToken', 'authToken', 'auth_token', ]); // URL query param keys to redact @@ -122,6 +123,9 @@ function sanitizeString(value: string): string { // GitHub token pattern if (isGitHubToken(value)) return maskSecret(value); + // MCP bearer tokens (gsm_mcp_…) + if (value.startsWith('gsm_mcp_')) return maskSecret(value); + // Plain-string secrets (e.g., sk-..., long base64-like strings) if (looksLikeSecret(value)) return maskSecret(value); diff --git a/src/utils/mcpHost.test.ts b/src/utils/mcpHost.test.ts new file mode 100644 index 00000000..6183dae0 --- /dev/null +++ b/src/utils/mcpHost.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { MCP_DEFAULT_HOST, normalizeMcpHost } from './mcpHost'; + +describe('normalizeMcpHost', () => { + it('maps empty / wildcards / IPv6 any to 127.0.0.1', () => { + expect(normalizeMcpHost(undefined)).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost(' ')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('0.0.0.0')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('::')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('[::]')).toBe(MCP_DEFAULT_HOST); + }); + + it('normalizes localhost / IPv6 loopback aliases to 127.0.0.1', () => { + expect(normalizeMcpHost('localhost')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('127.0.0.1')).toBe('127.0.0.1'); + expect(normalizeMcpHost('::1')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('[::1]')).toBe(MCP_DEFAULT_HOST); + }); + + it('forces non-loopback hosts to loopback', () => { + expect(normalizeMcpHost('192.168.1.1')).toBe(MCP_DEFAULT_HOST); + expect(normalizeMcpHost('example.com')).toBe(MCP_DEFAULT_HOST); + }); +}); diff --git a/src/utils/mcpHost.ts b/src/utils/mcpHost.ts new file mode 100644 index 00000000..f59d16cf --- /dev/null +++ b/src/utils/mcpHost.ts @@ -0,0 +1,28 @@ +/** Default loopback bind for desktop MCP. */ +export const MCP_DEFAULT_HOST = '127.0.0.1'; +export const MCP_DEFAULT_PORT = 3927; + +/** + * Normalize MCP listen host for display and bind. + * Maps unset / wildcard hosts to 127.0.0.1; non-loopback hosts are forced to loopback + * (desktop MCP must not expose the token surface on 0.0.0.0 / public interfaces). + */ +export function normalizeMcpHost(raw: unknown): string { + if (typeof raw !== 'string' || !raw.trim()) return MCP_DEFAULT_HOST; + const host = raw.trim(); + // Wildcards and IPv6 loopback (::1) → 127.0.0.1 so URL construction never + // emits unbracketed IPv6 hosts (invalid in http://host:port). + if ( + host === '0.0.0.0' || + host === '::' || + host === '[::]' || + host === '::1' || + host === '[::1]' || + host === 'localhost' + ) { + return MCP_DEFAULT_HOST; + } + if (host === '127.0.0.1') return host; + // Force loopback for any other value + return MCP_DEFAULT_HOST; +} diff --git a/src/utils/repoSearch.test.ts b/src/utils/repoSearch.test.ts new file mode 100644 index 00000000..841696f3 --- /dev/null +++ b/src/utils/repoSearch.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import type { Repository } from '../types'; +import { + performBasicTextSearch, + applyRepoFilters, + searchRepositories, + projectRepoForAgent, +} from './repoSearch'; + +function makeRepo(partial: Partial & Pick): Repository { + return { + description: null, + html_url: `https://github.com/${partial.full_name}`, + stargazers_count: 100, + forks_count: 10, + forks: 10, + language: 'TypeScript', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', + pushed_at: '2024-06-01T00:00:00Z', + owner: { login: 'owner', avatar_url: '' }, + topics: [], + ...partial, + }; +} + +const sample: Repository[] = [ + makeRepo({ + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + description: 'offline first CRDT', + language: 'Rust', + stargazers_count: 500, + ai_summary: 'A CRDT library for sync', + ai_tags: ['crdt', 'sync'], + ai_platforms: ['cli'], + topics: ['database'], + }), + makeRepo({ + id: 2, + name: 'beta', + full_name: 'acme/beta', + description: 'webdav client', + language: 'TypeScript', + stargazers_count: 50, + ai_tags: ['webdav'], + custom_category: 'tools', + }), + makeRepo({ + id: 3, + name: 'gamma', + full_name: 'acme/gamma', + description: 'ssrf helper', + language: 'Go', + stargazers_count: 200, + analyzed_at: '2024-01-01', + analysis_failed: true, + }), +]; + +describe('performBasicTextSearch', () => { + it('matches AND of words across AI fields', () => { + const hits = performBasicTextSearch(sample, 'crdt sync'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('matches custom tags and topics', () => { + const hits = performBasicTextSearch(sample, 'database'); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('returns all for empty query', () => { + expect(performBasicTextSearch(sample, ' ').length).toBe(3); + }); +}); + +describe('applyRepoFilters', () => { + it('filters by language and min stars', () => { + const hits = applyRepoFilters(sample, { languages: ['Rust'], minStars: 100 }); + expect(hits.map((r) => r.id)).toEqual([1]); + }); + + it('filters by tags across ai/topics/custom', () => { + const hits = applyRepoFilters(sample, { tags: ['webdav'] }); + expect(hits.map((r) => r.id)).toEqual([2]); + }); + + it('sorts by stars desc by default', () => { + const hits = applyRepoFilters(sample, { sortBy: 'stars', sortOrder: 'desc' }); + expect(hits.map((r) => r.id)).toEqual([1, 3, 2]); + }); +}); + +describe('searchRepositories', () => { + it('paginates and reports total', () => { + const { items, total } = searchRepositories(sample, { query: '', limit: 1, offset: 1, sortBy: 'stars', sortOrder: 'desc' }); + expect(total).toBe(3); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(3); + }); + + it('filters by custom_category', () => { + const { items, total } = searchRepositories(sample, { category: 'tools' }); + expect(total).toBe(1); + expect(items[0].full_name).toBe('acme/beta'); + }); +}); + +describe('projectRepoForAgent', () => { + it('truncates long summaries', () => { + const long = makeRepo({ + id: 9, + name: 'x', + full_name: 'a/x', + ai_summary: 'y'.repeat(500), + }); + const projected = projectRepoForAgent(long, { summaryMaxChars: 50 }); + expect(String(projected.ai_summary).length).toBeLessThanOrEqual(51); + }); +}); diff --git a/src/utils/repoSearch.ts b/src/utils/repoSearch.ts new file mode 100644 index 00000000..64eeafb6 --- /dev/null +++ b/src/utils/repoSearch.ts @@ -0,0 +1,233 @@ +import type { Category, Repository, SearchFilters } from '../types'; +import { isRepoCustomized } from './repoUtils'; + +/** Partial filters used by MCP and UI search (all fields optional except when provided). */ +export type RepoSearchFilterInput = Partial & { + category?: string; + limit?: number; + offset?: number; +}; + +export function performBasicTextSearch(repos: T[], query: string): T[] { + const normalizedQuery = query.toLowerCase().trim(); + if (!normalizedQuery) return repos; + + const queryWords = normalizedQuery.split(/\s+/).filter(Boolean); + + return repos.filter((repo) => { + const searchableText = [ + repo.name, + repo.full_name, + repo.description || '', + repo.custom_description || '', + repo.language || '', + ...(repo.topics || []), + repo.ai_summary || '', + ...(repo.ai_tags || []), + ...(repo.ai_platforms || []), + ...(repo.custom_tags || []), + repo.custom_category || '', + ] + .join(' ') + .toLowerCase(); + + return queryWords.every((word) => searchableText.includes(word)); + }); +} + +function getSortValue(repo: Repository, sortBy: SearchFilters['sortBy']): number | string { + switch (sortBy) { + case 'stars': + return repo.stargazers_count; + case 'updated': + return new Date(repo.pushed_at || repo.updated_at).getTime(); + case 'name': + return repo.name.toLowerCase(); + case 'starred': + return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; + default: + return new Date(repo.pushed_at || repo.updated_at).getTime(); + } +} + +export function sortRepositories( + repos: T[], + sortBy: SearchFilters['sortBy'] = 'stars', + sortOrder: SearchFilters['sortOrder'] = 'desc' +): T[] { + const sorted = [...repos]; + sorted.sort((a, b) => { + const aValue = getSortValue(a, sortBy); + const bValue = getSortValue(b, sortBy); + if (aValue < bValue) return sortOrder === 'desc' ? 1 : -1; + if (aValue > bValue) return sortOrder === 'desc' ? -1 : 1; + return 0; + }); + return sorted; +} + +export interface ApplyFiltersOptions { + releaseSubscriptions?: Set | number[]; + allCategories?: Category[]; + /** When true, skip isEdited filter that needs categories */ + skipEditedFilter?: boolean; +} + +/** + * Apply facet filters and sort. Does NOT auto-clear category-lock UI state + * (that side effect stays in SearchBar). + */ +export function applyRepoFilters( + repos: T[], + searchFilters: Partial, + options: ApplyFiltersOptions = {} +): T[] { + let filtered: T[] = repos; + const releaseSubscriptions = options.releaseSubscriptions + ? options.releaseSubscriptions instanceof Set + ? options.releaseSubscriptions + : new Set(options.releaseSubscriptions) + : new Set(); + const allCategories = options.allCategories ?? []; + + const languages = searchFilters.languages ?? []; + if (languages.length > 0) { + filtered = filtered.filter( + (repo) => repo.language && languages.includes(repo.language) + ); + } + + const tags = searchFilters.tags ?? []; + if (tags.length > 0) { + filtered = filtered.filter((repo) => { + const repoTags = [ + ...(repo.ai_tags || []), + ...(repo.topics || []), + ...(repo.custom_tags || []), + ]; + return tags.some((tag) => repoTags.includes(tag)); + }); + } + + const platforms = searchFilters.platforms ?? []; + if (platforms.length > 0) { + filtered = filtered.filter((repo) => { + const repoPlatforms = repo.ai_platforms || []; + return platforms.some((platform) => repoPlatforms.includes(platform)); + }); + } + + if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) { + filtered = filtered.filter((repo) => + searchFilters.isAnalyzed + ? !!repo.analyzed_at && !repo.analysis_failed + : !repo.analyzed_at + ); + } + + if (searchFilters.isSubscribed !== undefined) { + filtered = filtered.filter((repo) => + searchFilters.isSubscribed + ? releaseSubscriptions.has(repo.id) + : !releaseSubscriptions.has(repo.id) + ); + } + + if (searchFilters.isEdited !== undefined && !options.skipEditedFilter) { + filtered = filtered.filter((repo) => { + const customized = isRepoCustomized(repo, allCategories); + return searchFilters.isEdited ? customized : !customized; + }); + } + + if (searchFilters.isCategoryLocked !== undefined) { + filtered = filtered.filter((repo) => { + const isLocked = !!repo.category_locked; + return searchFilters.isCategoryLocked ? isLocked : !isLocked; + }); + } + + if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) { + filtered = filtered.filter((repo) => { + const hasFailed = !!(repo.analyzed_at && repo.analysis_failed); + return searchFilters.analysisFailed ? hasFailed : !hasFailed; + }); + } + + if (searchFilters.minStars !== undefined) { + filtered = filtered.filter((repo) => repo.stargazers_count >= searchFilters.minStars!); + } + if (searchFilters.maxStars !== undefined) { + filtered = filtered.filter((repo) => repo.stargazers_count <= searchFilters.maxStars!); + } + + const sortBy = searchFilters.sortBy ?? 'stars'; + const sortOrder = searchFilters.sortOrder ?? 'desc'; + return sortRepositories(filtered, sortBy, sortOrder); +} + +/** Full text search + filters + optional category + pagination for MCP/API use. */ +export function searchRepositories( + repos: T[], + input: RepoSearchFilterInput, + options: ApplyFiltersOptions = {} +): { items: T[]; total: number } { + let result = repos; + + if (input.query?.trim()) { + result = performBasicTextSearch(result, input.query); + } + + if (input.category && input.category !== 'all') { + const cat = input.category; + result = result.filter((repo) => { + const custom = repo.custom_category; + if (custom) return custom === cat; + // loose match on custom_category only when no AI category resolution available + return false; + }); + } + + result = applyRepoFilters(result, input, options); + + const total = result.length; + const offset = Math.max(0, input.offset ?? 0); + const limit = Math.min(100, Math.max(1, input.limit ?? 20)); + const items = result.slice(offset, offset + limit); + return { items, total }; +} + +/** Compact projection for agent context economy. */ +export function projectRepoForAgent( + repo: Repository, + opts: { summaryMaxChars?: number } = {} +): Record { + const max = opts.summaryMaxChars ?? 400; + const summary = repo.ai_summary || repo.custom_description || repo.description || null; + const truncated = + typeof summary === 'string' && summary.length > max + ? `${summary.slice(0, max)}…` + : summary; + + return { + id: repo.id, + full_name: repo.full_name, + name: repo.name, + html_url: repo.html_url, + description: repo.description, + language: repo.language, + stargazers_count: repo.stargazers_count, + topics: repo.topics ?? [], + ai_summary: truncated, + ai_tags: repo.ai_tags ?? [], + ai_platforms: repo.ai_platforms ?? [], + custom_description: repo.custom_description, + custom_tags: repo.custom_tags, + custom_category: repo.custom_category, + analyzed_at: repo.analyzed_at, + subscribed_to_releases: !!repo.subscribed_to_releases, + starred_at: repo.starred_at, + updated_at: repo.updated_at, + pushed_at: repo.pushed_at, + }; +}