Added /mcp command support and cleaned up broken tests (#817)

This commit is contained in:
Bryan Morgan
2025-06-07 15:06:18 -04:00
committed by GitHub
parent 6ea4479064
commit 28ff62e7b1
9 changed files with 449 additions and 33 deletions

View File

@@ -44,6 +44,8 @@ export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search.js';
export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js';
// Export telemetry functions
export * from './telemetry/index.js';

View File

@@ -91,7 +91,7 @@ Expectation for required parameters:
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.,
**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.
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`,
{
properties: {

View File

@@ -185,7 +185,9 @@ describe('GlobTool', () => {
});
it('should return error if pattern is missing (schema validation)', () => {
const params = { path: '.' } as unknown as GlobToolParams;
// Need to correctly define this as an object without pattern
const params = { path: '.' };
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toContain(
'Parameters failed schema validation',
);
@@ -209,7 +211,8 @@ describe('GlobTool', () => {
const params = {
pattern: '*.ts',
path: 123,
} as unknown as GlobToolParams;
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toContain(
'Parameters failed schema validation',
);
@@ -219,7 +222,8 @@ describe('GlobTool', () => {
const params = {
pattern: '*.ts',
case_sensitive: 'true',
} as unknown as GlobToolParams;
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toContain(
'Parameters failed schema validation',
);

View File

@@ -13,6 +13,83 @@ import { DiscoveredMCPTool } from './mcp-tool.js';
import { CallableTool, FunctionDeclaration, mcpToTool } from '@google/genai';
import { ToolRegistry } from './tool-registry.js';
/**
* Enum representing the connection status of an MCP server
*/
export enum MCPServerStatus {
/** Server is disconnected or experiencing errors */
DISCONNECTED = 'disconnected',
/** Server is in the process of connecting */
CONNECTING = 'connecting',
/** Server is connected and ready to use */
CONNECTED = 'connected',
}
/**
* Map to track the status of each MCP server within the core package
*/
const mcpServerStatusesInternal: Map<string, MCPServerStatus> = new Map();
/**
* Event listeners for MCP server status changes
*/
type StatusChangeListener = (
serverName: string,
status: MCPServerStatus,
) => void;
const statusChangeListeners: StatusChangeListener[] = [];
/**
* Add a listener for MCP server status changes
*/
export function addMCPStatusChangeListener(
listener: StatusChangeListener,
): void {
statusChangeListeners.push(listener);
}
/**
* Remove a listener for MCP server status changes
*/
export function removeMCPStatusChangeListener(
listener: StatusChangeListener,
): void {
const index = statusChangeListeners.indexOf(listener);
if (index !== -1) {
statusChangeListeners.splice(index, 1);
}
}
/**
* Update the status of an MCP server
*/
function updateMCPServerStatus(
serverName: string,
status: MCPServerStatus,
): void {
mcpServerStatusesInternal.set(serverName, status);
// Notify all listeners
for (const listener of statusChangeListeners) {
listener(serverName, status);
}
}
/**
* Get the current status of an MCP server
*/
export function getMCPServerStatus(serverName: string): MCPServerStatus {
return (
mcpServerStatusesInternal.get(serverName) || MCPServerStatus.DISCONNECTED
);
}
/**
* Get all MCP server statuses
*/
export function getAllMCPServerStatuses(): Map<string, MCPServerStatus> {
return new Map(mcpServerStatusesInternal);
}
export async function discoverMcpTools(
mcpServers: Record<string, MCPServerConfig>,
mcpServerCommand: string | undefined,
@@ -43,6 +120,9 @@ async function connectAndDiscover(
mcpServerConfig: MCPServerConfig,
toolRegistry: ToolRegistry,
): Promise<void> {
// Initialize the server status as connecting
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
let transport;
if (mcpServerConfig.url) {
transport = new SSEClientTransport(new URL(mcpServerConfig.url));
@@ -61,6 +141,8 @@ async function connectAndDiscover(
console.error(
`MCP server '${mcpServerName}' has invalid configuration: missing both url (for SSE) and command (for stdio). Skipping.`,
);
// Update status to disconnected
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
return;
}
@@ -72,16 +154,22 @@ async function connectAndDiscover(
try {
await mcpClient.connect(transport);
// Connection successful
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED);
} catch (error) {
console.error(
`failed to start or connect to MCP server '${mcpServerName}' ` +
`${JSON.stringify(mcpServerConfig)}; \n${error}`,
);
// Update status to disconnected
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
return;
}
mcpClient.onerror = (error) => {
console.error(`MCP ERROR (${mcpServerName}):`, error.toString());
// Update status to disconnected on error
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
};
if (transport instanceof StdioClientTransport && transport.stderr) {
@@ -110,6 +198,8 @@ async function connectAndDiscover(
} else if (transport instanceof SSEClientTransport) {
await transport.close();
}
// Update status to disconnected
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
return;
}
@@ -168,6 +258,8 @@ async function connectAndDiscover(
) {
await transport.close();
}
// Update status to disconnected
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
}
// If no tools were registered from this MCP server, the following 'if' block
@@ -184,6 +276,8 @@ async function connectAndDiscover(
transport instanceof SSEClientTransport
) {
await transport.close();
// Update status to disconnected
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
}
}
}

View File

@@ -45,6 +45,10 @@ describe('loadServerHierarchicalMemory', () => {
beforeEach(() => {
vi.resetAllMocks();
// Set environment variables to indicate test environment
process.env.NODE_ENV = 'test';
process.env.VITEST = 'true';
setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); // Use defined const
mockOs.homedir.mockReturnValue(USER_HOME);

View File

@@ -56,17 +56,29 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
return currentDir;
}
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'code' in error) {
const fsError = error as { code: string; message: string };
if (fsError.code !== 'ENOENT') {
// Don't log ENOENT errors as they're expected when .git doesn't exist
// Also don't log errors in test environments, which often have mocked fs
const isENOENT =
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'ENOENT';
// Only log unexpected errors in non-test environments
// process.env.NODE_ENV === 'test' or VITEST are common test indicators
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST;
if (!isENOENT && !isTestEnv) {
if (typeof error === 'object' && error !== null && 'code' in error) {
const fsError = error as { code: string; message: string };
logger.warn(
`Error checking for .git directory at ${gitPath}: ${fsError.message}`,
);
} else {
logger.warn(
`Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`,
);
}
} else {
logger.warn(
`Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`,
);
}
}
const parentDir = path.dirname(currentDir);
@@ -136,8 +148,12 @@ async function collectDownwardGeminiFiles(
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`Error scanning directory ${directory}: ${message}`);
// Only log warnings in non-test environments
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST;
if (!isTestEnv) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`Error scanning directory ${directory}: ${message}`);
}
if (debugMode) logger.debug(`Failed to scan directory: ${directory}`);
}
return collectedPaths;
@@ -283,10 +299,13 @@ async function readGeminiMdFiles(
`Successfully read: ${filePath} (Length: ${content.length})`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`Warning: Could not read ${getCurrentGeminiMdFilename()} file at ${filePath}. Error: ${message}`,
);
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST;
if (!isTestEnv) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`Warning: Could not read ${getCurrentGeminiMdFilename()} file at ${filePath}. Error: ${message}`,
);
}
results.push({ filePath, content: null }); // Still include it with null content
if (debugMode) logger.debug(`Failed to read: ${filePath}`);
}