mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(test-utils): add TestMcpServerBuilder and support in TestRig (#23491)
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"mcp_weather-server_get_weather","args":{"location":"London"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The weather in London is rainy."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
TestRig,
|
||||||
|
assertModelHasOutput,
|
||||||
|
TestMcpServerBuilder,
|
||||||
|
} from './test-helper.js';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
describe('test-mcp-support', () => {
|
||||||
|
let rig: TestRig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rig = new TestRig();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => await rig.cleanup());
|
||||||
|
|
||||||
|
it('should discover and call a tool on the test server', async () => {
|
||||||
|
await rig.setup('test-mcp-test', {
|
||||||
|
settings: {
|
||||||
|
tools: { core: [] }, // disable core tools to force using MCP
|
||||||
|
model: {
|
||||||
|
name: 'gemini-3-flash-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fakeResponsesPath: join(__dirname, 'test-mcp-support.responses'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workaround for ProjectRegistry save issue
|
||||||
|
const userGeminiDir = join(rig.homeDir!, '.gemini');
|
||||||
|
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
|
||||||
|
|
||||||
|
const builder = new TestMcpServerBuilder('weather-server').addTool(
|
||||||
|
'get_weather',
|
||||||
|
'Get the weather for a location',
|
||||||
|
'The weather in London is always rainy.',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
location: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
rig.addTestMcpServer('weather-server', builder.build());
|
||||||
|
|
||||||
|
// Run the CLI asking for weather
|
||||||
|
const output = await rig.run({
|
||||||
|
args: 'What is the weather in London? Answer with the raw tool response snippet.',
|
||||||
|
env: { GEMINI_API_KEY: 'dummy' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert tool call
|
||||||
|
const foundToolCall = await rig.waitForToolCall(
|
||||||
|
'mcp_weather-server_get_weather',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
foundToolCall,
|
||||||
|
'Expected to find a get_weather tool call',
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
assertModelHasOutput(output);
|
||||||
|
expect(output.toLowerCase()).toContain('rainy');
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
@@ -10,6 +10,58 @@ published to npm.
|
|||||||
- `src/file-system-test-helpers.ts`: Helpers for creating temporary file system
|
- `src/file-system-test-helpers.ts`: Helpers for creating temporary file system
|
||||||
fixtures.
|
fixtures.
|
||||||
- `src/mock-utils.ts`: Common mock utilities.
|
- `src/mock-utils.ts`: Common mock utilities.
|
||||||
|
- `src/test-mcp-server.ts`: Helper for building test MCP servers for tests.
|
||||||
|
- `src/test-mcp-server-template.mjs`: Generic template script for running
|
||||||
|
isolated MCP processes.
|
||||||
|
|
||||||
|
## Test MCP Servers
|
||||||
|
|
||||||
|
The `TestRig` provides a fully isolated, compliant way to test tool triggers and
|
||||||
|
workflows using local test MCP servers. This isolates your tests from live API
|
||||||
|
endpoints and rate-limiting.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. **Programmatic Builder:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TestMcpServerBuilder } from '@google/gemini-cli-test-utils';
|
||||||
|
|
||||||
|
const builder = new TestMcpServerBuilder('weather-server').addTool(
|
||||||
|
'get_weather',
|
||||||
|
'Get weather',
|
||||||
|
'It is rainy',
|
||||||
|
);
|
||||||
|
|
||||||
|
rig.addTestMcpServer('weather-server', builder.build());
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Predefined configurations via JSON:** Place a configuration file in
|
||||||
|
`packages/test-utils/assets/test-servers/google-workspace.json` and load it
|
||||||
|
by title:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
rig.addTestMcpServer('workspace-server', 'google-workspace');
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON Format Structure (`TestMcpConfig`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string (Fallback server name)",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "string (Tool execution name)",
|
||||||
|
"description": "string (Helpful summary for router)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": { ... }
|
||||||
|
},
|
||||||
|
"response": "string | object (The forced reply payload)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,4 @@
|
|||||||
export * from './file-system-test-helpers.js';
|
export * from './file-system-test-helpers.js';
|
||||||
export * from './test-rig.js';
|
export * from './test-rig.js';
|
||||||
export * from './mock-utils.js';
|
export * from './mock-utils.js';
|
||||||
|
export * from './test-mcp-server.js';
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import {
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
CallToolRequestSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const configPath = process.argv[2];
|
||||||
|
if (!configPath) {
|
||||||
|
console.error('Usage: node template.mjs <config-path>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{
|
||||||
|
name: config.name,
|
||||||
|
version: config.version || '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add tools handler
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: (config.tools || []).map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema || { type: 'object', properties: {} },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add call handler
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const toolName = request.params.name;
|
||||||
|
const tool = (config.tools || []).find((t) => t.name === toolName);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Error: Tool ${toolName} not found`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
// server.connect resolves when transport connects, but listening continues
|
||||||
|
console.error(`Test MCP Server '${config.name}' connected and listening.`);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response structure for a test tool call.
|
||||||
|
*/
|
||||||
|
export interface TestToolResponse {
|
||||||
|
content: { type: 'text'; text: string }[];
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition of a test tool.
|
||||||
|
*/
|
||||||
|
export interface TestTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema for input arguments */
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
response: TestToolResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration structure for the generic test MCP server template.
|
||||||
|
*/
|
||||||
|
export interface TestMcpConfig {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
tools: TestTool[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to easily configure a Test MCP Server in tests.
|
||||||
|
*/
|
||||||
|
export class TestMcpServerBuilder {
|
||||||
|
private config: TestMcpConfig;
|
||||||
|
|
||||||
|
constructor(name: string) {
|
||||||
|
this.config = { name, tools: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a tool to the test server configuration.
|
||||||
|
* @param name Tool name
|
||||||
|
* @param description Tool description
|
||||||
|
* @param response The response to return. Can be a string for simple text responses.
|
||||||
|
* @param inputSchema Optional JSON Schema for validation/documentation
|
||||||
|
*/
|
||||||
|
addTool(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
response: TestToolResponse | string,
|
||||||
|
inputSchema?: Record<string, unknown>,
|
||||||
|
): this {
|
||||||
|
const responseObj =
|
||||||
|
typeof response === 'string'
|
||||||
|
? { content: [{ type: 'text' as const, text: response }] }
|
||||||
|
: response;
|
||||||
|
|
||||||
|
this.config.tools.push({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
inputSchema,
|
||||||
|
response: responseObj,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): TestMcpConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export { GEMINI_DIR };
|
|||||||
import * as pty from '@lydell/node-pty';
|
import * as pty from '@lydell/node-pty';
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
|
import type { TestMcpConfig } from './test-mcp-server.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const BUNDLE_PATH = join(__dirname, '..', '..', '..', 'bundle/gemini.js');
|
const BUNDLE_PATH = join(__dirname, '..', '..', '..', 'bundle/gemini.js');
|
||||||
@@ -551,7 +552,95 @@ export class TestRig {
|
|||||||
}
|
}
|
||||||
const scriptPath = join(this.testDir, fileName);
|
const scriptPath = join(this.testDir, fileName);
|
||||||
writeFileSync(scriptPath, content);
|
writeFileSync(scriptPath, content);
|
||||||
return normalizePath(scriptPath);
|
return normalizePath(scriptPath)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a test MCP server to the test workspace.
|
||||||
|
* @param name The name of the server
|
||||||
|
* @param config Configuration object or name of predefined config (e.g. 'github')
|
||||||
|
*/
|
||||||
|
addTestMcpServer(name: string, config: TestMcpConfig | string) {
|
||||||
|
if (!this.testDir) {
|
||||||
|
throw new Error(
|
||||||
|
'TestRig.setup must be called before adding test servers',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let testConfig: TestMcpConfig;
|
||||||
|
if (typeof config === 'string') {
|
||||||
|
const assetsDir = join(__dirname, '..', 'assets', 'test-servers');
|
||||||
|
const configPath = join(assetsDir, `${config}.json`);
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Predefined test server config not found: ${configPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
testConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
testConfig.name = name; // Override name
|
||||||
|
} else {
|
||||||
|
testConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFileName = `test-mcp-${name}.json`;
|
||||||
|
const scriptFileName = `test-mcp-${name}.mjs`;
|
||||||
|
|
||||||
|
const configFilePath = join(this.testDir, configFileName);
|
||||||
|
const scriptFilePath = join(this.testDir, scriptFileName);
|
||||||
|
|
||||||
|
// Write config
|
||||||
|
fs.writeFileSync(configFilePath, JSON.stringify(testConfig, null, 2));
|
||||||
|
|
||||||
|
// Copy template script
|
||||||
|
const templatePath = join(__dirname, 'test-mcp-server-template.mjs');
|
||||||
|
if (!fs.existsSync(templatePath)) {
|
||||||
|
throw new Error(`Test template not found at ${templatePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(templatePath, scriptFilePath);
|
||||||
|
|
||||||
|
// Calculate path to monorepo node_modules
|
||||||
|
const monorepoNodeModules = join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'node_modules',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create symlink to node_modules in testDir for ESM resolution
|
||||||
|
const testNodeModules = join(this.testDir, 'node_modules');
|
||||||
|
if (!fs.existsSync(testNodeModules)) {
|
||||||
|
fs.symlinkSync(monorepoNodeModules, testNodeModules, 'dir');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings in workspace and home
|
||||||
|
const updateSettings = (dir: string) => {
|
||||||
|
const settingsPath = join(dir, GEMINI_DIR, 'settings.json');
|
||||||
|
let settings: any = {};
|
||||||
|
if (fs.existsSync(settingsPath)) {
|
||||||
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(join(dir, GEMINI_DIR), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.mcpServers) {
|
||||||
|
settings.mcpServers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.mcpServers[name] = {
|
||||||
|
command: 'node',
|
||||||
|
args: [scriptFilePath, configFilePath],
|
||||||
|
// Removed env.NODE_PATH as it is ignored in ESM
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSettings(this.testDir);
|
||||||
|
if (this.homeDir) {
|
||||||
|
updateSettings(this.homeDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getCleanEnv(
|
private _getCleanEnv(
|
||||||
|
|||||||
Reference in New Issue
Block a user