mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-09 04:41:19 -07:00
feat(test-utils): add TestMcpServerBuilder and support in TestRig (#23491)
This commit is contained in:
2
integration-tests/test-mcp-support.responses
Normal file
2
integration-tests/test-mcp-support.responses
Normal file
@@ -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}}]}
|
||||
75
integration-tests/test-mcp-support.test.ts
Normal file
75
integration-tests/test-mcp-support.test.ts
Normal file
@@ -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
|
||||
fixtures.
|
||||
- `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
|
||||
|
||||
|
||||
1816
packages/test-utils/assets/test-servers/google-workspace.json
Normal file
1816
packages/test-utils/assets/test-servers/google-workspace.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,4 @@
|
||||
export * from './file-system-test-helpers.js';
|
||||
export * from './test-rig.js';
|
||||
export * from './mock-utils.js';
|
||||
export * from './test-mcp-server.js';
|
||||
|
||||
69
packages/test-utils/src/test-mcp-server-template.mjs
Normal file
69
packages/test-utils/src/test-mcp-server-template.mjs
Normal file
@@ -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.`);
|
||||
75
packages/test-utils/src/test-mcp-server.ts
Normal file
75
packages/test-utils/src/test-mcp-server.ts
Normal file
@@ -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 stripAnsi from 'strip-ansi';
|
||||
import * as os from 'node:os';
|
||||
import type { TestMcpConfig } from './test-mcp-server.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLE_PATH = join(__dirname, '..', '..', '..', 'bundle/gemini.js');
|
||||
@@ -551,7 +552,95 @@ export class TestRig {
|
||||
}
|
||||
const scriptPath = join(this.testDir, fileName);
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user