diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 88d19d03f0..0ba1df8f30 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -1,15 +1,13 @@ import crypto from 'node:crypto' -import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { isLinux, isMac, isWin } from '@main/constant' import { createInMemoryMCPServer } from '@main/mcpServers/factory' import { makeSureDirExists } from '@main/utils' import { getBinaryName, getBinaryPath } from '@main/utils/process' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' -import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions @@ -33,6 +31,7 @@ import { memoize } from 'lodash' import { CacheService } from './CacheService' import { CallBackServer } from './mcp/oauth/callback' import { McpOAuthClientProvider } from './mcp/oauth/provider' +import getLoginShellEnvironment from './mcp/shell-env' // Generic type for caching wrapped functions type CachedFunction = (...args: T) => Promise @@ -231,13 +230,12 @@ class McpService { Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`) // Logger.info(`[MCP] Environment variables for server:`, server.env) - + const loginShellEnv = await this.getLoginShellEnv() const stdioTransport = new StdioClientTransport({ command: cmd, args, env: { - ...getDefaultEnvironment(), - PATH: await this.getEnhancedPath(process.env.PATH || ''), + ...loginShellEnv, ...server.env }, stderr: 'pipe' @@ -589,163 +587,19 @@ class McpService { return await cachedGetResource(server, uri) } - private findPowerShellExecutable() { - const psPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' // Standard WinPS path - const pwshPath = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' - - if (fs.existsSync(psPath)) { - return psPath - } - if (fs.existsSync(pwshPath)) { - return pwshPath - } - return 'powershell.exe' - } - - private getSystemPath = memoize(async (): Promise => { - return new Promise((resolve, reject) => { - let command: string - let shell: string - - if (process.platform === 'win32') { - shell = this.findPowerShellExecutable() - command = '$env:PATH' - } else { - // 尝试获取当前用户的默认 shell - - let userShell = process.env.SHELL - if (!userShell) { - if (fs.existsSync('/bin/zsh')) { - userShell = '/bin/zsh' - } else if (fs.existsSync('/bin/bash')) { - userShell = '/bin/bash' - } else if (fs.existsSync('/bin/fish')) { - userShell = '/bin/fish' - } else { - userShell = '/bin/sh' - } - } - shell = userShell - - // 根据不同的 shell 构建不同的命令 - if (userShell.includes('zsh')) { - command = - 'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH' - } else if (userShell.includes('bash')) { - command = - 'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH' - } else if (userShell.includes('fish')) { - command = - 'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH' - } else { - // 默认使用 zsh - shell = '/bin/zsh' - command = - 'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH' - } - } - - console.log(`Using shell: ${shell} with command: ${command}`) - const child = require('child_process').spawn(shell, ['-c', command], { - env: { ...process.env }, - cwd: app.getPath('home') - }) - - let path = '' - child.stdout.on('data', (data: Buffer) => { - path += data.toString() - }) - - child.stderr.on('data', (data: Buffer) => { - console.error('Error getting PATH:', data.toString()) - }) - - child.on('error', (error: Error) => { - reject(new Error(`Failed to get system PATH, ${error.message}`)) - }) - - child.on('close', (code: number) => { - if (code === 0) { - const trimmedPath = path.trim() - resolve(trimmedPath) - } else { - reject(new Error(`Failed to get system PATH, exit code: ${code}`)) - } - }) - }) - }) - - /** - * Get enhanced PATH including common tool locations - */ - private async getEnhancedPath(originalPath: string): Promise { - let systemPath = '' + private getLoginShellEnv = memoize(async (): Promise> => { try { - systemPath = await this.getSystemPath() + const loginEnv = await getLoginShellEnvironment() + const pathSeparator = process.platform === 'win32' ? ';' : ':' + const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin') + loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}` + Logger.info('[MCP] Successfully fetched login shell environment variables:', loginEnv) + return loginEnv } catch (error) { - Logger.error('[MCP] Failed to get system PATH:', error) + Logger.error('[MCP] Failed to fetch login shell environment variables:', error) + return {} } - // 将原始 PATH 按分隔符分割成数组 - const pathSeparator = process.platform === 'win32' ? ';' : ':' - const existingPaths = new Set( - [...systemPath.split(pathSeparator), ...originalPath.split(pathSeparator)].filter(Boolean) - ) - const homeDir = process.env.HOME || process.env.USERPROFILE || '' - - // 定义要添加的新路径 - const newPaths: string[] = [] - - if (isMac) { - newPaths.push( - '/bin', - '/usr/bin', - '/usr/local/bin', - '/usr/local/sbin', - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/opt/node/bin', - `${homeDir}/.nvm/current/bin`, - `${homeDir}/.npm-global/bin`, - `${homeDir}/.yarn/bin`, - `${homeDir}/.cargo/bin`, - `${homeDir}/.cherrystudio/bin`, - '/opt/local/bin' - ) - } - - if (isLinux) { - newPaths.push( - '/bin', - '/usr/bin', - '/usr/local/bin', - `${homeDir}/.nvm/current/bin`, - `${homeDir}/.npm-global/bin`, - `${homeDir}/.yarn/bin`, - `${homeDir}/.cargo/bin`, - `${homeDir}/.cherrystudio/bin`, - '/snap/bin' - ) - } - - if (isWin) { - newPaths.push( - `${process.env.APPDATA}\\npm`, - `${homeDir}\\AppData\\Local\\Yarn\\bin`, - `${homeDir}\\.cargo\\bin`, - `${homeDir}\\.cherrystudio\\bin` - ) - } - - // 只添加不存在的路径 - newPaths.forEach((path) => { - if (path && !existingPaths.has(path)) { - existingPaths.add(path) - } - }) - - // 转换回字符串 - return Array.from(existingPaths).join(pathSeparator) - } + }) } const mcpService = new McpService() diff --git a/src/main/services/mcp/shell-env.ts b/src/main/services/mcp/shell-env.ts new file mode 100644 index 0000000000..54cc21280f --- /dev/null +++ b/src/main/services/mcp/shell-env.ts @@ -0,0 +1,118 @@ +import { spawn } from 'child_process' +import Logger from 'electron-log' +import os from 'os' + +/** + * Spawns a login shell in the user's home directory to capture its environment variables. + * @returns {Promise} A promise that resolves with an object containing + * the environment variables, or rejects with an error. + */ +function getLoginShellEnvironment(): Promise> { + return new Promise((resolve, reject) => { + const homeDirectory = os.homedir() + if (!homeDirectory) { + return reject(new Error("Could not determine user's home directory.")) + } + + let shellPath = process.env.SHELL + let commandArgs + let shellCommandToGetEnv + + const platform = os.platform() + + if (platform === 'win32') { + // On Windows, 'cmd.exe' is the common shell. + // The 'set' command lists environment variables. + // We don't typically talk about "login shells" in the same way, + // but cmd will load the user's environment. + shellPath = process.env.COMSPEC || 'cmd.exe' + shellCommandToGetEnv = 'set' + commandArgs = ['/c', shellCommandToGetEnv] // /c Carries out the command specified by string and then terminates + } else { + // For POSIX systems (Linux, macOS) + if (!shellPath) { + // Fallback if process.env.SHELL is not set (less common for interactive users) + // Defaulting to bash, but this might not be the user's actual login shell. + // A more robust solution might involve checking /etc/passwd or similar, + // but that's more complex and often requires higher privileges or native modules. + Logger.warn("process.env.SHELL is not set. Defaulting to /bin/bash. This might not be the user's login shell.") + shellPath = '/bin/bash' // A common default + } + // -l: Make it a login shell. This sources profile files like .profile, .bash_profile, .zprofile etc. + // -i: Make it interactive. Some shells or profile scripts behave differently. + // 'env': The command to print environment variables. + // Using 'env -0' would be more robust for parsing if values contain newlines, + // but requires splitting by null character. For simplicity, we'll use 'env'. + shellCommandToGetEnv = 'env' + commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command + } + + Logger.log(`Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`) + + const child = spawn(shellPath, commandArgs, { + cwd: homeDirectory, // Run the command in the user's home directory + detached: true, // Allows the parent to exit independently of the child + stdio: ['ignore', 'pipe', 'pipe'], // stdin, stdout, stderr + shell: false // We are specifying the shell command directly + }) + + let output = '' + let errorOutput = '' + + child.stdout.on('data', (data) => { + output += data.toString() + }) + + child.stderr.on('data', (data) => { + errorOutput += data.toString() + }) + + child.on('error', (error) => { + Logger.error(`Failed to start shell process: ${shellPath}`, error) + reject(new Error(`Failed to start shell: ${error.message}`)) + }) + + child.on('close', (code) => { + if (code !== 0) { + const errorMessage = `Shell process exited with code ${code}. Shell: ${shellPath}. Args: ${commandArgs.join(' ')}. CWD: ${homeDirectory}. Stderr: ${errorOutput.trim()}` + Logger.error(errorMessage) + return reject(new Error(errorMessage)) + } + + if (errorOutput.trim()) { + // Some shells might output warnings or non-fatal errors to stderr + // during profile loading. Log it, but proceed if exit code is 0. + Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`) + } + + const env = {} + const lines = output.split('\n') + + lines.forEach((line) => { + const trimmedLine = line.trim() + if (trimmedLine) { + const separatorIndex = trimmedLine.indexOf('=') + if (separatorIndex > 0) { + // Ensure '=' is present and it's not the first character + const key = trimmedLine.substring(0, separatorIndex) + const value = trimmedLine.substring(separatorIndex + 1) + env[key] = value + } + } + }) + + if (Object.keys(env).length === 0 && output.length < 100) { + // Arbitrary small length check + // This might indicate an issue if no env vars were parsed or output was minimal + Logger.warn( + 'Parsed environment is empty or output was very short. This might indicate an issue with shell execution or environment variable retrieval.' + ) + Logger.warn('Raw output from shell:\n', output) + } + + resolve(env) + }) + }) +} + +export default getLoginShellEnvironment