feat(test-utils): add TestMcpServerBuilder and support in TestRig (#23491)

This commit is contained in:
Abhi
2026-03-23 18:15:46 -04:00
committed by GitHub
parent 4728028512
commit 2a18e78611
8 changed files with 2180 additions and 1 deletions

View 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}}]}

View 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);
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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';

View 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.`);

View 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;
}
}

View File

@@ -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(