Skip to main content
Glama
setup-clients.ts17.7 kB
#!/usr/bin/env npx tsx /** * Interactive setup script for configuring MCP clients. * * Reads environment variables from .env file and writes appropriate * configuration to selected MCP client config files. * * Usage: pnpm setup-clients */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as readline from "node:readline"; import * as dotenv from "dotenv"; import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; // Minimal logger for the setup script that avoids importing the main server // config (which performs strict environment validation and exits early when // required keys are missing). This keeps the bootstrap flow working even when // the .env file is incomplete. const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; type SetupLogLevel = (typeof LOG_LEVELS)[number]; function resolveLogLevel(): SetupLogLevel { const raw = process.env.SETUP_LOG_LEVEL?.toLowerCase(); if (raw && LOG_LEVELS.includes(raw as SetupLogLevel)) { return raw as SetupLogLevel; } return "info"; } const configuredLogLevel = resolveLogLevel(); function log(level: SetupLogLevel, message: string, ...extra: unknown[]): void { if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(configuredLogLevel)) { return; } const prefix = `[setup-clients:${level}]`; const writer = level === "error" ? console.error : level === "warn" ? console.warn : console.log; writer(prefix, message, ...extra); } // ============================================================================= // Types // ============================================================================= interface ClientConfig { name: string; configPath: string; format: "json" | "toml"; description: string; } interface McpServerConfig { command: string; args: string[]; env: Record<string, string>; } // ============================================================================= // Client Definitions // ============================================================================= const HOME = os.homedir(); const CLIENTS: ClientConfig[] = [ { name: "Claude Code CLI", configPath: path.join(HOME, ".claude", "settings.json"), format: "json", description: "Claude Code command-line tool", }, { name: "Claude Desktop (macOS)", configPath: path.join( HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json", ), format: "json", description: "Claude Desktop app on macOS", }, { name: "Claude Desktop (Linux)", configPath: path.join(HOME, ".config", "Claude", "claude_desktop_config.json"), format: "json", description: "Claude Desktop app on Linux", }, { name: "Claude Desktop (Windows)", configPath: path.join( process.env.APPDATA ?? path.join(HOME, "AppData", "Roaming"), "Claude", "claude_desktop_config.json", ), format: "json", description: "Claude Desktop app on Windows", }, { name: "Cursor", configPath: path.join(HOME, ".cursor", "mcp.json"), format: "json", description: "Cursor AI editor", }, { name: "VS Code (GitHub Copilot)", configPath: path.join(HOME, ".vscode", "mcp.json"), format: "json", description: "VS Code with GitHub Copilot MCP support", }, { name: "Windsurf", configPath: path.join(HOME, ".codeium", "windsurf", "mcp_config.json"), format: "json", description: "Windsurf (Codeium) editor", }, { name: "Gemini CLI", configPath: path.join(HOME, ".gemini", "settings.json"), format: "json", description: "Google Gemini CLI", }, { name: "OpenAI Codex CLI", configPath: path.join(HOME, ".codex", "config.toml"), format: "toml", description: "OpenAI Codex CLI (uses TOML format)", }, ]; function filterClientsForPlatform( platform: NodeJS.Platform, clients: ClientConfig[] = CLIENTS, ): ClientConfig[] { return clients.filter((client) => { if (platform === "darwin") { return !client.name.includes("(Linux)"); } if (platform === "linux") { return !client.name.includes("(macOS)"); } if (platform === "win32") { return !client.name.includes("(macOS)") && !client.name.includes("(Linux)"); } return !client.name.includes("(macOS)") && !client.name.includes("(Linux)"); }); } // ============================================================================= // Environment Variable Parsing // ============================================================================= function parseEnvFile(envPath: string): Record<string, string> { if (!fs.existsSync(envPath)) { return {}; } const content = fs.readFileSync(envPath, "utf-8"); return dotenv.parse(content); } // ============================================================================= // Config Generation // ============================================================================= function buildMcpConfig( invocation: Pick<McpServerConfig, "command" | "args">, envVars: Record<string, string>, ): McpServerConfig { // Filter to only include relevant env vars (exclude empty values) const filteredEnv: Record<string, string> = {}; // Required vars const requiredKeys = [ "BROWSER_PROVIDER", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSER_USE_API_KEY", "BROWSER_USE_PROFILE_ID", ]; // Optional vars const optionalKeys = [ "BROWSERBASE_CONTEXT_ID", "BROWSERBASE_SESSION_ID", "STAGEHAND_MODEL", "STAGEHAND_CACHE_DIR", "STAGEHAND_LLM_PROVIDER", "REWRITE_LLM_PROVIDER", "CLAUDE_MODEL", "OPENAI_MODEL", "GOOGLE_MODEL", "ANTHROPIC_MODEL", "CLAUDE_API_KEY", "OPENAI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "LOG_LEVEL", "LLM_REQUEST_TIMEOUT_MS", "CONNECT_TIMEOUT_MS", ]; for (const key of [...requiredKeys, ...optionalKeys]) { if (envVars[key]) { filteredEnv[key] = envVars[key]; } } return { command: invocation.command, args: invocation.args, env: filteredEnv, }; } function escapeTomlString(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); } function isCommandAvailable(command: string): boolean { const checker = process.platform === "win32" ? "where" : "which"; const result = spawnSync(checker, [command], { stdio: "ignore" }); return result.status === 0; } function resolveServerInvocation(projectRoot: string): { command: string; args: string[]; note: string; } { const binaryName = "grammarly-mcp-server"; const distPath = path.join(projectRoot, "dist", "server.js"); if (isCommandAvailable(binaryName)) { return { command: binaryName, args: [], note: "Using globally available grammarly-mcp-server for portability across repo moves.", }; } if (isCommandAvailable("npx")) { return { command: "npx", args: [binaryName], note: "Using npx to resolve grammarly-mcp-server (works if the package is installed locally or globally).", }; } return { command: "node", args: [distPath], note: "Fallback to local dist path; rerun setup after moving the repository to refresh configs.", }; } function generateJsonConfig( existingConfig: Record<string, unknown> | null, mcpConfig: McpServerConfig, ): string { const config = existingConfig ?? {}; // Ensure mcpServers object exists if (config.mcpServers == null || typeof config.mcpServers !== "object") { config.mcpServers = {}; } // Add/update grammarly server (config.mcpServers as Record<string, unknown>).grammarly = mcpConfig; return JSON.stringify(config, null, 2); } function generateTomlConfig( existingContent: string | null, mcpConfig: McpServerConfig, serverNote?: string, ): string { // Simple TOML generation for OpenAI Codex format const lines: string[] = []; // If there's existing content, preserve it but remove any existing grammarly section if (existingContent) { const existingLines = existingContent.split("\n"); let inGrammarlySection = false; for (const line of existingLines) { if (line.startsWith("[mcp_servers.grammarly]")) { inGrammarlySection = true; continue; } if (inGrammarlySection && /^\s*\[(?!mcp_servers\.grammarly\.)/.test(line)) { inGrammarlySection = false; } if (!inGrammarlySection) { lines.push(line); } } // Remove trailing empty lines while (lines.length > 0 && lines[lines.length - 1].trim() === "") { lines.pop(); } if (lines.length > 0) { lines.push(""); } } // Add grammarly section lines.push("[mcp_servers.grammarly]"); lines.push(`command = "${escapeTomlString(mcpConfig.command)}"`); lines.push(`args = [${mcpConfig.args .map((a) => `"${escapeTomlString(a)}"`) .join(", ")}]`); lines.push(""); if (serverNote) { lines.push(`# ${serverNote}`); } lines.push("[mcp_servers.grammarly.env]"); for (const [key, value] of Object.entries(mcpConfig.env)) { lines.push(`${key} = "${escapeTomlString(value)}"`); } lines.push(""); return lines.join("\n"); } // ============================================================================= // File Operations // ============================================================================= function ensureDir(filePath: string): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } function backupFile(filePath: string): string | null { if (!fs.existsSync(filePath)) { return null; } const backupPath = `${filePath}.backup.${Date.now()}`; fs.copyFileSync(filePath, backupPath); return backupPath; } function readExistingConfig(filePath: string): Record<string, unknown> | null { if (!fs.existsSync(filePath)) { return null; } try { const content = fs.readFileSync(filePath, "utf-8"); return JSON.parse(content) as Record<string, unknown>; } catch { return null; } } function readExistingToml(filePath: string): string | null { if (!fs.existsSync(filePath)) { return null; } try { return fs.readFileSync(filePath, "utf-8"); } catch { return null; } } // ============================================================================= // Interactive CLI // ============================================================================= function createReadlineInterface(): readline.Interface { return readline.createInterface({ input: process.stdin, output: process.stdout, }); } async function question(rl: readline.Interface, prompt: string): Promise<string> { return new Promise((resolve) => { rl.question(prompt, (answer) => { resolve(answer.trim()); }); }); } async function selectClients(rl: readline.Interface): Promise<ClientConfig[]> { console.log("\n=== Grammarly MCP Server Setup ===\n"); console.log("Available MCP clients:\n"); // Filter to clients that make sense for the current platform const platform = process.platform; const availableClients = filterClientsForPlatform(platform); for (let i = 0; i < availableClients.length; i++) { const client = availableClients[i]; const exists = fs.existsSync(client.configPath) ? "(config exists)" : ""; console.log(` ${i + 1}. ${client.name} ${exists}`); console.log(` ${client.description}`); console.log(` Path: ${client.configPath}\n`); } console.log(" a. Configure all clients"); console.log(" q. Quit\n"); // Keep prompting until the user provides at least one valid selection while (true) { const answer = await question( rl, "Enter client numbers (comma-separated) or 'a' for all: ", ); if (answer.toLowerCase() === "q") { return []; } if (answer.toLowerCase() === "a") { return availableClients; } const indices: number[] = []; for (const rawToken of answer.split(",")) { const token = rawToken.trim(); if (token === "") { log("warn", "Skipping empty selection."); continue; } const parsed = parseInt(token, 10); if (Number.isNaN(parsed)) { log("warn", `Skipping invalid number: "${token}"`); continue; } const zeroBased = parsed - 1; if (zeroBased < 0 || zeroBased >= availableClients.length) { log( "warn", `Selection out of range (must be 1-${availableClients.length}): "${token}"`, ); continue; } indices.push(zeroBased); } if (indices.length === 0) { log("warn", "No valid selections detected. Please try again.\n"); continue; } return indices.map((i) => availableClients[i]); } } // ============================================================================= // Main // ============================================================================= async function main(): Promise<void> { const moduleDir = typeof import.meta.dirname === "string" ? import.meta.dirname : (() => { try { return path.dirname(fileURLToPath(import.meta.url)); } catch { return process.cwd(); } })(); const projectRoot = path.resolve(moduleDir, ".."); const envPath = path.join(projectRoot, ".env"); const serverInvocation = resolveServerInvocation(projectRoot); const distPath = path.join(projectRoot, "dist", "server.js"); // Check if build exists when falling back to the local dist path if (serverInvocation.command === "node" && !fs.existsSync(distPath)) { console.error("\nError: dist/server.js not found."); console.error("Please run 'pnpm build' first.\n"); process.exit(1); } // Parse .env file const envVars = parseEnvFile(envPath); if (Object.keys(envVars).length === 0) { console.error("\nWarning: No .env file found or file is empty."); console.error(`Expected at: ${envPath}`); console.error("\nYou can still configure clients, but no env vars will be set."); console.error("You may need to manually add environment variables to configs.\n"); } else { console.log(`\nFound ${Object.keys(envVars).length} environment variables in .env`); } // Validate required vars based on provider const provider = envVars.BROWSER_PROVIDER ?? "stagehand"; if (provider === "stagehand") { if (!envVars.BROWSERBASE_API_KEY || !envVars.BROWSERBASE_PROJECT_ID) { console.error("\nWarning: BROWSERBASE_API_KEY and/or BROWSERBASE_PROJECT_ID not set."); console.error("These are required when using BROWSER_PROVIDER=stagehand (default).\n"); } } else if (provider === "browser-use") { if (!envVars.BROWSER_USE_API_KEY || !envVars.BROWSER_USE_PROFILE_ID) { console.error("\nWarning: BROWSER_USE_API_KEY and/or BROWSER_USE_PROFILE_ID not set."); console.error("These are required when using BROWSER_PROVIDER=browser-use.\n"); } } const rl = createReadlineInterface(); try { const selectedClients = await selectClients(rl); if (selectedClients.length === 0) { log("info", "No clients selected. Exiting."); return; } log("info", `Configuring ${selectedClients.length} client(s)...`); log( "info", `Server invocation: ${serverInvocation.command} ${serverInvocation.args.join(" ")}`, ); log("info", `Note: ${serverInvocation.note}`); const mcpConfig = buildMcpConfig(serverInvocation, envVars); for (const client of selectedClients) { log("info", `--- ${client.name} ---`); log("info", `Config path: ${client.configPath}`); try { // Backup existing config const backupPath = backupFile(client.configPath); if (backupPath) { log("info", `Backed up to: ${backupPath}`); } // Ensure directory exists ensureDir(client.configPath); // Generate and write config let content: string; if (client.format === "json") { const existing = readExistingConfig(client.configPath); content = generateJsonConfig(existing, mcpConfig); } else { const existing = readExistingToml(client.configPath); content = generateTomlConfig(existing, mcpConfig, serverInvocation.note); } fs.writeFileSync(client.configPath, content, "utf-8"); log("info", "Configuration written successfully!"); } catch (error) { log( "error", `Error configuring ${client.name}:`, error instanceof Error ? error : String(error), ); } } log("info", "=== Setup Complete ==="); log("info", "The grammarly MCP server has been configured for the selected clients."); log("info", "Restart your MCP clients to load the new configuration."); } finally { rl.close(); } } const isMainModule = typeof process.argv[1] === "string" && pathToFileURL(process.argv[1]).href === import.meta.url; if (isMainModule) { main().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); } export { CLIENTS, parseEnvFile, buildMcpConfig, escapeTomlString, generateJsonConfig, generateTomlConfig, filterClientsForPlatform, ensureDir, backupFile, readExistingConfig, readExistingToml, createReadlineInterface, question, selectClients, resolveServerInvocation, isCommandAvailable, main, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/BjornMelin/grammarly-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server