Skip to main content
Glama
claudeDesktop.ts11.6 kB
import fs from 'fs'; import path from 'path'; import os from 'os'; import { promisify } from 'util'; import { execFile } from 'child_process'; import axios from 'axios'; import { setTimeout as delay } from 'timers/promises'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { ClaudeDesktopAgentRequirement } from '../types'; import { AiAgentController, AgentPreparationResult } from './types'; const execFileAsync = promisify(execFile); const DEFAULT_CONFIG_PATH = path.join( os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json' ); const DEFAULT_SERVER_KEY = 'mcpx'; const DEFAULT_CONSUMER_TAG = 'Claude'; const DEFAULT_COMMAND = 'npx'; const DEFAULT_ARGS = [ 'mcp-remote@0.1.21', 'http://localhost:9000/mcp', '--header', `x-lunar-consumer-tag: ${DEFAULT_CONSUMER_TAG}`, ]; const DEFAULT_TIMEOUT_SEC = 120; const DEFAULT_MCP_URL = 'http://127.0.0.1:9000/mcp'; function expandHome(p: string): string { if (!p.startsWith('~')) return path.resolve(p); return path.resolve(path.join(os.homedir(), p.slice(1))); } function ensureJsonObject(value: unknown): Record<string, any> { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; return value as Record<string, any>; } async function fileExists(file: string): Promise<boolean> { try { await fs.promises.access(file, fs.constants.F_OK); return true; } catch { return false; } } async function directoryExists(dir: string): Promise<boolean> { try { const stat = await fs.promises.stat(dir); return stat.isDirectory(); } catch { return false; } } async function quitClaude(): Promise<void> { try { await execFileAsync('osascript', ['-e', 'tell application "Claude" to quit']); } catch (err: any) { // Claude might not be running; ignore errors where osascript reports it. if (err?.code !== 1) { throw err; } } } async function isClaudeRunning(): Promise<boolean> { try { await execFileAsync('pgrep', ['-f', 'Claude']); return true; } catch (err: any) { if (err?.code === 1) return false; // pgrep returns 1 when not found throw err; } } async function waitForClaudeExit(timeoutMs: number): Promise<void> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!(await isClaudeRunning())) return; await delay(500); } throw new Error('Claude Desktop did not exit within the expected timeout'); } interface ClaudeDesktopState { configPath: string; serverKey: string; consumerTag: string; command: string; args: string[]; startupTimeoutSec: number; } export class ClaudeDesktopController implements AiAgentController { public readonly requirement: ClaudeDesktopAgentRequirement; private readonly state: ClaudeDesktopState; private originalConfig?: string; private configExisted = false; private startedAgent = false; private useStub = false; private stubClient?: Client; private stubTransport?: SSEClientTransport; constructor(requirement: ClaudeDesktopAgentRequirement, state: ClaudeDesktopState) { this.requirement = requirement; this.state = state; } async prepare(): Promise<AgentPreparationResult> { if (process.platform !== 'darwin') { if (this.requirement.skipIfMissing ?? true) { console.warn('⚠️ Claude Desktop tests require macOS. Skipping scenario.'); return 'skip'; } throw new Error('Claude Desktop tests require macOS.'); } if (!(await this.detectInstallation())) { if (this.requirement.skipIfMissing ?? true) { console.warn('⚠️ Claude Desktop not detected. Skipping scenario.'); return 'skip'; } throw new Error('Claude Desktop not detected at expected locations.'); } await this.ensureConfigEntry(); // Ensure the desktop app is not currently running before we start MCPX. if (await isClaudeRunning()) { console.log('→ Claude Desktop is running, asking it to quit before the test'); await quitClaude(); await waitForClaudeExit(10_000); } return 'ready'; } async start(): Promise<void> { let launched = false; try { console.log('→ Launching Claude Desktop'); await execFileAsync('open', ['-gj', '-a', 'Claude']); this.startedAgent = true; launched = true; await this.waitForAgentConnection(); return; } catch (err) { const message = (err as Error)?.message ?? String(err); if (!launched) { console.warn(`⚠️ Failed to launch Claude Desktop (${message}). Falling back to stub.`); } else { console.warn( `⚠️ Claude Desktop did not connect within ${this.state.startupTimeoutSec}s (${message}). Falling back to stub.` ); try { await quitClaude(); await waitForClaudeExit(10_000); } catch (quitErr) { console.warn( '⚠️ Failed to stop Claude Desktop before stub fallback:', (quitErr as Error).message ); } this.startedAgent = false; await delay(1_000); } } await this.startStub(); } async cleanup(): Promise<void> { if (this.useStub) { await this.stopStub(); } if (this.startedAgent) { try { console.log('→ Stopping Claude Desktop'); await quitClaude(); await waitForClaudeExit(10_000); } catch (err) { console.warn('⚠️ Failed to stop Claude Desktop:', (err as Error).message); } } await this.restoreConfig(); } private async detectInstallation(): Promise<boolean> { const candidates = [ '/Applications/Claude.app', path.join(os.homedir(), 'Applications', 'Claude.app'), path.dirname(this.state.configPath), ]; for (const candidate of candidates) { if (await fileExists(candidate)) return true; } return false; } private async ensureConfigEntry(): Promise<void> { const { configPath, serverKey, command, args } = this.state; const dir = path.dirname(configPath); if (!(await directoryExists(dir))) { await fs.promises.mkdir(dir, { recursive: true }); } let existing: Record<string, any> = {}; if (await fileExists(configPath)) { this.configExisted = true; this.originalConfig = await fs.promises.readFile(configPath, 'utf8'); try { existing = ensureJsonObject(JSON.parse(this.originalConfig)); } catch (err) { throw new Error( `Failed to parse existing Claude config at ${configPath}: ${(err as Error).message}` ); } } const servers = ensureJsonObject(existing.mcpServers ?? {}); servers[serverKey] = { command, args, }; const updated = { ...existing, mcpServers: servers, }; const newContent = JSON.stringify(updated, null, 2) + '\n'; if (newContent !== this.originalConfig) { await fs.promises.writeFile(configPath, newContent, 'utf8'); } } private async restoreConfig(): Promise<void> { const { configPath } = this.state; try { if (this.configExisted) { if (this.originalConfig !== undefined) { await fs.promises.writeFile(configPath, this.originalConfig, 'utf8'); } } else { if (await fileExists(configPath)) { await fs.promises.unlink(configPath); } } } catch (err) { console.warn('⚠️ Failed to restore Claude config file:', (err as Error).message); } } private async waitForAgentConnection(): Promise<void> { const { consumerTag, startupTimeoutSec } = this.state; const timeoutMs = startupTimeoutSec * 1000; const deadline = Date.now() + timeoutMs; console.log('→ Waiting for Claude to connect to MCPX'); while (Date.now() < deadline) { try { const response = await axios.get('http://localhost:9000/system-state', { timeout: 5_000, }); const clients = response.data?.connectedClients; if (Array.isArray(clients)) { const found = clients.some((client: any) => client?.consumerTag === consumerTag); if (found) { console.log('✅ Claude agent connected'); return; } } } catch (err: any) { // Server might not be ready yet — ignore connection errors while polling if (err?.code !== 'ECONNREFUSED') { console.debug('Polling system-state failed:', err?.message ?? err); } } await delay(2_000); } throw new Error( `Timed out waiting ${startupTimeoutSec}s for Claude agent with tag "${consumerTag}" to connect` ); } private async startStub(): Promise<void> { console.log('→ Launching Claude stub connection'); this.useStub = true; const headers: Record<string, string> = { 'x-lunar-consumer-tag': this.state.consumerTag, 'user-agent': 'claude-stub/1.0.0', }; const transport = new SSEClientTransport(new URL(DEFAULT_MCP_URL), { requestInit: { headers }, }); transport.onerror = (error) => { console.warn('⚠️ Claude stub transport error:', error.message ?? error); }; this.stubTransport = transport; const client = new Client({ name: 'Claude Stub', version: '1.0.0' }); this.stubClient = client; try { await client.connect(transport); await this.waitForAgentConnection(); } catch (err) { await this.stopStub(); throw err; } } private async stopStub(): Promise<void> { const client = this.stubClient; const transport = this.stubTransport; this.stubClient = undefined; this.stubTransport = undefined; if (!client && !transport) { return; } console.log('→ Stopping Claude stub connection'); try { await client?.close(); } catch (err) { console.warn('⚠️ Failed to close Claude stub client:', (err as Error).message); } try { await transport?.close(); } catch (err) { console.warn('⚠️ Failed to close Claude stub transport:', (err as Error).message); } } } export function createClaudeDesktopController( requirement: ClaudeDesktopAgentRequirement ): ClaudeDesktopController { const configPath = expandHome(requirement.configPath ?? DEFAULT_CONFIG_PATH); const serverKey = requirement.serverKey ?? DEFAULT_SERVER_KEY; const consumerTag = requirement.headerTag ?? DEFAULT_CONSUMER_TAG; const command = requirement.command ?? DEFAULT_COMMAND; const args = requirement.args ?? [ DEFAULT_ARGS[0], DEFAULT_ARGS[1], DEFAULT_ARGS[2], `x-lunar-consumer-tag: ${consumerTag}`, ]; // Ensure the args array is copied so we don't mutate shared defaults const startupTimeoutSec = requirement.startupTimeoutSec ?? DEFAULT_TIMEOUT_SEC; const state: ClaudeDesktopState = { configPath, serverKey, consumerTag, command, args: [...args], startupTimeoutSec, }; if (!state.args.includes(`x-lunar-consumer-tag: ${consumerTag}`)) { // Ensure the consumer tag header is present unless caller explicitly provided it. const hasHeader = state.args.some( (value) => typeof value === 'string' && value.includes('x-lunar-consumer-tag') ); if (!hasHeader) { state.args = [...state.args, '--header', `x-lunar-consumer-tag: ${consumerTag}`]; } } return new ClaudeDesktopController(requirement, state); }

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/TheLunarCompany/lunar'

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