Skip to main content
Glama
cursorCli.ts14.2 kB
import path from 'path'; import os from 'os'; import { promisify } from 'util'; import { ChildProcess, execFile, spawn, type ExecFileException } from 'child_process'; import fs from 'fs'; import { setTimeout as delay } from 'timers/promises'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { CursorCliAgentRequirement } from '../types'; import { AiAgentController, AgentPreparationResult } from './types'; import { CursorConfigSnapshot, ensureCursorConfig, expandHome, restoreCursorConfig, waitForCursorConnection, } from './cursorShared'; const execFileAsync = promisify(execFile); const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.cursor', 'mcp.json'); const DEFAULT_SERVER_KEY = 'mcpx'; const DEFAULT_URL = 'http://127.0.0.1:9000/mcp'; const DEFAULT_TIMEOUT_SEC = 120; const DEFAULT_COMMAND = 'cursor-agent'; const DEFAULT_LAUNCH_ARGS = ['agent']; const DEFAULT_INSTALL_SCRIPT_URL = 'https://cursor.com/install'; interface CursorCliState { configPath: string; projectConfigPath: string; serverKey: string; url: string; startupTimeoutSec: number; command: string; launchArgs: string[]; installIfMissing: boolean; installScriptUrl: string; autoLogin: boolean; loginArgs?: string[]; resolvedCommand?: string; useStub: boolean; allowStubFallback: boolean; } export class CursorCliController implements AiAgentController { public readonly requirement: CursorCliAgentRequirement; private readonly state: CursorCliState; private configSnapshot?: CursorConfigSnapshot; private child?: ChildProcess; private agentReady = false; private useStub: boolean; private stubClient?: Client; private stubTransport?: SSEClientTransport; constructor(requirement: CursorCliAgentRequirement, state: CursorCliState) { this.requirement = requirement; this.state = state; this.useStub = state.useStub; } async prepare(): Promise<AgentPreparationResult> { if (!isSupportedPlatform()) { if (this.requirement.skipIfMissing ?? true) { console.warn('⚠️ Cursor CLI tests require macOS or Linux. Skipping scenario.'); return 'skip'; } throw new Error('Cursor CLI tests require macOS or Linux.'); } if (!this.useStub) { const resolved = await this.ensureCommand(); if (!resolved) { if (this.state.allowStubFallback) { console.warn('⚠️ Cursor Agent CLI not available. Falling back to stub implementation.'); this.useStub = true; } else if (this.requirement.skipIfMissing ?? true) { console.warn('⚠️ Cursor Agent CLI not available. Skipping scenario.'); return 'skip'; } else { throw new Error('Cursor Agent CLI executable not found and auto-install disabled.'); } } else { this.state.resolvedCommand = resolved; } } if (this.useStub) { console.log('→ Using Cursor Agent CLI stub implementation'); } this.configSnapshot = await ensureCursorConfig({ configPath: this.state.configPath, projectConfigPath: this.state.projectConfigPath, serverKey: this.state.serverKey, url: this.state.url, }); return 'ready'; } async start(): Promise<void> { this.agentReady = false; if (this.useStub) { await this.startStub(); return; } const command = this.state.resolvedCommand ?? this.state.command; console.log(`→ Launching Cursor Agent CLI: ${command} ${this.state.launchArgs.join(' ')}`); if (this.state.autoLogin) { await this.tryLogin(command); if (this.useStub) { await this.startStub(); return; } } const child = spawn(command, this.state.launchArgs, { env: this.buildEnv(), stdio: ['ignore', 'pipe', 'pipe'], }); this.child = child; child.stdout?.on('data', (chunk) => { process.stdout.write(`[cursor-agent] ${chunk}`); }); child.stderr?.on('data', (chunk) => { process.stderr.write(`[cursor-agent] ${chunk}`); }); let exitHandler: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; let errorHandler: ((err: Error) => void) | undefined; const exitPromise = new Promise<never>((_, reject) => { const onExit = (code: number | null, signal: NodeJS.Signals | null) => { if (!this.agentReady) { reject( new Error( `Cursor Agent CLI exited before connecting (code: ${code ?? 'null'}, signal: ${ signal ?? 'null' })` ) ); } }; const onError = (err: Error) => { if (!this.agentReady) { reject(err); } }; exitHandler = onExit; errorHandler = onError; child.once('exit', onExit); child.once('error', onError); }); try { await Promise.race([ waitForCursorConnection({ startupTimeoutSec: this.state.startupTimeoutSec }), exitPromise, ]); this.agentReady = true; } finally { if (exitHandler) { child.off('exit', exitHandler); } if (errorHandler) { child.off('error', errorHandler); } } } async cleanup(): Promise<void> { if (this.useStub) { await this.stopStub(); } else if (this.child) { try { console.log('→ Stopping Cursor Agent CLI'); const child = this.child; this.child = undefined; child.kill('SIGINT'); const exited = new Promise<void>((resolve) => { child.once('exit', () => resolve()); }); await Promise.race([exited, delay(5_000)]); if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); } } catch (err) { console.warn('⚠️ Failed to stop Cursor Agent CLI:', (err as Error).message); } } if (this.configSnapshot) { await restoreCursorConfig(this.configSnapshot); } } private async startStub(): Promise<void> { console.log('→ Launching Cursor Agent CLI stub'); const headers: Record<string, string> = { 'x-lunar-consumer-tag': 'Cursor', 'user-agent': 'cursor-agent-stub/1.0.0', }; const baseUrl = new URL(this.state.url); const sseUrl = new URL(baseUrl.toString()); sseUrl.pathname = '/sse'; const transport = new SSEClientTransport(sseUrl, { eventSourceInit: { fetch: async (url, init) => { const combined = new Headers(init?.headers); for (const [key, value] of Object.entries(headers)) { combined.set(key, value); } return fetch(url, { ...init, headers: combined }); }, }, requestInit: { headers }, }); transport.onerror = (error) => { console.warn('⚠️ Cursor Agent CLI stub transport error:', error.message ?? error); }; this.stubTransport = transport; const client = new Client({ name: 'Cursor CLI (Stub)', version: '1.0.0' }); this.stubClient = client; try { await client.connect(transport); await waitForCursorConnection({ startupTimeoutSec: this.state.startupTimeoutSec }); this.agentReady = true; } 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 Cursor Agent CLI stub'); try { await client?.close(); } catch (err) { console.warn('⚠️ Failed to close Cursor Agent CLI stub client:', (err as Error).message); } try { await transport?.close(); } catch (err) { console.warn('⚠️ Failed to close Cursor Agent CLI stub transport:', (err as Error).message); } } private async ensureCommand(): Promise<string | undefined> { const candidates = this.getCommandCandidates(); for (const candidate of candidates) { const resolved = await this.tryResolveCommand(candidate); if (resolved) return resolved; } if (!this.state.installIfMissing) { return undefined; } console.log('→ Cursor Agent CLI not found. Attempting installation...'); await this.installCursorCli(); for (const candidate of candidates) { const resolved = await this.tryResolveCommand(candidate); if (resolved) return resolved; } return undefined; } private getCommandCandidates(): string[] { const { command } = this.state; const expanded = expandHome(command); const candidates = [command]; if (expanded !== command) { candidates.push(expanded); } const localBin = path.join(os.homedir(), '.local', 'bin', command); if (!candidates.includes(localBin)) { candidates.push(localBin); } return candidates; } private async tryResolveCommand(candidate: string): Promise<string | undefined> { try { await execFileAsync(candidate, ['--version'], { env: this.buildEnv(), timeout: 10_000, }); return candidate; } catch (err: any) { if (err?.code === 'ENOENT') { return undefined; } // Some paths might exist but not be executable; fall back to fs check for better logging. if (await fileExists(candidate)) { console.warn(`⚠️ Found Cursor Agent CLI at ${candidate} but could not execute it:`, err); } return undefined; } } private async installCursorCli(): Promise<void> { const scriptUrl = this.state.installScriptUrl; const quotedUrl = `'${scriptUrl.replace(/'/g, "'\\''")}'`; const cmd = `curl ${quotedUrl} -fsS | bash`; try { await execFileAsync('bash', ['-lc', cmd], { env: this.buildEnv(), maxBuffer: 20 * 1024 * 1024, timeout: 5 * 60_000, }); console.log('✅ Cursor Agent CLI installation finished'); } catch (err) { console.warn('⚠️ Cursor Agent CLI installation failed:', (err as Error).message); } } private buildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; const localBin = path.join(os.homedir(), '.local', 'bin'); const currentPath = env.PATH ?? ''; const segments = currentPath.split(path.delimiter).filter(Boolean); if (!segments.includes(localBin)) { segments.unshift(localBin); env.PATH = segments.join(path.delimiter); } return env; } private async tryLogin(command: string): Promise<boolean> { const loginArgs = ['mcp', 'login', this.state.serverKey, ...(this.state.loginArgs ?? [])]; try { console.log(`→ Authenticating Cursor Agent CLI against MCP server ${this.state.serverKey}`); await execFileAsync(command, loginArgs, { env: this.buildEnv(), maxBuffer: 5 * 1024 * 1024, timeout: 60_000, }); return true; } catch (err) { const formatted = formatExecError(err); console.warn('⚠️ Cursor Agent CLI login failed (continuing):', formatted); if (this.state.allowStubFallback) { console.warn('⚠️ Falling back to Cursor Agent CLI stub after login failure.'); this.useStub = true; } return false; } } } export function createCursorCliController( requirement: CursorCliAgentRequirement ): CursorCliController { const configPath = expandHome(requirement.configPath ?? DEFAULT_CONFIG_PATH); const projectConfigPath = path.resolve('.cursor', 'mcp.json'); const serverKey = requirement.serverKey ?? DEFAULT_SERVER_KEY; const url = requirement.url ?? DEFAULT_URL; const startupTimeoutSec = requirement.startupTimeoutSec ?? DEFAULT_TIMEOUT_SEC; const command = requirement.command ?? DEFAULT_COMMAND; const launchArgs = requirement.launchArgs ?? DEFAULT_LAUNCH_ARGS; const installIfMissing = requirement.installIfMissing ?? true; const installScriptUrl = requirement.installScriptUrl ?? DEFAULT_INSTALL_SCRIPT_URL; const autoLogin = requirement.autoLogin ?? true; const envUseStub = parseEnvBoolean(process.env.MCPX_CURSOR_CLI_USE_STUB); const envAllowStubFallback = parseEnvBoolean(process.env.MCPX_CURSOR_CLI_ALLOW_STUB); const envCi = parseEnvBoolean(process.env.CI); const useStub = requirement.useStub ?? envUseStub ?? false; const allowStubFallback = requirement.allowStubFallback ?? envAllowStubFallback ?? (useStub || (envCi ?? false)); const state: CursorCliState = { configPath, serverKey, url, startupTimeoutSec, command, launchArgs, installIfMissing, installScriptUrl, autoLogin, loginArgs: requirement.loginArgs, useStub, allowStubFallback, projectConfigPath, }; return new CursorCliController(requirement, state); } function isSupportedPlatform(): boolean { return process.platform === 'darwin' || process.platform === 'linux'; } async function fileExists(file: string): Promise<boolean> { try { await fs.promises.access(file, fs.constants.F_OK); return true; } catch { return false; } } function formatExecError(err: unknown): string { if (!err || typeof err !== 'object') { return String(err ?? 'unknown error'); } const execErr = err as ExecFileException & { stdout?: string; stderr?: string }; const segments = [execErr.stdout, execErr.stderr] .map((segment) => (typeof segment === 'string' ? segment.trim() : '')) .filter((segment) => segment.length > 0); if (segments.length > 0) { return segments.join('\n'); } return execErr.message ?? 'unknown error'; } function parseEnvBoolean(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; const normalized = value.trim().toLowerCase(); if (normalized.length === 0) return undefined; if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; if (['0', 'false', 'no', 'off'].includes(normalized)) return false; return undefined; }

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