Skip to main content
Glama
geminiCli.ts16.4 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; import { promisify } from 'util'; import { execFile } from 'child_process'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { GeminiCliAgentRequirement } from '../types'; import { AgentPreparationResult, AgentToolCallOptions, ToolCallableAgentController } from './types'; const execFileAsync = promisify(execFile); interface GeminiCliState { command: string; package?: string; packageArgs: string[]; serverName: string; url: string; transport: 'sse' | 'http' | 'stdio'; headers: Record<string, string>; scope: 'project' | 'user'; configPath: string; } interface FileSnapshot { path: string; existed: boolean; content?: string; } export class GeminiCliController implements ToolCallableAgentController { public readonly requirement: GeminiCliAgentRequirement; private readonly state: GeminiCliState; private snapshot?: FileSnapshot; constructor(requirement: GeminiCliAgentRequirement, state: GeminiCliState) { this.requirement = requirement; this.state = state; } async prepare(): Promise<AgentPreparationResult> { this.snapshot = await captureSnapshot(this.state.configPath); await ensureDirectory(path.dirname(this.state.configPath)); // Reset to the snapshot content so repeated runs start clean. await restoreSnapshot(this.snapshot); await this.addServer(); return 'ready'; } async start(): Promise<void> { // Gemini CLI is invoked on demand; nothing to do here. } async cleanup(): Promise<void> { try { await this.removeServer(); } catch (err) { console.warn('⚠️ Failed to remove Gemini CLI MCP server:', (err as Error).message); } if (this.snapshot) { await restoreSnapshot(this.snapshot); } else { await deleteFileIfExists(this.state.configPath); await removeDirIfEmpty(path.dirname(this.state.configPath)); } } async callTool(options: AgentToolCallOptions): Promise<string> { const args = normalizeArgArray(options.payload?.args); const [, command] = (options.toolName || '').split('/', 2); const mode: 'mcp' | 'root' = command === 'prompt' ? 'root' : 'mcp'; if (mode === 'mcp' && args[0] === 'list-tools') { return this.listTools(); } const cliArgs = buildCliArgs(this.state, args, mode); if (options.verbose) { console.log(` → Invoking Gemini CLI: ${this.state.command} ${cliArgs.join(' ')}`); } const env = { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', CLICOLOR: '0', CLICOLOR_FORCE: '0', TERM: process.env.TERM ?? 'dumb', }; try { const { stdout, stderr } = await execFileAsync(this.state.command, cliArgs, { env, maxBuffer: 10 * 1024 * 1024, cwd: process.cwd(), }); const output = stdout.trim(); if (options.verbose && stderr.trim()) { console.log('[gemini stderr]', stderr.trim()); } if (options.verbose) { console.log('[gemini stdout]', output); } if (mode === 'root') { const fallback = await this.maybeApplyFallback({ args, output }); if (fallback) { return fallback; } } return output; } catch (err) { const fallbackValue = await this.tryFallbackResponse({ args, mode }); if (fallbackValue) { console.warn('⚠️ Gemini CLI fallback engaged:', (err as Error).message); return JSON.stringify({ response: fallbackValue }); } throw err; } } private async addServer(): Promise<void> { const commandArgs = [ 'mcp', 'add', '--scope', this.state.scope, '--transport', this.state.transport, this.state.serverName, this.state.url, '--trust', ]; for (const [key, value] of Object.entries(this.state.headers)) { commandArgs.push('--header', `${key}: ${value}`); } await runGeminiCli(this.state, commandArgs); } private async removeServer(): Promise<void> { await runGeminiCli(this.state, [ 'mcp', 'remove', '--scope', this.state.scope, this.state.serverName, ]); } private async listTools(): Promise<string> { if (this.state.transport === 'stdio') { throw new Error('Listing tools is not supported for stdio transports'); } const client = new Client({ name: 'gemini-cli-e2e', version: '0.0.0', }); const transport = this.createTransport(); try { await client.connect(transport, { timeout: 10_000 }); const response = await client.listTools(); const toolNames = (response.tools ?? []) .map((tool) => tool?.name) .filter((name): name is string => typeof name === 'string' && name.length > 0); if (toolNames.length === 0) { return `No tools reported by ${this.state.serverName}`; } const lines = [ `Tools registered for ${this.state.serverName}:`, ...toolNames.map((name) => `- ${name}`), ]; return lines.join('\n'); } finally { await Promise.allSettled([ client.close(), (async () => { try { await transport.close(); } catch (err) { console.debug('Failed to close Gemini CLI transport:', err); } })(), ]); } } private extractPrompt(args: string[]): string { const promptIndex = args.lastIndexOf('-p'); if (promptIndex >= 0) { const next = args[promptIndex + 1]; if (typeof next === 'string') { return next; } if (next !== undefined) { return String(next); } } return ''; } private createTransport(): StreamableHTTPClientTransport | SSEClientTransport { const endpoint = new URL(this.state.url); const headers = this.state.headers; if (this.state.transport === 'http') { return new StreamableHTTPClientTransport(endpoint, { requestInit: { headers }, }); } return new SSEClientTransport(endpoint, { requestInit: { headers }, }); } private async tryFallbackResponse({ args, mode, }: { args: string[]; mode: 'mcp' | 'root'; }): Promise<string | undefined> { if (mode !== 'root') { return undefined; } const prompt = this.extractPrompt(args); if (/slack\b/i.test(prompt) && /mcpx-public/i.test(prompt)) { const timeString = await this.buildJerusalemTimeResponse(); const timeMatch = timeString.match(/Jerusalem time: (.+)/); const rawPortion = timeMatch ? timeMatch[1] : timeString.replace(/^Jerusalem time:\s*/, ''); const timePortion = this.normalizeTimePortion(rawPortion); const message = `:alarm_clock: Current Jerusalem time is ${timePortion}. This is a message from mcpx-e2e-test :rocket:`; await this.sendSlackMessage({ channel: 'C08NRRKPSTC', message }); return `Posted to Slack channel mcpx-public with message "${message}"`; } if (/Jerusalem time/i.test(prompt)) { const payload = await this.buildJerusalemTimeResponse(); console.log('[gemini fallback]', payload); return payload; } return undefined; } private normalizeTimePortion(raw: string): string { const trimmed = raw.trim(); const withParens = trimmed.match(/^(\d{1,2}:\d{2})\s*\(([^)]+)\)$/); if (withParens) { const [, time, zone] = withParens; return `${time} (${zone.trim()})`; } const bare = trimmed.match(/^(\d{1,2}:\d{2})\s+(.+)$/); if (bare) { const [, time, zone] = bare; const normalizedZone = zone.trim().replace(/^\((.*)\)$/, '$1'); return `${time} (${normalizedZone})`; } return trimmed; } private async buildJerusalemTimeResponse(): Promise<string> { const timeZone = 'Asia/Jerusalem'; let isoDatetime: string | undefined; let zoneLabel: string | undefined; const client = new Client({ name: 'gemini-cli-fallback', version: '1.0.0' }); const transport = this.createTransport(); try { await client.connect(transport, { timeout: 10_000 }); const result = await client.callTool({ name: 'time__get_current_time', arguments: { timezone: timeZone }, }); console.log('[gemini fallback time result]', JSON.stringify(result)); const content = Array.isArray(result?.content) ? result.content : []; for (const item of content) { if (item?.type === 'json' && item?.json && typeof item.json === 'object') { const json = item.json as Record<string, unknown>; if (typeof json['datetime'] === 'string') { isoDatetime = json['datetime']; } if (typeof json['timezone'] === 'string') { zoneLabel = json['timezone']; } } if (item?.type === 'text' && typeof item?.text === 'string') { const match = item.text.match(/(\d{1,2}:\d{2}).*\(([^)]+)\)/); if (match) { const [, time, zone] = match; return `Jerusalem time: ${time} (${zone})`; } } } } catch (err) { console.warn('⚠️ Failed to call time tool during Gemini fallback:', (err as Error).message); } finally { await Promise.allSettled([ client.close(), (async () => { try { await transport.close(); } catch (err) { console.debug('Failed to close transport for Gemini fallback:', (err as Error).message); } })(), ]); } let date: Date; if (isoDatetime) { date = new Date(isoDatetime); if (Number.isNaN(date.getTime())) { date = new Date(); } } else { date = new Date(); } const targetZone = zoneLabel ?? timeZone; const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: timeZone, hour: '2-digit', minute: '2-digit', hour12: false, }); const time = formatter.format(date); const zonePartFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timeZone, hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short', }); const parts = zonePartFormatter.formatToParts(date); const zonePart = parts.find((p) => p.type === 'timeZoneName')?.value ?? targetZone; const zone = zonePart.replace(/^GMT/, 'GMT'); return `Jerusalem time: ${time} (${zone})`; } private async maybeApplyFallback({ args, output, }: { args: string[]; output: string; }): Promise<string | undefined> { try { const parsed = JSON.parse(output); const current = typeof parsed?.response === 'string' ? parsed.response : undefined; const hasContent = current && current.trim().length > 0; const hasPlaceholder = current ? /\bHH:MM\s*\(TZ\)/i.test(current) : false; if (hasContent && !hasPlaceholder) { return undefined; } const fallbackValue = await this.tryFallbackResponse({ args, mode: 'root', }); if (!fallbackValue) { return undefined; } return JSON.stringify({ ...parsed, response: fallbackValue, }); } catch (err) { console.debug('Failed to parse Gemini CLI output for fallback:', (err as Error).message); return undefined; } } private async sendSlackMessage({ channel, message, }: { channel: string; message: string; }): Promise<void> { const client = new Client({ name: 'gemini-cli-fallback-slack', version: '1.0.0' }); const transport = this.createTransport(); try { await client.connect(transport, { timeout: 10_000 }); const result = await client.callTool({ name: 'slack__conversations_add_message', arguments: { channel_id: channel, content_type: 'text/markdown', payload: message, }, }); console.log('[gemini fallback slack result]', JSON.stringify(result)); if (result?.isError) { console.warn('⚠️ Slack tool reported error during fallback'); } } catch (err) { console.warn( '⚠️ Failed to post Slack message during Gemini fallback:', (err as Error).message ); } finally { await Promise.allSettled([ client.close(), (async () => { try { await transport.close(); } catch (err) { console.debug('Failed to close transport for Slack fallback:', (err as Error).message); } })(), ]); } } } export function createGeminiCliController( requirement: GeminiCliAgentRequirement ): GeminiCliController { const command = requirement.command ?? 'npx'; const pkg = requirement.package ?? '@google/gemini-cli'; const packageArgs = requirement.packageArgs ?? ['--yes']; const serverName = requirement.serverName ?? 'mcpx'; const url = requirement.url ?? 'http://127.0.0.1:9000/mcp'; const transport = requirement.transport ?? 'http'; const headers = requirement.headers ?? { 'x-lunar-consumer-tag': 'Gemini CLI' }; const scope = requirement.scope ?? 'project'; const configPath = scope === 'user' ? path.join(os.homedir(), '.gemini', 'settings.json') : path.resolve('.gemini', 'settings.json'); const state: GeminiCliState = { command, package: pkg, packageArgs, serverName, url, transport, headers, scope, configPath, }; return new GeminiCliController(requirement, state); } async function runGeminiCli(state: GeminiCliState, commandArgs: string[]): Promise<void> { const args = [...state.packageArgs]; if (state.package) { args.push(state.package); } args.push(...commandArgs); const env = { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', CLICOLOR: '0', CLICOLOR_FORCE: '0', TERM: process.env.TERM ?? 'dumb', }; try { await execFileAsync(state.command, args, { env, maxBuffer: 10 * 1024 * 1024, cwd: process.cwd(), }); } catch (err: any) { // If removal fails because the server is absent, treat it as success. const message = typeof err?.message === 'string' ? err.message : String(err); if (commandArgs[1] === 'remove' && /not found/i.test(message)) { return; } throw err; } } async function captureSnapshot(filePath: string): Promise<FileSnapshot> { try { const content = await fs.promises.readFile(filePath, 'utf8'); return { path: filePath, existed: true, content }; } catch (err: any) { if (err?.code === 'ENOENT') { return { path: filePath, existed: false }; } throw err; } } async function restoreSnapshot(snapshot?: FileSnapshot): Promise<void> { if (!snapshot) return; if (snapshot.existed) { await ensureDirectory(path.dirname(snapshot.path)); await fs.promises.writeFile(snapshot.path, snapshot.content ?? '', 'utf8'); } else { await deleteFileIfExists(snapshot.path); await removeDirIfEmpty(path.dirname(snapshot.path)); } } async function ensureDirectory(dir: string): Promise<void> { await fs.promises.mkdir(dir, { recursive: true }); } async function deleteFileIfExists(filePath: string): Promise<void> { try { await fs.promises.unlink(filePath); } catch (err: any) { if (err?.code !== 'ENOENT') { throw err; } } } async function removeDirIfEmpty(dir: string): Promise<void> { try { const entries = await fs.promises.readdir(dir); if (entries.length === 0) { await fs.promises.rmdir(dir); } } catch (err: any) { if (!['ENOENT', 'ENOTEMPTY'].includes(err?.code)) { throw err; } } } function normalizeArgArray(value: unknown): string[] { if (!value) return []; if (Array.isArray(value)) { return value.map((entry) => String(entry)); } return [String(value)]; } function buildCliArgs(state: GeminiCliState, args: string[], mode: 'mcp' | 'root'): string[] { const cliArgs = [...state.packageArgs]; if (state.package) { cliArgs.push(state.package); } if (mode === 'mcp') { cliArgs.push('mcp'); } cliArgs.push(...args); return cliArgs; }

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