diff --git a/biome.jsonc b/biome.jsonc index 705b1e01f..6f925f5af 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -23,7 +23,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/.claude/**", "!**/.vscode/**"], + "includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"], "maxSize": 2097152 }, "formatter": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 64fdefa1d..9eb20d123 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -61,6 +61,7 @@ export default defineConfig([ 'tests/**', '.yarn/**', '.gitignore', + '.conductor/**', 'scripts/cloudflare-worker.js', 'src/main/integration/nutstore/sso/lib/**', 'src/main/integration/cherryai/index.js', diff --git a/src/main/mcpServers/__tests__/browser.test.ts b/src/main/mcpServers/__tests__/browser.test.ts new file mode 100644 index 000000000..712eaf94e --- /dev/null +++ b/src/main/mcpServers/__tests__/browser.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => { + const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => { + if (command === 'Runtime.evaluate') { + if (params?.expression === 'document.documentElement.outerHTML') { + return { result: { value: '

Test

Content

' } } + } + if (params?.expression === 'document.body.innerText') { + return { result: { value: 'Test\nContent' } } + } + return { result: { value: 'ok' } } + } + return {} + }) + + const debuggerObj = { + isAttached: vi.fn(() => true), + attach: vi.fn(), + detach: vi.fn(), + sendCommand + } + + const webContents = { + debugger: debuggerObj, + setUserAgent: vi.fn(), + getURL: vi.fn(() => 'https://example.com/'), + getTitle: vi.fn(async () => 'Example Title'), + once: vi.fn(), + removeListener: vi.fn(), + on: vi.fn() + } + + const loadURL = vi.fn(async () => {}) + + const windows: any[] = [] + + class MockBrowserWindow { + private destroyed = false + public webContents = webContents + public loadURL = loadURL + public isDestroyed = vi.fn(() => this.destroyed) + public close = vi.fn(() => { + this.destroyed = true + }) + public destroy = vi.fn(() => { + this.destroyed = true + }) + public on = vi.fn() + + constructor() { + windows.push(this) + } + } + + const app = { + isReady: vi.fn(() => true), + whenReady: vi.fn(async () => {}), + on: vi.fn() + } + + return { + BrowserWindow: MockBrowserWindow as any, + app, + __mockDebugger: debuggerObj, + __mockSendCommand: sendCommand, + __mockLoadURL: loadURL, + __mockWindows: windows + } +}) + +import * as electron from 'electron' +const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] } + +import { CdpBrowserController } from '../browser' + +describe('CdpBrowserController', () => { + it('executes single-line code via Runtime.evaluate', async () => { + const controller = new CdpBrowserController() + const result = await controller.execute('1+1') + expect(result).toBe('ok') + }) + + it('opens a URL (hidden) and returns current page info', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, false) + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('opens a URL (visible) when show=true', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, true, 'session-a') + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('reuses session for execute and supports multiline', async () => { + const controller = new CdpBrowserController() + await controller.open('https://foo.bar/', 5000, false, 'session-b') + const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b') + expect(result).toBe('ok') + }) + + it('evicts least recently used session when exceeding maxSessions', async () => { + const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 }) + await controller.open('https://foo.bar/', 5000, false, 's1') + await controller.open('https://foo.bar/', 5000, false, 's2') + await controller.open('https://foo.bar/', 5000, false, 's3') + const destroyedCount = __mockWindows.filter( + (w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0 + ).length + expect(destroyedCount).toBeGreaterThanOrEqual(1) + }) + + it('fetches URL and returns html format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'html') + expect(result).toBe('

Test

Content

') + }) + + it('fetches URL and returns txt format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'txt') + expect(result).toBe('Test\nContent') + }) + + it('fetches URL and returns markdown format (default)', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/') + expect(typeof result).toBe('string') + expect(result).toContain('Test') + }) +}) diff --git a/src/main/mcpServers/browser/controller.ts b/src/main/mcpServers/browser/controller.ts new file mode 100644 index 000000000..6246da45d --- /dev/null +++ b/src/main/mcpServers/browser/controller.ts @@ -0,0 +1,307 @@ +import { app, BrowserWindow } from 'electron' +import TurndownService from 'turndown' + +import { logger, userAgent } from './types' + +/** + * Controller for managing browser windows via Chrome DevTools Protocol (CDP). + * Supports multiple sessions with LRU eviction and idle timeout cleanup. + */ +export class CdpBrowserController { + private windows: Map = new Map() + private readonly maxSessions: number + private readonly idleTimeoutMs: number + + constructor(options?: { maxSessions?: number; idleTimeoutMs?: number }) { + this.maxSessions = options?.maxSessions ?? 5 + this.idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000 + } + + private async ensureAppReady() { + if (!app.isReady()) { + await app.whenReady() + } + } + + private touch(sessionId: string) { + const entry = this.windows.get(sessionId) + if (entry) entry.lastActive = Date.now() + } + + private closeWindow(win: BrowserWindow, sessionId: string) { + try { + if (!win.isDestroyed()) { + if (win.webContents.debugger.isAttached()) { + win.webContents.debugger.detach() + } + win.close() + } + } catch (error) { + logger.warn('Error closing window', { error, sessionId }) + } + } + + private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionId: string) { + if (!dbg.isAttached()) { + try { + logger.info('Attaching debugger', { sessionId }) + dbg.attach('1.3') + await dbg.sendCommand('Page.enable') + await dbg.sendCommand('Runtime.enable') + logger.info('Debugger attached and domains enabled') + } catch (error) { + logger.error('Failed to attach debugger', { error }) + throw error + } + } + } + + private sweepIdle() { + const now = Date.now() + for (const [id, entry] of this.windows.entries()) { + if (now - entry.lastActive > this.idleTimeoutMs) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + } + } + + private evictIfNeeded(newSessionId: string) { + if (this.windows.size < this.maxSessions) return + let lruId: string | null = null + let lruTime = Number.POSITIVE_INFINITY + for (const [id, entry] of this.windows.entries()) { + if (id === newSessionId) continue + if (entry.lastActive < lruTime) { + lruTime = entry.lastActive + lruId = id + } + } + if (lruId) { + const entry = this.windows.get(lruId) + if (entry) { + this.closeWindow(entry.win, lruId) + } + this.windows.delete(lruId) + logger.info('Evicted session to respect maxSessions', { evicted: lruId }) + } + } + + private async getWindow(sessionId = 'default', forceNew = false, show = false): Promise { + await this.ensureAppReady() + + this.sweepIdle() + + const existing = this.windows.get(sessionId) + if (existing && !existing.win.isDestroyed() && !forceNew) { + this.touch(sessionId) + return existing.win + } + + if (existing && !existing.win.isDestroyed() && forceNew) { + try { + if (existing.win.webContents.debugger.isAttached()) { + existing.win.webContents.debugger.detach() + } + } catch (error) { + logger.warn('Error detaching debugger before recreate', { error, sessionId }) + } + existing.win.destroy() + this.windows.delete(sessionId) + } + + this.evictIfNeeded(sessionId) + + const win = new BrowserWindow({ + show, + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + // Use a standard Chrome UA to avoid some anti-bot blocks + win.webContents.setUserAgent(userAgent) + + // Log navigation lifecycle to help diagnose slow loads + win.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { sessionId })) + win.webContents.on('dom-ready', () => logger.info(`dom-ready`, { sessionId })) + win.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { sessionId })) + win.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc })) + + win.on('closed', () => { + this.windows.delete(sessionId) + }) + + this.windows.set(sessionId, { win, lastActive: Date.now() }) + return win + } + + /** + * Opens a URL in a browser window and waits for navigation to complete. + * @param url - The URL to navigate to + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param show - Whether to show the browser window (default: false) + * @param sessionId - Session identifier for window reuse (default: 'default') + * @returns Object containing the current URL and page title after navigation + */ + public async open(url: string, timeout = 10000, show = false, sessionId = 'default') { + const win = await this.getWindow(sessionId, true, show) + logger.info('Loading URL', { url, sessionId }) + const { webContents } = win + this.touch(sessionId) + + // Track resolution state to prevent multiple handlers from firing + let resolved = false + let onFinish: () => void + let onDomReady: () => void + let onFail: (_event: Electron.Event, code: number, desc: string) => void + + // Define cleanup outside Promise to ensure it's callable in finally block, + // preventing memory leaks when timeout occurs before navigation completes + const cleanup = () => { + webContents.removeListener('did-finish-load', onFinish) + webContents.removeListener('did-fail-load', onFail) + webContents.removeListener('dom-ready', onDomReady) + } + + const loadPromise = new Promise((resolve, reject) => { + onFinish = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onDomReady = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onFail = (_event: Electron.Event, code: number, desc: string) => { + if (resolved) return + resolved = true + cleanup() + reject(new Error(`Navigation failed (${code}): ${desc}`)) + } + webContents.once('did-finish-load', onFinish) + webContents.once('dom-ready', onDomReady) + webContents.once('did-fail-load', onFail) + }) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Navigation timed out')), timeout) + }) + + try { + await Promise.race([win.loadURL(url), loadPromise, timeoutPromise]) + } finally { + // Always cleanup listeners to prevent memory leaks on timeout + cleanup() + } + + const currentUrl = webContents.getURL() + const title = await webContents.getTitle() + return { currentUrl, title } + } + + public async execute(code: string, timeout = 5000, sessionId = 'default') { + const win = await this.getWindow(sessionId) + this.touch(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + const evalPromise = dbg.sendCommand('Runtime.evaluate', { + expression: code, + awaitPromise: true, + returnByValue: true + }) + + const result = await Promise.race([ + evalPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out')), timeout)) + ]) + + const evalResult = result as any + + if (evalResult?.exceptionDetails) { + const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error' + logger.warn('Runtime.evaluate raised exception', { message }) + throw new Error(message) + } + + const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null + return value + } + + public async reset(sessionId?: string) { + if (sessionId) { + const entry = this.windows.get(sessionId) + if (entry) { + this.closeWindow(entry.win, sessionId) + } + this.windows.delete(sessionId) + logger.info('Browser CDP context reset', { sessionId }) + return + } + + for (const [id, entry] of this.windows.entries()) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + logger.info('Browser CDP context reset (all sessions)') + } + + /** + * Fetches a URL and returns content in the specified format. + * @param url - The URL to fetch + * @param format - Output format: 'html', 'txt', 'markdown', or 'json' (default: 'markdown') + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param sessionId - Session identifier (default: 'default') + * @returns Content in the requested format. For 'json', returns parsed object or { data: rawContent } if parsing fails + */ + public async fetch( + url: string, + format: 'html' | 'txt' | 'markdown' | 'json' = 'markdown', + timeout = 10000, + sessionId = 'default' + ) { + await this.open(url, timeout, false, sessionId) + + const win = await this.getWindow(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + let expression: string + if (format === 'json' || format === 'txt') { + expression = 'document.body.innerText' + } else { + expression = 'document.documentElement.outerHTML' + } + + const result = (await dbg.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true + })) as { result?: { value?: string } } + + const content = result?.result?.value ?? '' + + if (format === 'markdown') { + const turndownService = new TurndownService() + return turndownService.turndown(content) + } + if (format === 'json') { + // Attempt to parse as JSON; if content is not valid JSON, wrap it in a data object + try { + return JSON.parse(content) + } catch { + return { data: content } + } + } + return content + } +} diff --git a/src/main/mcpServers/browser/index.ts b/src/main/mcpServers/browser/index.ts new file mode 100644 index 000000000..fbdb0a0f6 --- /dev/null +++ b/src/main/mcpServers/browser/index.ts @@ -0,0 +1,3 @@ +export { CdpBrowserController } from './controller' +export { BrowserServer } from './server' +export { BrowserServer as default } from './server' diff --git a/src/main/mcpServers/browser/server.ts b/src/main/mcpServers/browser/server.ts new file mode 100644 index 000000000..3e889a7b6 --- /dev/null +++ b/src/main/mcpServers/browser/server.ts @@ -0,0 +1,50 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { Server as MCServer } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { app } from 'electron' + +import { CdpBrowserController } from './controller' +import { toolDefinitions, toolHandlers } from './tools' + +export class BrowserServer { + public server: Server + private controller = new CdpBrowserController() + + constructor() { + const server = new MCServer( + { + name: '@cherry/browser', + version: '0.1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: toolDefinitions + } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const handler = toolHandlers[name] + if (!handler) { + throw new Error('Tool not found') + } + return handler(this.controller, args) + }) + + app.on('before-quit', () => { + void this.controller.reset() + }) + + this.server = server + } +} + +export default BrowserServer diff --git a/src/main/mcpServers/browser/tools/execute.ts b/src/main/mcpServers/browser/tools/execute.ts new file mode 100644 index 000000000..1585a467a --- /dev/null +++ b/src/main/mcpServers/browser/tools/execute.ts @@ -0,0 +1,48 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const ExecuteSchema = z.object({ + code: z + .string() + .describe( + 'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.' + ), + timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), + sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)') +}) + +export const executeToolDefinition = { + name: 'execute', + description: + 'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.', + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'One-line JS to evaluate in page context' + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default 5000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; targets a specific page (default: default)' + } + }, + required: ['code'] + } +} + +export async function handleExecute(controller: CdpBrowserController, args: unknown) { + const { code, timeout, sessionId } = ExecuteSchema.parse(args) + try { + const value = await controller.execute(code, timeout, sessionId ?? 'default') + return successResponse(typeof value === 'string' ? value : JSON.stringify(value)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/fetch.ts b/src/main/mcpServers/browser/tools/fetch.ts new file mode 100644 index 000000000..b749aaff9 --- /dev/null +++ b/src/main/mcpServers/browser/tools/fetch.ts @@ -0,0 +1,49 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const FetchSchema = z.object({ + url: z.url().describe('URL to fetch'), + format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + sessionId: z.string().optional().describe('Session identifier (default: default)') +}) + +export const fetchToolDefinition = { + name: 'fetch', + description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to fetch' + }, + format: { + type: 'string', + enum: ['html', 'txt', 'markdown', 'json'], + description: 'Output format (default: markdown)' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default: 10000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleFetch(controller: CdpBrowserController, args: unknown) { + const { url, format, timeout, sessionId } = FetchSchema.parse(args) + try { + const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default') + return successResponse(typeof content === 'string' ? content : JSON.stringify(content)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/index.ts b/src/main/mcpServers/browser/tools/index.ts new file mode 100644 index 000000000..19f1ee416 --- /dev/null +++ b/src/main/mcpServers/browser/tools/index.ts @@ -0,0 +1,25 @@ +export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute' +export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch' +export { handleOpen, OpenSchema, openToolDefinition } from './open' +export { handleReset, resetToolDefinition } from './reset' + +import type { CdpBrowserController } from '../controller' +import { executeToolDefinition, handleExecute } from './execute' +import { fetchToolDefinition, handleFetch } from './fetch' +import { handleOpen, openToolDefinition } from './open' +import { handleReset, resetToolDefinition } from './reset' + +export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition] + +export const toolHandlers: Record< + string, + ( + controller: CdpBrowserController, + args: unknown + ) => Promise<{ content: { type: string; text: string }[]; isError: boolean }> +> = { + open: handleOpen, + execute: handleExecute, + reset: handleReset, + fetch: handleFetch +} diff --git a/src/main/mcpServers/browser/tools/open.ts b/src/main/mcpServers/browser/tools/open.ts new file mode 100644 index 000000000..9739b3bca --- /dev/null +++ b/src/main/mcpServers/browser/tools/open.ts @@ -0,0 +1,47 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +export const OpenSchema = z.object({ + url: z.url().describe('URL to open in the controlled Electron window'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + show: z.boolean().optional().describe('Whether to show the browser window (default: false)'), + sessionId: z + .string() + .optional() + .describe('Session identifier; separate sessions keep separate pages (default: default)') +}) + +export const openToolDefinition = { + name: 'open', + description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to load' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default 10000)' + }, + show: { + type: 'boolean', + description: 'Whether to show the browser window (default false)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; separate sessions keep separate pages (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleOpen(controller: CdpBrowserController, args: unknown) { + const { url, timeout, show, sessionId } = OpenSchema.parse(args) + const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default') + return successResponse(JSON.stringify(res)) +} diff --git a/src/main/mcpServers/browser/tools/reset.ts b/src/main/mcpServers/browser/tools/reset.ts new file mode 100644 index 000000000..d09d25111 --- /dev/null +++ b/src/main/mcpServers/browser/tools/reset.ts @@ -0,0 +1,34 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +/** Zod schema for validating reset tool arguments */ +export const ResetSchema = z.object({ + sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions') +}) + +/** MCP tool definition for the reset tool */ +export const resetToolDefinition = { + name: 'reset', + description: 'Reset the controlled window and detach debugger', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Session identifier to reset; omit to reset all sessions' + } + } + } +} + +/** + * Handler for the reset MCP tool. + * Closes browser window(s) and detaches debugger for the specified session or all sessions. + */ +export async function handleReset(controller: CdpBrowserController, args: unknown) { + const { sessionId } = ResetSchema.parse(args) + await controller.reset(sessionId) + return successResponse('reset') +} diff --git a/src/main/mcpServers/browser/tools/utils.ts b/src/main/mcpServers/browser/tools/utils.ts new file mode 100644 index 000000000..2c5ecc0f1 --- /dev/null +++ b/src/main/mcpServers/browser/tools/utils.ts @@ -0,0 +1,13 @@ +export function successResponse(text: string) { + return { + content: [{ type: 'text', text }], + isError: false + } +} + +export function errorResponse(error: Error) { + return { + content: [{ type: 'text', text: error.message }], + isError: true + } +} diff --git a/src/main/mcpServers/browser/types.ts b/src/main/mcpServers/browser/types.ts new file mode 100644 index 000000000..2cc934e6c --- /dev/null +++ b/src/main/mcpServers/browser/types.ts @@ -0,0 +1,4 @@ +import { loggerService } from '@logger' + +export const logger = loggerService.withContext('MCPBrowserCDP') +export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0' diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 2323701e4..ce736f684 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -4,6 +4,7 @@ import type { BuiltinMCPServerName } from '@types' import { BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' +import BrowserServer from './browser' import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' @@ -48,6 +49,9 @@ export function createInMemoryMCPServer( const apiKey = envs.DIDI_API_KEY return new DiDiMcpServer(apiKey).server } + case BuiltinMCPServerNames.browser: { + return new BrowserServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 434fb415f..283026708 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -343,7 +343,8 @@ const builtInMcpDescriptionKeyMap: Record = { [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', - [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' + [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp', + [BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 86f390032..298ffeb86 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3884,6 +3884,7 @@ "builtinServers": "Builtin Servers", "builtinServersDescriptions": { "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", + "browser": "Control a headless Electron window via Chrome DevTools Protocol. Tools: open URL, execute single-line JS, reset session.", "didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "fetch": "MCP server for retrieving URL web content", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a205408c4..8c10f3687 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3884,6 +3884,7 @@ "builtinServers": "内置服务器", "builtinServersDescriptions": { "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", + "browser": "通过 Chrome DevTools 协议控制隐藏的 Electron 窗口,支持打开 URL、执行单行 JS、重置会话", "didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "fetch": "用于获取 URL 网页内容的 MCP 服务器", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2896077fa..162373b55 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3884,6 +3884,7 @@ "builtinServers": "內建伺服器", "builtinServersDescriptions": { "brave_search": "一個整合了 Brave 搜尋 API 的 MCP 伺服器實做,提供網頁與本機搜尋雙重功能。需要設定 BRAVE_API_KEY 環境變數", + "browser": "透過 Chrome DevTools Protocol 控制 headless Electron 視窗。工具:開啟 URL、執行單行 JS、重設工作階段。", "didi_mcp": "一個整合了滴滴 MCP 伺服器實做,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要設定 DIDI_API_KEY 環境變數", "dify_knowledge": "Dify 的 MCP 伺服器實做,提供了一個簡單的 API 來與 Dify 進行互動。需要設定 Dify Key", "fetch": "用於取得 URL 網頁內容的 MCP 伺服器", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index cbb5bc637..4bc992759 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3884,6 +3884,7 @@ "builtinServers": "Integrierter Server", "builtinServersDescriptions": { "brave_search": "MCP-Server-Implementierung mit Brave-Search-API, die sowohl Web- als auch lokale Suchfunktionen bietet. BRAVE_API_KEY-Umgebungsvariable muss konfiguriert werden", + "browser": "Steuert ein headless Electron-Fenster über das Chrome DevTools Protocol. Tools: URL öffnen, einzeiligen JS ausführen, Sitzung zurücksetzen.", "didi_mcp": "An integrated Didi MCP server implementation that provides ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in mainland China. Requires the DIDI_API_KEY environment variable to be configured.", "dify_knowledge": "MCP-Server-Implementierung von Dify, die einen einfachen API-Zugriff auf Dify bietet. Dify Key muss konfiguriert werden", "fetch": "MCP-Server zum Abrufen von Webseiteninhalten", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index e26abd58f..44fba429f 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "Χρησιμοποιείται αυτόματα εντοπισμένο Git Bash", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "Διαγραφή προσαρμοσμένης διαδρομής" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "Χρησιμοποιείται προσαρμοσμένη διαδρομή: {{path}}", "error": { "description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από", "recheck": "Επανέλεγχος Εγκατάστασης του Git Bash", "title": "Απαιτείται Git Bash" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Το Git Bash διαμορφώθηκε" }, "notFound": "Το Git Bash δεν βρέθηκε. Παρακαλώ εγκαταστήστε το πρώτα.", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Επιλογή διαδρομής Git Bash", + "failed": "Αποτυχία ορισμού διαδρομής Git Bash", + "invalidPath": "Το επιλεγμένο αρχείο δεν είναι έγκυρο εκτελέσιμο Git Bash (bash.exe).", + "title": "Επιλογή εκτελέσιμου Git Bash" }, "success": "Το Git Bash εντοπίστηκε με επιτυχία!" }, @@ -547,7 +547,7 @@ "medium": "Μεσαίο", "minimal": "ελάχιστος", "off": "Απενεργοποίηση", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Εξαιρετικά Υψηλή" }, "regular_phrases": { "add": "Προσθήκη φράσης", @@ -3884,6 +3884,7 @@ "builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServersDescriptions": { "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", + "browser": "Ελέγχει ένα headless παράθυρο Electron μέσω του Chrome DevTools Protocol. Εργαλεία: άνοιγμα URL, εκτέλεση JS μίας γραμμής, επαναφορά συνεδρίας.", "didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 4316c8061..5cf620ed4 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -547,7 +547,7 @@ "medium": "Medio", "minimal": "minimal", "off": "Apagado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Agregar frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", + "browser": "Controla una ventana Electron headless mediante Chrome DevTools Protocol. Herramientas: abrir URL, ejecutar JS de una línea, reiniciar sesión.", "didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 66b9fef86..fdb72727b 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -547,7 +547,7 @@ "medium": "Moyen", "minimal": "minimal", "off": "Off", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Très élevée" }, "regular_phrases": { "add": "Добавить фразу", @@ -3884,6 +3884,7 @@ "builtinServers": "Serveurs intégrés", "builtinServersDescriptions": { "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", + "browser": "Contrôle une fenêtre Electron headless via Chrome DevTools Protocol. Outils : ouvrir une URL, exécuter du JS en une ligne, réinitialiser la session.", "didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 493d69358..d004d539e 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "自動検出されたGit Bashを使用中", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "カスタムパスをクリア" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "カスタムパスを使用中: {{path}}", "error": { "description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。", "recheck": "Git Bashのインストールを再確認してください", "title": "Git Bashが必要です" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Git Bashが設定されました" }, "notFound": "Git Bash が見つかりません。先にインストールしてください。", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Git Bashパスを選択", + "failed": "Git Bashパスの設定に失敗しました", + "invalidPath": "選択されたファイルは有効なGit Bash実行ファイル(bash.exe)ではありません。", + "title": "Git Bash実行ファイルを選択" }, "success": "Git Bashが正常に検出されました!" }, @@ -547,7 +547,7 @@ "medium": "普通の思考", "minimal": "最小限の思考", "off": "オフ", - "xhigh": "[to be translated]:Extra High" + "xhigh": "超高" }, "regular_phrases": { "add": "プロンプトを追加", @@ -3884,6 +3884,7 @@ "builtinServers": "組み込みサーバー", "builtinServersDescriptions": { "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", + "browser": "Chrome DevTools Protocolを介してheadless Electronウィンドウを制御します。ツール:URLを開く、単一行JSを実行、セッションをリセット。", "didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index fba1a8e70..32c1965f5 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -547,7 +547,7 @@ "medium": "Médio", "minimal": "mínimo", "off": "Desligado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Adicionar Frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", + "browser": "Controla uma janela Electron headless via Chrome DevTools Protocol. Ferramentas: abrir URL, executar JS de linha única, reiniciar sessão.", "didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "fetch": "servidor MCP para obter o conteúdo da página web do URL", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 297233640..3d022ae24 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -547,7 +547,7 @@ "medium": "Среднее", "minimal": "минимальный", "off": "Выключить", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Сверхвысокое" }, "regular_phrases": { "add": "Добавить подсказку", @@ -3884,6 +3884,7 @@ "builtinServers": "Встроенные серверы", "builtinServersDescriptions": { "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", + "browser": "Управление headless-окном Electron через Chrome DevTools Protocol. Инструменты: открытие URL, выполнение однострочного JS, сброс сессии.", "didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL", diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index aef38bcbe..ed7076bc1 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -174,6 +174,15 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ provider: 'CherryAI', installSource: 'builtin', isTrusted: true + }, + { + id: nanoid(), + name: BuiltinMCPServerNames.browser, + type: 'inMemory', + isActive: false, + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true } ] as const diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 0ff813162..197d21779 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -749,7 +749,8 @@ export const BuiltinMCPServerNames = { filesystem: '@cherry/filesystem', difyKnowledge: '@cherry/dify-knowledge', python: '@cherry/python', - didiMCP: '@cherry/didi-mcp' + didiMCP: '@cherry/didi-mcp', + browser: '@cherry/browser' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames]