Files
ccs/src/utils/websearch/mcp-installer.ts
T

330 lines
8.8 KiB
TypeScript

/**
* WebSearch MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { info, warn } from '../ui';
import { InstanceManager } from '../../management/instance-manager';
import { installWebSearchHook } from './hook-installer';
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getWebSearchMcpServerName(): string {
return WEBSEARCH_MCP_SERVER_NAME;
}
export function getWebSearchMcpServerPath(): string {
return path.join(getCcsMcpDir(), WEBSEARCH_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 (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`)
);
}
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', WEBSEARCH_MCP_SERVER),
path.join(__dirname, '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER),
path.join(__dirname, '..', 'lib', 'mcp', WEBSEARCH_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);
try {
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, configPath);
return true;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
const config = readClaudeUserConfig(configPath);
if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
}
return false;
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(WEBSEARCH_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[WEBSEARCH_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Removed WebSearch MCP config from ${configPath}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}`
)
);
}
return false;
}
}
export function installWebSearchMcpServer(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return false;
}
if (!installWebSearchHook()) {
if (process.env.CCS_DEBUG) {
console.error(
warn('WebSearch MCP server install skipped because hook runtime is unavailable')
);
}
return false;
}
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`));
}
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
const serverPath = getWebSearchMcpServerPath();
if (hasMatchingContents(sourcePath, serverPath)) {
return true;
}
const tempPath = getTempPath(serverPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, 0o755);
fs.renameSync(tempPath, serverPath);
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`));
}
return false;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
export function ensureWebSearchMcpConfig(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return false;
}
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning'));
}
return false;
}
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const desiredServerConfig = {
command: 'node',
args: [getWebSearchMcpServerPath()],
};
const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[WEBSEARCH_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
}
return false;
}
}
export function ensureWebSearchMcp(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return false;
}
return installWebSearchMcpServer() && ensureWebSearchMcpConfig();
}
export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
}
export function uninstallWebSearchMcpServer(): boolean {
const serverPath = getWebSearchMcpServerPath();
if (!fs.existsSync(serverPath)) {
return false;
}
try {
fs.unlinkSync(serverPath);
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`));
}
return false;
}
}
export function removeWebSearchMcpConfig(): 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 uninstallWebSearchMcp(): boolean {
const removedConfig = removeWebSearchMcpConfig();
const removedServer = uninstallWebSearchMcpServer();
return removedConfig || removedServer;
}
export function ensureWebSearchMcpOrThrow(): void {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return;
}
if (!ensureWebSearchMcp()) {
throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.');
}
}