summaryrefslogtreecommitdiff
path: root/packages/server/src/tools
diff options
context:
space:
mode:
authorTommaso Sciortino <[email protected]>2025-05-30 18:25:47 -0700
committerGitHub <[email protected]>2025-05-30 18:25:47 -0700
commit21fba832d1b4ea7af43fb887d9b2b38fcf8210d0 (patch)
tree7200d2fac3a55c385e0a2dac34b5282c942364bc /packages/server/src/tools
parentc81148a0cc8489f657901c2cc7247c0834075e1a (diff)
Rename server->core (#638)
Diffstat (limited to 'packages/server/src/tools')
-rw-r--r--packages/server/src/tools/diffOptions.ts12
-rw-r--r--packages/server/src/tools/edit.test.ts499
-rw-r--r--packages/server/src/tools/edit.ts449
-rw-r--r--packages/server/src/tools/glob.test.ts247
-rw-r--r--packages/server/src/tools/glob.ts213
-rw-r--r--packages/server/src/tools/grep.test.ts257
-rw-r--r--packages/server/src/tools/grep.ts566
-rw-r--r--packages/server/src/tools/ls.ts270
-rw-r--r--packages/server/src/tools/mcp-client.test.ts371
-rw-r--r--packages/server/src/tools/mcp-client.ts153
-rw-r--r--packages/server/src/tools/mcp-tool.test.ts167
-rw-r--r--packages/server/src/tools/mcp-tool.ts102
-rw-r--r--packages/server/src/tools/memoryTool.test.ts224
-rw-r--r--packages/server/src/tools/memoryTool.ts194
-rw-r--r--packages/server/src/tools/read-file.test.ts228
-rw-r--r--packages/server/src/tools/read-file.ts131
-rw-r--r--packages/server/src/tools/read-many-files.test.ts357
-rw-r--r--packages/server/src/tools/read-many-files.ts416
-rw-r--r--packages/server/src/tools/shell.json18
-rw-r--r--packages/server/src/tools/shell.md14
-rw-r--r--packages/server/src/tools/shell.ts313
-rw-r--r--packages/server/src/tools/tool-registry.test.ts776
-rw-r--r--packages/server/src/tools/tool-registry.ts187
-rw-r--r--packages/server/src/tools/tools.ts235
-rw-r--r--packages/server/src/tools/web-fetch.ts257
-rw-r--r--packages/server/src/tools/web-search.ts207
-rw-r--r--packages/server/src/tools/write-file.test.ts567
-rw-r--r--packages/server/src/tools/write-file.ts336
28 files changed, 0 insertions, 7766 deletions
diff --git a/packages/server/src/tools/diffOptions.ts b/packages/server/src/tools/diffOptions.ts
deleted file mode 100644
index 598b46f1..00000000
--- a/packages/server/src/tools/diffOptions.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import * as Diff from 'diff';
-
-export const DEFAULT_DIFF_OPTIONS: Diff.PatchOptions = {
- context: 3,
- ignoreWhitespace: true,
-};
diff --git a/packages/server/src/tools/edit.test.ts b/packages/server/src/tools/edit.test.ts
deleted file mode 100644
index 08d0860d..00000000
--- a/packages/server/src/tools/edit.test.ts
+++ /dev/null
@@ -1,499 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
-const mockGenerateJson = vi.hoisted(() => vi.fn());
-
-vi.mock('../utils/editCorrector.js', () => ({
- ensureCorrectEdit: mockEnsureCorrectEdit,
-}));
-
-vi.mock('../core/client.js', () => ({
- GeminiClient: vi.fn().mockImplementation(() => ({
- generateJson: mockGenerateJson,
- })),
-}));
-
-import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
-import { EditTool, EditToolParams } from './edit.js';
-import { FileDiff } from './tools.js';
-import path from 'path';
-import fs from 'fs';
-import os from 'os';
-import { Config } from '../config/config.js';
-import { Content, Part, SchemaUnion } from '@google/genai';
-
-describe('EditTool', () => {
- let tool: EditTool;
- let tempDir: string;
- let rootDir: string;
- let mockConfig: Config;
-
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
- rootDir = path.join(tempDir, 'root');
- fs.mkdirSync(rootDir);
-
- mockConfig = {
- getTargetDir: () => rootDir,
- getAlwaysSkipModificationConfirmation: vi.fn(() => false),
- setAlwaysSkipModificationConfirmation: vi.fn(),
- // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method
- // Add other properties/methods of Config if EditTool uses them
- // Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses:
- getApiKey: () => 'test-api-key',
- getModel: () => 'test-model',
- getSandbox: () => false,
- getDebugMode: () => false,
- getQuestion: () => undefined,
- getFullContext: () => false,
- getToolDiscoveryCommand: () => undefined,
- getToolCallCommand: () => undefined,
- getMcpServerCommand: () => undefined,
- getMcpServers: () => undefined,
- getUserAgent: () => 'test-agent',
- getUserMemory: () => '',
- setUserMemory: vi.fn(),
- getGeminiMdFileCount: () => 0,
- setGeminiMdFileCount: vi.fn(),
- getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry
- } as unknown as Config;
-
- // Reset mocks before each test
- (mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockClear();
- (mockConfig.setAlwaysSkipModificationConfirmation as Mock).mockClear();
- // Default to not skipping confirmation
- (mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockReturnValue(
- false,
- );
-
- // Reset mocks and set default implementation for ensureCorrectEdit
- mockEnsureCorrectEdit.mockReset();
- mockEnsureCorrectEdit.mockImplementation(async (currentContent, params) => {
- let occurrences = 0;
- if (params.old_string && currentContent) {
- // Simple string counting for the mock
- let index = currentContent.indexOf(params.old_string);
- while (index !== -1) {
- occurrences++;
- index = currentContent.indexOf(params.old_string, index + 1);
- }
- } else if (params.old_string === '') {
- occurrences = 0; // Creating a new file
- }
- return Promise.resolve({ params, occurrences });
- });
-
- // Default mock for generateJson to return the snippet unchanged
- mockGenerateJson.mockReset();
- mockGenerateJson.mockImplementation(
- async (contents: Content[], schema: SchemaUnion) => {
- // The problematic_snippet is the last part of the user's content
- const userContent = contents.find((c: Content) => c.role === 'user');
- let promptText = '';
- if (userContent && userContent.parts) {
- promptText = userContent.parts
- .filter((p: Part) => typeof (p as any).text === 'string')
- .map((p: Part) => (p as any).text)
- .join('\n');
- }
- const snippetMatch = promptText.match(
- /Problematic target snippet:\n```\n([\s\S]*?)\n```/,
- );
- const problematicSnippet =
- snippetMatch && snippetMatch[1] ? snippetMatch[1] : '';
-
- if (((schema as any).properties as any)?.corrected_target_snippet) {
- return Promise.resolve({
- corrected_target_snippet: problematicSnippet,
- });
- }
- if (((schema as any).properties as any)?.corrected_new_string) {
- // For new_string correction, we might need more sophisticated logic,
- // but for now, returning original is a safe default if not specified by a test.
- const originalNewStringMatch = promptText.match(
- /original_new_string \(what was intended to replace original_old_string\):\n```\n([\s\S]*?)\n```/,
- );
- const originalNewString =
- originalNewStringMatch && originalNewStringMatch[1]
- ? originalNewStringMatch[1]
- : '';
- return Promise.resolve({ corrected_new_string: originalNewString });
- }
- return Promise.resolve({}); // Default empty object if schema doesn't match
- },
- );
-
- tool = new EditTool(mockConfig);
- });
-
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- });
-
- describe('_applyReplacement', () => {
- // Access private method for testing
- // Note: `tool` is initialized in `beforeEach` of the parent describe block
- it('should return newString if isNewFile is true', () => {
- expect((tool as any)._applyReplacement(null, 'old', 'new', true)).toBe(
- 'new',
- );
- expect(
- (tool as any)._applyReplacement('existing', 'old', 'new', true),
- ).toBe('new');
- });
-
- it('should return newString if currentContent is null and oldString is empty (defensive)', () => {
- expect((tool as any)._applyReplacement(null, '', 'new', false)).toBe(
- 'new',
- );
- });
-
- it('should return empty string if currentContent is null and oldString is not empty (defensive)', () => {
- expect((tool as any)._applyReplacement(null, 'old', 'new', false)).toBe(
- '',
- );
- });
-
- it('should replace oldString with newString in currentContent', () => {
- expect(
- (tool as any)._applyReplacement(
- 'hello old world old',
- 'old',
- 'new',
- false,
- ),
- ).toBe('hello new world new');
- });
-
- it('should return currentContent if oldString is empty and not a new file', () => {
- expect(
- (tool as any)._applyReplacement('hello world', '', 'new', false),
- ).toBe('hello world');
- });
- });
-
- describe('validateParams', () => {
- it('should return null for valid params', () => {
- const params: EditToolParams = {
- file_path: path.join(rootDir, 'test.txt'),
- old_string: 'old',
- new_string: 'new',
- };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return error for relative path', () => {
- const params: EditToolParams = {
- file_path: 'test.txt',
- old_string: 'old',
- new_string: 'new',
- };
- expect(tool.validateParams(params)).toMatch(/File path must be absolute/);
- });
-
- it('should return error for path outside root', () => {
- const params: EditToolParams = {
- file_path: path.join(tempDir, 'outside-root.txt'),
- old_string: 'old',
- new_string: 'new',
- };
- expect(tool.validateParams(params)).toMatch(
- /File path must be within the root directory/,
- );
- });
- });
-
- describe('shouldConfirmExecute', () => {
- const testFile = 'edit_me.txt';
- let filePath: string;
-
- beforeEach(() => {
- filePath = path.join(rootDir, testFile);
- });
-
- it('should return false if params are invalid', async () => {
- const params: EditToolParams = {
- file_path: 'relative.txt',
- old_string: 'old',
- new_string: 'new',
- };
- expect(
- await tool.shouldConfirmExecute(params, new AbortController().signal),
- ).toBe(false);
- });
-
- it('should request confirmation for valid edit', async () => {
- fs.writeFileSync(filePath, 'some old content here');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- };
- // ensureCorrectEdit will be called by shouldConfirmExecute
- mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 1 });
- const confirmation = await tool.shouldConfirmExecute(
- params,
- new AbortController().signal,
- );
- expect(confirmation).toEqual(
- expect.objectContaining({
- title: `Confirm Edit: ${testFile}`,
- fileName: testFile,
- fileDiff: expect.any(String),
- }),
- );
- });
-
- it('should return false if old_string is not found (ensureCorrectEdit returns 0)', async () => {
- fs.writeFileSync(filePath, 'some content here');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'not_found',
- new_string: 'new',
- };
- mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 });
- expect(
- await tool.shouldConfirmExecute(params, new AbortController().signal),
- ).toBe(false);
- });
-
- it('should return false if multiple occurrences of old_string are found (ensureCorrectEdit returns > 1)', async () => {
- fs.writeFileSync(filePath, 'old old content here');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- };
- mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 2 });
- expect(
- await tool.shouldConfirmExecute(params, new AbortController().signal),
- ).toBe(false);
- });
-
- it('should request confirmation for creating a new file (empty old_string)', async () => {
- const newFileName = 'new_file.txt';
- const newFilePath = path.join(rootDir, newFileName);
- const params: EditToolParams = {
- file_path: newFilePath,
- old_string: '',
- new_string: 'new file content',
- };
- // ensureCorrectEdit might not be called if old_string is empty,
- // as shouldConfirmExecute handles this for diff generation.
- // If it is called, it should return 0 occurrences for a new file.
- mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 });
- const confirmation = await tool.shouldConfirmExecute(
- params,
- new AbortController().signal,
- );
- expect(confirmation).toEqual(
- expect.objectContaining({
- title: `Confirm Edit: ${newFileName}`,
- fileName: newFileName,
- fileDiff: expect.any(String),
- }),
- );
- });
-
- it('should use corrected params from ensureCorrectEdit for diff generation', async () => {
- const originalContent = 'This is the original string to be replaced.';
- const originalOldString = 'original string';
- const originalNewString = 'new string';
-
- const correctedOldString = 'original string to be replaced'; // More specific
- const correctedNewString = 'completely new string'; // Different replacement
- const expectedFinalContent = 'This is the completely new string.';
-
- fs.writeFileSync(filePath, originalContent);
- const params: EditToolParams = {
- file_path: filePath,
- old_string: originalOldString,
- new_string: originalNewString,
- };
-
- // The main beforeEach already calls mockEnsureCorrectEdit.mockReset()
- // Set a specific mock for this test case
- let mockCalled = false;
- mockEnsureCorrectEdit.mockImplementationOnce(
- async (content, p, client) => {
- console.log('mockEnsureCorrectEdit CALLED IN TEST');
- mockCalled = true;
- expect(content).toBe(originalContent);
- expect(p).toBe(params);
- expect(client).toBe((tool as any).client);
- return {
- params: {
- file_path: filePath,
- old_string: correctedOldString,
- new_string: correctedNewString,
- },
- occurrences: 1,
- };
- },
- );
-
- const confirmation = (await tool.shouldConfirmExecute(
- params,
- new AbortController().signal,
- )) as FileDiff;
-
- expect(mockCalled).toBe(true); // Check if the mock implementation was run
- // expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(originalContent, params, expect.anything()); // Keep this commented for now
- expect(confirmation).toEqual(
- expect.objectContaining({
- title: `Confirm Edit: ${testFile}`,
- fileName: testFile,
- }),
- );
- // Check that the diff is based on the corrected strings leading to the new state
- expect(confirmation.fileDiff).toContain(`-${originalContent}`);
- expect(confirmation.fileDiff).toContain(`+${expectedFinalContent}`);
-
- // Verify that applying the correctedOldString and correctedNewString to originalContent
- // indeed produces the expectedFinalContent, which is what the diff should reflect.
- const patchedContent = originalContent.replace(
- correctedOldString, // This was the string identified by ensureCorrectEdit for replacement
- correctedNewString, // This was the string identified by ensureCorrectEdit as the replacement
- );
- expect(patchedContent).toBe(expectedFinalContent);
- });
- });
-
- describe('execute', () => {
- const testFile = 'execute_me.txt';
- let filePath: string;
-
- beforeEach(() => {
- filePath = path.join(rootDir, testFile);
- // Default for execute tests, can be overridden
- mockEnsureCorrectEdit.mockImplementation(async (content, params) => {
- let occurrences = 0;
- if (params.old_string && content) {
- let index = content.indexOf(params.old_string);
- while (index !== -1) {
- occurrences++;
- index = content.indexOf(params.old_string, index + 1);
- }
- } else if (params.old_string === '') {
- occurrences = 0;
- }
- return { params, occurrences };
- });
- });
-
- it('should return error if params are invalid', async () => {
- const params: EditToolParams = {
- file_path: 'relative.txt',
- old_string: 'old',
- new_string: 'new',
- };
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
- expect(result.returnDisplay).toMatch(/Error: File path must be absolute/);
- });
-
- it('should edit an existing file and return diff with fileName', async () => {
- const initialContent = 'This is some old text.';
- const newContent = 'This is some new text.'; // old -> new
- fs.writeFileSync(filePath, initialContent, 'utf8');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- };
-
- // Specific mock for this test's execution path in calculateEdit
- // ensureCorrectEdit is NOT called by calculateEdit, only by shouldConfirmExecute
- // So, the default mockEnsureCorrectEdit should correctly return 1 occurrence for 'old' in initialContent
-
- // Simulate confirmation by setting shouldAlwaysEdit
- (tool as any).shouldAlwaysEdit = true;
-
- const result = await tool.execute(params, new AbortController().signal);
-
- (tool as any).shouldAlwaysEdit = false; // Reset for other tests
-
- expect(result.llmContent).toMatch(/Successfully modified file/);
- expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
- const display = result.returnDisplay as FileDiff;
- expect(display.fileDiff).toMatch(initialContent);
- expect(display.fileDiff).toMatch(newContent);
- expect(display.fileName).toBe(testFile);
- });
-
- it('should create a new file if old_string is empty and file does not exist, and return created message', async () => {
- const newFileName = 'brand_new_file.txt';
- const newFilePath = path.join(rootDir, newFileName);
- const fileContent = 'Content for the new file.';
- const params: EditToolParams = {
- file_path: newFilePath,
- old_string: '',
- new_string: fileContent,
- };
-
- (
- mockConfig.getAlwaysSkipModificationConfirmation as Mock
- ).mockReturnValueOnce(true);
- const result = await tool.execute(params, new AbortController().signal);
-
- expect(result.llmContent).toMatch(/Created new file/);
- expect(fs.existsSync(newFilePath)).toBe(true);
- expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
- expect(result.returnDisplay).toBe(`Created ${newFileName}`);
- });
-
- it('should return error if old_string is not found in file', async () => {
- fs.writeFileSync(filePath, 'Some content.', 'utf8');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'nonexistent',
- new_string: 'replacement',
- };
- // The default mockEnsureCorrectEdit will return 0 occurrences for 'nonexistent'
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(
- /0 occurrences found for old_string in/,
- );
- expect(result.returnDisplay).toMatch(
- /Failed to edit, could not find the string to replace./,
- );
- });
-
- it('should return error if multiple occurrences of old_string are found', async () => {
- fs.writeFileSync(filePath, 'multiple old old strings', 'utf8');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- };
- // The default mockEnsureCorrectEdit will return 2 occurrences for 'old'
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(
- /Expected 1 occurrences but found 2 for old_string in file/,
- );
- expect(result.returnDisplay).toMatch(
- /Failed to edit, expected 1 occurrence\(s\) but found 2/,
- );
- });
-
- it('should return error if trying to create a file that already exists (empty old_string)', async () => {
- fs.writeFileSync(filePath, 'Existing content', 'utf8');
- const params: EditToolParams = {
- file_path: filePath,
- old_string: '',
- new_string: 'new content',
- };
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/File already exists, cannot create/);
- expect(result.returnDisplay).toMatch(
- /Attempted to create a file that already exists/,
- );
- });
- });
-});
diff --git a/packages/server/src/tools/edit.ts b/packages/server/src/tools/edit.ts
deleted file mode 100644
index d85c89b0..00000000
--- a/packages/server/src/tools/edit.ts
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import * as Diff from 'diff';
-import {
- BaseTool,
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolEditConfirmationDetails,
- ToolResult,
- ToolResultDisplay,
-} from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { makeRelative, shortenPath } from '../utils/paths.js';
-import { isNodeError } from '../utils/errors.js';
-import { ReadFileTool } from './read-file.js';
-import { GeminiClient } from '../core/client.js';
-import { Config } from '../config/config.js';
-import { ensureCorrectEdit } from '../utils/editCorrector.js';
-import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
-
-/**
- * Parameters for the Edit tool
- */
-export interface EditToolParams {
- /**
- * The absolute path to the file to modify
- */
- file_path: string;
-
- /**
- * The text to replace
- */
- old_string: string;
-
- /**
- * The text to replace it with
- */
- new_string: string;
-}
-
-interface CalculatedEdit {
- currentContent: string | null;
- newContent: string;
- occurrences: number;
- error?: { display: string; raw: string };
- isNewFile: boolean;
-}
-
-/**
- * Implementation of the Edit tool logic
- */
-export class EditTool extends BaseTool<EditToolParams, ToolResult> {
- static readonly Name = 'replace';
- private readonly config: Config;
- private readonly rootDirectory: string;
- private readonly client: GeminiClient;
-
- /**
- * Creates a new instance of the EditLogic
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(config: Config) {
- super(
- EditTool.Name,
- 'Edit',
- `Replaces a single, unique occurrence of text within a file. This tool requires providing significant context around the change to ensure uniqueness and precise targeting. Always use the ${ReadFileTool} tool to examine the file's current content before attempting a text replacement.
-
-Expectation for parameters:
-1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown.
-2. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.).
-3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic.
-4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
-**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.`,
- {
- properties: {
- file_path: {
- description:
- "The absolute path to the file to modify. Must start with '/'.",
- type: 'string',
- },
- old_string: {
- description:
- 'The exact literal text to replace, preferably unescaped. CRITICAL: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it), matches multiple locations, or does not match exactly, the tool will fail.',
- type: 'string',
- },
- new_string: {
- description:
- 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
- type: 'string',
- },
- },
- required: ['file_path', 'old_string', 'new_string'],
- type: 'object',
- },
- );
- this.config = config;
- this.rootDirectory = path.resolve(this.config.getTargetDir());
- this.client = new GeminiClient(this.config);
- }
-
- /**
- * Checks if a path is within the root directory.
- * @param pathToCheck The absolute path to check.
- * @returns True if the path is within the root directory, false otherwise.
- */
- private isWithinRoot(pathToCheck: string): boolean {
- const normalizedPath = path.normalize(pathToCheck);
- const normalizedRoot = this.rootDirectory;
- const rootWithSep = normalizedRoot.endsWith(path.sep)
- ? normalizedRoot
- : normalizedRoot + path.sep;
- return (
- normalizedPath === normalizedRoot ||
- normalizedPath.startsWith(rootWithSep)
- );
- }
-
- /**
- * Validates the parameters for the Edit tool
- * @param params Parameters to validate
- * @returns Error message string or null if valid
- */
- validateParams(params: EditToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
-
- if (!path.isAbsolute(params.file_path)) {
- return `File path must be absolute: ${params.file_path}`;
- }
-
- if (!this.isWithinRoot(params.file_path)) {
- return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
- }
-
- return null;
- }
-
- private _applyReplacement(
- currentContent: string | null,
- oldString: string,
- newString: string,
- isNewFile: boolean,
- ): string {
- if (isNewFile) {
- return newString;
- }
- if (currentContent === null) {
- // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
- return oldString === '' ? newString : '';
- }
- // If oldString is empty and it's not a new file, do not modify the content.
- if (oldString === '' && !isNewFile) {
- return currentContent;
- }
- return currentContent.replaceAll(oldString, newString);
- }
-
- /**
- * Calculates the potential outcome of an edit operation.
- * @param params Parameters for the edit operation
- * @returns An object describing the potential edit outcome
- * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
- */
- private async calculateEdit(
- params: EditToolParams,
- abortSignal: AbortSignal,
- ): Promise<CalculatedEdit> {
- const expectedReplacements = 1;
- let currentContent: string | null = null;
- let fileExists = false;
- let isNewFile = false;
- let finalNewString = params.new_string;
- let finalOldString = params.old_string;
- let occurrences = 0;
- let error: { display: string; raw: string } | undefined = undefined;
-
- try {
- currentContent = fs.readFileSync(params.file_path, 'utf8');
- fileExists = true;
- } catch (err: unknown) {
- if (!isNodeError(err) || err.code !== 'ENOENT') {
- // Rethrow unexpected FS errors (permissions, etc.)
- throw err;
- }
- fileExists = false;
- }
-
- if (params.old_string === '' && !fileExists) {
- // Creating a new file
- isNewFile = true;
- } else if (!fileExists) {
- // Trying to edit a non-existent file (and old_string is not empty)
- error = {
- display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,
- raw: `File not found: ${params.file_path}`,
- };
- } else if (currentContent !== null) {
- // Editing an existing file
- const correctedEdit = await ensureCorrectEdit(
- currentContent,
- params,
- this.client,
- abortSignal,
- );
- finalOldString = correctedEdit.params.old_string;
- finalNewString = correctedEdit.params.new_string;
- occurrences = correctedEdit.occurrences;
-
- if (params.old_string === '') {
- // Error: Trying to create a file that already exists
- error = {
- display: `Failed to edit. Attempted to create a file that already exists.`,
- raw: `File already exists, cannot create: ${params.file_path}`,
- };
- } else if (occurrences === 0) {
- error = {
- display: `Failed to edit, could not find the string to replace.`,
- raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`,
- };
- } else if (occurrences !== expectedReplacements) {
- error = {
- display: `Failed to edit, expected ${expectedReplacements} occurrence(s) but found ${occurrences}.`,
- raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} for old_string in file: ${params.file_path}`,
- };
- }
- } else {
- // Should not happen if fileExists and no exception was thrown, but defensively:
- error = {
- display: `Failed to read content of file.`,
- raw: `Failed to read content of existing file: ${params.file_path}`,
- };
- }
-
- const newContent = this._applyReplacement(
- currentContent,
- finalOldString,
- finalNewString,
- isNewFile,
- );
-
- return {
- currentContent,
- newContent,
- occurrences,
- error,
- isNewFile,
- };
- }
-
- /**
- * Handles the confirmation prompt for the Edit tool in the CLI.
- * It needs to calculate the diff to show the user.
- */
- async shouldConfirmExecute(
- params: EditToolParams,
- abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.config.getAlwaysSkipModificationConfirmation()) {
- return false;
- }
- const validationError = this.validateToolParams(params);
- if (validationError) {
- console.error(
- `[EditTool Wrapper] Attempted confirmation with invalid parameters: ${validationError}`,
- );
- return false;
- }
- let currentContent: string | null = null;
- let fileExists = false;
- let finalNewString = params.new_string;
- let finalOldString = params.old_string;
- let occurrences = 0;
-
- try {
- currentContent = fs.readFileSync(params.file_path, 'utf8');
- fileExists = true;
- } catch (err: unknown) {
- if (isNodeError(err) && err.code === 'ENOENT') {
- fileExists = false;
- } else {
- console.error(`Error reading file for confirmation diff: ${err}`);
- return false;
- }
- }
-
- if (params.old_string === '' && !fileExists) {
- // Creating new file, newContent is just params.new_string
- } else if (!fileExists) {
- return false; // Cannot edit non-existent file if old_string is not empty
- } else if (currentContent !== null) {
- const correctedEdit = await ensureCorrectEdit(
- currentContent,
- params,
- this.client,
- abortSignal,
- );
- finalOldString = correctedEdit.params.old_string;
- finalNewString = correctedEdit.params.new_string;
- occurrences = correctedEdit.occurrences;
-
- if (occurrences === 0 || occurrences !== 1) {
- return false;
- }
- } else {
- return false; // Should not happen
- }
-
- const isNewFileScenario = params.old_string === '' && !fileExists;
- const newContent = this._applyReplacement(
- currentContent,
- finalOldString,
- finalNewString,
- isNewFileScenario,
- );
-
- const fileName = path.basename(params.file_path);
- const fileDiff = Diff.createPatch(
- fileName,
- currentContent ?? '',
- newContent,
- 'Current',
- 'Proposed',
- DEFAULT_DIFF_OPTIONS,
- );
- const confirmationDetails: ToolEditConfirmationDetails = {
- type: 'edit',
- title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
- fileName,
- fileDiff,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.config.setAlwaysSkipModificationConfirmation(true);
- }
- },
- };
- return confirmationDetails;
- }
-
- getDescription(params: EditToolParams): string {
- const relativePath = makeRelative(params.file_path, this.rootDirectory);
- if (params.old_string === '') {
- return `Create ${shortenPath(relativePath)}`;
- }
- const oldStringSnippet =
- params.old_string.split('\n')[0].substring(0, 30) +
- (params.old_string.length > 30 ? '...' : '');
- const newStringSnippet =
- params.new_string.split('\n')[0].substring(0, 30) +
- (params.new_string.length > 30 ? '...' : '');
- return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
- }
-
- /**
- * Executes the edit operation with the given parameters.
- * @param params Parameters for the edit operation
- * @returns Result of the edit operation
- */
- async execute(
- params: EditToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: `Error: ${validationError}`,
- };
- }
-
- let editData: CalculatedEdit;
- try {
- editData = await this.calculateEdit(params, _signal);
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- return {
- llmContent: `Error preparing edit: ${errorMsg}`,
- returnDisplay: `Error preparing edit: ${errorMsg}`,
- };
- }
-
- if (editData.error) {
- return {
- llmContent: editData.error.raw,
- returnDisplay: `Error: ${editData.error.display}`,
- };
- }
-
- try {
- this.ensureParentDirectoriesExist(params.file_path);
- fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
-
- let displayResult: ToolResultDisplay;
- if (editData.isNewFile) {
- displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
- } else {
- // Generate diff for display, even though core logic doesn't technically need it
- // The CLI wrapper will use this part of the ToolResult
- const fileName = path.basename(params.file_path);
- const fileDiff = Diff.createPatch(
- fileName,
- editData.currentContent ?? '', // Should not be null here if not isNewFile
- editData.newContent,
- 'Current',
- 'Proposed',
- DEFAULT_DIFF_OPTIONS,
- );
- displayResult = { fileDiff, fileName };
- }
-
- const llmSuccessMessage = editData.isNewFile
- ? `Created new file: ${params.file_path} with provided content.`
- : `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`;
-
- return {
- llmContent: llmSuccessMessage,
- returnDisplay: displayResult,
- };
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- return {
- llmContent: `Error executing edit: ${errorMsg}`,
- returnDisplay: `Error writing file: ${errorMsg}`,
- };
- }
- }
-
- /**
- * Creates parent directories if they don't exist
- */
- private ensureParentDirectoriesExist(filePath: string): void {
- const dirName = path.dirname(filePath);
- if (!fs.existsSync(dirName)) {
- fs.mkdirSync(dirName, { recursive: true });
- }
- }
-}
diff --git a/packages/server/src/tools/glob.test.ts b/packages/server/src/tools/glob.test.ts
deleted file mode 100644
index d42e5b1c..00000000
--- a/packages/server/src/tools/glob.test.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { GlobTool, GlobToolParams } from './glob.js';
-import { partListUnionToString } from '../core/geminiRequest.js';
-// import { ToolResult } from './tools.js'; // ToolResult is implicitly used by execute
-import path from 'path';
-import fs from 'fs/promises';
-import os from 'os';
-import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // Removed vi
-
-describe('GlobTool', () => {
- let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance
- let globTool: GlobTool;
- const abortSignal = new AbortController().signal;
-
- beforeEach(async () => {
- // Create a unique root directory for each test run
- tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));
- globTool = new GlobTool(tempRootDir);
-
- // Create some test files and directories within this root
- // Top-level files
- await fs.writeFile(path.join(tempRootDir, 'fileA.txt'), 'contentA');
- await fs.writeFile(path.join(tempRootDir, 'FileB.TXT'), 'contentB'); // Different case for testing
-
- // Subdirectory and files within it
- await fs.mkdir(path.join(tempRootDir, 'sub'));
- await fs.writeFile(path.join(tempRootDir, 'sub', 'fileC.md'), 'contentC');
- await fs.writeFile(path.join(tempRootDir, 'sub', 'FileD.MD'), 'contentD'); // Different case
-
- // Deeper subdirectory
- await fs.mkdir(path.join(tempRootDir, 'sub', 'deep'));
- await fs.writeFile(
- path.join(tempRootDir, 'sub', 'deep', 'fileE.log'),
- 'contentE',
- );
-
- // Files for mtime sorting test
- await fs.writeFile(path.join(tempRootDir, 'older.sortme'), 'older_content');
- // Ensure a noticeable difference in modification time
- await new Promise((resolve) => setTimeout(resolve, 50));
- await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content');
- });
-
- afterEach(async () => {
- // Clean up the temporary root directory
- await fs.rm(tempRootDir, { recursive: true, force: true });
- });
-
- describe('execute', () => {
- it('should find files matching a simple pattern in the root', async () => {
- const params: GlobToolParams = { pattern: '*.txt' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 2 file(s)');
- expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
- expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
- expect(result.returnDisplay).toBe('Found 2 matching file(s)');
- });
-
- it('should find files case-sensitively when case_sensitive is true', async () => {
- const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 1 file(s)');
- expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
- expect(result.llmContent).not.toContain(
- path.join(tempRootDir, 'FileB.TXT'),
- );
- });
-
- it('should find files case-insensitively by default (pattern: *.TXT)', async () => {
- const params: GlobToolParams = { pattern: '*.TXT' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 2 file(s)');
- expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
- expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
- });
-
- it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => {
- const params: GlobToolParams = {
- pattern: '*.TXT',
- case_sensitive: false,
- };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 2 file(s)');
- expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
- expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
- });
-
- it('should find files using a pattern that includes a subdirectory', async () => {
- const params: GlobToolParams = { pattern: 'sub/*.md' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 2 file(s)');
- expect(result.llmContent).toContain(
- path.join(tempRootDir, 'sub', 'fileC.md'),
- );
- expect(result.llmContent).toContain(
- path.join(tempRootDir, 'sub', 'FileD.MD'),
- );
- });
-
- it('should find files in a specified relative path (relative to rootDir)', async () => {
- const params: GlobToolParams = { pattern: '*.md', path: 'sub' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 2 file(s)');
- expect(result.llmContent).toContain(
- path.join(tempRootDir, 'sub', 'fileC.md'),
- );
- expect(result.llmContent).toContain(
- path.join(tempRootDir, 'sub', 'FileD.MD'),
- );
- });
-
- it('should find files using a deep globstar pattern (e.g., **/*.log)', async () => {
- const params: GlobToolParams = { pattern: '**/*.log' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain('Found 1 file(s)');
- expect(result.llmContent).toContain(
- path.join(tempRootDir, 'sub', 'deep', 'fileE.log'),
- );
- });
-
- it('should return "No files found" message when pattern matches nothing', async () => {
- const params: GlobToolParams = { pattern: '*.nonexistent' };
- const result = await globTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'No files found matching pattern "*.nonexistent"',
- );
- expect(result.returnDisplay).toBe('No files found');
- });
-
- it('should correctly sort files by modification time (newest first)', async () => {
- const params: GlobToolParams = { pattern: '*.sortme' };
- const result = await globTool.execute(params, abortSignal);
- const llmContent = partListUnionToString(result.llmContent);
-
- expect(llmContent).toContain('Found 2 file(s)');
- // Ensure llmContent is a string for TypeScript type checking
- expect(typeof llmContent).toBe('string');
-
- const filesListed = llmContent
- .substring(llmContent.indexOf(':') + 1)
- .trim()
- .split('\n');
- expect(filesListed[0]).toContain(path.join(tempRootDir, 'newer.sortme'));
- expect(filesListed[1]).toContain(path.join(tempRootDir, 'older.sortme'));
- });
- });
-
- describe('validateToolParams', () => {
- it('should return null for valid parameters (pattern only)', () => {
- const params: GlobToolParams = { pattern: '*.js' };
- expect(globTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return null for valid parameters (pattern and path)', () => {
- const params: GlobToolParams = { pattern: '*.js', path: 'sub' };
- expect(globTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return null for valid parameters (pattern, path, and case_sensitive)', () => {
- const params: GlobToolParams = {
- pattern: '*.js',
- path: 'sub',
- case_sensitive: true,
- };
- expect(globTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return error if pattern is missing (schema validation)', () => {
- const params = { path: '.' } as unknown as GlobToolParams;
- expect(globTool.validateToolParams(params)).toContain(
- 'Parameters failed schema validation',
- );
- });
-
- it('should return error if pattern is an empty string', () => {
- const params: GlobToolParams = { pattern: '' };
- expect(globTool.validateToolParams(params)).toContain(
- "The 'pattern' parameter cannot be empty.",
- );
- });
-
- it('should return error if pattern is only whitespace', () => {
- const params: GlobToolParams = { pattern: ' ' };
- expect(globTool.validateToolParams(params)).toContain(
- "The 'pattern' parameter cannot be empty.",
- );
- });
-
- it('should return error if path is provided but is not a string (schema validation)', () => {
- const params = {
- pattern: '*.ts',
- path: 123,
- } as unknown as GlobToolParams;
- expect(globTool.validateToolParams(params)).toContain(
- 'Parameters failed schema validation',
- );
- });
-
- it('should return error if case_sensitive is provided but is not a boolean (schema validation)', () => {
- const params = {
- pattern: '*.ts',
- case_sensitive: 'true',
- } as unknown as GlobToolParams;
- expect(globTool.validateToolParams(params)).toContain(
- 'Parameters failed schema validation',
- );
- });
-
- it("should return error if search path resolves outside the tool's root directory", () => {
- // Create a globTool instance specifically for this test, with a deeper root
- const deeperRootDir = path.join(tempRootDir, 'sub');
- const specificGlobTool = new GlobTool(deeperRootDir);
- // const params: GlobToolParams = { pattern: '*.txt', path: '..' }; // This line is unused and will be removed.
- // This should be fine as tempRootDir is still within the original tempRootDir (the parent of deeperRootDir)
- // Let's try to go further up.
- const paramsOutside: GlobToolParams = {
- pattern: '*.txt',
- path: '../../../../../../../../../../tmp',
- }; // Definitely outside
- expect(specificGlobTool.validateToolParams(paramsOutside)).toContain(
- "resolves outside the tool's root directory",
- );
- });
-
- it('should return error if specified search path does not exist', async () => {
- const params: GlobToolParams = {
- pattern: '*.txt',
- path: 'nonexistent_subdir',
- };
- expect(globTool.validateToolParams(params)).toContain(
- 'Search path does not exist',
- );
- });
-
- it('should return error if specified search path is a file, not a directory', async () => {
- const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' };
- expect(globTool.validateToolParams(params)).toContain(
- 'Search path is not a directory',
- );
- });
- });
-});
diff --git a/packages/server/src/tools/glob.ts b/packages/server/src/tools/glob.ts
deleted file mode 100644
index 86aef44f..00000000
--- a/packages/server/src/tools/glob.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import fg from 'fast-glob';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { BaseTool, ToolResult } from './tools.js';
-import { shortenPath, makeRelative } from '../utils/paths.js';
-
-/**
- * Parameters for the GlobTool
- */
-export interface GlobToolParams {
- /**
- * The glob pattern to match files against
- */
- pattern: string;
-
- /**
- * The directory to search in (optional, defaults to current directory)
- */
- path?: string;
-
- /**
- * Whether the search should be case-sensitive (optional, defaults to false)
- */
- case_sensitive?: boolean;
-}
-
-/**
- * Implementation of the Glob tool logic
- */
-export class GlobTool extends BaseTool<GlobToolParams, ToolResult> {
- static readonly Name = 'glob';
- /**
- * Creates a new instance of the GlobLogic
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(private rootDirectory: string) {
- super(
- GlobTool.Name,
- 'FindFiles',
- 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
- {
- properties: {
- pattern: {
- description:
- "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
- type: 'string',
- },
- path: {
- description:
- 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
- type: 'string',
- },
- case_sensitive: {
- description:
- 'Optional: Whether the search should be case-sensitive. Defaults to false.',
- type: 'boolean',
- },
- },
- required: ['pattern'],
- type: 'object',
- },
- );
-
- this.rootDirectory = path.resolve(rootDirectory);
- }
-
- /**
- * Checks if a path is within the root directory.
- */
- private isWithinRoot(pathToCheck: string): boolean {
- const absolutePathToCheck = path.resolve(pathToCheck);
- const normalizedPath = path.normalize(absolutePathToCheck);
- const normalizedRoot = path.normalize(this.rootDirectory);
- const rootWithSep = normalizedRoot.endsWith(path.sep)
- ? normalizedRoot
- : normalizedRoot + path.sep;
- return (
- normalizedPath === normalizedRoot ||
- normalizedPath.startsWith(rootWithSep)
- );
- }
-
- /**
- * Validates the parameters for the tool.
- */
- validateToolParams(params: GlobToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return "Parameters failed schema validation. Ensure 'pattern' is a string, 'path' (if provided) is a string, and 'case_sensitive' (if provided) is a boolean.";
- }
-
- const searchDirAbsolute = path.resolve(
- this.rootDirectory,
- params.path || '.',
- );
-
- if (!this.isWithinRoot(searchDirAbsolute)) {
- return `Search path ("${searchDirAbsolute}") resolves outside the tool's root directory ("${this.rootDirectory}").`;
- }
-
- const targetDir = searchDirAbsolute || this.rootDirectory;
- try {
- if (!fs.existsSync(targetDir)) {
- return `Search path does not exist ${targetDir}`;
- }
- if (!fs.statSync(targetDir).isDirectory()) {
- return `Search path is not a directory: ${targetDir}`;
- }
- } catch (e: unknown) {
- return `Error accessing search path: ${e}`;
- }
-
- if (
- !params.pattern ||
- typeof params.pattern !== 'string' ||
- params.pattern.trim() === ''
- ) {
- return "The 'pattern' parameter cannot be empty.";
- }
-
- return null;
- }
-
- /**
- * Gets a description of the glob operation.
- */
- getDescription(params: GlobToolParams): string {
- let description = `'${params.pattern}'`;
- if (params.path) {
- const searchDir = path.resolve(this.rootDirectory, params.path || '.');
- const relativePath = makeRelative(searchDir, this.rootDirectory);
- description += ` within ${shortenPath(relativePath)}`;
- }
- return description;
- }
-
- /**
- * Executes the glob search with the given parameters
- */
- async execute(
- params: GlobToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: validationError,
- };
- }
-
- try {
- const searchDirAbsolute = path.resolve(
- this.rootDirectory,
- params.path || '.',
- );
-
- const entries = await fg(params.pattern, {
- cwd: searchDirAbsolute,
- absolute: true,
- onlyFiles: true,
- stats: true,
- dot: true,
- caseSensitiveMatch: params.case_sensitive ?? false,
- ignore: ['**/node_modules/**', '**/.git/**'],
- followSymbolicLinks: false,
- suppressErrors: true,
- });
-
- if (!entries || entries.length === 0) {
- return {
- llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`,
- returnDisplay: `No files found`,
- };
- }
-
- entries.sort((a, b) => {
- const mtimeA = a.stats?.mtime?.getTime() ?? 0;
- const mtimeB = b.stats?.mtime?.getTime() ?? 0;
- return mtimeB - mtimeA;
- });
-
- const sortedAbsolutePaths = entries.map((entry) => entry.path);
- const fileListDescription = sortedAbsolutePaths.join('\n');
- const fileCount = sortedAbsolutePaths.length;
-
- return {
- llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${searchDirAbsolute}, sorted by modification time (newest first):\n${fileListDescription}`,
- returnDisplay: `Found ${fileCount} matching file(s)`,
- };
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error);
- console.error(`GlobLogic execute Error: ${errorMessage}`, error);
- return {
- llmContent: `Error during glob search operation: ${errorMessage}`,
- returnDisplay: `Error: An unexpected error occurred.`,
- };
- }
- }
-}
diff --git a/packages/server/src/tools/grep.test.ts b/packages/server/src/tools/grep.test.ts
deleted file mode 100644
index 59eb75a4..00000000
--- a/packages/server/src/tools/grep.test.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { GrepTool, GrepToolParams } from './grep.js';
-import path from 'path';
-import fs from 'fs/promises';
-import os from 'os';
-
-// Mock the child_process module to control grep/git grep behavior
-vi.mock('child_process', () => ({
- spawn: vi.fn(() => ({
- on: (event: string, cb: (...args: unknown[]) => void) => {
- if (event === 'error' || event === 'close') {
- // Simulate command not found or error for git grep and system grep
- // to force fallback to JS implementation.
- setTimeout(() => cb(1), 0); // cb(1) for error/close
- }
- },
- stdout: { on: vi.fn() },
- stderr: { on: vi.fn() },
- })),
-}));
-
-describe('GrepTool', () => {
- let tempRootDir: string;
- let grepTool: GrepTool;
- const abortSignal = new AbortController().signal;
-
- beforeEach(async () => {
- tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
- grepTool = new GrepTool(tempRootDir);
-
- // Create some test files and directories
- await fs.writeFile(
- path.join(tempRootDir, 'fileA.txt'),
- 'hello world\nsecond line with world',
- );
- await fs.writeFile(
- path.join(tempRootDir, 'fileB.js'),
- 'const foo = "bar";\nfunction baz() { return "hello"; }',
- );
- await fs.mkdir(path.join(tempRootDir, 'sub'));
- await fs.writeFile(
- path.join(tempRootDir, 'sub', 'fileC.txt'),
- 'another world in sub dir',
- );
- await fs.writeFile(
- path.join(tempRootDir, 'sub', 'fileD.md'),
- '# Markdown file\nThis is a test.',
- );
- });
-
- afterEach(async () => {
- await fs.rm(tempRootDir, { recursive: true, force: true });
- });
-
- describe('validateToolParams', () => {
- it('should return null for valid params (pattern only)', () => {
- const params: GrepToolParams = { pattern: 'hello' };
- expect(grepTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return null for valid params (pattern and path)', () => {
- const params: GrepToolParams = { pattern: 'hello', path: '.' };
- expect(grepTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return null for valid params (pattern, path, and include)', () => {
- const params: GrepToolParams = {
- pattern: 'hello',
- path: '.',
- include: '*.txt',
- };
- expect(grepTool.validateToolParams(params)).toBeNull();
- });
-
- it('should return error if pattern is missing', () => {
- const params = { path: '.' } as unknown as GrepToolParams;
- expect(grepTool.validateToolParams(params)).toContain(
- 'Parameters failed schema validation',
- );
- });
-
- it('should return error for invalid regex pattern', () => {
- const params: GrepToolParams = { pattern: '[[' };
- expect(grepTool.validateToolParams(params)).toContain(
- 'Invalid regular expression pattern',
- );
- });
-
- it('should return error if path does not exist', () => {
- const params: GrepToolParams = { pattern: 'hello', path: 'nonexistent' };
- // Check for the core error message, as the full path might vary
- expect(grepTool.validateToolParams(params)).toContain(
- 'Failed to access path stats for',
- );
- expect(grepTool.validateToolParams(params)).toContain('nonexistent');
- });
-
- it('should return error if path is a file, not a directory', async () => {
- const filePath = path.join(tempRootDir, 'fileA.txt');
- const params: GrepToolParams = { pattern: 'hello', path: filePath };
- expect(grepTool.validateToolParams(params)).toContain(
- `Path is not a directory: ${filePath}`,
- );
- });
- });
-
- describe('execute', () => {
- it('should find matches for a simple pattern in all files', async () => {
- const params: GrepToolParams = { pattern: 'world' };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 3 match(es) for pattern "world" in path "."',
- );
- expect(result.llmContent).toContain('File: fileA.txt');
- expect(result.llmContent).toContain('L1: hello world');
- expect(result.llmContent).toContain('L2: second line with world');
- expect(result.llmContent).toContain('File: sub/fileC.txt');
- expect(result.llmContent).toContain('L1: another world in sub dir');
- expect(result.returnDisplay).toBe('Found 3 matche(s)');
- });
-
- it('should find matches in a specific path', async () => {
- const params: GrepToolParams = { pattern: 'world', path: 'sub' };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 1 match(es) for pattern "world" in path "sub"',
- );
- expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub'
- expect(result.llmContent).toContain('L1: another world in sub dir');
- expect(result.returnDisplay).toBe('Found 1 matche(s)');
- });
-
- it('should find matches with an include glob', async () => {
- const params: GrepToolParams = { pattern: 'hello', include: '*.js' };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 1 match(es) for pattern "hello" in path "." (filter: "*.js")',
- );
- expect(result.llmContent).toContain('File: fileB.js');
- expect(result.llmContent).toContain(
- 'L2: function baz() { return "hello"; }',
- );
- expect(result.returnDisplay).toBe('Found 1 matche(s)');
- });
-
- it('should find matches with an include glob and path', async () => {
- await fs.writeFile(
- path.join(tempRootDir, 'sub', 'another.js'),
- 'const greeting = "hello";',
- );
- const params: GrepToolParams = {
- pattern: 'hello',
- path: 'sub',
- include: '*.js',
- };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 1 match(es) for pattern "hello" in path "sub" (filter: "*.js")',
- );
- expect(result.llmContent).toContain('File: another.js');
- expect(result.llmContent).toContain('L1: const greeting = "hello";');
- expect(result.returnDisplay).toBe('Found 1 matche(s)');
- });
-
- it('should return "No matches found" when pattern does not exist', async () => {
- const params: GrepToolParams = { pattern: 'nonexistentpattern' };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'No matches found for pattern "nonexistentpattern" in path "."',
- );
- expect(result.returnDisplay).toBe('No matches found');
- });
-
- it('should handle regex special characters correctly', async () => {
- const params: GrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";'
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 1 match(es) for pattern "foo.*bar" in path "."',
- );
- expect(result.llmContent).toContain('File: fileB.js');
- expect(result.llmContent).toContain('L1: const foo = "bar";');
- });
-
- it('should be case-insensitive by default (JS fallback)', async () => {
- const params: GrepToolParams = { pattern: 'HELLO' };
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Found 2 match(es) for pattern "HELLO" in path "."',
- );
- expect(result.llmContent).toContain('File: fileA.txt');
- expect(result.llmContent).toContain('L1: hello world');
- expect(result.llmContent).toContain('File: fileB.js');
- expect(result.llmContent).toContain(
- 'L2: function baz() { return "hello"; }',
- );
- });
-
- it('should return an error if params are invalid', async () => {
- const params = { path: '.' } as unknown as GrepToolParams; // Invalid: pattern missing
- const result = await grepTool.execute(params, abortSignal);
- expect(result.llmContent).toContain(
- 'Error: Invalid parameters provided. Reason: Parameters failed schema validation',
- );
- expect(result.returnDisplay).toContain(
- 'Model provided invalid parameters. Error: Parameters failed schema validation',
- );
- });
- });
-
- describe('getDescription', () => {
- it('should generate correct description with pattern only', () => {
- const params: GrepToolParams = { pattern: 'testPattern' };
- expect(grepTool.getDescription(params)).toBe("'testPattern'");
- });
-
- it('should generate correct description with pattern and include', () => {
- const params: GrepToolParams = {
- pattern: 'testPattern',
- include: '*.ts',
- };
- expect(grepTool.getDescription(params)).toBe("'testPattern' in *.ts");
- });
-
- it('should generate correct description with pattern and path', () => {
- const params: GrepToolParams = {
- pattern: 'testPattern',
- path: 'src/app',
- };
- // The path will be relative to the tempRootDir, so we check for containment.
- expect(grepTool.getDescription(params)).toContain("'testPattern' within");
- expect(grepTool.getDescription(params)).toContain('src/app');
- });
-
- it('should generate correct description with pattern, include, and path', () => {
- const params: GrepToolParams = {
- pattern: 'testPattern',
- include: '*.ts',
- path: 'src/app',
- };
- expect(grepTool.getDescription(params)).toContain(
- "'testPattern' in *.ts within",
- );
- expect(grepTool.getDescription(params)).toContain('src/app');
- });
-
- it('should use ./ for root path in description', () => {
- const params: GrepToolParams = { pattern: 'testPattern', path: '.' };
- expect(grepTool.getDescription(params)).toBe("'testPattern' within ./");
- });
- });
-});
diff --git a/packages/server/src/tools/grep.ts b/packages/server/src/tools/grep.ts
deleted file mode 100644
index acdf0bc8..00000000
--- a/packages/server/src/tools/grep.ts
+++ /dev/null
@@ -1,566 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import fsPromises from 'fs/promises';
-import path from 'path';
-import { EOL } from 'os';
-import { spawn } from 'child_process';
-import fastGlob from 'fast-glob';
-import { BaseTool, ToolResult } from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { makeRelative, shortenPath } from '../utils/paths.js';
-import { getErrorMessage, isNodeError } from '../utils/errors.js';
-
-// --- Interfaces ---
-
-/**
- * Parameters for the GrepTool
- */
-export interface GrepToolParams {
- /**
- * The regular expression pattern to search for in file contents
- */
- pattern: string;
-
- /**
- * The directory to search in (optional, defaults to current directory relative to root)
- */
- path?: string;
-
- /**
- * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
- */
- include?: string;
-}
-
-/**
- * Result object for a single grep match
- */
-interface GrepMatch {
- filePath: string;
- lineNumber: number;
- line: string;
-}
-
-// --- GrepLogic Class ---
-
-/**
- * Implementation of the Grep tool logic (moved from CLI)
- */
-export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
- static readonly Name = 'search_file_content'; // Keep static name
-
- /**
- * Creates a new instance of the GrepLogic
- * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory.
- */
- constructor(private rootDirectory: string) {
- super(
- GrepTool.Name,
- 'SearchText',
- 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
- {
- properties: {
- pattern: {
- description:
- "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
- type: 'string',
- },
- path: {
- description:
- 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
- type: 'string',
- },
- include: {
- description:
- "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
- type: 'string',
- },
- },
- required: ['pattern'],
- type: 'object',
- },
- );
- // Ensure rootDirectory is absolute and normalized
- this.rootDirectory = path.resolve(rootDirectory);
- }
-
- // --- Validation Methods ---
-
- /**
- * Checks if a path is within the root directory and resolves it.
- * @param relativePath Path relative to the root directory (or undefined for root).
- * @returns The absolute path if valid and exists.
- * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
- */
- private resolveAndValidatePath(relativePath?: string): string {
- const targetPath = path.resolve(this.rootDirectory, relativePath || '.');
-
- // Security Check: Ensure the resolved path is still within the root directory.
- if (
- !targetPath.startsWith(this.rootDirectory) &&
- targetPath !== this.rootDirectory
- ) {
- throw new Error(
- `Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`,
- );
- }
-
- // Check existence and type after resolving
- try {
- const stats = fs.statSync(targetPath);
- if (!stats.isDirectory()) {
- throw new Error(`Path is not a directory: ${targetPath}`);
- }
- } catch (error: unknown) {
- if (isNodeError(error) && error.code !== 'ENOENT') {
- throw new Error(`Path does not exist: ${targetPath}`);
- }
- throw new Error(
- `Failed to access path stats for ${targetPath}: ${error}`,
- );
- }
-
- return targetPath;
- }
-
- /**
- * Validates the parameters for the tool
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
- */
- validateToolParams(params: GrepToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
-
- try {
- new RegExp(params.pattern);
- } catch (error) {
- return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
- }
-
- try {
- this.resolveAndValidatePath(params.path);
- } catch (error) {
- return getErrorMessage(error);
- }
-
- return null; // Parameters are valid
- }
-
- // --- Core Execution ---
-
- /**
- * Executes the grep search with the given parameters
- * @param params Parameters for the grep search
- * @returns Result of the grep search
- */
- async execute(
- params: GrepToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: `Model provided invalid parameters. Error: ${validationError}`,
- };
- }
-
- let searchDirAbs: string;
- try {
- searchDirAbs = this.resolveAndValidatePath(params.path);
- const searchDirDisplay = params.path || '.';
-
- const matches: GrepMatch[] = await this.performGrepSearch({
- pattern: params.pattern,
- path: searchDirAbs,
- include: params.include,
- });
-
- if (matches.length === 0) {
- const noMatchMsg = `No matches found for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}.`;
- return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
- }
-
- const matchesByFile = matches.reduce(
- (acc, match) => {
- const relativeFilePath =
- path.relative(
- searchDirAbs,
- path.resolve(searchDirAbs, match.filePath),
- ) || path.basename(match.filePath);
- if (!acc[relativeFilePath]) {
- acc[relativeFilePath] = [];
- }
- acc[relativeFilePath].push(match);
- acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber);
- return acc;
- },
- {} as Record<string, GrepMatch[]>,
- );
-
- let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`;
-
- for (const filePath in matchesByFile) {
- llmContent += `File: ${filePath}\n`;
- matchesByFile[filePath].forEach((match) => {
- const trimmedLine = match.line.trim();
- llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
- });
- llmContent += '---\n';
- }
-
- return {
- llmContent: llmContent.trim(),
- returnDisplay: `Found ${matches.length} matche(s)`,
- };
- } catch (error) {
- console.error(`Error during GrepLogic execution: ${error}`);
- const errorMessage = getErrorMessage(error);
- return {
- llmContent: `Error during grep search operation: ${errorMessage}`,
- returnDisplay: `Error: ${errorMessage}`,
- };
- }
- }
-
- // --- Grep Implementation Logic ---
-
- /**
- * Checks if a command is available in the system's PATH.
- * @param {string} command The command name (e.g., 'git', 'grep').
- * @returns {Promise<boolean>} True if the command is available, false otherwise.
- */
- private isCommandAvailable(command: string): Promise<boolean> {
- return new Promise((resolve) => {
- const checkCommand = process.platform === 'win32' ? 'where' : 'command';
- const checkArgs =
- process.platform === 'win32' ? [command] : ['-v', command];
- try {
- const child = spawn(checkCommand, checkArgs, {
- stdio: 'ignore',
- shell: process.platform === 'win32',
- });
- child.on('close', (code) => resolve(code === 0));
- child.on('error', () => resolve(false));
- } catch {
- resolve(false);
- }
- });
- }
-
- /**
- * Checks if a directory or its parent directories contain a .git folder.
- * @param {string} dirPath Absolute path to the directory to check.
- * @returns {Promise<boolean>} True if it's a Git repository, false otherwise.
- */
- private async isGitRepository(dirPath: string): Promise<boolean> {
- let currentPath = path.resolve(dirPath);
- const root = path.parse(currentPath).root;
-
- try {
- while (true) {
- const gitPath = path.join(currentPath, '.git');
- try {
- const stats = await fsPromises.stat(gitPath);
- if (stats.isDirectory() || stats.isFile()) {
- return true;
- }
- // If .git exists but isn't a file/dir, something is weird, return false
- return false;
- } catch (error: unknown) {
- if (!isNodeError(error) || error.code !== 'ENOENT') {
- console.debug(
- `Error checking for .git in ${currentPath}: ${error}`,
- );
- return false;
- }
- }
-
- if (currentPath === root) {
- break;
- }
- currentPath = path.dirname(currentPath);
- }
- } catch (error: unknown) {
- console.debug(
- `Error traversing directory structure upwards from ${dirPath}: ${getErrorMessage(error)}`,
- );
- }
- return false;
- }
-
- /**
- * Parses the standard output of grep-like commands (git grep, system grep).
- * Expects format: filePath:lineNumber:lineContent
- * Handles colons within file paths and line content correctly.
- * @param {string} output The raw stdout string.
- * @param {string} basePath The absolute directory the search was run from, for relative paths.
- * @returns {GrepMatch[]} Array of match objects.
- */
- private parseGrepOutput(output: string, basePath: string): GrepMatch[] {
- const results: GrepMatch[] = [];
- if (!output) return results;
-
- const lines = output.split(EOL); // Use OS-specific end-of-line
-
- for (const line of lines) {
- if (!line.trim()) continue;
-
- // Find the index of the first colon.
- const firstColonIndex = line.indexOf(':');
- if (firstColonIndex === -1) continue; // Malformed
-
- // Find the index of the second colon, searching *after* the first one.
- const secondColonIndex = line.indexOf(':', firstColonIndex + 1);
- if (secondColonIndex === -1) continue; // Malformed
-
- // Extract parts based on the found colon indices
- const filePathRaw = line.substring(0, firstColonIndex);
- const lineNumberStr = line.substring(
- firstColonIndex + 1,
- secondColonIndex,
- );
- const lineContent = line.substring(secondColonIndex + 1);
-
- const lineNumber = parseInt(lineNumberStr, 10);
-
- if (!isNaN(lineNumber)) {
- const absoluteFilePath = path.resolve(basePath, filePathRaw);
- const relativeFilePath = path.relative(basePath, absoluteFilePath);
-
- results.push({
- filePath: relativeFilePath || path.basename(absoluteFilePath),
- lineNumber,
- line: lineContent,
- });
- }
- }
- return results;
- }
-
- /**
- * Gets a description of the grep operation
- * @param params Parameters for the grep operation
- * @returns A string describing the grep
- */
- getDescription(params: GrepToolParams): string {
- let description = `'${params.pattern}'`;
- if (params.include) {
- description += ` in ${params.include}`;
- }
- if (params.path) {
- const resolvedPath = path.resolve(this.rootDirectory, params.path);
- if (resolvedPath === this.rootDirectory || params.path === '.') {
- description += ` within ./`;
- } else {
- const relativePath = makeRelative(resolvedPath, this.rootDirectory);
- description += ` within ${shortenPath(relativePath)}`;
- }
- }
- return description;
- }
-
- /**
- * Performs the actual search using the prioritized strategies.
- * @param options Search options including pattern, absolute path, and include glob.
- * @returns A promise resolving to an array of match objects.
- */
- private async performGrepSearch(options: {
- pattern: string;
- path: string; // Expects absolute path
- include?: string;
- }): Promise<GrepMatch[]> {
- const { pattern, path: absolutePath, include } = options;
- let strategyUsed = 'none';
-
- try {
- // --- Strategy 1: git grep ---
- const isGit = await this.isGitRepository(absolutePath);
- const gitAvailable = isGit && (await this.isCommandAvailable('git'));
-
- if (gitAvailable) {
- strategyUsed = 'git grep';
- const gitArgs = [
- 'grep',
- '--untracked',
- '-n',
- '-E',
- '--ignore-case',
- pattern,
- ];
- if (include) {
- gitArgs.push('--', include);
- }
-
- try {
- const output = await new Promise<string>((resolve, reject) => {
- const child = spawn('git', gitArgs, {
- cwd: absolutePath,
- windowsHide: true,
- });
- const stdoutChunks: Buffer[] = [];
- const stderrChunks: Buffer[] = [];
-
- child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
- child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
- child.on('error', (err) =>
- reject(new Error(`Failed to start git grep: ${err.message}`)),
- );
- child.on('close', (code) => {
- const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
- const stderrData = Buffer.concat(stderrChunks).toString('utf8');
- if (code === 0) resolve(stdoutData);
- else if (code === 1)
- resolve(''); // No matches
- else
- reject(
- new Error(`git grep exited with code ${code}: ${stderrData}`),
- );
- });
- });
- return this.parseGrepOutput(output, absolutePath);
- } catch (gitError: unknown) {
- console.debug(
- `GrepLogic: git grep failed: ${getErrorMessage(gitError)}. Falling back...`,
- );
- }
- }
-
- // --- Strategy 2: System grep ---
- const grepAvailable = await this.isCommandAvailable('grep');
- if (grepAvailable) {
- strategyUsed = 'system grep';
- const grepArgs = ['-r', '-n', '-H', '-E'];
- const commonExcludes = ['.git', 'node_modules', 'bower_components'];
- commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
- if (include) {
- grepArgs.push(`--include=${include}`);
- }
- grepArgs.push(pattern);
- grepArgs.push('.');
-
- try {
- const output = await new Promise<string>((resolve, reject) => {
- const child = spawn('grep', grepArgs, {
- cwd: absolutePath,
- windowsHide: true,
- });
- const stdoutChunks: Buffer[] = [];
- const stderrChunks: Buffer[] = [];
-
- child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
- child.stderr.on('data', (chunk) => {
- const stderrStr = chunk.toString();
- // Suppress common harmless stderr messages
- if (
- !stderrStr.includes('Permission denied') &&
- !/grep:.*: Is a directory/i.test(stderrStr)
- ) {
- stderrChunks.push(chunk);
- }
- });
- child.on('error', (err) =>
- reject(new Error(`Failed to start system grep: ${err.message}`)),
- );
- child.on('close', (code) => {
- const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
- const stderrData = Buffer.concat(stderrChunks)
- .toString('utf8')
- .trim();
- if (code === 0) resolve(stdoutData);
- else if (code === 1)
- resolve(''); // No matches
- else {
- if (stderrData)
- reject(
- new Error(
- `System grep exited with code ${code}: ${stderrData}`,
- ),
- );
- else resolve(''); // Exit code > 1 but no stderr, likely just suppressed errors
- }
- });
- });
- return this.parseGrepOutput(output, absolutePath);
- } catch (grepError: unknown) {
- console.debug(
- `GrepLogic: System grep failed: ${getErrorMessage(grepError)}. Falling back...`,
- );
- }
- }
-
- // --- Strategy 3: Pure JavaScript Fallback ---
- console.debug(
- 'GrepLogic: Falling back to JavaScript grep implementation.',
- );
- strategyUsed = 'javascript fallback';
- const globPattern = include ? include : '**/*';
- const ignorePatterns = [
- '.git/**',
- 'node_modules/**',
- 'bower_components/**',
- '.svn/**',
- '.hg/**',
- ]; // Use glob patterns for ignores here
-
- const filesStream = fastGlob.stream(globPattern, {
- cwd: absolutePath,
- dot: true,
- ignore: ignorePatterns,
- absolute: true,
- onlyFiles: true,
- suppressErrors: true,
- stats: false,
- });
-
- const regex = new RegExp(pattern, 'i');
- const allMatches: GrepMatch[] = [];
-
- for await (const filePath of filesStream) {
- const fileAbsolutePath = filePath as string;
- try {
- const content = await fsPromises.readFile(fileAbsolutePath, 'utf8');
- const lines = content.split(/\r?\n/);
- lines.forEach((line, index) => {
- if (regex.test(line)) {
- allMatches.push({
- filePath:
- path.relative(absolutePath, fileAbsolutePath) ||
- path.basename(fileAbsolutePath),
- lineNumber: index + 1,
- line,
- });
- }
- });
- } catch (readError: unknown) {
- // Ignore errors like permission denied or file gone during read
- if (!isNodeError(readError) || readError.code !== 'ENOENT') {
- console.debug(
- `GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage(readError)}`,
- );
- }
- }
- }
-
- return allMatches;
- } catch (error: unknown) {
- console.error(
- `GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage(error)}`,
- );
- throw error; // Re-throw
- }
- }
-}
diff --git a/packages/server/src/tools/ls.ts b/packages/server/src/tools/ls.ts
deleted file mode 100644
index fea95187..00000000
--- a/packages/server/src/tools/ls.ts
+++ /dev/null
@@ -1,270 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import { BaseTool, ToolResult } from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { makeRelative, shortenPath } from '../utils/paths.js';
-
-/**
- * Parameters for the LS tool
- */
-export interface LSToolParams {
- /**
- * The absolute path to the directory to list
- */
- path: string;
-
- /**
- * List of glob patterns to ignore
- */
- ignore?: string[];
-}
-
-/**
- * File entry returned by LS tool
- */
-export interface FileEntry {
- /**
- * Name of the file or directory
- */
- name: string;
-
- /**
- * Absolute path to the file or directory
- */
- path: string;
-
- /**
- * Whether this entry is a directory
- */
- isDirectory: boolean;
-
- /**
- * Size of the file in bytes (0 for directories)
- */
- size: number;
-
- /**
- * Last modified timestamp
- */
- modifiedTime: Date;
-}
-
-/**
- * Implementation of the LS tool logic
- */
-export class LSTool extends BaseTool<LSToolParams, ToolResult> {
- static readonly Name = 'list_directory';
-
- /**
- * Creates a new instance of the LSLogic
- * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory.
- */
- constructor(private rootDirectory: string) {
- super(
- LSTool.Name,
- 'ReadFolder',
- 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
- {
- properties: {
- path: {
- description:
- 'The absolute path to the directory to list (must be absolute, not relative)',
- type: 'string',
- },
- ignore: {
- description: 'List of glob patterns to ignore',
- items: {
- type: 'string',
- },
- type: 'array',
- },
- },
- required: ['path'],
- type: 'object',
- },
- );
-
- // Set the root directory
- this.rootDirectory = path.resolve(rootDirectory);
- }
-
- /**
- * Checks if a path is within the root directory
- * @param dirpath The path to check
- * @returns True if the path is within the root directory, false otherwise
- */
- private isWithinRoot(dirpath: string): boolean {
- const normalizedPath = path.normalize(dirpath);
- const normalizedRoot = path.normalize(this.rootDirectory);
- // Ensure the normalizedRoot ends with a path separator for proper path comparison
- const rootWithSep = normalizedRoot.endsWith(path.sep)
- ? normalizedRoot
- : normalizedRoot + path.sep;
- return (
- normalizedPath === normalizedRoot ||
- normalizedPath.startsWith(rootWithSep)
- );
- }
-
- /**
- * Validates the parameters for the tool
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
- */
- validateToolParams(params: LSToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
- if (!path.isAbsolute(params.path)) {
- return `Path must be absolute: ${params.path}`;
- }
- if (!this.isWithinRoot(params.path)) {
- return `Path must be within the root directory (${this.rootDirectory}): ${params.path}`;
- }
- return null;
- }
-
- /**
- * Checks if a filename matches any of the ignore patterns
- * @param filename Filename to check
- * @param patterns Array of glob patterns to check against
- * @returns True if the filename should be ignored
- */
- private shouldIgnore(filename: string, patterns?: string[]): boolean {
- if (!patterns || patterns.length === 0) {
- return false;
- }
- for (const pattern of patterns) {
- // Convert glob pattern to RegExp
- const regexPattern = pattern
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
- .replace(/\*/g, '.*')
- .replace(/\?/g, '.');
- const regex = new RegExp(`^${regexPattern}$`);
- if (regex.test(filename)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Gets a description of the file reading operation
- * @param params Parameters for the file reading
- * @returns A string describing the file being read
- */
- getDescription(params: LSToolParams): string {
- const relativePath = makeRelative(params.path, this.rootDirectory);
- return shortenPath(relativePath);
- }
-
- // Helper for consistent error formatting
- private errorResult(llmContent: string, returnDisplay: string): ToolResult {
- return {
- llmContent,
- // Keep returnDisplay simpler in core logic
- returnDisplay: `Error: ${returnDisplay}`,
- };
- }
-
- /**
- * Executes the LS operation with the given parameters
- * @param params Parameters for the LS operation
- * @returns Result of the LS operation
- */
- async execute(
- params: LSToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return this.errorResult(
- `Error: Invalid parameters provided. Reason: ${validationError}`,
- `Failed to execute tool.`,
- );
- }
-
- try {
- const stats = fs.statSync(params.path);
- if (!stats) {
- // fs.statSync throws on non-existence, so this check might be redundant
- // but keeping for clarity. Error message adjusted.
- return this.errorResult(
- `Error: Directory not found or inaccessible: ${params.path}`,
- `Directory not found or inaccessible.`,
- );
- }
- if (!stats.isDirectory()) {
- return this.errorResult(
- `Error: Path is not a directory: ${params.path}`,
- `Path is not a directory.`,
- );
- }
-
- const files = fs.readdirSync(params.path);
- const entries: FileEntry[] = [];
- if (files.length === 0) {
- // Changed error message to be more neutral for LLM
- return {
- llmContent: `Directory ${params.path} is empty.`,
- returnDisplay: `Directory is empty.`,
- };
- }
-
- for (const file of files) {
- if (this.shouldIgnore(file, params.ignore)) {
- continue;
- }
-
- const fullPath = path.join(params.path, file);
- try {
- const stats = fs.statSync(fullPath);
- const isDir = stats.isDirectory();
- entries.push({
- name: file,
- path: fullPath,
- isDirectory: isDir,
- size: isDir ? 0 : stats.size,
- modifiedTime: stats.mtime,
- });
- } catch (error) {
- // Log error internally but don't fail the whole listing
- console.error(`Error accessing ${fullPath}: ${error}`);
- }
- }
-
- // Sort entries (directories first, then alphabetically)
- entries.sort((a, b) => {
- if (a.isDirectory && !b.isDirectory) return -1;
- if (!a.isDirectory && b.isDirectory) return 1;
- return a.name.localeCompare(b.name);
- });
-
- // Create formatted content for LLM
- const directoryContent = entries
- .map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`)
- .join('\n');
-
- return {
- llmContent: `Directory listing for ${params.path}:\n${directoryContent}`,
- // Simplified display, CLI wrapper can enhance
- returnDisplay: `Listed ${entries.length} item(s).`,
- };
- } catch (error) {
- const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
- return this.errorResult(errorMsg, 'Failed to list directory.');
- }
- }
-}
diff --git a/packages/server/src/tools/mcp-client.test.ts b/packages/server/src/tools/mcp-client.test.ts
deleted file mode 100644
index 4664669d..00000000
--- a/packages/server/src/tools/mcp-client.test.ts
+++ /dev/null
@@ -1,371 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import {
- describe,
- it,
- expect,
- vi,
- beforeEach,
- afterEach,
- Mocked,
-} from 'vitest';
-import { discoverMcpTools } from './mcp-client.js';
-import { Config, MCPServerConfig } from '../config/config.js';
-import { ToolRegistry } from './tool-registry.js';
-import { DiscoveredMCPTool } from './mcp-tool.js';
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
-import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
-import { parse, ParseEntry } from 'shell-quote';
-
-// Mock dependencies
-vi.mock('shell-quote');
-
-vi.mock('@modelcontextprotocol/sdk/client/index.js', () => {
- const MockedClient = vi.fn();
- MockedClient.prototype.connect = vi.fn();
- MockedClient.prototype.listTools = vi.fn();
- // Ensure instances have an onerror property that can be spied on or assigned to
- MockedClient.mockImplementation(() => ({
- connect: MockedClient.prototype.connect,
- listTools: MockedClient.prototype.listTools,
- onerror: vi.fn(), // Each instance gets its own onerror mock
- }));
- return { Client: MockedClient };
-});
-
-// Define a global mock for stderr.on that can be cleared and checked
-const mockGlobalStdioStderrOn = vi.fn();
-
-vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => {
- // This is the constructor for StdioClientTransport
- const MockedStdioTransport = vi.fn().mockImplementation(function (
- this: any,
- options: any,
- ) {
- // Always return a new object with a fresh reference to the global mock for .on
- this.options = options;
- this.stderr = { on: mockGlobalStdioStderrOn };
- return this;
- });
- return { StdioClientTransport: MockedStdioTransport };
-});
-
-vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => {
- const MockedSSETransport = vi.fn();
- return { SSEClientTransport: MockedSSETransport };
-});
-
-vi.mock('./tool-registry.js');
-
-describe('discoverMcpTools', () => {
- let mockConfig: Mocked<Config>;
- let mockToolRegistry: Mocked<ToolRegistry>;
-
- beforeEach(() => {
- mockConfig = {
- getMcpServers: vi.fn().mockReturnValue({}),
- getMcpServerCommand: vi.fn().mockReturnValue(undefined),
- } as any;
-
- mockToolRegistry = new (ToolRegistry as any)(
- mockConfig,
- ) as Mocked<ToolRegistry>;
- mockToolRegistry.registerTool = vi.fn();
-
- vi.mocked(parse).mockClear();
- vi.mocked(Client).mockClear();
- vi.mocked(Client.prototype.connect)
- .mockClear()
- .mockResolvedValue(undefined);
- vi.mocked(Client.prototype.listTools)
- .mockClear()
- .mockResolvedValue({ tools: [] });
-
- vi.mocked(StdioClientTransport).mockClear();
- mockGlobalStdioStderrOn.mockClear(); // Clear the global mock in beforeEach
-
- vi.mocked(SSEClientTransport).mockClear();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('should do nothing if no MCP servers or command are configured', async () => {
- await discoverMcpTools(mockConfig, mockToolRegistry);
- expect(mockConfig.getMcpServers).toHaveBeenCalledTimes(1);
- expect(mockConfig.getMcpServerCommand).toHaveBeenCalledTimes(1);
- expect(Client).not.toHaveBeenCalled();
- expect(mockToolRegistry.registerTool).not.toHaveBeenCalled();
- });
-
- it('should discover tools via mcpServerCommand', async () => {
- const commandString = 'my-mcp-server --start';
- const parsedCommand = ['my-mcp-server', '--start'] as ParseEntry[];
- mockConfig.getMcpServerCommand.mockReturnValue(commandString);
- vi.mocked(parse).mockReturnValue(parsedCommand);
-
- const mockTool = {
- name: 'tool1',
- description: 'desc1',
- inputSchema: { type: 'object' as const, properties: {} },
- };
- vi.mocked(Client.prototype.listTools).mockResolvedValue({
- tools: [mockTool],
- });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(parse).toHaveBeenCalledWith(commandString, process.env);
- expect(StdioClientTransport).toHaveBeenCalledWith({
- command: parsedCommand[0],
- args: parsedCommand.slice(1),
- env: expect.any(Object),
- cwd: undefined,
- stderr: 'pipe',
- });
- expect(Client.prototype.connect).toHaveBeenCalledTimes(1);
- expect(Client.prototype.listTools).toHaveBeenCalledTimes(1);
- expect(mockToolRegistry.registerTool).toHaveBeenCalledTimes(1);
- expect(mockToolRegistry.registerTool).toHaveBeenCalledWith(
- expect.any(DiscoveredMCPTool),
- );
- const registeredTool = mockToolRegistry.registerTool.mock
- .calls[0][0] as DiscoveredMCPTool;
- expect(registeredTool.name).toBe('tool1');
- expect(registeredTool.serverToolName).toBe('tool1');
- });
-
- it('should discover tools via mcpServers config (stdio)', async () => {
- const serverConfig: MCPServerConfig = {
- command: './mcp-stdio',
- args: ['arg1'],
- };
- mockConfig.getMcpServers.mockReturnValue({ 'stdio-server': serverConfig });
-
- const mockTool = {
- name: 'tool-stdio',
- description: 'desc-stdio',
- inputSchema: { type: 'object' as const, properties: {} },
- };
- vi.mocked(Client.prototype.listTools).mockResolvedValue({
- tools: [mockTool],
- });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(StdioClientTransport).toHaveBeenCalledWith({
- command: serverConfig.command,
- args: serverConfig.args,
- env: expect.any(Object),
- cwd: undefined,
- stderr: 'pipe',
- });
- expect(mockToolRegistry.registerTool).toHaveBeenCalledWith(
- expect.any(DiscoveredMCPTool),
- );
- const registeredTool = mockToolRegistry.registerTool.mock
- .calls[0][0] as DiscoveredMCPTool;
- expect(registeredTool.name).toBe('tool-stdio');
- });
-
- it('should discover tools via mcpServers config (sse)', async () => {
- const serverConfig: MCPServerConfig = { url: 'http://localhost:1234/sse' };
- mockConfig.getMcpServers.mockReturnValue({ 'sse-server': serverConfig });
-
- const mockTool = {
- name: 'tool-sse',
- description: 'desc-sse',
- inputSchema: { type: 'object' as const, properties: {} },
- };
- vi.mocked(Client.prototype.listTools).mockResolvedValue({
- tools: [mockTool],
- });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(SSEClientTransport).toHaveBeenCalledWith(new URL(serverConfig.url!));
- expect(mockToolRegistry.registerTool).toHaveBeenCalledWith(
- expect.any(DiscoveredMCPTool),
- );
- const registeredTool = mockToolRegistry.registerTool.mock
- .calls[0][0] as DiscoveredMCPTool;
- expect(registeredTool.name).toBe('tool-sse');
- });
-
- it('should prefix tool names if multiple MCP servers are configured', async () => {
- const serverConfig1: MCPServerConfig = { command: './mcp1' };
- const serverConfig2: MCPServerConfig = { url: 'http://mcp2/sse' };
- mockConfig.getMcpServers.mockReturnValue({
- server1: serverConfig1,
- server2: serverConfig2,
- });
-
- const mockTool1 = {
- name: 'toolA',
- description: 'd1',
- inputSchema: { type: 'object' as const, properties: {} },
- };
- const mockTool2 = {
- name: 'toolB',
- description: 'd2',
- inputSchema: { type: 'object' as const, properties: {} },
- };
-
- vi.mocked(Client.prototype.listTools)
- .mockResolvedValueOnce({ tools: [mockTool1] })
- .mockResolvedValueOnce({ tools: [mockTool2] });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(mockToolRegistry.registerTool).toHaveBeenCalledTimes(2);
- const registeredTool1 = mockToolRegistry.registerTool.mock
- .calls[0][0] as DiscoveredMCPTool;
- const registeredTool2 = mockToolRegistry.registerTool.mock
- .calls[1][0] as DiscoveredMCPTool;
-
- expect(registeredTool1.name).toBe('server1__toolA');
- expect(registeredTool1.serverToolName).toBe('toolA');
- expect(registeredTool2.name).toBe('server2__toolB');
- expect(registeredTool2.serverToolName).toBe('toolB');
- });
-
- it('should clean schema properties ($schema, additionalProperties)', async () => {
- const serverConfig: MCPServerConfig = { command: './mcp-clean' };
- mockConfig.getMcpServers.mockReturnValue({ 'clean-server': serverConfig });
-
- const rawSchema = {
- type: 'object' as const,
- $schema: 'http://json-schema.org/draft-07/schema#',
- additionalProperties: true,
- properties: {
- prop1: { type: 'string', $schema: 'remove-this' },
- prop2: {
- type: 'object' as const,
- additionalProperties: false,
- properties: { nested: { type: 'number' } },
- },
- },
- };
- const mockTool = {
- name: 'cleanTool',
- description: 'd',
- inputSchema: JSON.parse(JSON.stringify(rawSchema)),
- };
- vi.mocked(Client.prototype.listTools).mockResolvedValue({
- tools: [mockTool],
- });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(mockToolRegistry.registerTool).toHaveBeenCalledTimes(1);
- const registeredTool = mockToolRegistry.registerTool.mock
- .calls[0][0] as DiscoveredMCPTool;
- const cleanedParams = registeredTool.schema.parameters as any;
-
- expect(cleanedParams).not.toHaveProperty('$schema');
- expect(cleanedParams).not.toHaveProperty('additionalProperties');
- expect(cleanedParams.properties.prop1).not.toHaveProperty('$schema');
- expect(cleanedParams.properties.prop2).not.toHaveProperty(
- 'additionalProperties',
- );
- expect(cleanedParams.properties.prop2.properties.nested).not.toHaveProperty(
- '$schema',
- );
- expect(cleanedParams.properties.prop2.properties.nested).not.toHaveProperty(
- 'additionalProperties',
- );
- });
-
- it('should handle error if mcpServerCommand parsing fails', async () => {
- const commandString = 'my-mcp-server "unterminated quote';
- mockConfig.getMcpServerCommand.mockReturnValue(commandString);
- vi.mocked(parse).mockImplementation(() => {
- throw new Error('Parsing failed');
- });
- vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(
- discoverMcpTools(mockConfig, mockToolRegistry),
- ).rejects.toThrow('Parsing failed');
- expect(mockToolRegistry.registerTool).not.toHaveBeenCalled();
- expect(console.error).not.toHaveBeenCalled();
- });
-
- it('should log error and skip server if config is invalid (missing url and command)', async () => {
- mockConfig.getMcpServers.mockReturnValue({ 'bad-server': {} as any });
- vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(console.error).toHaveBeenCalledWith(
- expect.stringContaining(
- "MCP server 'bad-server' has invalid configuration",
- ),
- );
- // Client constructor should not be called if config is invalid before instantiation
- expect(Client).not.toHaveBeenCalled();
- });
-
- it('should log error and skip server if mcpClient.connect fails', async () => {
- const serverConfig: MCPServerConfig = { command: './mcp-fail-connect' };
- mockConfig.getMcpServers.mockReturnValue({
- 'fail-connect-server': serverConfig,
- });
- vi.mocked(Client.prototype.connect).mockRejectedValue(
- new Error('Connection refused'),
- );
- vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(console.error).toHaveBeenCalledWith(
- expect.stringContaining(
- "failed to start or connect to MCP server 'fail-connect-server'",
- ),
- );
- expect(Client.prototype.listTools).not.toHaveBeenCalled();
- expect(mockToolRegistry.registerTool).not.toHaveBeenCalled();
- });
-
- it('should log error and skip server if mcpClient.listTools fails', async () => {
- const serverConfig: MCPServerConfig = { command: './mcp-fail-list' };
- mockConfig.getMcpServers.mockReturnValue({
- 'fail-list-server': serverConfig,
- });
- vi.mocked(Client.prototype.listTools).mockRejectedValue(
- new Error('ListTools error'),
- );
- vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- expect(console.error).toHaveBeenCalledWith(
- expect.stringContaining(
- "Failed to list or register tools for MCP server 'fail-list-server'",
- ),
- );
- expect(mockToolRegistry.registerTool).not.toHaveBeenCalled();
- });
-
- it('should assign mcpClient.onerror handler', async () => {
- const serverConfig: MCPServerConfig = { command: './mcp-onerror' };
- mockConfig.getMcpServers.mockReturnValue({
- 'onerror-server': serverConfig,
- });
-
- await discoverMcpTools(mockConfig, mockToolRegistry);
-
- const clientInstances = vi.mocked(Client).mock.results;
- expect(clientInstances.length).toBeGreaterThan(0);
- const lastClientInstance =
- clientInstances[clientInstances.length - 1]?.value;
- expect(lastClientInstance?.onerror).toEqual(expect.any(Function));
- });
-});
diff --git a/packages/server/src/tools/mcp-client.ts b/packages/server/src/tools/mcp-client.ts
deleted file mode 100644
index 97a73289..00000000
--- a/packages/server/src/tools/mcp-client.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
-import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
-import { parse } from 'shell-quote';
-import { Config, MCPServerConfig } from '../config/config.js';
-import { DiscoveredMCPTool } from './mcp-tool.js';
-import { ToolRegistry } from './tool-registry.js';
-
-export async function discoverMcpTools(
- config: Config,
- toolRegistry: ToolRegistry,
-): Promise<void> {
- const mcpServers = config.getMcpServers() || {};
-
- if (config.getMcpServerCommand()) {
- const cmd = config.getMcpServerCommand()!;
- const args = parse(cmd, process.env) as string[];
- if (args.some((arg) => typeof arg !== 'string')) {
- throw new Error('failed to parse mcpServerCommand: ' + cmd);
- }
- // use generic server name 'mcp'
- mcpServers['mcp'] = {
- command: args[0],
- args: args.slice(1),
- };
- }
-
- const discoveryPromises = Object.entries(mcpServers).map(
- ([mcpServerName, mcpServerConfig]) =>
- connectAndDiscover(
- mcpServerName,
- mcpServerConfig,
- toolRegistry,
- mcpServers,
- ),
- );
- await Promise.all(discoveryPromises);
-}
-
-async function connectAndDiscover(
- mcpServerName: string,
- mcpServerConfig: MCPServerConfig,
- toolRegistry: ToolRegistry,
- mcpServers: Record<string, MCPServerConfig>,
-): Promise<void> {
- let transport;
- if (mcpServerConfig.url) {
- transport = new SSEClientTransport(new URL(mcpServerConfig.url));
- } else if (mcpServerConfig.command) {
- transport = new StdioClientTransport({
- command: mcpServerConfig.command,
- args: mcpServerConfig.args || [],
- env: {
- ...process.env,
- ...(mcpServerConfig.env || {}),
- } as Record<string, string>,
- cwd: mcpServerConfig.cwd,
- stderr: 'pipe',
- });
- } else {
- console.error(
- `MCP server '${mcpServerName}' has invalid configuration: missing both url (for SSE) and command (for stdio). Skipping.`,
- );
- return; // Return a resolved promise as this path doesn't throw.
- }
-
- const mcpClient = new Client({
- name: 'gemini-cli-mcp-client',
- version: '0.0.1',
- });
-
- try {
- await mcpClient.connect(transport);
- } catch (error) {
- console.error(
- `failed to start or connect to MCP server '${mcpServerName}' ` +
- `${JSON.stringify(mcpServerConfig)}; \n${error}`,
- );
- return; // Return a resolved promise, let other MCP servers be discovered.
- }
-
- mcpClient.onerror = (error) => {
- console.error('MCP ERROR', error.toString());
- };
-
- if (transport instanceof StdioClientTransport && transport.stderr) {
- transport.stderr.on('data', (data) => {
- if (!data.toString().includes('] INFO')) {
- console.debug('MCP STDERR', data.toString());
- }
- });
- }
-
- try {
- const result = await mcpClient.listTools();
- for (const tool of result.tools) {
- // Recursively remove additionalProperties and $schema from the inputSchema
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- This function recursively navigates a deeply nested and potentially heterogeneous JSON schema object. Using 'any' is a pragmatic choice here to avoid overly complex type definitions for all possible schema variations.
- const removeSchemaProps = (obj: any) => {
- if (typeof obj !== 'object' || obj === null) {
- return;
- }
- if (Array.isArray(obj)) {
- obj.forEach(removeSchemaProps);
- } else {
- delete obj.additionalProperties;
- delete obj.$schema;
- Object.values(obj).forEach(removeSchemaProps);
- }
- };
- removeSchemaProps(tool.inputSchema);
-
- // if there are multiple MCP servers, prefix tool name with mcpServerName to avoid collisions
- let toolNameForModel = tool.name;
- if (Object.keys(mcpServers).length > 1) {
- toolNameForModel = mcpServerName + '__' + toolNameForModel;
- }
-
- // replace invalid characters (based on 400 error message) with underscores
- toolNameForModel = toolNameForModel.replace(/[^a-zA-Z0-9_.-]/g, '_');
-
- // if longer than 63 characters, replace middle with '___'
- // note 400 error message says max length is 64, but actual limit seems to be 63
- if (toolNameForModel.length > 63) {
- toolNameForModel =
- toolNameForModel.slice(0, 28) + '___' + toolNameForModel.slice(-32);
- }
- toolRegistry.registerTool(
- new DiscoveredMCPTool(
- mcpClient,
- mcpServerName,
- toolNameForModel,
- tool.description ?? '',
- tool.inputSchema,
- tool.name,
- mcpServerConfig.timeout,
- mcpServerConfig.trust,
- ),
- );
- }
- } catch (error) {
- console.error(
- `Failed to list or register tools for MCP server '${mcpServerName}': ${error}`,
- );
- // Do not re-throw, allow other servers to proceed.
- }
-}
diff --git a/packages/server/src/tools/mcp-tool.test.ts b/packages/server/src/tools/mcp-tool.test.ts
deleted file mode 100644
index 5c784c5d..00000000
--- a/packages/server/src/tools/mcp-tool.test.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import {
- describe,
- it,
- expect,
- vi,
- beforeEach,
- afterEach,
- Mocked,
-} from 'vitest';
-import {
- DiscoveredMCPTool,
- MCP_TOOL_DEFAULT_TIMEOUT_MSEC,
-} from './mcp-tool.js';
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import { ToolResult } from './tools.js';
-
-// Mock MCP SDK Client
-vi.mock('@modelcontextprotocol/sdk/client/index.js', () => {
- const MockClient = vi.fn();
- MockClient.prototype.callTool = vi.fn();
- return { Client: MockClient };
-});
-
-describe('DiscoveredMCPTool', () => {
- let mockMcpClient: Mocked<Client>;
- const toolName = 'test-mcp-tool';
- const serverToolName = 'actual-server-tool-name';
- const baseDescription = 'A test MCP tool.';
- const inputSchema = {
- type: 'object' as const,
- properties: { param: { type: 'string' } },
- };
-
- beforeEach(() => {
- // Create a new mock client for each test to reset call history
- mockMcpClient = new (Client as any)({
- name: 'test-client',
- version: '0.0.1',
- }) as Mocked<Client>;
- vi.mocked(mockMcpClient.callTool).mockClear();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- describe('constructor', () => {
- it('should set properties correctly and augment description', () => {
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- baseDescription,
- inputSchema,
- serverToolName,
- );
-
- expect(tool.name).toBe(toolName);
- expect(tool.schema.name).toBe(toolName);
- expect(tool.schema.description).toContain(baseDescription);
- expect(tool.schema.description).toContain('This MCP tool was discovered');
- // Corrected assertion for backticks and template literal
- expect(tool.schema.description).toContain(
- `tools/call\` method for tool name \`${toolName}\``,
- );
- expect(tool.schema.parameters).toEqual(inputSchema);
- expect(tool.serverToolName).toBe(serverToolName);
- expect(tool.timeout).toBeUndefined();
- });
-
- it('should accept and store a custom timeout', () => {
- const customTimeout = 5000;
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- baseDescription,
- inputSchema,
- serverToolName,
- customTimeout,
- );
- expect(tool.timeout).toBe(customTimeout);
- });
- });
-
- describe('execute', () => {
- it('should call mcpClient.callTool with correct parameters and default timeout', async () => {
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- baseDescription,
- inputSchema,
- serverToolName,
- );
- const params = { param: 'testValue' };
- const expectedMcpResult = { success: true, details: 'executed' };
- vi.mocked(mockMcpClient.callTool).mockResolvedValue(expectedMcpResult);
-
- const result: ToolResult = await tool.execute(params);
-
- expect(mockMcpClient.callTool).toHaveBeenCalledWith(
- {
- name: serverToolName,
- arguments: params,
- },
- undefined,
- {
- timeout: MCP_TOOL_DEFAULT_TIMEOUT_MSEC,
- },
- );
- const expectedOutput =
- '```json\n' + JSON.stringify(expectedMcpResult, null, 2) + '\n```';
- expect(result.llmContent).toBe(expectedOutput);
- expect(result.returnDisplay).toBe(expectedOutput);
- });
-
- it('should call mcpClient.callTool with custom timeout if provided', async () => {
- const customTimeout = 15000;
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- baseDescription,
- inputSchema,
- serverToolName,
- customTimeout,
- );
- const params = { param: 'anotherValue' };
- const expectedMcpResult = { result: 'done' };
- vi.mocked(mockMcpClient.callTool).mockResolvedValue(expectedMcpResult);
-
- await tool.execute(params);
-
- expect(mockMcpClient.callTool).toHaveBeenCalledWith(
- expect.anything(),
- undefined,
- {
- timeout: customTimeout,
- },
- );
- });
-
- it('should propagate rejection if mcpClient.callTool rejects', async () => {
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- baseDescription,
- inputSchema,
- serverToolName,
- );
- const params = { param: 'failCase' };
- const expectedError = new Error('MCP call failed');
- vi.mocked(mockMcpClient.callTool).mockRejectedValue(expectedError);
-
- await expect(tool.execute(params)).rejects.toThrow(expectedError);
- });
- });
-});
diff --git a/packages/server/src/tools/mcp-tool.ts b/packages/server/src/tools/mcp-tool.ts
deleted file mode 100644
index d02b8632..00000000
--- a/packages/server/src/tools/mcp-tool.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import {
- BaseTool,
- ToolResult,
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolMcpConfirmationDetails,
-} from './tools.js';
-
-type ToolParams = Record<string, unknown>;
-
-export const MCP_TOOL_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
-
-export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
- private static readonly whitelist: Set<string> = new Set();
-
- constructor(
- private readonly mcpClient: Client,
- private readonly serverName: string, // Added for server identification
- readonly name: string,
- readonly description: string,
- readonly parameterSchema: Record<string, unknown>,
- readonly serverToolName: string,
- readonly timeout?: number,
- readonly trust?: boolean,
- ) {
- description += `
-
-This MCP tool was discovered from a local MCP server using JSON RPC 2.0 over stdio transport protocol.
-When called, this tool will invoke the \`tools/call\` method for tool name \`${name}\`.
-MCP servers can be configured in project or user settings.
-Returns the MCP server response as a json string.
-`;
- super(
- name,
- name,
- description,
- parameterSchema,
- true, // isOutputMarkdown
- false, // canUpdateOutput
- );
- }
-
- async shouldConfirmExecute(
- _params: ToolParams,
- _abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false> {
- const serverWhitelistKey = this.serverName;
- const toolWhitelistKey = `${this.serverName}.${this.serverToolName}`;
-
- if (this.trust) {
- return false; // server is trusted, no confirmation needed
- }
-
- if (
- DiscoveredMCPTool.whitelist.has(serverWhitelistKey) ||
- DiscoveredMCPTool.whitelist.has(toolWhitelistKey)
- ) {
- return false; // server and/or tool already whitelisted
- }
-
- const confirmationDetails: ToolMcpConfirmationDetails = {
- type: 'mcp',
- title: 'Confirm MCP Tool Execution',
- serverName: this.serverName,
- toolName: this.serverToolName,
- toolDisplayName: this.name,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
- DiscoveredMCPTool.whitelist.add(serverWhitelistKey);
- } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {
- DiscoveredMCPTool.whitelist.add(toolWhitelistKey);
- }
- },
- };
- return confirmationDetails;
- }
-
- async execute(params: ToolParams): Promise<ToolResult> {
- const result = await this.mcpClient.callTool(
- {
- name: this.serverToolName,
- arguments: params,
- },
- undefined, // skip resultSchema to specify options (RequestOptions)
- {
- timeout: this.timeout ?? MCP_TOOL_DEFAULT_TIMEOUT_MSEC,
- },
- );
- const output = '```json\n' + JSON.stringify(result, null, 2) + '\n```';
- return {
- llmContent: output,
- returnDisplay: output,
- };
- }
-}
diff --git a/packages/server/src/tools/memoryTool.test.ts b/packages/server/src/tools/memoryTool.test.ts
deleted file mode 100644
index 42b1329d..00000000
--- a/packages/server/src/tools/memoryTool.test.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
-import { MemoryTool } from './memoryTool.js';
-import * as fs from 'fs/promises';
-import * as path from 'path';
-import * as os from 'os';
-
-// Mock dependencies
-vi.mock('fs/promises');
-vi.mock('os');
-
-const MEMORY_SECTION_HEADER = '## Gemini Added Memories';
-
-// Define a type for our fsAdapter to ensure consistency
-interface FsAdapter {
- readFile: (path: string, encoding: 'utf-8') => Promise<string>;
- writeFile: (path: string, data: string, encoding: 'utf-8') => Promise<void>;
- mkdir: (
- path: string,
- options: { recursive: boolean },
- ) => Promise<string | undefined>;
-}
-
-describe('MemoryTool', () => {
- const mockAbortSignal = new AbortController().signal;
-
- const mockFsAdapter: {
- readFile: Mock<FsAdapter['readFile']>;
- writeFile: Mock<FsAdapter['writeFile']>;
- mkdir: Mock<FsAdapter['mkdir']>;
- } = {
- readFile: vi.fn(),
- writeFile: vi.fn(),
- mkdir: vi.fn(),
- };
-
- beforeEach(() => {
- vi.mocked(os.homedir).mockReturnValue('/mock/home');
- mockFsAdapter.readFile.mockReset();
- mockFsAdapter.writeFile.mockReset().mockResolvedValue(undefined);
- mockFsAdapter.mkdir
- .mockReset()
- .mockResolvedValue(undefined as string | undefined);
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- describe('performAddMemoryEntry (static method)', () => {
- const testFilePath = path.join('/mock/home', '.gemini', 'GEMINI.md');
-
- it('should create section and save a fact if file does not exist', async () => {
- mockFsAdapter.readFile.mockRejectedValue({ code: 'ENOENT' }); // Simulate file not found
- const fact = 'The sky is blue';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
-
- expect(mockFsAdapter.mkdir).toHaveBeenCalledWith(
- path.dirname(testFilePath),
- {
- recursive: true,
- },
- );
- expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce();
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- expect(writeFileCall[0]).toBe(testFilePath);
- const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- expect(writeFileCall[2]).toBe('utf-8');
- });
-
- it('should create section and save a fact if file is empty', async () => {
- mockFsAdapter.readFile.mockResolvedValue(''); // Simulate empty file
- const fact = 'The sky is blue';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- });
-
- it('should add a fact to an existing section', async () => {
- const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n`;
- mockFsAdapter.readFile.mockResolvedValue(initialContent);
- const fact = 'New fact 2';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
-
- expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce();
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n- ${fact}\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- });
-
- it('should add a fact to an existing empty section', async () => {
- const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n`; // Empty section
- mockFsAdapter.readFile.mockResolvedValue(initialContent);
- const fact = 'First fact in section';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
-
- expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce();
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- ${fact}\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- });
-
- it('should add a fact when other ## sections exist and preserve spacing', async () => {
- const initialContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n\n## Another Section\nSome other text.`;
- mockFsAdapter.readFile.mockResolvedValue(initialContent);
- const fact = 'Fact 2';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
-
- expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce();
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- // Note: The implementation ensures a single newline at the end if content exists.
- const expectedContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n- ${fact}\n\n## Another Section\nSome other text.\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- });
-
- it('should correctly trim and add a fact that starts with a dash', async () => {
- mockFsAdapter.readFile.mockResolvedValue(`${MEMORY_SECTION_HEADER}\n`);
- const fact = '- - My fact with dashes';
- await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter);
- const writeFileCall = mockFsAdapter.writeFile.mock.calls[0];
- const expectedContent = `${MEMORY_SECTION_HEADER}\n- My fact with dashes\n`;
- expect(writeFileCall[1]).toBe(expectedContent);
- });
-
- it('should handle error from fsAdapter.writeFile', async () => {
- mockFsAdapter.readFile.mockResolvedValue('');
- mockFsAdapter.writeFile.mockRejectedValue(new Error('Disk full'));
- const fact = 'This will fail';
- await expect(
- MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter),
- ).rejects.toThrow('[MemoryTool] Failed to add memory entry: Disk full');
- });
- });
-
- describe('execute (instance method)', () => {
- let memoryTool: MemoryTool;
- let performAddMemoryEntrySpy: Mock<typeof MemoryTool.performAddMemoryEntry>;
-
- beforeEach(() => {
- memoryTool = new MemoryTool();
- // Spy on the static method for these tests
- performAddMemoryEntrySpy = vi
- .spyOn(MemoryTool, 'performAddMemoryEntry')
- .mockResolvedValue(undefined) as Mock<
- typeof MemoryTool.performAddMemoryEntry
- >;
- // Cast needed as spyOn returns MockInstance
- });
-
- it('should have correct name, displayName, description, and schema', () => {
- expect(memoryTool.name).toBe('save_memory');
- expect(memoryTool.displayName).toBe('Save Memory');
- expect(memoryTool.description).toContain(
- 'Saves a specific piece of information',
- );
- expect(memoryTool.schema).toBeDefined();
- expect(memoryTool.schema.name).toBe('save_memory');
- expect(memoryTool.schema.parameters?.properties?.fact).toBeDefined();
- });
-
- it('should call performAddMemoryEntry with correct parameters and return success', async () => {
- const params = { fact: 'The sky is blue' };
- const result = await memoryTool.execute(params, mockAbortSignal);
- const expectedFilePath = path.join('/mock/home', '.gemini', 'GEMINI.md');
-
- // For this test, we expect the actual fs methods to be passed
- const expectedFsArgument = {
- readFile: fs.readFile,
- writeFile: fs.writeFile,
- mkdir: fs.mkdir,
- };
-
- expect(performAddMemoryEntrySpy).toHaveBeenCalledWith(
- params.fact,
- expectedFilePath,
- expectedFsArgument,
- );
- const successMessage = `Okay, I've remembered that: "${params.fact}"`;
- expect(result.llmContent).toBe(
- JSON.stringify({ success: true, message: successMessage }),
- );
- expect(result.returnDisplay).toBe(successMessage);
- });
-
- it('should return an error if fact is empty', async () => {
- const params = { fact: ' ' }; // Empty fact
- const result = await memoryTool.execute(params, mockAbortSignal);
- const errorMessage = 'Parameter "fact" must be a non-empty string.';
-
- expect(performAddMemoryEntrySpy).not.toHaveBeenCalled();
- expect(result.llmContent).toBe(
- JSON.stringify({ success: false, error: errorMessage }),
- );
- expect(result.returnDisplay).toBe(`Error: ${errorMessage}`);
- });
-
- it('should handle errors from performAddMemoryEntry', async () => {
- const params = { fact: 'This will fail' };
- const underlyingError = new Error(
- '[MemoryTool] Failed to add memory entry: Disk full',
- );
- performAddMemoryEntrySpy.mockRejectedValue(underlyingError);
-
- const result = await memoryTool.execute(params, mockAbortSignal);
-
- expect(result.llmContent).toBe(
- JSON.stringify({
- success: false,
- error: `Failed to save memory. Detail: ${underlyingError.message}`,
- }),
- );
- expect(result.returnDisplay).toBe(
- `Error saving memory: ${underlyingError.message}`,
- );
- });
- });
-});
diff --git a/packages/server/src/tools/memoryTool.ts b/packages/server/src/tools/memoryTool.ts
deleted file mode 100644
index 49dce59d..00000000
--- a/packages/server/src/tools/memoryTool.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { BaseTool, ToolResult } from './tools.js';
-import * as fs from 'fs/promises';
-import * as path from 'path';
-import { homedir } from 'os';
-
-const memoryToolSchemaData = {
- name: 'save_memory',
- description:
- 'Saves a specific piece of information or fact to your long-term memory. Use this when the user explicitly asks you to remember something, or when they state a clear, concise fact that seems important to retain for future interactions.',
- parameters: {
- type: 'object',
- properties: {
- fact: {
- type: 'string',
- description:
- 'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
- },
- },
- required: ['fact'],
- },
-};
-
-const memoryToolDescription = `
-Saves a specific piece of information or fact to your long-term memory.
-
-Use this tool:
-
-- When the user explicitly asks you to remember something (e.g., "Remember that I like pineapple on pizza", "Please save this: my cat's name is Whiskers").
-- When the user states a clear, concise fact about themselves, their preferences, or their environment that seems important for you to retain for future interactions to provide a more personalized and effective assistance.
-
-Do NOT use this tool:
-
-- To remember conversational context that is only relevant for the current session.
-- To save long, complex, or rambling pieces of text. The fact should be relatively short and to the point.
-- If you are unsure whether the information is a fact worth remembering long-term. If in doubt, you can ask the user, "Should I remember that for you?"
-
-## Parameters
-
-- \`fact\` (string, required): The specific fact or piece of information to remember. This should be a clear, self-contained statement. For example, if the user says "My favorite color is blue", the fact would be "My favorite color is blue".
-`;
-
-export const GEMINI_CONFIG_DIR = '.gemini';
-export const GEMINI_MD_FILENAME = 'GEMINI.md';
-export const MEMORY_SECTION_HEADER = '## Gemini Added Memories';
-
-interface SaveMemoryParams {
- fact: string;
-}
-
-function getGlobalMemoryFilePath(): string {
- return path.join(homedir(), GEMINI_CONFIG_DIR, GEMINI_MD_FILENAME);
-}
-
-/**
- * Ensures proper newline separation before appending content.
- */
-function ensureNewlineSeparation(currentContent: string): string {
- if (currentContent.length === 0) return '';
- if (currentContent.endsWith('\n\n') || currentContent.endsWith('\r\n\r\n'))
- return '';
- if (currentContent.endsWith('\n') || currentContent.endsWith('\r\n'))
- return '\n';
- return '\n\n';
-}
-
-export class MemoryTool extends BaseTool<SaveMemoryParams, ToolResult> {
- static readonly Name: string = memoryToolSchemaData.name;
- constructor() {
- super(
- MemoryTool.Name,
- 'Save Memory',
- memoryToolDescription,
- memoryToolSchemaData.parameters as Record<string, unknown>,
- );
- }
-
- static async performAddMemoryEntry(
- text: string,
- memoryFilePath: string,
- fsAdapter: {
- readFile: (path: string, encoding: 'utf-8') => Promise<string>;
- writeFile: (
- path: string,
- data: string,
- encoding: 'utf-8',
- ) => Promise<void>;
- mkdir: (
- path: string,
- options: { recursive: boolean },
- ) => Promise<string | undefined>;
- },
- ): Promise<void> {
- let processedText = text.trim();
- // Remove leading hyphens and spaces that might be misinterpreted as markdown list items
- processedText = processedText.replace(/^(-+\s*)+/, '').trim();
- const newMemoryItem = `- ${processedText}`;
-
- try {
- await fsAdapter.mkdir(path.dirname(memoryFilePath), { recursive: true });
- let content = '';
- try {
- content = await fsAdapter.readFile(memoryFilePath, 'utf-8');
- } catch (_e) {
- // File doesn't exist, will be created with header and item.
- }
-
- const headerIndex = content.indexOf(MEMORY_SECTION_HEADER);
-
- if (headerIndex === -1) {
- // Header not found, append header and then the entry
- const separator = ensureNewlineSeparation(content);
- content += `${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n`;
- } else {
- // Header found, find where to insert the new memory entry
- const startOfSectionContent =
- headerIndex + MEMORY_SECTION_HEADER.length;
- let endOfSectionIndex = content.indexOf('\n## ', startOfSectionContent);
- if (endOfSectionIndex === -1) {
- endOfSectionIndex = content.length; // End of file
- }
-
- const beforeSectionMarker = content
- .substring(0, startOfSectionContent)
- .trimEnd();
- let sectionContent = content
- .substring(startOfSectionContent, endOfSectionIndex)
- .trimEnd();
- const afterSectionMarker = content.substring(endOfSectionIndex);
-
- sectionContent += `\n${newMemoryItem}`;
- content =
- `${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() +
- '\n';
- }
- await fsAdapter.writeFile(memoryFilePath, content, 'utf-8');
- } catch (error) {
- console.error(
- `[MemoryTool] Error adding memory entry to ${memoryFilePath}:`,
- error,
- );
- throw new Error(
- `[MemoryTool] Failed to add memory entry: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- }
-
- async execute(
- params: SaveMemoryParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const { fact } = params;
-
- if (!fact || typeof fact !== 'string' || fact.trim() === '') {
- const errorMessage = 'Parameter "fact" must be a non-empty string.';
- return {
- llmContent: JSON.stringify({ success: false, error: errorMessage }),
- returnDisplay: `Error: ${errorMessage}`,
- };
- }
-
- try {
- // Use the static method with actual fs promises
- await MemoryTool.performAddMemoryEntry(fact, getGlobalMemoryFilePath(), {
- readFile: fs.readFile,
- writeFile: fs.writeFile,
- mkdir: fs.mkdir,
- });
- const successMessage = `Okay, I've remembered that: "${fact}"`;
- return {
- llmContent: JSON.stringify({ success: true, message: successMessage }),
- returnDisplay: successMessage,
- };
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error);
- console.error(
- `[MemoryTool] Error executing save_memory for fact "${fact}": ${errorMessage}`,
- );
- return {
- llmContent: JSON.stringify({
- success: false,
- error: `Failed to save memory. Detail: ${errorMessage}`,
- }),
- returnDisplay: `Error saving memory: ${errorMessage}`,
- };
- }
- }
-}
diff --git a/packages/server/src/tools/read-file.test.ts b/packages/server/src/tools/read-file.test.ts
deleted file mode 100644
index 8ea42134..00000000
--- a/packages/server/src/tools/read-file.test.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
-import { ReadFileTool, ReadFileToolParams } from './read-file.js';
-import * as fileUtils from '../utils/fileUtils.js';
-import path from 'path';
-import os from 'os';
-import fs from 'fs'; // For actual fs operations in setup
-
-// Mock fileUtils.processSingleFileContent
-vi.mock('../utils/fileUtils', async () => {
- const actualFileUtils =
- await vi.importActual<typeof fileUtils>('../utils/fileUtils');
- return {
- ...actualFileUtils, // Spread actual implementations
- processSingleFileContent: vi.fn(), // Mock specific function
- };
-});
-
-const mockProcessSingleFileContent = fileUtils.processSingleFileContent as Mock;
-
-describe('ReadFileTool', () => {
- let tempRootDir: string;
- let tool: ReadFileTool;
- const abortSignal = new AbortController().signal;
-
- beforeEach(() => {
- // Create a unique temporary root directory for each test run
- tempRootDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'read-file-tool-root-'),
- );
- tool = new ReadFileTool(tempRootDir);
- mockProcessSingleFileContent.mockReset();
- });
-
- afterEach(() => {
- // Clean up the temporary root directory
- if (fs.existsSync(tempRootDir)) {
- fs.rmSync(tempRootDir, { recursive: true, force: true });
- }
- });
-
- describe('validateToolParams', () => {
- it('should return null for valid params (absolute path within root)', () => {
- const params: ReadFileToolParams = {
- path: path.join(tempRootDir, 'test.txt'),
- };
- expect(tool.validateToolParams(params)).toBeNull();
- });
-
- it('should return null for valid params with offset and limit', () => {
- const params: ReadFileToolParams = {
- path: path.join(tempRootDir, 'test.txt'),
- offset: 0,
- limit: 10,
- };
- expect(tool.validateToolParams(params)).toBeNull();
- });
-
- it('should return error for relative path', () => {
- const params: ReadFileToolParams = { path: 'test.txt' };
- expect(tool.validateToolParams(params)).toMatch(
- /File path must be absolute/,
- );
- });
-
- it('should return error for path outside root', () => {
- const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt');
- const params: ReadFileToolParams = { path: outsidePath };
- expect(tool.validateToolParams(params)).toMatch(
- /File path must be within the root directory/,
- );
- });
-
- it('should return error for negative offset', () => {
- const params: ReadFileToolParams = {
- path: path.join(tempRootDir, 'test.txt'),
- offset: -1,
- limit: 10,
- };
- expect(tool.validateToolParams(params)).toBe(
- 'Offset must be a non-negative number',
- );
- });
-
- it('should return error for non-positive limit', () => {
- const paramsZero: ReadFileToolParams = {
- path: path.join(tempRootDir, 'test.txt'),
- offset: 0,
- limit: 0,
- };
- expect(tool.validateToolParams(paramsZero)).toBe(
- 'Limit must be a positive number',
- );
- const paramsNegative: ReadFileToolParams = {
- path: path.join(tempRootDir, 'test.txt'),
- offset: 0,
- limit: -5,
- };
- expect(tool.validateToolParams(paramsNegative)).toBe(
- 'Limit must be a positive number',
- );
- });
-
- it('should return error for schema validation failure (e.g. missing path)', () => {
- const params = { offset: 0 } as unknown as ReadFileToolParams;
- expect(tool.validateToolParams(params)).toBe(
- 'Parameters failed schema validation.',
- );
- });
- });
-
- describe('getDescription', () => {
- it('should return a shortened, relative path', () => {
- const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
- const params: ReadFileToolParams = { path: filePath };
- // Assuming tempRootDir is something like /tmp/read-file-tool-root-XXXXXX
- // The relative path would be sub/dir/file.txt
- expect(tool.getDescription(params)).toBe('sub/dir/file.txt');
- });
-
- it('should return . if path is the root directory', () => {
- const params: ReadFileToolParams = { path: tempRootDir };
- expect(tool.getDescription(params)).toBe('.');
- });
- });
-
- describe('execute', () => {
- it('should return validation error if params are invalid', async () => {
- const params: ReadFileToolParams = { path: 'relative/path.txt' };
- const result = await tool.execute(params, abortSignal);
- expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
- expect(result.returnDisplay).toMatch(/File path must be absolute/);
- });
-
- it('should return error from processSingleFileContent if it fails', async () => {
- const filePath = path.join(tempRootDir, 'error.txt');
- const params: ReadFileToolParams = { path: filePath };
- const errorMessage = 'Simulated read error';
- mockProcessSingleFileContent.mockResolvedValue({
- llmContent: `Error reading file ${filePath}: ${errorMessage}`,
- returnDisplay: `Error reading file ${filePath}: ${errorMessage}`,
- error: errorMessage,
- });
-
- const result = await tool.execute(params, abortSignal);
- expect(mockProcessSingleFileContent).toHaveBeenCalledWith(
- filePath,
- tempRootDir,
- undefined,
- undefined,
- );
- expect(result.llmContent).toContain(errorMessage);
- expect(result.returnDisplay).toContain(errorMessage);
- });
-
- it('should return success result for a text file', async () => {
- const filePath = path.join(tempRootDir, 'textfile.txt');
- const fileContent = 'This is a test file.';
- const params: ReadFileToolParams = { path: filePath };
- mockProcessSingleFileContent.mockResolvedValue({
- llmContent: fileContent,
- returnDisplay: `Read text file: ${path.basename(filePath)}`,
- });
-
- const result = await tool.execute(params, abortSignal);
- expect(mockProcessSingleFileContent).toHaveBeenCalledWith(
- filePath,
- tempRootDir,
- undefined,
- undefined,
- );
- expect(result.llmContent).toBe(fileContent);
- expect(result.returnDisplay).toBe(
- `Read text file: ${path.basename(filePath)}`,
- );
- });
-
- it('should return success result for an image file', async () => {
- const filePath = path.join(tempRootDir, 'image.png');
- const imageData = {
- inlineData: { mimeType: 'image/png', data: 'base64...' },
- };
- const params: ReadFileToolParams = { path: filePath };
- mockProcessSingleFileContent.mockResolvedValue({
- llmContent: imageData,
- returnDisplay: `Read image file: ${path.basename(filePath)}`,
- });
-
- const result = await tool.execute(params, abortSignal);
- expect(mockProcessSingleFileContent).toHaveBeenCalledWith(
- filePath,
- tempRootDir,
- undefined,
- undefined,
- );
- expect(result.llmContent).toEqual(imageData);
- expect(result.returnDisplay).toBe(
- `Read image file: ${path.basename(filePath)}`,
- );
- });
-
- it('should pass offset and limit to processSingleFileContent', async () => {
- const filePath = path.join(tempRootDir, 'paginated.txt');
- const params: ReadFileToolParams = {
- path: filePath,
- offset: 10,
- limit: 5,
- };
- mockProcessSingleFileContent.mockResolvedValue({
- llmContent: 'some lines',
- returnDisplay: 'Read text file (paginated)',
- });
-
- await tool.execute(params, abortSignal);
- expect(mockProcessSingleFileContent).toHaveBeenCalledWith(
- filePath,
- tempRootDir,
- 10,
- 5,
- );
- });
- });
-});
diff --git a/packages/server/src/tools/read-file.ts b/packages/server/src/tools/read-file.ts
deleted file mode 100644
index 4bb3bd56..00000000
--- a/packages/server/src/tools/read-file.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import path from 'path';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { makeRelative, shortenPath } from '../utils/paths.js';
-import { BaseTool, ToolResult } from './tools.js';
-import { isWithinRoot, processSingleFileContent } from '../utils/fileUtils.js';
-
-/**
- * Parameters for the ReadFile tool
- */
-export interface ReadFileToolParams {
- /**
- * The absolute path to the file to read
- */
- path: string;
-
- /**
- * The line number to start reading from (optional)
- */
- offset?: number;
-
- /**
- * The number of lines to read (optional)
- */
- limit?: number;
-}
-
-/**
- * Implementation of the ReadFile tool logic
- */
-export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
- static readonly Name: string = 'read_file';
-
- constructor(private rootDirectory: string) {
- super(
- ReadFileTool.Name,
- 'ReadFile',
- 'Reads and returns the content of a specified file from the local filesystem. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.',
- {
- properties: {
- path: {
- description:
- "The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
- type: 'string',
- },
- offset: {
- description:
- "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
- type: 'number',
- },
- limit: {
- description:
- "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
- type: 'number',
- },
- },
- required: ['path'],
- type: 'object',
- },
- );
- this.rootDirectory = path.resolve(rootDirectory);
- }
-
- validateToolParams(params: ReadFileToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
- const filePath = params.path;
- if (!path.isAbsolute(filePath)) {
- return `File path must be absolute: ${filePath}`;
- }
- if (!isWithinRoot(filePath, this.rootDirectory)) {
- return `File path must be within the root directory (${this.rootDirectory}): ${filePath}`;
- }
- if (params.offset !== undefined && params.offset < 0) {
- return 'Offset must be a non-negative number';
- }
- if (params.limit !== undefined && params.limit <= 0) {
- return 'Limit must be a positive number';
- }
- return null;
- }
-
- getDescription(params: ReadFileToolParams): string {
- const relativePath = makeRelative(params.path, this.rootDirectory);
- return shortenPath(relativePath);
- }
-
- async execute(
- params: ReadFileToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: validationError,
- };
- }
-
- const result = await processSingleFileContent(
- params.path,
- this.rootDirectory,
- params.offset,
- params.limit,
- );
-
- if (result.error) {
- return {
- llmContent: result.error, // The detailed error for LLM
- returnDisplay: result.returnDisplay, // User-friendly error
- };
- }
-
- return {
- llmContent: result.llmContent,
- returnDisplay: result.returnDisplay,
- };
- }
-}
diff --git a/packages/server/src/tools/read-many-files.test.ts b/packages/server/src/tools/read-many-files.test.ts
deleted file mode 100644
index 5c6d94fa..00000000
--- a/packages/server/src/tools/read-many-files.test.ts
+++ /dev/null
@@ -1,357 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { vi } from 'vitest';
-import type { Mock } from 'vitest';
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { mockControl } from '../__mocks__/fs/promises.js';
-import { ReadManyFilesTool } from './read-many-files.js';
-import path from 'path';
-import fs from 'fs'; // Actual fs for setup
-import os from 'os';
-
-describe('ReadManyFilesTool', () => {
- let tool: ReadManyFilesTool;
- let tempRootDir: string;
- let tempDirOutsideRoot: string;
- let mockReadFileFn: Mock;
-
- beforeEach(async () => {
- tempRootDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'read-many-files-root-'),
- );
- tempDirOutsideRoot = fs.mkdtempSync(
- path.join(os.tmpdir(), 'read-many-files-external-'),
- );
- tool = new ReadManyFilesTool(tempRootDir);
-
- mockReadFileFn = mockControl.mockReadFile;
- mockReadFileFn.mockReset();
-
- mockReadFileFn.mockImplementation(
- async (filePath: fs.PathLike, options?: Record<string, unknown>) => {
- const fp =
- typeof filePath === 'string'
- ? filePath
- : (filePath as Buffer).toString();
-
- if (fs.existsSync(fp)) {
- const originalFs = await vi.importActual<typeof fs>('fs');
- return originalFs.promises.readFile(fp, options);
- }
-
- if (fp.endsWith('nonexistent-file.txt')) {
- const err = new Error(
- `ENOENT: no such file or directory, open '${fp}'`,
- );
- (err as NodeJS.ErrnoException).code = 'ENOENT';
- throw err;
- }
- if (fp.endsWith('unreadable.txt')) {
- const err = new Error(`EACCES: permission denied, open '${fp}'`);
- (err as NodeJS.ErrnoException).code = 'EACCES';
- throw err;
- }
- if (fp.endsWith('.png'))
- return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header
- if (fp.endsWith('.pdf')) return Buffer.from('%PDF-1.4...'); // PDF start
- if (fp.endsWith('binary.bin'))
- return Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03]);
-
- const err = new Error(
- `ENOENT: no such file or directory, open '${fp}' (unmocked path)`,
- );
- (err as NodeJS.ErrnoException).code = 'ENOENT';
- throw err;
- },
- );
- });
-
- afterEach(() => {
- if (fs.existsSync(tempRootDir)) {
- fs.rmSync(tempRootDir, { recursive: true, force: true });
- }
- if (fs.existsSync(tempDirOutsideRoot)) {
- fs.rmSync(tempDirOutsideRoot, { recursive: true, force: true });
- }
- });
-
- describe('validateParams', () => {
- it('should return null for valid relative paths within root', () => {
- const params = { paths: ['file1.txt', 'subdir/file2.txt'] };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return null for valid glob patterns within root', () => {
- const params = { paths: ['*.txt', 'subdir/**/*.js'] };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return null for paths trying to escape the root (e.g., ../) as execute handles this', () => {
- const params = { paths: ['../outside.txt'] };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return null for absolute paths as execute handles this', () => {
- const params = { paths: [path.join(tempDirOutsideRoot, 'absolute.txt')] };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return error if paths array is empty', () => {
- const params = { paths: [] };
- expect(tool.validateParams(params)).toBe(
- 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.',
- );
- });
-
- it('should return null for valid exclude and include patterns', () => {
- const params = {
- paths: ['src/**/*.ts'],
- exclude: ['**/*.test.ts'],
- include: ['src/utils/*.ts'],
- };
- expect(tool.validateParams(params)).toBeNull();
- });
-
- it('should return error if paths array contains an empty string', () => {
- const params = { paths: ['file1.txt', ''] };
- expect(tool.validateParams(params)).toBe(
- 'Each item in "paths" must be a non-empty string/glob pattern.',
- );
- });
-
- it('should return error if include array contains non-string elements', () => {
- const params = {
- paths: ['file1.txt'],
- include: ['*.ts', 123] as string[],
- };
- expect(tool.validateParams(params)).toBe(
- 'If provided, "include" must be an array of strings/glob patterns.',
- );
- });
-
- it('should return error if exclude array contains non-string elements', () => {
- const params = {
- paths: ['file1.txt'],
- exclude: ['*.log', {}] as string[],
- };
- expect(tool.validateParams(params)).toBe(
- 'If provided, "exclude" must be an array of strings/glob patterns.',
- );
- });
- });
-
- describe('execute', () => {
- const createFile = (filePath: string, content = '') => {
- const fullPath = path.join(tempRootDir, filePath);
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
- fs.writeFileSync(fullPath, content);
- };
- const createBinaryFile = (filePath: string, data: Uint8Array) => {
- const fullPath = path.join(tempRootDir, filePath);
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
- fs.writeFileSync(fullPath, data);
- };
-
- it('should read a single specified file', async () => {
- createFile('file1.txt', 'Content of file1');
- const params = { paths: ['file1.txt'] };
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- '--- file1.txt ---\n\nContent of file1\n\n',
- ]);
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **1 file(s)**',
- );
- });
-
- it('should read multiple specified files', async () => {
- createFile('file1.txt', 'Content1');
- createFile('subdir/file2.js', 'Content2');
- const params = { paths: ['file1.txt', 'subdir/file2.js'] };
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(
- content.some((c) => c.includes('--- file1.txt ---\n\nContent1\n\n')),
- ).toBe(true);
- expect(
- content.some((c) =>
- c.includes('--- subdir/file2.js ---\n\nContent2\n\n'),
- ),
- ).toBe(true);
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **2 file(s)**',
- );
- });
-
- it('should handle glob patterns', async () => {
- createFile('file.txt', 'Text file');
- createFile('another.txt', 'Another text');
- createFile('sub/data.json', '{}');
- const params = { paths: ['*.txt'] };
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(
- content.some((c) => c.includes('--- file.txt ---\n\nText file\n\n')),
- ).toBe(true);
- expect(
- content.some((c) =>
- c.includes('--- another.txt ---\n\nAnother text\n\n'),
- ),
- ).toBe(true);
- expect(content.find((c) => c.includes('sub/data.json'))).toBeUndefined();
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **2 file(s)**',
- );
- });
-
- it('should respect exclude patterns', async () => {
- createFile('src/main.ts', 'Main content');
- createFile('src/main.test.ts', 'Test content');
- const params = { paths: ['src/**/*.ts'], exclude: ['**/*.test.ts'] };
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(content).toEqual(['--- src/main.ts ---\n\nMain content\n\n']);
- expect(
- content.find((c) => c.includes('src/main.test.ts')),
- ).toBeUndefined();
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **1 file(s)**',
- );
- });
-
- it('should handle non-existent specific files gracefully', async () => {
- const params = { paths: ['nonexistent-file.txt'] };
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- 'No files matching the criteria were found or all were skipped.',
- ]);
- expect(result.returnDisplay).toContain(
- 'No files were read and concatenated based on the criteria.',
- );
- });
-
- it('should use default excludes', async () => {
- createFile('node_modules/some-lib/index.js', 'lib code');
- createFile('src/app.js', 'app code');
- const params = { paths: ['**/*.js'] };
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(content).toEqual(['--- src/app.js ---\n\napp code\n\n']);
- expect(
- content.find((c) => c.includes('node_modules/some-lib/index.js')),
- ).toBeUndefined();
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **1 file(s)**',
- );
- });
-
- it('should NOT use default excludes if useDefaultExcludes is false', async () => {
- createFile('node_modules/some-lib/index.js', 'lib code');
- createFile('src/app.js', 'app code');
- const params = { paths: ['**/*.js'], useDefaultExcludes: false };
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(
- content.some((c) =>
- c.includes('--- node_modules/some-lib/index.js ---\n\nlib code\n\n'),
- ),
- ).toBe(true);
- expect(
- content.some((c) => c.includes('--- src/app.js ---\n\napp code\n\n')),
- ).toBe(true);
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **2 file(s)**',
- );
- });
-
- it('should include images as inlineData parts if explicitly requested by extension', async () => {
- createBinaryFile(
- 'image.png',
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
- );
- const params = { paths: ['*.png'] }; // Explicitly requesting .png
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- {
- inlineData: {
- data: Buffer.from([
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
- ]).toString('base64'),
- mimeType: 'image/png',
- },
- },
- ]);
- expect(result.returnDisplay).toContain(
- 'Successfully read and concatenated content from **1 file(s)**',
- );
- });
-
- it('should include images as inlineData parts if explicitly requested by name', async () => {
- createBinaryFile(
- 'myExactImage.png',
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
- );
- const params = { paths: ['myExactImage.png'] }; // Explicitly requesting by full name
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- {
- inlineData: {
- data: Buffer.from([
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
- ]).toString('base64'),
- mimeType: 'image/png',
- },
- },
- ]);
- });
-
- it('should skip PDF files if not explicitly requested by extension or name', async () => {
- createBinaryFile('document.pdf', Buffer.from('%PDF-1.4...'));
- createFile('notes.txt', 'text notes');
- const params = { paths: ['*'] }; // Generic glob, not specific to .pdf
- const result = await tool.execute(params, new AbortController().signal);
- const content = result.llmContent as string[];
- expect(
- content.some(
- (c) => typeof c === 'string' && c.includes('--- notes.txt ---'),
- ),
- ).toBe(true);
- expect(result.returnDisplay).toContain('**Skipped 1 item(s):**');
- expect(result.returnDisplay).toContain(
- '- `document.pdf` (Reason: asset file (image/pdf) was not explicitly requested by name or extension)',
- );
- });
-
- it('should include PDF files as inlineData parts if explicitly requested by extension', async () => {
- createBinaryFile('important.pdf', Buffer.from('%PDF-1.4...'));
- const params = { paths: ['*.pdf'] }; // Explicitly requesting .pdf files
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- {
- inlineData: {
- data: Buffer.from('%PDF-1.4...').toString('base64'),
- mimeType: 'application/pdf',
- },
- },
- ]);
- });
-
- it('should include PDF files as inlineData parts if explicitly requested by name', async () => {
- createBinaryFile('report-final.pdf', Buffer.from('%PDF-1.4...'));
- const params = { paths: ['report-final.pdf'] };
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toEqual([
- {
- inlineData: {
- data: Buffer.from('%PDF-1.4...').toString('base64'),
- mimeType: 'application/pdf',
- },
- },
- ]);
- });
- });
-});
diff --git a/packages/server/src/tools/read-many-files.ts b/packages/server/src/tools/read-many-files.ts
deleted file mode 100644
index d826c9ba..00000000
--- a/packages/server/src/tools/read-many-files.ts
+++ /dev/null
@@ -1,416 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { BaseTool, ToolResult } from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { getErrorMessage } from '../utils/errors.js';
-import * as path from 'path';
-import fg from 'fast-glob';
-import { GEMINI_MD_FILENAME } from './memoryTool.js';
-import {
- detectFileType,
- processSingleFileContent,
- DEFAULT_ENCODING,
-} from '../utils/fileUtils.js';
-import { PartListUnion } from '@google/genai';
-
-/**
- * Parameters for the ReadManyFilesTool.
- */
-export interface ReadManyFilesParams {
- /**
- * An array of file paths or directory paths to search within.
- * Paths are relative to the tool's configured target directory.
- * Glob patterns can be used directly in these paths.
- */
- paths: string[];
-
- /**
- * Optional. Glob patterns for files to include.
- * These are effectively combined with the `paths`.
- * Example: ["*.ts", "src/** /*.md"]
- */
- include?: string[];
-
- /**
- * Optional. Glob patterns for files/directories to exclude.
- * Applied as ignore patterns.
- * Example: ["*.log", "dist/**"]
- */
- exclude?: string[];
-
- /**
- * Optional. Search directories recursively.
- * This is generally controlled by glob patterns (e.g., `**`).
- * The glob implementation is recursive by default for `**`.
- * For simplicity, we'll rely on `**` for recursion.
- */
- recursive?: boolean;
-
- /**
- * Optional. Apply default exclusion patterns. Defaults to true.
- */
- useDefaultExcludes?: boolean;
-}
-
-/**
- * Default exclusion patterns for commonly ignored directories and binary file types.
- * These are compatible with glob ignore patterns.
- * TODO(adh): Consider making this configurable or extendable through a command line arguement.
- * TODO(adh): Look into sharing this list with the glob tool.
- */
-const DEFAULT_EXCLUDES: string[] = [
- '**/node_modules/**',
- '**/.git/**',
- '**/.vscode/**',
- '**/.idea/**',
- '**/dist/**',
- '**/build/**',
- '**/coverage/**',
- '**/__pycache__/**',
- '**/*.pyc',
- '**/*.pyo',
- '**/*.bin',
- '**/*.exe',
- '**/*.dll',
- '**/*.so',
- '**/*.dylib',
- '**/*.class',
- '**/*.jar',
- '**/*.war',
- '**/*.zip',
- '**/*.tar',
- '**/*.gz',
- '**/*.bz2',
- '**/*.rar',
- '**/*.7z',
- '**/*.doc',
- '**/*.docx',
- '**/*.xls',
- '**/*.xlsx',
- '**/*.ppt',
- '**/*.pptx',
- '**/*.odt',
- '**/*.ods',
- '**/*.odp',
- '**/*.DS_Store',
- '**/.env',
- `**/${GEMINI_MD_FILENAME}`,
-];
-
-const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
-
-/**
- * Tool implementation for finding and reading multiple text files from the local filesystem
- * within a specified target directory. The content is concatenated.
- * It is intended to run in an environment with access to the local file system (e.g., a Node.js backend).
- */
-export class ReadManyFilesTool extends BaseTool<
- ReadManyFilesParams,
- ToolResult
-> {
- static readonly Name: string = 'read_many_files';
-
- /**
- * Creates an instance of ReadManyFilesTool.
- * @param targetDir The absolute root directory within which this tool is allowed to operate.
- * All paths provided in `params` will be resolved relative to this directory.
- */
- constructor(readonly targetDir: string) {
- const parameterSchema: Record<string, unknown> = {
- type: 'object',
- properties: {
- paths: {
- type: 'array',
- items: { type: 'string' },
- description:
- "Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']",
- },
- include: {
- type: 'array',
- items: { type: 'string' },
- description:
- 'Optional. Additional glob patterns to include. These are merged with `paths`. Example: ["*.test.ts"] to specifically add test files if they were broadly excluded.',
- default: [],
- },
- exclude: {
- type: 'array',
- items: { type: 'string' },
- description:
- 'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: ["**/*.log", "temp/"]',
- default: [],
- },
- recursive: {
- type: 'boolean',
- description:
- 'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.',
- default: true,
- },
- useDefaultExcludes: {
- type: 'boolean',
- description:
- 'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
- default: true,
- },
- },
- required: ['paths'],
- };
-
- super(
- ReadManyFilesTool.Name,
- 'ReadManyFiles',
- `Reads content from multiple files specified by paths or glob patterns within a configured target directory. For text files, it concatenates their content into a single string. It is primarily designed for text-based files. However, it can also process image (e.g., .png, .jpg) and PDF (.pdf) files if their file names or extensions are explicitly included in the 'paths' argument. For these explicitly requested non-text files, their data is read and included in a format suitable for model consumption (e.g., base64 encoded).
-
-This tool is useful when you need to understand or analyze a collection of files, such as:
-- Getting an overview of a codebase or parts of it (e.g., all TypeScript files in the 'src' directory).
-- Finding where specific functionality is implemented if the user asks broad questions about code.
-- Reviewing documentation files (e.g., all Markdown files in the 'docs' directory).
-- Gathering context from multiple configuration files.
-- When the user asks to "read all files in X directory" or "show me the content of all Y files".
-
-Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
- parameterSchema,
- );
- this.targetDir = path.resolve(targetDir);
- }
-
- validateParams(params: ReadManyFilesParams): string | null {
- if (
- !params.paths ||
- !Array.isArray(params.paths) ||
- params.paths.length === 0
- ) {
- return 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.';
- }
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- if (
- !params.paths ||
- !Array.isArray(params.paths) ||
- params.paths.length === 0
- ) {
- return 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.';
- }
- return 'Parameters failed schema validation. Ensure "paths" is a non-empty array and other parameters match their expected types.';
- }
- for (const p of params.paths) {
- if (typeof p !== 'string' || p.trim() === '') {
- return 'Each item in "paths" must be a non-empty string/glob pattern.';
- }
- }
- if (
- params.include &&
- (!Array.isArray(params.include) ||
- !params.include.every((item) => typeof item === 'string'))
- ) {
- return 'If provided, "include" must be an array of strings/glob patterns.';
- }
- if (
- params.exclude &&
- (!Array.isArray(params.exclude) ||
- !params.exclude.every((item) => typeof item === 'string'))
- ) {
- return 'If provided, "exclude" must be an array of strings/glob patterns.';
- }
- return null;
- }
-
- getDescription(params: ReadManyFilesParams): string {
- const allPatterns = [...params.paths, ...(params.include || [])];
- const pathDesc = `using patterns: \`${allPatterns.join('`, `')}\` (within target directory: \`${this.targetDir}\`)`;
-
- let effectiveExcludes =
- params.useDefaultExcludes !== false ? [...DEFAULT_EXCLUDES] : [];
- if (params.exclude && params.exclude.length > 0) {
- effectiveExcludes = [...effectiveExcludes, ...params.exclude];
- }
- const excludeDesc = `Excluding: ${effectiveExcludes.length > 0 ? `patterns like \`${effectiveExcludes.slice(0, 2).join('`, `')}${effectiveExcludes.length > 2 ? '...`' : '`'}` : 'none explicitly (beyond default non-text file avoidance).'}`;
-
- return `Will attempt to read and concatenate files ${pathDesc}. ${excludeDesc}. File encoding: ${DEFAULT_ENCODING}. Separator: "${DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace('{filePath}', 'path/to/file.ext')}".`;
- }
-
- async execute(
- params: ReadManyFilesParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters for ${this.displayName}. Reason: ${validationError}`,
- returnDisplay: `## Parameter Error\n\n${validationError}`,
- };
- }
-
- const {
- paths: inputPatterns,
- include = [],
- exclude = [],
- useDefaultExcludes = true,
- } = params;
-
- const toolBaseDir = this.targetDir;
- const filesToConsider = new Set<string>();
- const skippedFiles: Array<{ path: string; reason: string }> = [];
- const processedFilesRelativePaths: string[] = [];
- const contentParts: PartListUnion = [];
-
- const effectiveExcludes = useDefaultExcludes
- ? [...DEFAULT_EXCLUDES, ...exclude]
- : [...exclude];
-
- const searchPatterns = [...inputPatterns, ...include];
- if (searchPatterns.length === 0) {
- return {
- llmContent: 'No search paths or include patterns provided.',
- returnDisplay: `## Information\n\nNo search paths or include patterns were specified. Nothing to read or concatenate.`,
- };
- }
-
- try {
- // Using fast-glob (fg) for file searching based on patterns.
- // The `cwd` option scopes the search to the toolBaseDir.
- // `ignore` handles exclusions.
- // `onlyFiles` ensures only files are returned.
- // `dot` allows matching dotfiles (which can still be excluded by patterns).
- // `absolute` returns absolute paths for consistent handling.
- const entries = await fg(searchPatterns, {
- cwd: toolBaseDir,
- ignore: effectiveExcludes,
- onlyFiles: true,
- dot: true,
- absolute: true,
- caseSensitiveMatch: false,
- });
-
- for (const absoluteFilePath of entries) {
- // Security check: ensure the glob library didn't return something outside targetDir.
- // This should be guaranteed by `cwd` and the library's sandboxing, but an extra check is good practice.
- if (!absoluteFilePath.startsWith(toolBaseDir)) {
- skippedFiles.push({
- path: absoluteFilePath,
- reason: `Security: Glob library returned path outside target directory. Base: ${toolBaseDir}, Path: ${absoluteFilePath}`,
- });
- continue;
- }
- filesToConsider.add(absoluteFilePath);
- }
- } catch (error) {
- return {
- llmContent: `Error during file search: ${getErrorMessage(error)}`,
- returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``,
- };
- }
-
- const sortedFiles = Array.from(filesToConsider).sort();
-
- for (const filePath of sortedFiles) {
- const relativePathForDisplay = path
- .relative(toolBaseDir, filePath)
- .replace(/\\/g, '/');
-
- const fileType = detectFileType(filePath);
-
- if (fileType === 'image' || fileType === 'pdf') {
- const fileExtension = path.extname(filePath).toLowerCase();
- const fileNameWithoutExtension = path.basename(filePath, fileExtension);
- const requestedExplicitly = inputPatterns.some(
- (pattern: string) =>
- pattern.toLowerCase().includes(fileExtension) ||
- pattern.includes(fileNameWithoutExtension),
- );
-
- if (!requestedExplicitly) {
- skippedFiles.push({
- path: relativePathForDisplay,
- reason:
- 'asset file (image/pdf) was not explicitly requested by name or extension',
- });
- continue;
- }
- }
-
- // Use processSingleFileContent for all file types now
- const fileReadResult = await processSingleFileContent(
- filePath,
- toolBaseDir,
- );
-
- if (fileReadResult.error) {
- skippedFiles.push({
- path: relativePathForDisplay,
- reason: `Read error: ${fileReadResult.error}`,
- });
- } else {
- if (typeof fileReadResult.llmContent === 'string') {
- const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
- '{filePath}',
- relativePathForDisplay,
- );
- contentParts.push(`${separator}\n\n${fileReadResult.llmContent}\n\n`);
- } else {
- contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
- }
- processedFilesRelativePaths.push(relativePathForDisplay);
- }
- }
-
- let displayMessage = `### ReadManyFiles Result (Target Dir: \`${this.targetDir}\`)\n\n`;
- if (processedFilesRelativePaths.length > 0) {
- displayMessage += `Successfully read and concatenated content from **${processedFilesRelativePaths.length} file(s)**.\n`;
- if (processedFilesRelativePaths.length <= 10) {
- displayMessage += `\n**Processed Files:**\n`;
- processedFilesRelativePaths.forEach(
- (p) => (displayMessage += `- \`${p}\`\n`),
- );
- } else {
- displayMessage += `\n**Processed Files (first 10 shown):**\n`;
- processedFilesRelativePaths
- .slice(0, 10)
- .forEach((p) => (displayMessage += `- \`${p}\`\n`));
- displayMessage += `- ...and ${processedFilesRelativePaths.length - 10} more.\n`;
- }
- }
-
- if (skippedFiles.length > 0) {
- if (processedFilesRelativePaths.length === 0) {
- displayMessage += `No files were read and concatenated based on the criteria.\n`;
- }
- if (skippedFiles.length <= 5) {
- displayMessage += `\n**Skipped ${skippedFiles.length} item(s):**\n`;
- } else {
- displayMessage += `\n**Skipped ${skippedFiles.length} item(s) (first 5 shown):**\n`;
- }
- skippedFiles
- .slice(0, 5)
- .forEach(
- (f) => (displayMessage += `- \`${f.path}\` (Reason: ${f.reason})\n`),
- );
- if (skippedFiles.length > 5) {
- displayMessage += `- ...and ${skippedFiles.length - 5} more.\n`;
- }
- } else if (
- processedFilesRelativePaths.length === 0 &&
- skippedFiles.length === 0
- ) {
- displayMessage += `No files were read and concatenated based on the criteria.\n`;
- }
-
- if (contentParts.length === 0) {
- contentParts.push(
- 'No files matching the criteria were found or all were skipped.',
- );
- }
- return {
- llmContent: contentParts,
- returnDisplay: displayMessage.trim(),
- };
- }
-}
diff --git a/packages/server/src/tools/shell.json b/packages/server/src/tools/shell.json
deleted file mode 100644
index a4c018c7..00000000
--- a/packages/server/src/tools/shell.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "type": "object",
- "properties": {
- "command": {
- "description": "Exact bash command to execute as `bash -c <command>`",
- "type": "string"
- },
- "description": {
- "description": "Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.",
- "type": "string"
- },
- "directory": {
- "description": "(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.",
- "type": "string"
- }
- },
- "required": ["command"]
-}
diff --git a/packages/server/src/tools/shell.md b/packages/server/src/tools/shell.md
deleted file mode 100644
index 069a76db..00000000
--- a/packages/server/src/tools/shell.md
+++ /dev/null
@@ -1,14 +0,0 @@
-This tool executes a given shell command as `bash -c <command>`.
-Command can start background processes using `&`.
-Command itself is executed as a subprocess.
-
-The following information is returned:
-
-Command: Executed command.
-Directory: Directory (relative to project root) where command was executed, or `(root)`.
-Stdout: Output on stdout stream. Can be `(empty)` or partial on error and for any unwaited background processes.
-Stderr: Output on stderr stream. Can be `(empty)` or partial on error and for any unwaited background processes.
-Error: Error or `(none)` if no error was reported for the subprocess.
-Exit Code: Exit code or `(none)` if terminated by signal.
-Signal: Signal number or `(none)` if no signal was received.
-Background PIDs: List of background processes started or `(none)`.
diff --git a/packages/server/src/tools/shell.ts b/packages/server/src/tools/shell.ts
deleted file mode 100644
index 4efc3500..00000000
--- a/packages/server/src/tools/shell.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import os from 'os';
-import crypto from 'crypto';
-import { Config } from '../config/config.js';
-import {
- BaseTool,
- ToolResult,
- ToolCallConfirmationDetails,
- ToolExecuteConfirmationDetails,
- ToolConfirmationOutcome,
-} from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { getErrorMessage } from '../utils/errors.js';
-export interface ShellToolParams {
- command: string;
- description?: string;
- directory?: string;
-}
-import { spawn } from 'child_process';
-
-const OUTPUT_UPDATE_INTERVAL_MS = 1000;
-
-export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
- static Name: string = 'execute_bash_command';
- private whitelist: Set<string> = new Set();
-
- constructor(private readonly config: Config) {
- const toolDisplayName = 'Shell';
- const descriptionUrl = new URL('shell.md', import.meta.url);
- const toolDescription = fs.readFileSync(descriptionUrl, 'utf-8');
- const schemaUrl = new URL('shell.json', import.meta.url);
- const toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8'));
- super(
- ShellTool.Name,
- toolDisplayName,
- toolDescription,
- toolParameterSchema,
- false, // output is not markdown
- true, // output can be updated
- );
- }
-
- getDescription(params: ShellToolParams): string {
- let description = `${params.command}`;
- // append optional [in directory]
- // note description is needed even if validation fails due to absolute path
- if (params.directory) {
- description += ` [in ${params.directory}]`;
- }
- // append optional (description), replacing any line breaks with spaces
- if (params.description) {
- description += ` (${params.description.replace(/\n/g, ' ')})`;
- }
- return description;
- }
-
- getCommandRoot(command: string): string | undefined {
- return command
- .trim() // remove leading and trailing whitespace
- .replace(/[{}()]/g, '') // remove all grouping operators
- .split(/[\s;&|]+/)[0] // split on any whitespace or separator or chaining operators and take first part
- ?.split(/[/\\]/) // split on any path separators (or return undefined if previous line was undefined)
- .pop(); // take last part and return command root (or undefined if previous line was empty)
- }
-
- validateToolParams(params: ShellToolParams): string | null {
- if (
- !SchemaValidator.validate(
- this.parameterSchema as Record<string, unknown>,
- params,
- )
- ) {
- return `Parameters failed schema validation.`;
- }
- if (!params.command.trim()) {
- return 'Command cannot be empty.';
- }
- if (!this.getCommandRoot(params.command)) {
- return 'Could not identify command root to obtain permission from user.';
- }
- if (params.directory) {
- if (path.isAbsolute(params.directory)) {
- return 'Directory cannot be absolute. Must be relative to the project root directory.';
- }
- const directory = path.resolve(
- this.config.getTargetDir(),
- params.directory,
- );
- if (!fs.existsSync(directory)) {
- return 'Directory must exist.';
- }
- }
- return null;
- }
-
- async shouldConfirmExecute(
- params: ShellToolParams,
- _abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.validateToolParams(params)) {
- return false; // skip confirmation, execute call will fail immediately
- }
- const rootCommand = this.getCommandRoot(params.command)!; // must be non-empty string post-validation
- if (this.whitelist.has(rootCommand)) {
- return false; // already approved and whitelisted
- }
- const confirmationDetails: ToolExecuteConfirmationDetails = {
- type: 'exec',
- title: 'Confirm Shell Command',
- command: params.command,
- rootCommand,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.whitelist.add(rootCommand);
- }
- },
- };
- return confirmationDetails;
- }
-
- async execute(
- params: ShellToolParams,
- abortSignal: AbortSignal,
- updateOutput?: (chunk: string) => void,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: [
- `Command rejected: ${params.command}`,
- `Reason: ${validationError}`,
- ].join('\n'),
- returnDisplay: `Error: ${validationError}`,
- };
- }
-
- // wrap command to append subprocess pids (via pgrep) to temporary file
- const tempFileName = `shell_pgrep_${crypto.randomBytes(6).toString('hex')}.tmp`;
- const tempFilePath = path.join(os.tmpdir(), tempFileName);
-
- let command = params.command.trim();
- if (!command.endsWith('&')) command += ';';
- command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
-
- // spawn command in specified directory (or project root if not specified)
- const shell = spawn('bash', ['-c', command], {
- stdio: ['ignore', 'pipe', 'pipe'],
- detached: true, // ensure subprocess starts its own process group (esp. in Linux)
- cwd: path.resolve(this.config.getTargetDir(), params.directory || ''),
- });
-
- let exited = false;
- let stdout = '';
- let output = '';
- let lastUpdateTime = Date.now();
-
- const appendOutput = (str: string) => {
- output += str;
- if (
- updateOutput &&
- Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
- ) {
- updateOutput(output);
- lastUpdateTime = Date.now();
- }
- };
-
- shell.stdout.on('data', (data: Buffer) => {
- // continue to consume post-exit for background processes
- // removing listeners can overflow OS buffer and block subprocesses
- // destroying (e.g. shell.stdout.destroy()) can terminate subprocesses via SIGPIPE
- if (!exited) {
- const str = data.toString();
- stdout += str;
- appendOutput(str);
- }
- });
-
- let stderr = '';
- shell.stderr.on('data', (data: Buffer) => {
- if (!exited) {
- const str = data.toString();
- stderr += str;
- appendOutput(str);
- }
- });
-
- let error: Error | null = null;
- shell.on('error', (err: Error) => {
- error = err;
- // remove wrapper from user's command in error message
- error.message = error.message.replace(command, params.command);
- });
-
- let code: number | null = null;
- let processSignal: NodeJS.Signals | null = null;
- const exitHandler = (
- _code: number | null,
- _signal: NodeJS.Signals | null,
- ) => {
- exited = true;
- code = _code;
- processSignal = _signal;
- };
- shell.on('exit', exitHandler);
-
- const abortHandler = async () => {
- if (shell.pid && !exited) {
- try {
- // attempt to SIGTERM process group (negative PID)
- // fall back to SIGKILL (to group) after 200ms
- process.kill(-shell.pid, 'SIGTERM');
- await new Promise((resolve) => setTimeout(resolve, 200));
- if (shell.pid && !exited) {
- process.kill(-shell.pid, 'SIGKILL');
- }
- } catch (_e) {
- // if group kill fails, fall back to killing just the main process
- try {
- if (shell.pid) {
- shell.kill('SIGKILL');
- }
- } catch (_e) {
- console.error(`failed to kill shell process ${shell.pid}: ${_e}`);
- }
- }
- }
- };
- abortSignal.addEventListener('abort', abortHandler);
-
- // wait for the shell to exit
- await new Promise((resolve) => shell.on('exit', resolve));
-
- abortSignal.removeEventListener('abort', abortHandler);
-
- // parse pids (pgrep output) from temporary file and remove it
- const backgroundPIDs: number[] = [];
- if (fs.existsSync(tempFilePath)) {
- const pgrepLines = fs
- .readFileSync(tempFilePath, 'utf8')
- .split('\n')
- .filter(Boolean);
- for (const line of pgrepLines) {
- if (!/^\d+$/.test(line)) {
- console.error(`pgrep: ${line}`);
- }
- const pid = Number(line);
- // exclude the shell subprocess pid
- if (pid !== shell.pid) {
- backgroundPIDs.push(pid);
- }
- }
- fs.unlinkSync(tempFilePath);
- } else {
- if (!abortSignal.aborted) {
- console.error('missing pgrep output');
- }
- }
-
- let llmContent = '';
- if (abortSignal.aborted) {
- llmContent = 'Command was cancelled by user before it could complete.';
- if (output.trim()) {
- llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`;
- } else {
- llmContent += ' There was no output before it was cancelled.';
- }
- } else {
- llmContent = [
- `Command: ${params.command}`,
- `Directory: ${params.directory || '(root)'}`,
- `Stdout: ${stdout || '(empty)'}`,
- `Stderr: ${stderr || '(empty)'}`,
- `Error: ${error ?? '(none)'}`,
- `Exit Code: ${code ?? '(none)'}`,
- `Signal: ${processSignal ?? '(none)'}`,
- `Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`,
- ].join('\n');
- }
-
- let returnDisplayMessage = '';
- if (this.config.getDebugMode()) {
- returnDisplayMessage = llmContent;
- } else {
- if (output.trim()) {
- returnDisplayMessage = output;
- } else {
- // Output is empty, let's provide a reason if the command failed or was cancelled
- if (abortSignal.aborted) {
- returnDisplayMessage = 'Command cancelled by user.';
- } else if (processSignal) {
- returnDisplayMessage = `Command terminated by signal: ${processSignal}`;
- } else if (error) {
- // If error is not null, it's an Error object (or other truthy value)
- returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`;
- } else if (code !== null && code !== 0) {
- returnDisplayMessage = `Command exited with code: ${code}`;
- }
- // If output is empty and command succeeded (code 0, no error/signal/abort),
- // returnDisplayMessage will remain empty, which is fine.
- }
- }
-
- return { llmContent, returnDisplay: returnDisplayMessage };
- }
-}
diff --git a/packages/server/src/tools/tool-registry.test.ts b/packages/server/src/tools/tool-registry.test.ts
deleted file mode 100644
index 121e91c8..00000000
--- a/packages/server/src/tools/tool-registry.test.ts
+++ /dev/null
@@ -1,776 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import {
- describe,
- it,
- expect,
- vi,
- beforeEach,
- afterEach,
- Mocked,
-} from 'vitest';
-import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
-import { DiscoveredMCPTool } from './mcp-tool.js';
-import { Config, ConfigParameters } from '../config/config.js';
-import { BaseTool, ToolResult } from './tools.js';
-import { FunctionDeclaration } from '@google/genai';
-import { execSync, spawn } from 'node:child_process'; // Import spawn here
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
-
-// Mock node:child_process
-vi.mock('node:child_process', async () => {
- const actual = await vi.importActual('node:child_process');
- return {
- ...actual,
- execSync: vi.fn(),
- spawn: vi.fn(),
- };
-});
-
-// Mock MCP SDK
-vi.mock('@modelcontextprotocol/sdk/client/index.js', () => {
- const Client = vi.fn();
- Client.prototype.connect = vi.fn();
- Client.prototype.listTools = vi.fn();
- Client.prototype.callTool = vi.fn();
- return { Client };
-});
-
-vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => {
- const StdioClientTransport = vi.fn();
- StdioClientTransport.prototype.stderr = {
- on: vi.fn(),
- };
- return { StdioClientTransport };
-});
-
-class MockTool extends BaseTool<{ param: string }, ToolResult> {
- constructor(name = 'mock-tool', description = 'A mock tool') {
- super(name, name, description, {
- type: 'object',
- properties: {
- param: { type: 'string' },
- },
- required: ['param'],
- });
- }
-
- async execute(params: { param: string }): Promise<ToolResult> {
- return {
- llmContent: `Executed with ${params.param}`,
- returnDisplay: `Executed with ${params.param}`,
- };
- }
-}
-
-const baseConfigParams: ConfigParameters = {
- apiKey: 'test-api-key',
- model: 'test-model',
- sandbox: false,
- targetDir: '/test/dir',
- debugMode: false,
- question: undefined,
- fullContext: false,
- coreTools: undefined,
- toolDiscoveryCommand: undefined,
- toolCallCommand: undefined,
- mcpServerCommand: undefined,
- mcpServers: undefined,
- userAgent: 'TestAgent/1.0',
- userMemory: '',
- geminiMdFileCount: 0,
- alwaysSkipModificationConfirmation: false,
- vertexai: false,
-};
-
-describe('ToolRegistry', () => {
- let config: Config;
- let toolRegistry: ToolRegistry;
-
- beforeEach(() => {
- config = new Config(baseConfigParams); // Use base params
- toolRegistry = new ToolRegistry(config);
- vi.spyOn(console, 'warn').mockImplementation(() => {}); // Suppress console.warn
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- describe('registerTool', () => {
- it('should register a new tool', () => {
- const tool = new MockTool();
- toolRegistry.registerTool(tool);
- expect(toolRegistry.getTool('mock-tool')).toBe(tool);
- });
-
- it('should overwrite an existing tool with the same name and log a warning', () => {
- const tool1 = new MockTool('tool1');
- const tool2 = new MockTool('tool1'); // Same name
- toolRegistry.registerTool(tool1);
- toolRegistry.registerTool(tool2);
- expect(toolRegistry.getTool('tool1')).toBe(tool2);
- expect(console.warn).toHaveBeenCalledWith(
- 'Tool with name "tool1" is already registered. Overwriting.',
- );
- });
- });
-
- describe('getFunctionDeclarations', () => {
- it('should return an empty array if no tools are registered', () => {
- expect(toolRegistry.getFunctionDeclarations()).toEqual([]);
- });
-
- it('should return function declarations for registered tools', () => {
- const tool1 = new MockTool('tool1');
- const tool2 = new MockTool('tool2');
- toolRegistry.registerTool(tool1);
- toolRegistry.registerTool(tool2);
- const declarations = toolRegistry.getFunctionDeclarations();
- expect(declarations).toHaveLength(2);
- expect(declarations.map((d: FunctionDeclaration) => d.name)).toContain(
- 'tool1',
- );
- expect(declarations.map((d: FunctionDeclaration) => d.name)).toContain(
- 'tool2',
- );
- });
- });
-
- describe('getAllTools', () => {
- it('should return an empty array if no tools are registered', () => {
- expect(toolRegistry.getAllTools()).toEqual([]);
- });
-
- it('should return all registered tools', () => {
- const tool1 = new MockTool('tool1');
- const tool2 = new MockTool('tool2');
- toolRegistry.registerTool(tool1);
- toolRegistry.registerTool(tool2);
- const tools = toolRegistry.getAllTools();
- expect(tools).toHaveLength(2);
- expect(tools).toContain(tool1);
- expect(tools).toContain(tool2);
- });
- });
-
- describe('getTool', () => {
- it('should return undefined if the tool is not found', () => {
- expect(toolRegistry.getTool('non-existent-tool')).toBeUndefined();
- });
-
- it('should return the tool if found', () => {
- const tool = new MockTool();
- toolRegistry.registerTool(tool);
- expect(toolRegistry.getTool('mock-tool')).toBe(tool);
- });
- });
-
- // New describe block for coreTools testing
- describe('core tool registration based on config.coreTools', () => {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const MOCK_TOOL_ALPHA_CLASS_NAME = 'MockCoreToolAlpha'; // Class.name
- const MOCK_TOOL_ALPHA_STATIC_NAME = 'ToolAlphaFromStatic'; // Tool.Name and registration name
- class MockCoreToolAlpha extends BaseTool<any, ToolResult> {
- static readonly Name = MOCK_TOOL_ALPHA_STATIC_NAME;
- constructor() {
- super(
- MockCoreToolAlpha.Name,
- MockCoreToolAlpha.Name,
- 'Description for Alpha Tool',
- {},
- );
- }
- async execute(_params: any): Promise<ToolResult> {
- return { llmContent: 'AlphaExecuted', returnDisplay: 'AlphaExecuted' };
- }
- }
-
- const MOCK_TOOL_BETA_CLASS_NAME = 'MockCoreToolBeta'; // Class.name
- const MOCK_TOOL_BETA_STATIC_NAME = 'ToolBetaFromStatic'; // Tool.Name and registration name
- class MockCoreToolBeta extends BaseTool<any, ToolResult> {
- static readonly Name = MOCK_TOOL_BETA_STATIC_NAME;
- constructor() {
- super(
- MockCoreToolBeta.Name,
- MockCoreToolBeta.Name,
- 'Description for Beta Tool',
- {},
- );
- }
- async execute(_params: any): Promise<ToolResult> {
- return { llmContent: 'BetaExecuted', returnDisplay: 'BetaExecuted' };
- }
- }
-
- const availableCoreToolClasses = [MockCoreToolAlpha, MockCoreToolBeta];
- let currentConfig: Config;
- let currentToolRegistry: ToolRegistry;
-
- // Helper to set up Config, ToolRegistry, and simulate core tool registration
- const setupRegistryAndSimulateRegistration = (
- coreToolsValueInConfig: string[] | undefined,
- ) => {
- currentConfig = new Config({
- ...baseConfigParams, // Use base and override coreTools
- coreTools: coreToolsValueInConfig,
- });
-
- // We assume Config has a getter like getCoreTools() or stores it publicly.
- // For this test, we'll directly use coreToolsValueInConfig for the simulation logic,
- // as that's what Config would provide.
- const coreToolsListFromConfig = coreToolsValueInConfig; // Simulating config.getCoreTools()
-
- currentToolRegistry = new ToolRegistry(currentConfig);
-
- // Simulate the external process that registers core tools based on config
- if (coreToolsListFromConfig === undefined) {
- // If coreTools is undefined, all available core tools are registered
- availableCoreToolClasses.forEach((ToolClass) => {
- currentToolRegistry.registerTool(new ToolClass());
- });
- } else {
- // If coreTools is an array, register tools if their static Name or class name is in the list
- availableCoreToolClasses.forEach((ToolClass) => {
- if (
- coreToolsListFromConfig.includes(ToolClass.Name) || // Check against static Name
- coreToolsListFromConfig.includes(ToolClass.name) // Check against class name
- ) {
- currentToolRegistry.registerTool(new ToolClass());
- }
- });
- }
- };
-
- // beforeEach for this nested describe is not strictly needed if setup is per-test,
- // but ensure console.warn is mocked if any registration overwrites occur (though unlikely with this setup).
- beforeEach(() => {
- vi.spyOn(console, 'warn').mockImplementation(() => {});
- });
-
- it('should register all core tools if coreTools config is undefined', () => {
- setupRegistryAndSimulateRegistration(undefined);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_ALPHA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolAlpha);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_BETA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolBeta);
- expect(currentToolRegistry.getAllTools()).toHaveLength(2);
- });
-
- it('should register no core tools if coreTools config is an empty array []', () => {
- setupRegistryAndSimulateRegistration([]);
- expect(currentToolRegistry.getAllTools()).toHaveLength(0);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_ALPHA_STATIC_NAME),
- ).toBeUndefined();
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_BETA_STATIC_NAME),
- ).toBeUndefined();
- });
-
- it('should register only tools specified by their static Name (ToolClass.Name) in coreTools config', () => {
- setupRegistryAndSimulateRegistration([MOCK_TOOL_ALPHA_STATIC_NAME]); // e.g., ["ToolAlphaFromStatic"]
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_ALPHA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolAlpha);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_BETA_STATIC_NAME),
- ).toBeUndefined();
- expect(currentToolRegistry.getAllTools()).toHaveLength(1);
- });
-
- it('should register only tools specified by their class name (ToolClass.name) in coreTools config', () => {
- // ToolBeta is registered under MOCK_TOOL_BETA_STATIC_NAME ('ToolBetaFromStatic')
- // We configure coreTools with its class name: MOCK_TOOL_BETA_CLASS_NAME ('MockCoreToolBeta')
- setupRegistryAndSimulateRegistration([MOCK_TOOL_BETA_CLASS_NAME]);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_BETA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolBeta);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_ALPHA_STATIC_NAME),
- ).toBeUndefined();
- expect(currentToolRegistry.getAllTools()).toHaveLength(1);
- });
-
- it('should register tools if specified by either static Name or class name in a mixed coreTools config', () => {
- // Config: ["ToolAlphaFromStatic", "MockCoreToolBeta"]
- // ToolAlpha matches by static Name. ToolBeta matches by class name.
- setupRegistryAndSimulateRegistration([
- MOCK_TOOL_ALPHA_STATIC_NAME, // Matches MockCoreToolAlpha.Name
- MOCK_TOOL_BETA_CLASS_NAME, // Matches MockCoreToolBeta.name
- ]);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_ALPHA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolAlpha);
- expect(
- currentToolRegistry.getTool(MOCK_TOOL_BETA_STATIC_NAME),
- ).toBeInstanceOf(MockCoreToolBeta); // Registered under its static Name
- expect(currentToolRegistry.getAllTools()).toHaveLength(2);
- });
- });
-
- describe('discoverTools', () => {
- let mockConfigGetToolDiscoveryCommand: ReturnType<typeof vi.spyOn>;
- let mockConfigGetMcpServers: ReturnType<typeof vi.spyOn>;
- let mockConfigGetMcpServerCommand: ReturnType<typeof vi.spyOn>;
- let mockExecSync: ReturnType<typeof vi.mocked<typeof execSync>>;
-
- beforeEach(() => {
- mockConfigGetToolDiscoveryCommand = vi.spyOn(
- config,
- 'getToolDiscoveryCommand',
- );
- mockConfigGetMcpServers = vi.spyOn(config, 'getMcpServers');
- mockConfigGetMcpServerCommand = vi.spyOn(config, 'getMcpServerCommand');
- mockExecSync = vi.mocked(execSync);
-
- // Clear any tools registered by previous tests in this describe block
- toolRegistry = new ToolRegistry(config);
- });
-
- it('should discover tools using discovery command', async () => {
- const discoveryCommand = 'my-discovery-command';
- mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
- const mockToolDeclarations: FunctionDeclaration[] = [
- {
- name: 'discovered-tool-1',
- description: 'A discovered tool',
- parameters: { type: 'object', properties: {} } as Record<
- string,
- unknown
- >,
- },
- ];
- mockExecSync.mockReturnValue(
- Buffer.from(
- JSON.stringify([{ function_declarations: mockToolDeclarations }]),
- ),
- );
-
- await toolRegistry.discoverTools();
-
- expect(execSync).toHaveBeenCalledWith(discoveryCommand);
- const discoveredTool = toolRegistry.getTool('discovered-tool-1');
- expect(discoveredTool).toBeInstanceOf(DiscoveredTool);
- expect(discoveredTool?.name).toBe('discovered-tool-1');
- expect(discoveredTool?.description).toContain('A discovered tool');
- expect(discoveredTool?.description).toContain(discoveryCommand);
- });
-
- it('should remove previously discovered tools before discovering new ones', async () => {
- const discoveryCommand = 'my-discovery-command';
- mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
- mockExecSync.mockReturnValueOnce(
- Buffer.from(
- JSON.stringify([
- {
- function_declarations: [
- {
- name: 'old-discovered-tool',
- description: 'old',
- parameters: { type: 'object' },
- },
- ],
- },
- ]),
- ),
- );
- await toolRegistry.discoverTools();
- expect(toolRegistry.getTool('old-discovered-tool')).toBeInstanceOf(
- DiscoveredTool,
- );
-
- mockExecSync.mockReturnValueOnce(
- Buffer.from(
- JSON.stringify([
- {
- function_declarations: [
- {
- name: 'new-discovered-tool',
- description: 'new',
- parameters: { type: 'object' },
- },
- ],
- },
- ]),
- ),
- );
- await toolRegistry.discoverTools();
- expect(toolRegistry.getTool('old-discovered-tool')).toBeUndefined();
- expect(toolRegistry.getTool('new-discovered-tool')).toBeInstanceOf(
- DiscoveredTool,
- );
- });
-
- it('should discover tools using MCP servers defined in getMcpServers and strip schema properties', async () => {
- mockConfigGetToolDiscoveryCommand.mockReturnValue(undefined); // No regular discovery
- mockConfigGetMcpServerCommand.mockReturnValue(undefined); // No command-based MCP
- mockConfigGetMcpServers.mockReturnValue({
- 'my-mcp-server': {
- command: 'mcp-server-cmd',
- args: ['--port', '1234'],
- },
- });
-
- const mockMcpClientInstance = vi.mocked(Client.prototype);
- mockMcpClientInstance.listTools.mockResolvedValue({
- tools: [
- {
- name: 'mcp-tool-1',
- description: 'An MCP tool',
- inputSchema: {
- type: 'object',
- properties: {
- param1: { type: 'string', $schema: 'remove-me' },
- param2: {
- type: 'object',
- additionalProperties: false,
- properties: {
- nested: { type: 'number' },
- },
- },
- },
- additionalProperties: true,
- $schema: 'http://json-schema.org/draft-07/schema#',
- },
- },
- ],
- });
- mockMcpClientInstance.connect.mockResolvedValue(undefined);
-
- await toolRegistry.discoverTools();
-
- expect(Client).toHaveBeenCalledTimes(1);
- expect(StdioClientTransport).toHaveBeenCalledWith({
- command: 'mcp-server-cmd',
- args: ['--port', '1234'],
- env: expect.any(Object),
- stderr: 'pipe',
- });
- expect(mockMcpClientInstance.connect).toHaveBeenCalled();
- expect(mockMcpClientInstance.listTools).toHaveBeenCalled();
-
- const discoveredTool = toolRegistry.getTool('mcp-tool-1');
- expect(discoveredTool).toBeInstanceOf(DiscoveredMCPTool);
- expect(discoveredTool?.name).toBe('mcp-tool-1');
- expect(discoveredTool?.description).toContain('An MCP tool');
- expect(discoveredTool?.description).toContain('mcp-tool-1');
-
- // Verify that $schema and additionalProperties are removed
- const cleanedSchema = discoveredTool?.schema.parameters;
- expect(cleanedSchema).not.toHaveProperty('$schema');
- expect(cleanedSchema).not.toHaveProperty('additionalProperties');
- expect(cleanedSchema?.properties?.param1).not.toHaveProperty('$schema');
- expect(cleanedSchema?.properties?.param2).not.toHaveProperty(
- 'additionalProperties',
- );
- expect(
- cleanedSchema?.properties?.param2?.properties?.nested,
- ).not.toHaveProperty('$schema');
- expect(
- cleanedSchema?.properties?.param2?.properties?.nested,
- ).not.toHaveProperty('additionalProperties');
- });
-
- it('should discover tools using MCP server command from getMcpServerCommand', async () => {
- mockConfigGetToolDiscoveryCommand.mockReturnValue(undefined);
- mockConfigGetMcpServers.mockReturnValue({}); // No direct MCP servers
- mockConfigGetMcpServerCommand.mockReturnValue(
- 'mcp-server-start-command --param',
- );
-
- const mockMcpClientInstance = vi.mocked(Client.prototype);
- mockMcpClientInstance.listTools.mockResolvedValue({
- tools: [
- {
- name: 'mcp-tool-cmd',
- description: 'An MCP tool from command',
- inputSchema: { type: 'object' },
- }, // Corrected: Add type: 'object'
- ],
- });
- mockMcpClientInstance.connect.mockResolvedValue(undefined);
-
- await toolRegistry.discoverTools();
-
- expect(Client).toHaveBeenCalledTimes(1);
- expect(StdioClientTransport).toHaveBeenCalledWith({
- command: 'mcp-server-start-command',
- args: ['--param'],
- env: expect.any(Object),
- stderr: 'pipe',
- });
- expect(mockMcpClientInstance.connect).toHaveBeenCalled();
- expect(mockMcpClientInstance.listTools).toHaveBeenCalled();
-
- const discoveredTool = toolRegistry.getTool('mcp-tool-cmd'); // Name is not prefixed if only one MCP server
- expect(discoveredTool).toBeInstanceOf(DiscoveredMCPTool);
- expect(discoveredTool?.name).toBe('mcp-tool-cmd');
- });
-
- it('should handle errors during MCP tool discovery gracefully', async () => {
- mockConfigGetToolDiscoveryCommand.mockReturnValue(undefined);
- mockConfigGetMcpServers.mockReturnValue({
- 'failing-mcp': { command: 'fail-cmd' },
- });
- vi.spyOn(console, 'error').mockImplementation(() => {});
-
- const mockMcpClientInstance = vi.mocked(Client.prototype);
- mockMcpClientInstance.connect.mockRejectedValue(
- new Error('Connection failed'),
- );
-
- // Need to await the async IIFE within discoverTools.
- // Since discoverTools itself isn't async, we can't directly await it.
- // We'll check the console.error mock.
- await toolRegistry.discoverTools();
-
- expect(console.error).toHaveBeenCalledWith(
- `failed to start or connect to MCP server 'failing-mcp' ${JSON.stringify({ command: 'fail-cmd' })}; \nError: Connection failed`,
- );
- expect(toolRegistry.getAllTools()).toHaveLength(0); // No tools should be registered
- });
- });
-});
-
-describe('DiscoveredTool', () => {
- let config: Config;
- const toolName = 'my-discovered-tool';
- const toolDescription = 'Does something cool.';
- const toolParamsSchema = {
- type: 'object',
- properties: { path: { type: 'string' } },
- };
- let mockSpawnInstance: Partial<ReturnType<typeof spawn>>;
-
- beforeEach(() => {
- config = new Config(baseConfigParams); // Use base params
- vi.spyOn(config, 'getToolDiscoveryCommand').mockReturnValue(
- 'discovery-cmd',
- );
- vi.spyOn(config, 'getToolCallCommand').mockReturnValue('call-cmd');
-
- const mockStdin = {
- write: vi.fn(),
- end: vi.fn(),
- on: vi.fn(),
- writable: true,
- } as any;
-
- const mockStdout = {
- on: vi.fn(),
- read: vi.fn(),
- readable: true,
- } as any;
-
- const mockStderr = {
- on: vi.fn(),
- read: vi.fn(),
- readable: true,
- } as any;
-
- mockSpawnInstance = {
- stdin: mockStdin,
- stdout: mockStdout,
- stderr: mockStderr,
- on: vi.fn(), // For process events like 'close', 'error'
- kill: vi.fn(),
- pid: 123,
- connected: true,
- disconnect: vi.fn(),
- ref: vi.fn(),
- unref: vi.fn(),
- spawnargs: [],
- spawnfile: '',
- channel: null,
- exitCode: null,
- signalCode: null,
- killed: false,
- stdio: [mockStdin, mockStdout, mockStderr, null, null] as any,
- };
- vi.mocked(spawn).mockReturnValue(mockSpawnInstance as any);
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('constructor should set up properties correctly and enhance description', () => {
- const tool = new DiscoveredTool(
- config,
- toolName,
- toolDescription,
- toolParamsSchema,
- );
- expect(tool.name).toBe(toolName);
- expect(tool.schema.description).toContain(toolDescription);
- expect(tool.schema.description).toContain('discovery-cmd');
- expect(tool.schema.description).toContain('call-cmd my-discovered-tool');
- expect(tool.schema.parameters).toEqual(toolParamsSchema);
- });
-
- it('execute should call spawn with correct command and params, and return stdout on success', async () => {
- const tool = new DiscoveredTool(
- config,
- toolName,
- toolDescription,
- toolParamsSchema,
- );
- const params = { path: '/foo/bar' };
- const expectedOutput = JSON.stringify({ result: 'success' });
-
- // Simulate successful execution
- (mockSpawnInstance.stdout!.on as Mocked<any>).mockImplementation(
- (event: string, callback: (data: string) => void) => {
- if (event === 'data') {
- callback(expectedOutput);
- }
- },
- );
- (mockSpawnInstance.on as Mocked<any>).mockImplementation(
- (
- event: string,
- callback: (code: number | null, signal: NodeJS.Signals | null) => void,
- ) => {
- if (event === 'close') {
- callback(0, null); // Success
- }
- },
- );
-
- const result = await tool.execute(params);
-
- expect(spawn).toHaveBeenCalledWith('call-cmd', [toolName]);
- expect(mockSpawnInstance.stdin!.write).toHaveBeenCalledWith(
- JSON.stringify(params),
- );
- expect(mockSpawnInstance.stdin!.end).toHaveBeenCalled();
- expect(result.llmContent).toBe(expectedOutput);
- expect(result.returnDisplay).toBe(expectedOutput);
- });
-
- it('execute should return error details if spawn results in an error', async () => {
- const tool = new DiscoveredTool(
- config,
- toolName,
- toolDescription,
- toolParamsSchema,
- );
- const params = { path: '/foo/bar' };
- const stderrOutput = 'Something went wrong';
- const error = new Error('Spawn error');
-
- // Simulate error during spawn
- (mockSpawnInstance.stderr!.on as Mocked<any>).mockImplementation(
- (event: string, callback: (data: string) => void) => {
- if (event === 'data') {
- callback(stderrOutput);
- }
- },
- );
- (mockSpawnInstance.on as Mocked<any>).mockImplementation(
- (
- event: string,
- callback:
- | ((code: number | null, signal: NodeJS.Signals | null) => void)
- | ((error: Error) => void),
- ) => {
- if (event === 'error') {
- (callback as (error: Error) => void)(error); // Simulate 'error' event
- }
- if (event === 'close') {
- (
- callback as (
- code: number | null,
- signal: NodeJS.Signals | null,
- ) => void
- )(1, null); // Non-zero exit code
- }
- },
- );
-
- const result = await tool.execute(params);
-
- expect(result.llmContent).toContain(`Stderr: ${stderrOutput}`);
- expect(result.llmContent).toContain(`Error: ${error.toString()}`);
- expect(result.llmContent).toContain('Exit Code: 1');
- expect(result.returnDisplay).toBe(result.llmContent);
- });
-});
-
-describe('DiscoveredMCPTool', () => {
- let mockMcpClient: Client;
- const toolName = 'my-mcp-tool';
- const toolDescription = 'An MCP-discovered tool.';
- const toolInputSchema = {
- type: 'object',
- properties: { data: { type: 'string' } },
- };
-
- beforeEach(() => {
- mockMcpClient = new Client({
- name: 'test-client',
- version: '0.0.0',
- }) as Mocked<Client>;
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('constructor should set up properties correctly and enhance description', () => {
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- toolDescription,
- toolInputSchema,
- toolName,
- );
- expect(tool.name).toBe(toolName);
- expect(tool.schema.description).toContain(toolDescription);
- expect(tool.schema.description).toContain('tools/call');
- expect(tool.schema.description).toContain(toolName);
- expect(tool.schema.parameters).toEqual(toolInputSchema);
- });
-
- it('execute should call mcpClient.callTool with correct params and return serialized result', async () => {
- const tool = new DiscoveredMCPTool(
- mockMcpClient,
- 'mock-mcp-server',
- toolName,
- toolDescription,
- toolInputSchema,
- toolName,
- );
- const params = { data: 'test_data' };
- const mcpResult = { success: true, value: 'processed' };
-
- vi.mocked(mockMcpClient.callTool).mockResolvedValue(mcpResult);
-
- const result = await tool.execute(params);
-
- expect(mockMcpClient.callTool).toHaveBeenCalledWith(
- {
- name: toolName,
- arguments: params,
- },
- undefined,
- {
- timeout: 10 * 60 * 1000,
- },
- );
- const expectedOutput =
- '```json\n' + JSON.stringify(mcpResult, null, 2) + '\n```';
- expect(result.llmContent).toBe(expectedOutput);
- expect(result.returnDisplay).toBe(expectedOutput);
- });
-});
diff --git a/packages/server/src/tools/tool-registry.ts b/packages/server/src/tools/tool-registry.ts
deleted file mode 100644
index e241ada5..00000000
--- a/packages/server/src/tools/tool-registry.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { FunctionDeclaration } from '@google/genai';
-import { Tool, ToolResult, BaseTool } from './tools.js';
-import { Config } from '../config/config.js';
-import { spawn, execSync } from 'node:child_process';
-import { discoverMcpTools } from './mcp-client.js';
-import { DiscoveredMCPTool } from './mcp-tool.js';
-
-type ToolParams = Record<string, unknown>;
-
-export class DiscoveredTool extends BaseTool<ToolParams, ToolResult> {
- constructor(
- private readonly config: Config,
- readonly name: string,
- readonly description: string,
- readonly parameterSchema: Record<string, unknown>,
- ) {
- const discoveryCmd = config.getToolDiscoveryCommand()!;
- const callCommand = config.getToolCallCommand()!;
- description += `
-
-This tool was discovered from the project by executing the command \`${discoveryCmd}\` on project root.
-When called, this tool will execute the command \`${callCommand} ${name}\` on project root.
-Tool discovery and call commands can be configured in project or user settings.
-
-When called, the tool call command is executed as a subprocess.
-On success, tool output is returned as a json string.
-Otherwise, the following information is returned:
-
-Stdout: Output on stdout stream. Can be \`(empty)\` or partial.
-Stderr: Output on stderr stream. Can be \`(empty)\` or partial.
-Error: Error or \`(none)\` if no error was reported for the subprocess.
-Exit Code: Exit code or \`(none)\` if terminated by signal.
-Signal: Signal number or \`(none)\` if no signal was received.
-`;
- super(
- name,
- name,
- description,
- parameterSchema,
- false, // isOutputMarkdown
- false, // canUpdateOutput
- );
- }
-
- async execute(params: ToolParams): Promise<ToolResult> {
- const callCommand = this.config.getToolCallCommand()!;
- const child = spawn(callCommand, [this.name]);
- child.stdin.write(JSON.stringify(params));
- child.stdin.end();
- let stdout = '';
- let stderr = '';
- child.stdout.on('data', (data) => {
- stdout += data.toString();
- });
- child.stderr.on('data', (data) => {
- stderr += data.toString();
- });
- let error: Error | null = null;
- child.on('error', (err: Error) => {
- error = err;
- });
- let code: number | null = null;
- let signal: NodeJS.Signals | null = null;
- child.on(
- 'close',
- (_code: number | null, _signal: NodeJS.Signals | null) => {
- code = _code;
- signal = _signal;
- },
- );
- await new Promise((resolve) => child.on('close', resolve));
-
- // if there is any error, non-zero exit code, signal, or stderr, return error details instead of stdout
- if (error || code !== 0 || signal || stderr) {
- const llmContent = [
- `Stdout: ${stdout || '(empty)'}`,
- `Stderr: ${stderr || '(empty)'}`,
- `Error: ${error ?? '(none)'}`,
- `Exit Code: ${code ?? '(none)'}`,
- `Signal: ${signal ?? '(none)'}`,
- ].join('\n');
- return {
- llmContent,
- returnDisplay: llmContent,
- };
- }
-
- return {
- llmContent: stdout,
- returnDisplay: stdout,
- };
- }
-}
-
-export class ToolRegistry {
- private tools: Map<string, Tool> = new Map();
- private config: Config;
-
- constructor(config: Config) {
- this.config = config;
- }
-
- /**
- * Registers a tool definition.
- * @param tool - The tool object containing schema and execution logic.
- */
- registerTool(tool: Tool): void {
- if (this.tools.has(tool.name)) {
- // Decide on behavior: throw error, log warning, or allow overwrite
- console.warn(
- `Tool with name "${tool.name}" is already registered. Overwriting.`,
- );
- }
- this.tools.set(tool.name, tool);
- }
-
- /**
- * Discovers tools from project, if a discovery command is configured.
- * Can be called multiple times to update discovered tools.
- */
- async discoverTools(): Promise<void> {
- // remove any previously discovered tools
- for (const tool of this.tools.values()) {
- if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
- this.tools.delete(tool.name);
- } else {
- // Keep manually registered tools
- }
- }
- // discover tools using discovery command, if configured
- const discoveryCmd = this.config.getToolDiscoveryCommand();
- if (discoveryCmd) {
- // execute discovery command and extract function declarations
- const functions: FunctionDeclaration[] = [];
- for (const tool of JSON.parse(execSync(discoveryCmd).toString().trim())) {
- functions.push(...tool['function_declarations']);
- }
- // register each function as a tool
- for (const func of functions) {
- this.registerTool(
- new DiscoveredTool(
- this.config,
- func.name!,
- func.description!,
- func.parameters! as Record<string, unknown>,
- ),
- );
- }
- }
- // discover tools using MCP servers, if configured
- await discoverMcpTools(this.config, this);
- }
-
- /**
- * Retrieves the list of tool schemas (FunctionDeclaration array).
- * Extracts the declarations from the ToolListUnion structure.
- * Includes discovered (vs registered) tools if configured.
- * @returns An array of FunctionDeclarations.
- */
- getFunctionDeclarations(): FunctionDeclaration[] {
- const declarations: FunctionDeclaration[] = [];
- this.tools.forEach((tool) => {
- declarations.push(tool.schema);
- });
- return declarations;
- }
-
- /**
- * Returns an array of all registered and discovered tool instances.
- */
- getAllTools(): Tool[] {
- return Array.from(this.tools.values());
- }
-
- /**
- * Get the definition of a specific tool.
- */
- getTool(name: string): Tool | undefined {
- return this.tools.get(name);
- }
-}
diff --git a/packages/server/src/tools/tools.ts b/packages/server/src/tools/tools.ts
deleted file mode 100644
index a2e7fa06..00000000
--- a/packages/server/src/tools/tools.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { FunctionDeclaration, PartListUnion, Schema } from '@google/genai';
-
-/**
- * Interface representing the base Tool functionality
- */
-export interface Tool<
- TParams = unknown,
- TResult extends ToolResult = ToolResult,
-> {
- /**
- * The internal name of the tool (used for API calls)
- */
- name: string;
-
- /**
- * The user-friendly display name of the tool
- */
- displayName: string;
-
- /**
- * Description of what the tool does
- */
- description: string;
-
- /**
- * Function declaration schema from @google/genai
- */
- schema: FunctionDeclaration;
-
- /**
- * Whether the tool's output should be rendered as markdown
- */
- isOutputMarkdown: boolean;
-
- /**
- * Whether the tool supports live (streaming) output
- */
- canUpdateOutput: boolean;
-
- /**
- * Validates the parameters for the tool
- * Should be called from both `shouldConfirmExecute` and `execute`
- * `shouldConfirmExecute` should return false immediately if invalid
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
- */
- validateToolParams(params: TParams): string | null;
-
- /**
- * Gets a pre-execution description of the tool operation
- * @param params Parameters for the tool execution
- * @returns A markdown string describing what the tool will do
- * Optional for backward compatibility
- */
- getDescription(params: TParams): string;
-
- /**
- * Determines if the tool should prompt for confirmation before execution
- * @param params Parameters for the tool execution
- * @returns Whether execute should be confirmed.
- */
- shouldConfirmExecute(
- params: TParams,
- abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false>;
-
- /**
- * Executes the tool with the given parameters
- * @param params Parameters for the tool execution
- * @returns Result of the tool execution
- */
- execute(
- params: TParams,
- signal: AbortSignal,
- updateOutput?: (output: string) => void,
- ): Promise<TResult>;
-}
-
-/**
- * Base implementation for tools with common functionality
- */
-export abstract class BaseTool<
- TParams = unknown,
- TResult extends ToolResult = ToolResult,
-> implements Tool<TParams, TResult>
-{
- /**
- * Creates a new instance of BaseTool
- * @param name Internal name of the tool (used for API calls)
- * @param displayName User-friendly display name of the tool
- * @param description Description of what the tool does
- * @param isOutputMarkdown Whether the tool's output should be rendered as markdown
- * @param canUpdateOutput Whether the tool supports live (streaming) output
- * @param parameterSchema JSON Schema defining the parameters
- */
- constructor(
- readonly name: string,
- readonly displayName: string,
- readonly description: string,
- readonly parameterSchema: Record<string, unknown>,
- readonly isOutputMarkdown: boolean = true,
- readonly canUpdateOutput: boolean = false,
- ) {}
-
- /**
- * Function declaration schema computed from name, description, and parameterSchema
- */
- get schema(): FunctionDeclaration {
- return {
- name: this.name,
- description: this.description,
- parameters: this.parameterSchema as Schema,
- };
- }
-
- /**
- * Validates the parameters for the tool
- * This is a placeholder implementation and should be overridden
- * Should be called from both `shouldConfirmExecute` and `execute`
- * `shouldConfirmExecute` should return false immediately if invalid
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
- */
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- validateToolParams(params: TParams): string | null {
- // Implementation would typically use a JSON Schema validator
- // This is a placeholder that should be implemented by derived classes
- return null;
- }
-
- /**
- * Gets a pre-execution description of the tool operation
- * Default implementation that should be overridden by derived classes
- * @param params Parameters for the tool execution
- * @returns A markdown string describing what the tool will do
- */
- getDescription(params: TParams): string {
- return JSON.stringify(params);
- }
-
- /**
- * Determines if the tool should prompt for confirmation before execution
- * @param params Parameters for the tool execution
- * @returns Whether or not execute should be confirmed by the user.
- */
- shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: TParams,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Abstract method to execute the tool with the given parameters
- * Must be implemented by derived classes
- * @param params Parameters for the tool execution
- * @param signal AbortSignal for tool cancellation
- * @returns Result of the tool execution
- */
- abstract execute(
- params: TParams,
- signal: AbortSignal,
- updateOutput?: (output: string) => void,
- ): Promise<TResult>;
-}
-
-export interface ToolResult {
- /**
- * Content meant to be included in LLM history.
- * This should represent the factual outcome of the tool execution.
- */
- llmContent: PartListUnion;
-
- /**
- * Markdown string for user display.
- * This provides a user-friendly summary or visualization of the result.
- * NOTE: This might also be considered UI-specific and could potentially be
- * removed or modified in a further refactor if the server becomes purely API-driven.
- * For now, we keep it as the core logic in ReadFileTool currently produces it.
- */
- returnDisplay: ToolResultDisplay;
-}
-
-export type ToolResultDisplay = string | FileDiff;
-
-export interface FileDiff {
- fileDiff: string;
- fileName: string;
-}
-
-export interface ToolEditConfirmationDetails {
- type: 'edit';
- title: string;
- onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
- fileName: string;
- fileDiff: string;
-}
-
-export interface ToolExecuteConfirmationDetails {
- type: 'exec';
- title: string;
- onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
- command: string;
- rootCommand: string;
-}
-
-export interface ToolMcpConfirmationDetails {
- type: 'mcp';
- title: string;
- serverName: string;
- toolName: string;
- toolDisplayName: string;
- onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void> | void;
-}
-
-export type ToolCallConfirmationDetails =
- | ToolEditConfirmationDetails
- | ToolExecuteConfirmationDetails
- | ToolMcpConfirmationDetails;
-
-export enum ToolConfirmationOutcome {
- ProceedOnce = 'proceed_once',
- ProceedAlways = 'proceed_always',
- ProceedAlwaysServer = 'proceed_always_server',
- ProceedAlwaysTool = 'proceed_always_tool',
- Cancel = 'cancel',
-}
diff --git a/packages/server/src/tools/web-fetch.ts b/packages/server/src/tools/web-fetch.ts
deleted file mode 100644
index 24617902..00000000
--- a/packages/server/src/tools/web-fetch.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { GoogleGenAI, GroundingMetadata } from '@google/genai';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { BaseTool, ToolResult } from './tools.js';
-import { getErrorMessage } from '../utils/errors.js';
-import { Config } from '../config/config.js';
-import { getResponseText } from '../utils/generateContentResponseUtilities.js';
-import { retryWithBackoff } from '../utils/retry.js';
-
-// Interfaces for grounding metadata (similar to web-search.ts)
-interface GroundingChunkWeb {
- uri?: string;
- title?: string;
-}
-
-interface GroundingChunkItem {
- web?: GroundingChunkWeb;
-}
-
-interface GroundingSupportSegment {
- startIndex: number;
- endIndex: number;
- text?: string;
-}
-
-interface GroundingSupportItem {
- segment?: GroundingSupportSegment;
- groundingChunkIndices?: number[];
-}
-
-/**
- * Parameters for the WebFetch tool
- */
-export interface WebFetchToolParams {
- /**
- * The prompt containing URL(s) (up to 20) and instructions for processing their content.
- */
- prompt: string;
-}
-
-/**
- * Implementation of the WebFetch tool logic
- */
-export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
- static readonly Name: string = 'web_fetch';
-
- private ai: GoogleGenAI;
- private modelName: string;
-
- constructor(private readonly config: Config) {
- super(
- WebFetchTool.Name,
- 'WebFetch',
- "Processes content from URL(s) embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
- {
- properties: {
- prompt: {
- description:
- 'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
- type: 'string',
- },
- },
- required: ['prompt'],
- type: 'object',
- },
- );
-
- const apiKeyFromConfig = this.config.getApiKey();
- this.ai = new GoogleGenAI({
- apiKey: apiKeyFromConfig === '' ? undefined : apiKeyFromConfig,
- });
- this.modelName = this.config.getModel();
- }
-
- validateParams(params: WebFetchToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
- if (!params.prompt || params.prompt.trim() === '') {
- return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
- }
- if (
- !params.prompt.includes('http://') &&
- !params.prompt.includes('https://')
- ) {
- return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
- }
- return null;
- }
-
- getDescription(params: WebFetchToolParams): string {
- const displayPrompt =
- params.prompt.length > 100
- ? params.prompt.substring(0, 97) + '...'
- : params.prompt;
- return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
- }
-
- async execute(
- params: WebFetchToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: validationError,
- };
- }
-
- const userPrompt = params.prompt;
-
- try {
- const apiCall = () =>
- this.ai.models.generateContent({
- model: this.modelName,
- contents: [
- {
- role: 'user',
- parts: [{ text: userPrompt }],
- },
- ],
- config: {
- tools: [{ urlContext: {} }],
- },
- });
-
- const response = await retryWithBackoff(apiCall);
-
- console.debug(
- `[WebFetchTool] Full response for prompt "${userPrompt.substring(0, 50)}...":`,
- JSON.stringify(response, null, 2),
- );
-
- let responseText = getResponseText(response) || '';
- const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
- const groundingMetadata = response.candidates?.[0]?.groundingMetadata as
- | GroundingMetadata
- | undefined;
- const sources = groundingMetadata?.groundingChunks as
- | GroundingChunkItem[]
- | undefined;
- const groundingSupports = groundingMetadata?.groundingSupports as
- | GroundingSupportItem[]
- | undefined;
-
- // Error Handling
- let processingError = false;
- let errorDetail = 'An unknown error occurred during content processing.';
-
- if (
- urlContextMeta?.urlMetadata &&
- urlContextMeta.urlMetadata.length > 0
- ) {
- const allStatuses = urlContextMeta.urlMetadata.map(
- (m) => m.urlRetrievalStatus,
- );
- if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
- processingError = true;
- errorDetail = `All URL retrieval attempts failed. Statuses: ${allStatuses.join(', ')}. API reported: "${responseText || 'No additional detail.'}"`;
- }
- } else if (!responseText.trim() && !sources?.length) {
- // No URL metadata and no content/sources
- processingError = true;
- errorDetail =
- 'No content was returned and no URL metadata was available to determine fetch status.';
- }
-
- if (
- !processingError &&
- !responseText.trim() &&
- (!sources || sources.length === 0)
- ) {
- // Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
- processingError = true;
- errorDetail =
- 'URL(s) processed, but no substantive content or grounding information was found.';
- }
-
- if (processingError) {
- const errorText = `Failed to process prompt and fetch URL data. ${errorDetail}`;
- return {
- llmContent: `Error: ${errorText}`,
- returnDisplay: `Error: ${errorText}`,
- };
- }
-
- const sourceListFormatted: string[] = [];
- if (sources && sources.length > 0) {
- sources.forEach((source: GroundingChunkItem, index: number) => {
- const title = source.web?.title || 'Untitled';
- const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
- sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
- });
-
- if (groundingSupports && groundingSupports.length > 0) {
- const insertions: Array<{ index: number; marker: string }> = [];
- groundingSupports.forEach((support: GroundingSupportItem) => {
- if (support.segment && support.groundingChunkIndices) {
- const citationMarker = support.groundingChunkIndices
- .map((chunkIndex: number) => `[${chunkIndex + 1}]`)
- .join('');
- insertions.push({
- index: support.segment.endIndex,
- marker: citationMarker,
- });
- }
- });
-
- insertions.sort((a, b) => b.index - a.index);
- const responseChars = responseText.split('');
- insertions.forEach((insertion) => {
- responseChars.splice(insertion.index, 0, insertion.marker);
- });
- responseText = responseChars.join('');
- }
-
- if (sourceListFormatted.length > 0) {
- responseText += `
-
-Sources:
-${sourceListFormatted.join('\n')}`;
- }
- }
-
- const llmContent = responseText;
-
- console.debug(
- `[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
- llmContent,
- );
-
- return {
- llmContent,
- returnDisplay: `Content processed from prompt.`,
- };
- } catch (error: unknown) {
- const errorMessage = `Error processing web content for prompt "${userPrompt.substring(0, 50)}...": ${getErrorMessage(error)}`;
- console.error(errorMessage, error);
- return {
- llmContent: `Error: ${errorMessage}`,
- returnDisplay: `Error: ${errorMessage}`,
- };
- }
- }
-}
diff --git a/packages/server/src/tools/web-search.ts b/packages/server/src/tools/web-search.ts
deleted file mode 100644
index ed2f341f..00000000
--- a/packages/server/src/tools/web-search.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { GoogleGenAI, GroundingMetadata } from '@google/genai';
-import { BaseTool, ToolResult } from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-
-import { getErrorMessage } from '../utils/errors.js';
-import { Config } from '../config/config.js';
-import { getResponseText } from '../utils/generateContentResponseUtilities.js';
-import { retryWithBackoff } from '../utils/retry.js';
-
-interface GroundingChunkWeb {
- uri?: string;
- title?: string;
-}
-
-interface GroundingChunkItem {
- web?: GroundingChunkWeb;
- // Other properties might exist if needed in the future
-}
-
-interface GroundingSupportSegment {
- startIndex: number;
- endIndex: number;
- text?: string; // text is optional as per the example
-}
-
-interface GroundingSupportItem {
- segment?: GroundingSupportSegment;
- groundingChunkIndices?: number[];
- confidenceScores?: number[]; // Optional as per example
-}
-
-/**
- * Parameters for the WebSearchTool.
- */
-export interface WebSearchToolParams {
- /**
- * The search query.
- */
-
- query: string;
-}
-
-/**
- * Extends ToolResult to include sources for web search.
- */
-export interface WebSearchToolResult extends ToolResult {
- sources?: GroundingMetadata extends { groundingChunks: GroundingChunkItem[] }
- ? GroundingMetadata['groundingChunks']
- : GroundingChunkItem[];
-}
-
-/**
- * A tool to perform web searches using Google Search via the Gemini API.
- */
-export class WebSearchTool extends BaseTool<
- WebSearchToolParams,
- WebSearchToolResult
-> {
- static readonly Name: string = 'google_web_search';
-
- private ai: GoogleGenAI;
- private modelName: string;
-
- constructor(private readonly config: Config) {
- super(
- WebSearchTool.Name,
- 'GoogleSearch',
- 'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
- {
- type: 'object',
- properties: {
- query: {
- type: 'string',
- description: 'The search query to find information on the web.',
- },
- },
- required: ['query'],
- },
- );
-
- const apiKeyFromConfig = this.config.getApiKey();
- // Initialize GoogleGenAI, allowing fallback to environment variables for API key
- this.ai = new GoogleGenAI({
- apiKey: apiKeyFromConfig === '' ? undefined : apiKeyFromConfig,
- });
- this.modelName = this.config.getModel();
- }
-
- validateParams(params: WebSearchToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return "Parameters failed schema validation. Ensure 'query' is a string.";
- }
- if (!params.query || params.query.trim() === '') {
- return "The 'query' parameter cannot be empty.";
- }
- return null;
- }
-
- getDescription(params: WebSearchToolParams): string {
- return `Searching the web for: "${params.query}"`;
- }
-
- async execute(params: WebSearchToolParams): Promise<WebSearchToolResult> {
- const validationError = this.validateParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: validationError,
- };
- }
-
- try {
- const apiCall = () =>
- this.ai.models.generateContent({
- model: this.modelName,
- contents: [{ role: 'user', parts: [{ text: params.query }] }],
- config: {
- tools: [{ googleSearch: {} }],
- },
- });
-
- const response = await retryWithBackoff(apiCall);
-
- const responseText = getResponseText(response);
- const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
- const sources = groundingMetadata?.groundingChunks as
- | GroundingChunkItem[]
- | undefined;
- const groundingSupports = groundingMetadata?.groundingSupports as
- | GroundingSupportItem[]
- | undefined;
-
- if (!responseText || !responseText.trim()) {
- return {
- llmContent: `No search results or information found for query: "${params.query}"`,
- returnDisplay: 'No information found.',
- };
- }
-
- let modifiedResponseText = responseText;
- const sourceListFormatted: string[] = [];
-
- if (sources && sources.length > 0) {
- sources.forEach((source: GroundingChunkItem, index: number) => {
- const title = source.web?.title || 'Untitled';
- const uri = source.web?.uri || 'No URI';
- sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
- });
-
- if (groundingSupports && groundingSupports.length > 0) {
- const insertions: Array<{ index: number; marker: string }> = [];
- groundingSupports.forEach((support: GroundingSupportItem) => {
- if (support.segment && support.groundingChunkIndices) {
- const citationMarker = support.groundingChunkIndices
- .map((chunkIndex: number) => `[${chunkIndex + 1}]`)
- .join('');
- insertions.push({
- index: support.segment.endIndex,
- marker: citationMarker,
- });
- }
- });
-
- // Sort insertions by index in descending order to avoid shifting subsequent indices
- insertions.sort((a, b) => b.index - a.index);
-
- const responseChars = modifiedResponseText.split(''); // Use new variable
- insertions.forEach((insertion) => {
- // Fixed arrow function syntax
- responseChars.splice(insertion.index, 0, insertion.marker);
- });
- modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
- }
-
- if (sourceListFormatted.length > 0) {
- modifiedResponseText +=
- '\n\nSources:\n' + sourceListFormatted.join('\n'); // Fixed string concatenation
- }
- }
-
- return {
- llmContent: `Web search results for "${params.query}":\n\n${modifiedResponseText}`,
- returnDisplay: `Search results for "${params.query}" returned.`,
- sources,
- };
- } catch (error: unknown) {
- const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(error)}`;
- console.error(errorMessage, error);
- return {
- llmContent: `Error: ${errorMessage}`,
- returnDisplay: `Error performing web search.`,
- };
- }
- }
-}
diff --git a/packages/server/src/tools/write-file.test.ts b/packages/server/src/tools/write-file.test.ts
deleted file mode 100644
index 3fd97c9e..00000000
--- a/packages/server/src/tools/write-file.test.ts
+++ /dev/null
@@ -1,567 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- describe,
- it,
- expect,
- beforeEach,
- afterEach,
- vi,
- type Mocked,
-} from 'vitest';
-import { WriteFileTool } from './write-file.js';
-import {
- FileDiff,
- ToolConfirmationOutcome,
- ToolEditConfirmationDetails,
-} from './tools.js';
-import { type EditToolParams } from './edit.js';
-import { Config } from '../config/config.js';
-import { ToolRegistry } from './tool-registry.js';
-import path from 'path';
-import fs from 'fs';
-import os from 'os';
-import { GeminiClient } from '../core/client.js';
-import {
- ensureCorrectEdit,
- ensureCorrectFileContent,
- CorrectedEditResult,
-} from '../utils/editCorrector.js';
-
-const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
-
-// --- MOCKS ---
-vi.mock('../core/client.js');
-vi.mock('../utils/editCorrector.js');
-
-let mockGeminiClientInstance: Mocked<GeminiClient>;
-const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
-const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
-
-// Wire up the mocked functions to be used by the actual module imports
-vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit);
-vi.mocked(ensureCorrectFileContent).mockImplementation(
- mockEnsureCorrectFileContent,
-);
-
-// Mock Config
-const mockConfigInternal = {
- getTargetDir: () => rootDir,
- getAlwaysSkipModificationConfirmation: vi.fn(() => false),
- setAlwaysSkipModificationConfirmation: vi.fn(),
- getApiKey: () => 'test-key',
- getModel: () => 'test-model',
- getSandbox: () => false,
- getDebugMode: () => false,
- getQuestion: () => undefined,
- getFullContext: () => false,
- getToolDiscoveryCommand: () => undefined,
- getToolCallCommand: () => undefined,
- getMcpServerCommand: () => undefined,
- getMcpServers: () => undefined,
- getUserAgent: () => 'test-agent',
- getUserMemory: () => '',
- setUserMemory: vi.fn(),
- getGeminiMdFileCount: () => 0,
- setGeminiMdFileCount: vi.fn(),
- getToolRegistry: () =>
- ({
- registerTool: vi.fn(),
- discoverTools: vi.fn(),
- }) as unknown as ToolRegistry,
-};
-const mockConfig = mockConfigInternal as unknown as Config;
-// --- END MOCKS ---
-
-describe('WriteFileTool', () => {
- let tool: WriteFileTool;
- let tempDir: string;
-
- beforeEach(() => {
- // Create a unique temporary directory for files created outside the root
- tempDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'write-file-test-external-'),
- );
- // Ensure the rootDir for the tool exists
- if (!fs.existsSync(rootDir)) {
- fs.mkdirSync(rootDir, { recursive: true });
- }
-
- // Setup GeminiClient mock
- mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
- mockConfig,
- ) as Mocked<GeminiClient>;
- vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);
-
- tool = new WriteFileTool(mockConfig);
-
- // Reset mocks before each test
- mockConfigInternal.getAlwaysSkipModificationConfirmation.mockReturnValue(
- false,
- );
- mockConfigInternal.setAlwaysSkipModificationConfirmation.mockClear();
- mockEnsureCorrectEdit.mockReset();
- mockEnsureCorrectFileContent.mockReset();
-
- // Default mock implementations that return valid structures
- mockEnsureCorrectEdit.mockImplementation(
- async (
- _currentContent: string,
- params: EditToolParams,
- _client: GeminiClient,
- signal?: AbortSignal, // Make AbortSignal optional to match usage
- ): Promise<CorrectedEditResult> => {
- if (signal?.aborted) {
- return Promise.reject(new Error('Aborted'));
- }
- return Promise.resolve({
- params: { ...params, new_string: params.new_string ?? '' },
- occurrences: 1,
- });
- },
- );
- mockEnsureCorrectFileContent.mockImplementation(
- async (
- content: string,
- _client: GeminiClient,
- signal?: AbortSignal,
- ): Promise<string> => {
- // Make AbortSignal optional
- if (signal?.aborted) {
- return Promise.reject(new Error('Aborted'));
- }
- return Promise.resolve(content ?? '');
- },
- );
- });
-
- afterEach(() => {
- // Clean up the temporary directories
- if (fs.existsSync(tempDir)) {
- fs.rmSync(tempDir, { recursive: true, force: true });
- }
- if (fs.existsSync(rootDir)) {
- fs.rmSync(rootDir, { recursive: true, force: true });
- }
- vi.clearAllMocks();
- });
-
- describe('validateToolParams', () => {
- it('should return null for valid absolute path within root', () => {
- const params = {
- file_path: path.join(rootDir, 'test.txt'),
- content: 'hello',
- };
- expect(tool.validateToolParams(params)).toBeNull();
- });
-
- it('should return error for relative path', () => {
- const params = { file_path: 'test.txt', content: 'hello' };
- expect(tool.validateToolParams(params)).toMatch(
- /File path must be absolute/,
- );
- });
-
- it('should return error for path outside root', () => {
- const outsidePath = path.resolve(tempDir, 'outside-root.txt');
- const params = {
- file_path: outsidePath,
- content: 'hello',
- };
- expect(tool.validateToolParams(params)).toMatch(
- /File path must be within the root directory/,
- );
- });
-
- it('should return error if path is a directory', () => {
- const dirAsFilePath = path.join(rootDir, 'a_directory');
- fs.mkdirSync(dirAsFilePath);
- const params = {
- file_path: dirAsFilePath,
- content: 'hello',
- };
- expect(tool.validateToolParams(params)).toMatch(
- `Path is a directory, not a file: ${dirAsFilePath}`,
- );
- });
- });
-
- describe('_getCorrectedFileContent', () => {
- it('should call ensureCorrectFileContent for a new file', async () => {
- const filePath = path.join(rootDir, 'new_corrected_file.txt');
- const proposedContent = 'Proposed new content.';
- const correctedContent = 'Corrected new content.';
- const abortSignal = new AbortController().signal;
- // Ensure the mock is set for this specific test case if needed, or rely on beforeEach
- mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
-
- // @ts-expect-error _getCorrectedFileContent is private
- const result = await tool._getCorrectedFileContent(
- filePath,
- proposedContent,
- abortSignal,
- );
-
- expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
- proposedContent,
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(mockEnsureCorrectEdit).not.toHaveBeenCalled();
- expect(result.correctedContent).toBe(correctedContent);
- expect(result.originalContent).toBe('');
- expect(result.fileExists).toBe(false);
- expect(result.error).toBeUndefined();
- });
-
- it('should call ensureCorrectEdit for an existing file', async () => {
- const filePath = path.join(rootDir, 'existing_corrected_file.txt');
- const originalContent = 'Original existing content.';
- const proposedContent = 'Proposed replacement content.';
- const correctedProposedContent = 'Corrected replacement content.';
- const abortSignal = new AbortController().signal;
- fs.writeFileSync(filePath, originalContent, 'utf8');
-
- // Ensure this mock is active and returns the correct structure
- mockEnsureCorrectEdit.mockResolvedValue({
- params: {
- file_path: filePath,
- old_string: originalContent,
- new_string: correctedProposedContent,
- },
- occurrences: 1,
- } as CorrectedEditResult);
-
- // @ts-expect-error _getCorrectedFileContent is private
- const result = await tool._getCorrectedFileContent(
- filePath,
- proposedContent,
- abortSignal,
- );
-
- expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
- originalContent,
- {
- old_string: originalContent,
- new_string: proposedContent,
- file_path: filePath,
- },
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
- expect(result.correctedContent).toBe(correctedProposedContent);
- expect(result.originalContent).toBe(originalContent);
- expect(result.fileExists).toBe(true);
- expect(result.error).toBeUndefined();
- });
-
- it('should return error if reading an existing file fails (e.g. permissions)', async () => {
- const filePath = path.join(rootDir, 'unreadable_file.txt');
- const proposedContent = 'some content';
- const abortSignal = new AbortController().signal;
- fs.writeFileSync(filePath, 'content', { mode: 0o000 });
-
- const readError = new Error('Permission denied');
- const originalReadFileSync = fs.readFileSync;
- vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
- throw readError;
- });
-
- // @ts-expect-error _getCorrectedFileContent is private
- const result = await tool._getCorrectedFileContent(
- filePath,
- proposedContent,
- abortSignal,
- );
-
- expect(fs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8');
- expect(mockEnsureCorrectEdit).not.toHaveBeenCalled();
- expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
- expect(result.correctedContent).toBe(proposedContent);
- expect(result.originalContent).toBe('');
- expect(result.fileExists).toBe(true);
- expect(result.error).toEqual({
- message: 'Permission denied',
- code: undefined,
- });
-
- vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
- fs.chmodSync(filePath, 0o600);
- });
- });
-
- describe('shouldConfirmExecute', () => {
- const abortSignal = new AbortController().signal;
- it('should return false if params are invalid (relative path)', async () => {
- const params = { file_path: 'relative.txt', content: 'test' };
- const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
- expect(confirmation).toBe(false);
- });
-
- it('should return false if params are invalid (outside root)', async () => {
- const outsidePath = path.resolve(tempDir, 'outside-root.txt');
- const params = { file_path: outsidePath, content: 'test' };
- const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
- expect(confirmation).toBe(false);
- });
-
- it('should return false if _getCorrectedFileContent returns an error', async () => {
- const filePath = path.join(rootDir, 'confirm_error_file.txt');
- const params = { file_path: filePath, content: 'test content' };
- fs.writeFileSync(filePath, 'original', { mode: 0o000 });
-
- const readError = new Error('Simulated read error for confirmation');
- const originalReadFileSync = fs.readFileSync;
- vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
- throw readError;
- });
-
- const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
- expect(confirmation).toBe(false);
-
- vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
- fs.chmodSync(filePath, 0o600);
- });
-
- it('should request confirmation with diff for a new file (with corrected content)', async () => {
- const filePath = path.join(rootDir, 'confirm_new_file.txt');
- const proposedContent = 'Proposed new content for confirmation.';
- const correctedContent = 'Corrected new content for confirmation.';
- mockEnsureCorrectFileContent.mockResolvedValue(correctedContent); // Ensure this mock is active
-
- const params = { file_path: filePath, content: proposedContent };
- const confirmation = (await tool.shouldConfirmExecute(
- params,
- abortSignal,
- )) as ToolEditConfirmationDetails;
-
- expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
- proposedContent,
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(confirmation).toEqual(
- expect.objectContaining({
- title: `Confirm Write: ${path.basename(filePath)}`,
- fileName: 'confirm_new_file.txt',
- fileDiff: expect.stringContaining(correctedContent),
- }),
- );
- expect(confirmation.fileDiff).toMatch(
- /--- confirm_new_file.txt\tCurrent/,
- );
- expect(confirmation.fileDiff).toMatch(
- /\+\+\+ confirm_new_file.txt\tProposed/,
- );
- });
-
- it('should request confirmation with diff for an existing file (with corrected content)', async () => {
- const filePath = path.join(rootDir, 'confirm_existing_file.txt');
- const originalContent = 'Original content for confirmation.';
- const proposedContent = 'Proposed replacement for confirmation.';
- const correctedProposedContent =
- 'Corrected replacement for confirmation.';
- fs.writeFileSync(filePath, originalContent, 'utf8');
-
- mockEnsureCorrectEdit.mockResolvedValue({
- params: {
- file_path: filePath,
- old_string: originalContent,
- new_string: correctedProposedContent,
- },
- occurrences: 1,
- });
-
- const params = { file_path: filePath, content: proposedContent };
- const confirmation = (await tool.shouldConfirmExecute(
- params,
- abortSignal,
- )) as ToolEditConfirmationDetails;
-
- expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
- originalContent,
- {
- old_string: originalContent,
- new_string: proposedContent,
- file_path: filePath,
- },
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(confirmation).toEqual(
- expect.objectContaining({
- title: `Confirm Write: ${path.basename(filePath)}`,
- fileName: 'confirm_existing_file.txt',
- fileDiff: expect.stringContaining(correctedProposedContent),
- }),
- );
- expect(confirmation.fileDiff).toMatch(
- originalContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
- );
- });
- });
-
- describe('execute', () => {
- const abortSignal = new AbortController().signal;
- it('should return error if params are invalid (relative path)', async () => {
- const params = { file_path: 'relative.txt', content: 'test' };
- const result = await tool.execute(params, abortSignal);
- expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
- expect(result.returnDisplay).toMatch(/Error: File path must be absolute/);
- });
-
- it('should return error if params are invalid (path outside root)', async () => {
- const outsidePath = path.resolve(tempDir, 'outside-root.txt');
- const params = { file_path: outsidePath, content: 'test' };
- const result = await tool.execute(params, abortSignal);
- expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
- expect(result.returnDisplay).toMatch(
- /Error: File path must be within the root directory/,
- );
- });
-
- it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
- const filePath = path.join(rootDir, 'execute_error_file.txt');
- const params = { file_path: filePath, content: 'test content' };
- fs.writeFileSync(filePath, 'original', { mode: 0o000 });
-
- const readError = new Error('Simulated read error for execute');
- const originalReadFileSync = fs.readFileSync;
- vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
- throw readError;
- });
-
- const result = await tool.execute(params, abortSignal);
- expect(result.llmContent).toMatch(/Error checking existing file/);
- expect(result.returnDisplay).toMatch(
- /Error checking existing file: Simulated read error for execute/,
- );
-
- vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
- fs.chmodSync(filePath, 0o600);
- });
-
- it('should write a new file with corrected content and return diff', async () => {
- const filePath = path.join(rootDir, 'execute_new_corrected_file.txt');
- const proposedContent = 'Proposed new content for execute.';
- const correctedContent = 'Corrected new content for execute.';
- mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
-
- const params = { file_path: filePath, content: proposedContent };
-
- const confirmDetails = await tool.shouldConfirmExecute(
- params,
- abortSignal,
- );
- if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
- await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
- }
-
- const result = await tool.execute(params, abortSignal);
-
- expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
- proposedContent,
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(result.llmContent).toMatch(
- /Successfully created and wrote to new file/,
- );
- expect(fs.existsSync(filePath)).toBe(true);
- expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedContent);
- const display = result.returnDisplay as FileDiff;
- expect(display.fileName).toBe('execute_new_corrected_file.txt');
- expect(display.fileDiff).toMatch(
- /--- execute_new_corrected_file.txt\tOriginal/,
- );
- expect(display.fileDiff).toMatch(
- /\+\+\+ execute_new_corrected_file.txt\tWritten/,
- );
- expect(display.fileDiff).toMatch(
- correctedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
- );
- });
-
- it('should overwrite an existing file with corrected content and return diff', async () => {
- const filePath = path.join(
- rootDir,
- 'execute_existing_corrected_file.txt',
- );
- const initialContent = 'Initial content for execute.';
- const proposedContent = 'Proposed overwrite for execute.';
- const correctedProposedContent = 'Corrected overwrite for execute.';
- fs.writeFileSync(filePath, initialContent, 'utf8');
-
- mockEnsureCorrectEdit.mockResolvedValue({
- params: {
- file_path: filePath,
- old_string: initialContent,
- new_string: correctedProposedContent,
- },
- occurrences: 1,
- });
-
- const params = { file_path: filePath, content: proposedContent };
-
- const confirmDetails = await tool.shouldConfirmExecute(
- params,
- abortSignal,
- );
- if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
- await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
- }
-
- const result = await tool.execute(params, abortSignal);
-
- expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
- initialContent,
- {
- old_string: initialContent,
- new_string: proposedContent,
- file_path: filePath,
- },
- mockGeminiClientInstance,
- abortSignal,
- );
- expect(result.llmContent).toMatch(/Successfully overwrote file/);
- expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedProposedContent);
- const display = result.returnDisplay as FileDiff;
- expect(display.fileName).toBe('execute_existing_corrected_file.txt');
- expect(display.fileDiff).toMatch(
- initialContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
- );
- expect(display.fileDiff).toMatch(
- correctedProposedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
- );
- });
-
- it('should create directory if it does not exist', async () => {
- const dirPath = path.join(rootDir, 'new_dir_for_write');
- const filePath = path.join(dirPath, 'file_in_new_dir.txt');
- const content = 'Content in new directory';
- mockEnsureCorrectFileContent.mockResolvedValue(content); // Ensure this mock is active
-
- const params = { file_path: filePath, content };
- // Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
- const confirmDetails = await tool.shouldConfirmExecute(
- params,
- abortSignal,
- );
- if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
- await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
- }
-
- await tool.execute(params, abortSignal);
-
- expect(fs.existsSync(dirPath)).toBe(true);
- expect(fs.statSync(dirPath).isDirectory()).toBe(true);
- expect(fs.existsSync(filePath)).toBe(true);
- expect(fs.readFileSync(filePath, 'utf8')).toBe(content);
- });
- });
-});
diff --git a/packages/server/src/tools/write-file.ts b/packages/server/src/tools/write-file.ts
deleted file mode 100644
index 2285c819..00000000
--- a/packages/server/src/tools/write-file.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import * as Diff from 'diff';
-import { Config } from '../config/config.js';
-import {
- BaseTool,
- ToolResult,
- FileDiff,
- ToolEditConfirmationDetails,
- ToolConfirmationOutcome,
- ToolCallConfirmationDetails,
-} from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { makeRelative, shortenPath } from '../utils/paths.js';
-import { getErrorMessage, isNodeError } from '../utils/errors.js';
-import {
- ensureCorrectEdit,
- ensureCorrectFileContent,
-} from '../utils/editCorrector.js';
-import { GeminiClient } from '../core/client.js';
-import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
-
-/**
- * Parameters for the WriteFile tool
- */
-export interface WriteFileToolParams {
- /**
- * The absolute path to the file to write to
- */
- file_path: string;
-
- /**
- * The content to write to the file
- */
- content: string;
-}
-
-interface GetCorrectedFileContentResult {
- originalContent: string;
- correctedContent: string;
- fileExists: boolean;
- error?: { message: string; code?: string };
-}
-
-/**
- * Implementation of the WriteFile tool logic
- */
-export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
- static readonly Name: string = 'write_file';
- private readonly client: GeminiClient;
-
- constructor(private readonly config: Config) {
- super(
- WriteFileTool.Name,
- 'WriteFile',
- 'Writes content to a specified file in the local filesystem.',
- {
- properties: {
- file_path: {
- description:
- "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
- type: 'string',
- },
- content: {
- description: 'The content to write to the file.',
- type: 'string',
- },
- },
- required: ['file_path', 'content'],
- type: 'object',
- },
- );
-
- this.client = new GeminiClient(this.config);
- }
-
- private isWithinRoot(pathToCheck: string): boolean {
- const normalizedPath = path.normalize(pathToCheck);
- const normalizedRoot = path.normalize(this.config.getTargetDir());
- const rootWithSep = normalizedRoot.endsWith(path.sep)
- ? normalizedRoot
- : normalizedRoot + path.sep;
- return (
- normalizedPath === normalizedRoot ||
- normalizedPath.startsWith(rootWithSep)
- );
- }
-
- validateToolParams(params: WriteFileToolParams): string | null {
- if (
- this.schema.parameters &&
- !SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
- params,
- )
- ) {
- return 'Parameters failed schema validation.';
- }
- const filePath = params.file_path;
- if (!path.isAbsolute(filePath)) {
- return `File path must be absolute: ${filePath}`;
- }
- if (!this.isWithinRoot(filePath)) {
- return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`;
- }
-
- try {
- // This check should be performed only if the path exists.
- // If it doesn't exist, it's a new file, which is valid for writing.
- if (fs.existsSync(filePath)) {
- const stats = fs.lstatSync(filePath);
- if (stats.isDirectory()) {
- return `Path is a directory, not a file: ${filePath}`;
- }
- }
- } catch (statError: unknown) {
- // If fs.existsSync is true but lstatSync fails (e.g., permissions, race condition where file is deleted)
- // this indicates an issue with accessing the path that should be reported.
- return `Error accessing path properties for validation: ${filePath}. Reason: ${statError instanceof Error ? statError.message : String(statError)}`;
- }
-
- return null;
- }
-
- getDescription(params: WriteFileToolParams): string {
- const relativePath = makeRelative(
- params.file_path,
- this.config.getTargetDir(),
- );
- return `Writing to ${shortenPath(relativePath)}`;
- }
-
- /**
- * Handles the confirmation prompt for the WriteFile tool.
- */
- async shouldConfirmExecute(
- params: WriteFileToolParams,
- abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.config.getAlwaysSkipModificationConfirmation()) {
- return false;
- }
-
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return false;
- }
-
- const correctedContentResult = await this._getCorrectedFileContent(
- params.file_path,
- params.content,
- abortSignal,
- );
-
- if (correctedContentResult.error) {
- // If file exists but couldn't be read, we can't show a diff for confirmation.
- return false;
- }
-
- const { originalContent, correctedContent } = correctedContentResult;
- const relativePath = makeRelative(
- params.file_path,
- this.config.getTargetDir(),
- );
- const fileName = path.basename(params.file_path);
-
- const fileDiff = Diff.createPatch(
- fileName,
- originalContent, // Original content (empty if new file or unreadable)
- correctedContent, // Content after potential correction
- 'Current',
- 'Proposed',
- DEFAULT_DIFF_OPTIONS,
- );
-
- const confirmationDetails: ToolEditConfirmationDetails = {
- type: 'edit',
- title: `Confirm Write: ${shortenPath(relativePath)}`,
- fileName,
- fileDiff,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.config.setAlwaysSkipModificationConfirmation(true);
- }
- },
- };
- return confirmationDetails;
- }
-
- async execute(
- params: WriteFileToolParams,
- abortSignal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: `Error: ${validationError}`,
- };
- }
-
- const correctedContentResult = await this._getCorrectedFileContent(
- params.file_path,
- params.content,
- abortSignal,
- );
-
- if (correctedContentResult.error) {
- const errDetails = correctedContentResult.error;
- const errorMsg = `Error checking existing file: ${errDetails.message}`;
- return {
- llmContent: `Error checking existing file ${params.file_path}: ${errDetails.message}`,
- returnDisplay: errorMsg,
- };
- }
-
- const {
- originalContent,
- correctedContent: fileContent,
- fileExists,
- } = correctedContentResult;
- // fileExists is true if the file existed (and was readable or unreadable but caught by readError).
- // fileExists is false if the file did not exist (ENOENT).
- const isNewFile =
- !fileExists ||
- (correctedContentResult.error !== undefined &&
- !correctedContentResult.fileExists);
-
- try {
- const dirName = path.dirname(params.file_path);
- if (!fs.existsSync(dirName)) {
- fs.mkdirSync(dirName, { recursive: true });
- }
-
- fs.writeFileSync(params.file_path, fileContent, 'utf8');
-
- // Generate diff for display result
- const fileName = path.basename(params.file_path);
- // If there was a readError, originalContent in correctedContentResult is '',
- // but for the diff, we want to show the original content as it was before the write if possible.
- // However, if it was unreadable, currentContentForDiff will be empty.
- const currentContentForDiff = correctedContentResult.error
- ? '' // Or some indicator of unreadable content
- : originalContent;
-
- const fileDiff = Diff.createPatch(
- fileName,
- currentContentForDiff,
- fileContent,
- 'Original',
- 'Written',
- DEFAULT_DIFF_OPTIONS,
- );
-
- const llmSuccessMessage = isNewFile
- ? `Successfully created and wrote to new file: ${params.file_path}`
- : `Successfully overwrote file: ${params.file_path}`;
-
- const displayResult: FileDiff = { fileDiff, fileName };
-
- return {
- llmContent: llmSuccessMessage,
- returnDisplay: displayResult,
- };
- } catch (error) {
- const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`;
- return {
- llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`,
- returnDisplay: `Error: ${errorMsg}`,
- };
- }
- }
-
- private async _getCorrectedFileContent(
- filePath: string,
- proposedContent: string,
- abortSignal: AbortSignal,
- ): Promise<GetCorrectedFileContentResult> {
- let originalContent = '';
- let fileExists = false;
- let correctedContent = proposedContent;
-
- try {
- originalContent = fs.readFileSync(filePath, 'utf8');
- fileExists = true; // File exists and was read
- } catch (err) {
- if (isNodeError(err) && err.code === 'ENOENT') {
- fileExists = false;
- originalContent = '';
- } else {
- // File exists but could not be read (permissions, etc.)
- fileExists = true; // Mark as existing but problematic
- originalContent = ''; // Can't use its content
- const error = {
- message: getErrorMessage(err),
- code: isNodeError(err) ? err.code : undefined,
- };
- // Return early as we can't proceed with content correction meaningfully
- return { originalContent, correctedContent, fileExists, error };
- }
- }
-
- // If readError is set, we have returned.
- // So, file was either read successfully (fileExists=true, originalContent set)
- // or it was ENOENT (fileExists=false, originalContent='').
-
- if (fileExists) {
- // This implies originalContent is available
- const { params: correctedParams } = await ensureCorrectEdit(
- originalContent,
- {
- old_string: originalContent, // Treat entire current content as old_string
- new_string: proposedContent,
- file_path: filePath,
- },
- this.client,
- abortSignal,
- );
- correctedContent = correctedParams.new_string;
- } else {
- // This implies new file (ENOENT)
- correctedContent = await ensureCorrectFileContent(
- proposedContent,
- this.client,
- abortSignal,
- );
- }
- return { originalContent, correctedContent, fileExists };
- }
-}