Files
ccs/src/utils/browser/mcp-installer.ts
T
walkerandClaude Opus 4.6 eb8149e8fa feat(browser): 接入 CCS browser MCP 与最小交互闭环
新增 browser MCP 安装、Chrome 复用与运行时接线,
实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:19:16 +08:00

376 lines
9.9 KiB
TypeScript

/**
* Browser MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { InstanceManager } from '../../management/instance-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { getCcsDir } from '../config-manager';
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
interface ManagedBrowserMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
function resolvePackageRoot(fromPath: string): string | null {
let currentDir = path.dirname(fromPath);
while (true) {
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
function getBrowserMcpServerEnv(): Record<string, string> {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return {};
}
const packageRoot = resolvePackageRoot(sourcePath);
if (!packageRoot) {
return {};
}
const nodeModulesPath = path.join(packageRoot, 'node_modules');
return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {};
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getBrowserMcpServerName(): string {
return BROWSER_MCP_SERVER_NAME;
}
export function getBrowserMcpServerPath(): string {
return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER);
}
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
if (!fs.existsSync(destinationPath)) {
return false;
}
const source = fs.readFileSync(sourcePath);
try {
const destination = fs.readFileSync(destinationPath);
return source.equals(destination);
} catch {
return false;
}
}
function getTempPath(targetPath: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${targetPath}.${suffix}.tmp`;
}
function resolveBundledServerSourcePath(): string | null {
const possiblePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
];
for (const candidate of possiblePaths) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
if (!fs.existsSync(configPath)) {
return {};
}
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as ClaudeUserConfig;
} catch {
return null;
}
}
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
const tempPath = getTempPath(configPath);
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
try {
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.chmodSync(tempPath, fileMode);
fs.renameSync(tempPath, configPath);
return true;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
try {
return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(BROWSER_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[BROWSER_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
return writeClaudeUserConfig(configPath, nextConfig);
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function installBrowserMcpServer(): boolean {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
const serverPath = getBrowserMcpServerPath();
const sourceMode = fs.statSync(sourcePath).mode & 0o777;
if (hasMatchingContents(sourcePath, serverPath)) {
if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) {
fs.chmodSync(serverPath, sourceMode);
}
return true;
}
const tempPath = getTempPath(serverPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, sourceMode);
try {
fs.renameSync(tempPath, serverPath);
} catch (renameError) {
const errorCode = (renameError as NodeJS.ErrnoException).code;
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
throw renameError;
}
if (!hasMatchingContents(sourcePath, serverPath)) {
fs.copyFileSync(tempPath, serverPath);
fs.chmodSync(serverPath, sourceMode);
}
}
return true;
} catch {
return false;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
export function ensureBrowserMcpConfig(): boolean {
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const desiredServerConfig: ManagedBrowserMcpConfig = {
type: 'stdio',
command: 'node',
args: [getBrowserMcpServerPath()],
env: getBrowserMcpServerEnv(),
};
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[BROWSER_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
return true;
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function ensureBrowserMcp(): boolean {
const installed = installBrowserMcpServer();
const configured = installed && ensureBrowserMcpConfig();
return installed && configured;
}
export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
}
export function uninstallBrowserMcpServer(): boolean {
const serverPath = getBrowserMcpServerPath();
if (!fs.existsSync(serverPath)) {
return false;
}
try {
fs.unlinkSync(serverPath);
return true;
} catch {
return false;
}
}
export function removeBrowserMcpConfig(): boolean {
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
const instancePath = instanceManager.getInstancePath(instanceName);
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
}
return removed;
}
export function uninstallBrowserMcp(): boolean {
const removedConfig = removeBrowserMcpConfig();
const removedServer = uninstallBrowserMcpServer();
return removedConfig || removedServer;
}
export function ensureBrowserMcpOrThrow(): boolean {
const ready = ensureBrowserMcp();
if (!ready) {
throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
}
return ready;
}