Allow simple extensions for registering MCPservers (#890)

This commit is contained in:
Tommaso Sciortino 2025-06-10 15:48:39 -07:00 committed by GitHub
parent 916cfee08d
commit 4e84431df3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 3 deletions

38
docs/extension.md Normal file
View File

@ -0,0 +1,38 @@
# Gemini CLI Extensions
Gemini CLI supports extensions that can be used to configure and extend its functionality.
## How it works
On startup, Gemini CLI looks for extensions in two locations:
1. `<workspace>/.gemini/extensions`
2. `<home>/.gemini/extensions`
It will load all extensions from both locations, but if an extension with the same name exists in both, the one in the workspace directory will take precedence.
Each extension is a directory that contains a `gemini-extension.json` file. This file contains the configuration for the extension.
### `gemini-extension.json`
The `gemini-extension.json` file has the following structure:
```json
{
"name": "my-extension",
"version": "1.0.0",
"mcpServers": {
"my-server": {
"command": "node my-server.js"
}
},
"contextFileName": "GEMINI.md"
}
```
- `name`: The name of the extension. This is used to uniquely identify the extension.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like mcpServers configured in settings.json. If an extension and settings.json configure a mcp server with the same name, settings.json will take precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. NOT YET SUPPORTED
When Gemini CLI starts, it will load all the extensions and merge their configurations. If there are any conflicts, the workspace configuration will take precedence.

View File

@ -19,6 +19,7 @@ This documentation is organized into the following sections:
- **[CLI Introduction](./cli/index.md):** An overview of the command-line interface. - **[CLI Introduction](./cli/index.md):** An overview of the command-line interface.
- **[Commands](./cli/commands.md):** Detailed descriptions of all available CLI commands. - **[Commands](./cli/commands.md):** Detailed descriptions of all available CLI commands.
- **[Configuration](./cli/configuration.md):** How to configure the CLI. - **[Configuration](./cli/configuration.md):** How to configure the CLI.
- **[Extensions](./extension.md):** How to extend the CLI with new functionality.
- **Core Details:** - **Core Details:**
- **[Core Introduction](./core/index.md):** An overview of the core component. - **[Core Introduction](./core/index.md):** An overview of the core component.
- **[Configuration](./core/configuration.md):** How to configure the core. - **[Configuration](./core/configuration.md):** How to configure the core.

View File

@ -18,6 +18,7 @@ import {
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
import { getEffectiveModel } from '../utils/modelCheck.js'; import { getEffectiveModel } from '../utils/modelCheck.js';
import { ExtensionConfig } from './extension.js';
// Simple console logger for now - replace with actual logger if available // Simple console logger for now - replace with actual logger if available
const logger = { const logger = {
@ -117,6 +118,7 @@ export async function loadHierarchicalGeminiMemory(
export async function loadCliConfig( export async function loadCliConfig(
settings: Settings, settings: Settings,
extensions: ExtensionConfig[],
geminiIgnorePatterns: string[], geminiIgnorePatterns: string[],
): Promise<Config> { ): Promise<Config> {
loadEnvironment(); loadEnvironment();
@ -143,6 +145,8 @@ export async function loadCliConfig(
const contentGeneratorConfig = await createContentGeneratorConfig(argv); const contentGeneratorConfig = await createContentGeneratorConfig(argv);
const mcpServers = mergeMcpServers(settings, extensions);
let sandbox = argv.sandbox ?? settings.sandbox; let sandbox = argv.sandbox ?? settings.sandbox;
if (argv.yolo) { if (argv.yolo) {
sandbox = false; sandbox = false;
@ -160,7 +164,7 @@ export async function loadCliConfig(
toolDiscoveryCommand: settings.toolDiscoveryCommand, toolDiscoveryCommand: settings.toolDiscoveryCommand,
toolCallCommand: settings.toolCallCommand, toolCallCommand: settings.toolCallCommand,
mcpServerCommand: settings.mcpServerCommand, mcpServerCommand: settings.mcpServerCommand,
mcpServers: settings.mcpServers, mcpServers,
userMemory: memoryContent, userMemory: memoryContent,
geminiMdFileCount: fileCount, geminiMdFileCount: fileCount,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT, approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
@ -180,6 +184,22 @@ export async function loadCliConfig(
}); });
} }
function mergeMcpServers(settings: Settings, extensions: ExtensionConfig[]) {
const mcpServers = settings.mcpServers || {};
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
logger.warn(
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
);
return;
}
mcpServers[key] = server;
});
}
return mcpServers;
}
async function createContentGeneratorConfig( async function createContentGeneratorConfig(
argv: CliArgs, argv: CliArgs,
): Promise<ContentGeneratorConfig> { ): Promise<ContentGeneratorConfig> {

View File

@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
loadExtensions,
} from './extension.js';
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
return {
...os,
homedir: vi.fn(),
};
});
describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should deduplicate extensions, prioritizing the workspace directory', () => {
// Create extensions in the workspace
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
createExtension(workspaceExtensionsDir, 'ext2', '2.0.0');
// Create extensions in the home directory
const homeExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(homeExtensionsDir, { recursive: true });
createExtension(homeExtensionsDir, 'ext1', '1.1.0'); // Duplicate that should be ignored
createExtension(homeExtensionsDir, 'ext3', '3.0.0');
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(3);
expect(extensions.find((e) => e.name === 'ext1')?.version).toBe('1.0.0'); // Workspace version should be kept
expect(extensions.find((e) => e.name === 'ext2')?.version).toBe('2.0.0');
expect(extensions.find((e) => e.name === 'ext3')?.version).toBe('3.0.0');
});
});
function createExtension(
extensionsDir: string,
name: string,
version: string,
): void {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir);
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version }),
);
}

View File

@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MCPServerConfig } from '@gemini-cli/core';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string;
}
export function loadExtensions(workspaceDir: string): ExtensionConfig[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(os.homedir()),
];
const uniqueExtensions: ExtensionConfig[] = [];
const seenNames = new Set<string>();
for (const extension of allExtensions) {
if (!seenNames.has(extension.name)) {
console.log(
`Loading extension: ${extension.name} (version: ${extension.version})`,
);
uniqueExtensions.push(extension);
seenNames.add(extension.name);
}
}
return uniqueExtensions;
}
function loadExtensionsFromDir(dir: string): ExtensionConfig[] {
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: ExtensionConfig[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
console.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`,
);
continue;
}
const extensionPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(extensionPath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${extensionPath}.`,
);
continue;
}
try {
const fileContent = fs.readFileSync(extensionPath, 'utf-8');
const extensionConfig = JSON.parse(fileContent) as ExtensionConfig;
if (!extensionConfig.name || !extensionConfig.version) {
console.error(
`Invalid extension config in ${extensionPath}: missing name or version.`,
);
continue;
}
extensions.push(extensionConfig);
} catch (e) {
console.error(
`Failed to load extension config from ${extensionPath}:`,
e,
);
}
}
return extensions;
}

View File

@ -17,6 +17,7 @@ import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js'; import { getStartupWarnings } from './utils/startupWarnings.js';
import { runNonInteractive } from './nonInteractiveCli.js'; import { runNonInteractive } from './nonInteractiveCli.js';
import { loadGeminiIgnorePatterns } from './utils/loadIgnorePatterns.js'; import { loadGeminiIgnorePatterns } from './utils/loadIgnorePatterns.js';
import { loadExtensions, ExtensionConfig } from './config/extension.js';
import { import {
ApprovalMode, ApprovalMode,
Config, Config,
@ -74,7 +75,12 @@ export async function main() {
process.exit(1); process.exit(1);
} }
const config = await loadCliConfig(settings.merged, geminiIgnorePatterns); const extensions = loadExtensions(workspaceRoot);
const config = await loadCliConfig(
settings.merged,
extensions,
geminiIgnorePatterns,
);
// Initialize centralized FileDiscoveryService // Initialize centralized FileDiscoveryService
await config.getFileService(); await config.getFileService();
@ -124,7 +130,11 @@ export async function main() {
} }
// Non-interactive mode handled by runNonInteractive // Non-interactive mode handled by runNonInteractive
const nonInteractiveConfig = await loadNonInteractiveConfig(config, settings); const nonInteractiveConfig = await loadNonInteractiveConfig(
config,
extensions,
settings,
);
await runNonInteractive(nonInteractiveConfig, input); await runNonInteractive(nonInteractiveConfig, input);
process.exit(0); process.exit(0);
@ -157,6 +167,7 @@ process.on('unhandledRejection', (reason, _promise) => {
async function loadNonInteractiveConfig( async function loadNonInteractiveConfig(
config: Config, config: Config,
extensions: ExtensionConfig[],
settings: LoadedSettings, settings: LoadedSettings,
) { ) {
if (config.getApprovalMode() === ApprovalMode.YOLO) { if (config.getApprovalMode() === ApprovalMode.YOLO) {
@ -190,6 +201,7 @@ async function loadNonInteractiveConfig(
}; };
return await loadCliConfig( return await loadCliConfig(
nonInteractiveSettings, nonInteractiveSettings,
extensions,
config.getGeminiIgnorePatterns(), config.getGeminiIgnorePatterns(),
); );
} }