mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.1.18' of https://github.com/google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.18
This commit is contained in:
@@ -58,9 +58,7 @@ describe('mcp-client', () => {
|
||||
const mockedClient = {} as unknown as ClientLib.Client;
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const testError = new Error('Invalid tool name');
|
||||
vi.mocked(DiscoveredMCPTool).mockImplementation(
|
||||
@@ -113,12 +111,17 @@ describe('mcp-client', () => {
|
||||
{ name: 'prompt2' },
|
||||
],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
{ method: 'prompts/list', params: {} },
|
||||
expect.anything(),
|
||||
@@ -129,37 +132,67 @@ describe('mcp-client', () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
prompts: [],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleLogSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).toHaveBeenCalledOnce();
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should do nothing if the server has no prompt support', async () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
prompts: [],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({});
|
||||
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleLogSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error if discovery fails', async () => {
|
||||
const testError = new Error('test error');
|
||||
testError.message = 'test error';
|
||||
const mockRequest = vi.fn().mockRejectedValue(testError);
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
|
||||
@@ -496,6 +496,9 @@ export async function discoverPrompts(
|
||||
promptRegistry: PromptRegistry,
|
||||
): Promise<Prompt[]> {
|
||||
try {
|
||||
// Only request prompts if the server supports them.
|
||||
if (mcpClient.getServerCapabilities()?.prompts == null) return [];
|
||||
|
||||
const response = await mcpClient.request(
|
||||
{ method: 'prompts/list', params: {} },
|
||||
ListPromptsResultSchema,
|
||||
|
||||
@@ -131,8 +131,11 @@ describe('DiscoveredMCPTool', () => {
|
||||
success: true,
|
||||
details: 'executed',
|
||||
};
|
||||
const mockFunctionResponseContent: Part[] = [
|
||||
{ text: JSON.stringify(mockToolSuccessResultObject) },
|
||||
const mockFunctionResponseContent = [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(mockToolSuccessResultObject),
|
||||
},
|
||||
];
|
||||
const mockMcpToolResponseParts: Part[] = [
|
||||
{
|
||||
@@ -149,11 +152,13 @@ describe('DiscoveredMCPTool', () => {
|
||||
expect(mockCallTool).toHaveBeenCalledWith([
|
||||
{ name: serverToolName, args: params },
|
||||
]);
|
||||
expect(toolResult.llmContent).toEqual(mockMcpToolResponseParts);
|
||||
|
||||
const stringifiedResponseContent = JSON.stringify(
|
||||
mockToolSuccessResultObject,
|
||||
);
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: stringifiedResponseContent },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(stringifiedResponseContent);
|
||||
});
|
||||
|
||||
@@ -170,6 +175,9 @@ describe('DiscoveredMCPTool', () => {
|
||||
mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty);
|
||||
const toolResult: ToolResult = await tool.execute(params);
|
||||
expect(toolResult.returnDisplay).toBe('```json\n[]\n```');
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: '[Error: Could not parse tool response]' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should propagate rejection if mcpTool.callTool rejects', async () => {
|
||||
@@ -186,6 +194,361 @@ describe('DiscoveredMCPTool', () => {
|
||||
|
||||
await expect(tool.execute(params)).rejects.toThrow(expectedError);
|
||||
});
|
||||
|
||||
it('should handle a simple text response correctly', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { query: 'test' };
|
||||
const successMessage = 'This is a success message.';
|
||||
|
||||
// Simulate the response from the GenAI SDK, which wraps the MCP
|
||||
// response in a functionResponse Part.
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
// The `content` array contains MCP ContentBlocks.
|
||||
content: [{ type: 'text', text: successMessage }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
// 1. Assert that the llmContent sent to the scheduler is a clean Part array.
|
||||
expect(toolResult.llmContent).toEqual([{ text: successMessage }]);
|
||||
|
||||
// 2. Assert that the display output is the simple text message.
|
||||
expect(toolResult.returnDisplay).toBe(successMessage);
|
||||
|
||||
// 3. Verify that the underlying callTool was made correctly.
|
||||
expect(mockCallTool).toHaveBeenCalledWith([
|
||||
{ name: serverToolName, args: params },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle an AudioBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'play' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
data: 'BASE64_AUDIO_DATA',
|
||||
mimeType: 'audio/mp3',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following audio data with mime-type: audio/mp3]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'audio/mp3',
|
||||
data: 'BASE64_AUDIO_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe('[Audio: audio/mp3]');
|
||||
});
|
||||
|
||||
it('should handle a ResourceLinkBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///path/to/thing',
|
||||
name: 'resource-name',
|
||||
title: 'My Resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: 'Resource Link: My Resource at file:///path/to/thing',
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'[Link to My Resource: file:///path/to/thing]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an embedded text ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/text.txt',
|
||||
text: 'This is the text content.',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'This is the text content.' },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe('This is the text content.');
|
||||
});
|
||||
|
||||
it('should handle an embedded binary ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/data.bin',
|
||||
blob: 'BASE64_BINARY_DATA',
|
||||
mimeType: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following embedded resource with mime-type: application/octet-stream]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'application/octet-stream',
|
||||
data: 'BASE64_BINARY_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'[Embedded Resource: application/octet-stream]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'First part.' },
|
||||
{
|
||||
type: 'image',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
{ type: 'text', text: 'Second part.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'First part.' },
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
},
|
||||
},
|
||||
{ text: 'Second part.' },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'First part.\n[Image: image/jpeg]\nSecond part.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore unknown content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'test' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Valid part.' },
|
||||
{ type: 'future_block', data: 'some-data' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'Valid part.\n[Unknown content type: future_block]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a complex mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'super-complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is a resource.' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///path/to/resource',
|
||||
name: 'resource-name',
|
||||
title: 'My Resource',
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/text.txt',
|
||||
text: 'Embedded text content.',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'Here is a resource.' },
|
||||
{
|
||||
text: 'Resource Link: My Resource at file:///path/to/resource',
|
||||
},
|
||||
{ text: 'Embedded text content.' },
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
|
||||
@@ -22,6 +22,40 @@ import {
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
||||
// Discriminated union for MCP Content Blocks to ensure type safety.
|
||||
type McpTextBlock = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
type McpMediaBlock = {
|
||||
type: 'image' | 'audio';
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
type McpResourceBlock = {
|
||||
type: 'resource';
|
||||
resource: {
|
||||
text?: string;
|
||||
blob?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type McpResourceLinkBlock = {
|
||||
type: 'resource_link';
|
||||
uri: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type McpContentBlock =
|
||||
| McpTextBlock
|
||||
| McpMediaBlock
|
||||
| McpResourceBlock
|
||||
| McpResourceLinkBlock;
|
||||
|
||||
export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
|
||||
@@ -114,70 +148,145 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
},
|
||||
];
|
||||
|
||||
const responseParts: Part[] = await this.mcpTool.callTool(functionCalls);
|
||||
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
|
||||
const transformedParts = transformMcpContentToParts(rawResponseParts);
|
||||
|
||||
return {
|
||||
llmContent: responseParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(responseParts),
|
||||
llmContent: transformedParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an array of `Part` objects, primarily from a tool's execution result,
|
||||
* to generate a user-friendly string representation, typically for display in a CLI.
|
||||
*
|
||||
* The `result` array can contain various types of `Part` objects:
|
||||
* 1. `FunctionResponse` parts:
|
||||
* - If the `response.content` of a `FunctionResponse` is an array consisting solely
|
||||
* of `TextPart` objects, their text content is concatenated into a single string.
|
||||
* This is to present simple textual outputs directly.
|
||||
* - If `response.content` is an array but contains other types of `Part` objects (or a mix),
|
||||
* the `content` array itself is preserved. This handles structured data like JSON objects or arrays
|
||||
* returned by a tool.
|
||||
* - If `response.content` is not an array or is missing, the entire `functionResponse`
|
||||
* object is preserved.
|
||||
* 2. Other `Part` types (e.g., `TextPart` directly in the `result` array):
|
||||
* - These are preserved as is.
|
||||
*
|
||||
* All processed parts are then collected into an array, which is JSON.stringify-ed
|
||||
* with indentation and wrapped in a markdown JSON code block.
|
||||
*/
|
||||
function getStringifiedResultForDisplay(result: Part[]) {
|
||||
if (!result || result.length === 0) {
|
||||
return '```json\n[]\n```';
|
||||
function transformTextBlock(block: McpTextBlock): Part {
|
||||
return { text: block.text };
|
||||
}
|
||||
|
||||
function transformImageAudioBlock(
|
||||
block: McpMediaBlock,
|
||||
toolName: string,
|
||||
): Part[] {
|
||||
return [
|
||||
{
|
||||
text: `[Tool '${toolName}' provided the following ${
|
||||
block.type
|
||||
} data with mime-type: ${block.mimeType}]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: block.mimeType,
|
||||
data: block.data,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function transformResourceBlock(
|
||||
block: McpResourceBlock,
|
||||
toolName: string,
|
||||
): Part | Part[] | null {
|
||||
const resource = block.resource;
|
||||
if (resource?.text) {
|
||||
return { text: resource.text };
|
||||
}
|
||||
if (resource?.blob) {
|
||||
const mimeType = resource.mimeType || 'application/octet-stream';
|
||||
return [
|
||||
{
|
||||
text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: resource.blob,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const processFunctionResponse = (part: Part) => {
|
||||
if (part.functionResponse) {
|
||||
const responseContent = part.functionResponse.response?.content;
|
||||
if (responseContent && Array.isArray(responseContent)) {
|
||||
// Check if all parts in responseContent are simple TextParts
|
||||
const allTextParts = responseContent.every(
|
||||
(p: Part) => p.text !== undefined,
|
||||
);
|
||||
if (allTextParts) {
|
||||
return responseContent.map((p: Part) => p.text).join('');
|
||||
}
|
||||
// If not all simple text parts, return the array of these content parts for JSON stringification
|
||||
return responseContent;
|
||||
}
|
||||
|
||||
// If no content, or not an array, or not a functionResponse, stringify the whole functionResponse part for inspection
|
||||
return part.functionResponse;
|
||||
}
|
||||
return part; // Fallback for unexpected structure or non-FunctionResponsePart
|
||||
function transformResourceLinkBlock(block: McpResourceLinkBlock): Part {
|
||||
return {
|
||||
text: `Resource Link: ${block.title || block.name} at ${block.uri}`,
|
||||
};
|
||||
}
|
||||
|
||||
const processedResults =
|
||||
result.length === 1
|
||||
? processFunctionResponse(result[0])
|
||||
: result.map(processFunctionResponse);
|
||||
if (typeof processedResults === 'string') {
|
||||
return processedResults;
|
||||
/**
|
||||
* Transforms the raw MCP content blocks from the SDK response into a
|
||||
* standard GenAI Part array.
|
||||
* @param sdkResponse The raw Part[] array from `mcpTool.callTool()`.
|
||||
* @returns A clean Part[] array ready for the scheduler.
|
||||
*/
|
||||
function transformMcpContentToParts(sdkResponse: Part[]): Part[] {
|
||||
const funcResponse = sdkResponse?.[0]?.functionResponse;
|
||||
const mcpContent = funcResponse?.response?.content as McpContentBlock[];
|
||||
const toolName = funcResponse?.name || 'unknown tool';
|
||||
|
||||
if (!Array.isArray(mcpContent)) {
|
||||
return [{ text: '[Error: Could not parse tool response]' }];
|
||||
}
|
||||
|
||||
return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```';
|
||||
const transformed = mcpContent.flatMap(
|
||||
(block: McpContentBlock): Part | Part[] | null => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return transformTextBlock(block);
|
||||
case 'image':
|
||||
case 'audio':
|
||||
return transformImageAudioBlock(block, toolName);
|
||||
case 'resource':
|
||||
return transformResourceBlock(block, toolName);
|
||||
case 'resource_link':
|
||||
return transformResourceLinkBlock(block);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return transformed.filter((part): part is Part => part !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the raw response from the MCP tool to generate a clean,
|
||||
* human-readable string for display in the CLI. It summarizes non-text
|
||||
* content and presents text directly.
|
||||
*
|
||||
* @param rawResponse The raw Part[] array from the GenAI SDK.
|
||||
* @returns A formatted string representing the tool's output.
|
||||
*/
|
||||
function getStringifiedResultForDisplay(rawResponse: Part[]): string {
|
||||
const mcpContent = rawResponse?.[0]?.functionResponse?.response
|
||||
?.content as McpContentBlock[];
|
||||
|
||||
if (!Array.isArray(mcpContent)) {
|
||||
return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```';
|
||||
}
|
||||
|
||||
const displayParts = mcpContent.map((block: McpContentBlock): string => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return block.text;
|
||||
case 'image':
|
||||
return `[Image: ${block.mimeType}]`;
|
||||
case 'audio':
|
||||
return `[Audio: ${block.mimeType}]`;
|
||||
case 'resource_link':
|
||||
return `[Link to ${block.title || block.name}: ${block.uri}]`;
|
||||
case 'resource':
|
||||
if (block.resource?.text) {
|
||||
return block.resource.text;
|
||||
}
|
||||
return `[Embedded Resource: ${
|
||||
block.resource?.mimeType || 'unknown type'
|
||||
}]`;
|
||||
default:
|
||||
return `[Unknown content type: ${(block as { type: string }).type}]`;
|
||||
}
|
||||
});
|
||||
|
||||
return displayParts.join('\n');
|
||||
}
|
||||
|
||||
/** Visible for testing */
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
|
||||
@@ -148,6 +149,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
const stats = await fsp.stat(diffDir);
|
||||
@@ -165,6 +167,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||
@@ -183,6 +186,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||
@@ -211,6 +215,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||
@@ -241,6 +246,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
),
|
||||
).rejects.toThrow('Editor failed to open');
|
||||
|
||||
@@ -267,6 +273,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
@@ -290,6 +297,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||
@@ -311,6 +319,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -138,6 +138,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||
modifyContext: ModifyContext<ToolParams>,
|
||||
editorType: EditorType,
|
||||
_abortSignal: AbortSignal,
|
||||
onEditorClose: () => void,
|
||||
): Promise<ModifyResult<ToolParams>> {
|
||||
const currentContent = await modifyContext.getCurrentContent(originalParams);
|
||||
const proposedContent =
|
||||
@@ -150,7 +151,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||
);
|
||||
|
||||
try {
|
||||
await openDiff(oldPath, newPath, editorType);
|
||||
await openDiff(oldPath, newPath, editorType, onEditorClose);
|
||||
const result = getUpdatedParams(
|
||||
oldPath,
|
||||
newPath,
|
||||
|
||||
@@ -477,4 +477,139 @@ describe('ReadManyFilesTool', () => {
|
||||
fs.rmSync(tempDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const fileName = `file${i}.txt`;
|
||||
createFile(fileName, `${contentPrefix} ${i}`);
|
||||
files.push(fileName);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const createFile = (filePath: string, content = '') => {
|
||||
const fullPath = path.join(tempRootDir, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
};
|
||||
|
||||
it('should process files in parallel for performance', async () => {
|
||||
// Mock detectFileType to add artificial delay to simulate I/O
|
||||
const detectFileTypeSpy = vi.spyOn(
|
||||
await import('../utils/fileUtils.js'),
|
||||
'detectFileType',
|
||||
);
|
||||
|
||||
// Create files
|
||||
const fileCount = 4;
|
||||
const files = createMultipleFiles(fileCount, 'Batch test');
|
||||
|
||||
// Mock with 100ms delay per file to simulate I/O operations
|
||||
detectFileTypeSpy.mockImplementation(async (_filePath: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return 'text';
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const params = { paths: files };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const endTime = Date.now();
|
||||
|
||||
const processingTime = endTime - startTime;
|
||||
|
||||
console.log(
|
||||
`Processing time: ${processingTime}ms for ${fileCount} files`,
|
||||
);
|
||||
|
||||
// Verify parallel processing performance improvement
|
||||
// Parallel processing should complete in ~100ms (single file time)
|
||||
// Sequential would take ~400ms (4 files × 100ms each)
|
||||
expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation
|
||||
|
||||
// Verify all files were processed
|
||||
const content = result.llmContent as string[];
|
||||
expect(content).toHaveLength(fileCount);
|
||||
|
||||
// Cleanup mock
|
||||
detectFileTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle batch processing errors gracefully', async () => {
|
||||
// Create mix of valid and problematic files
|
||||
createFile('valid1.txt', 'Valid content 1');
|
||||
createFile('valid2.txt', 'Valid content 2');
|
||||
createFile('valid3.txt', 'Valid content 3');
|
||||
|
||||
const params = {
|
||||
paths: [
|
||||
'valid1.txt',
|
||||
'valid2.txt',
|
||||
'nonexistent-file.txt', // This will fail
|
||||
'valid3.txt',
|
||||
],
|
||||
};
|
||||
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
|
||||
// Should successfully process valid files despite one failure
|
||||
expect(content.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.returnDisplay).toContain('Successfully read');
|
||||
|
||||
// Verify valid files were processed
|
||||
const expectedPath1 = path.join(tempRootDir, 'valid1.txt');
|
||||
const expectedPath3 = path.join(tempRootDir, 'valid3.txt');
|
||||
expect(content.some((c) => c.includes(expectedPath1))).toBe(true);
|
||||
expect(content.some((c) => c.includes(expectedPath3))).toBe(true);
|
||||
});
|
||||
|
||||
it('should execute file operations concurrently', async () => {
|
||||
// Track execution order to verify concurrency
|
||||
const executionOrder: string[] = [];
|
||||
const detectFileTypeSpy = vi.spyOn(
|
||||
await import('../utils/fileUtils.js'),
|
||||
'detectFileType',
|
||||
);
|
||||
|
||||
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
|
||||
files.forEach((file) => createFile(file, 'test content'));
|
||||
|
||||
// Mock to track concurrent vs sequential execution
|
||||
detectFileTypeSpy.mockImplementation(async (filePath: string) => {
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
executionOrder.push(`start:${fileName}`);
|
||||
|
||||
// Add delay to make timing differences visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
executionOrder.push(`end:${fileName}`);
|
||||
return 'text';
|
||||
});
|
||||
|
||||
await tool.execute({ paths: files }, new AbortController().signal);
|
||||
|
||||
console.log('Execution order:', executionOrder);
|
||||
|
||||
// Verify concurrent execution pattern
|
||||
// In parallel execution: all "start:" events should come before all "end:" events
|
||||
// In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc.
|
||||
|
||||
const startEvents = executionOrder.filter((e) =>
|
||||
e.startsWith('start:'),
|
||||
).length;
|
||||
const firstEndIndex = executionOrder.findIndex((e) =>
|
||||
e.startsWith('end:'),
|
||||
);
|
||||
const startsBeforeFirstEnd = executionOrder
|
||||
.slice(0, firstEndIndex)
|
||||
.filter((e) => e.startsWith('start:')).length;
|
||||
|
||||
// For parallel processing, ALL start events should happen before the first end event
|
||||
expect(startsBeforeFirstEnd).toBe(startEvents); // Should PASS with parallel implementation
|
||||
|
||||
detectFileTypeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,27 @@ export interface ReadManyFilesParams {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for file processing operations
|
||||
*/
|
||||
type FileProcessingResult =
|
||||
| {
|
||||
success: true;
|
||||
filePath: string;
|
||||
relativePathForDisplay: string;
|
||||
fileReadResult: NonNullable<
|
||||
Awaited<ReturnType<typeof processSingleFileContent>>
|
||||
>;
|
||||
reason?: undefined;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
filePath: string;
|
||||
relativePathForDisplay: string;
|
||||
fileReadResult?: undefined;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default exclusion patterns for commonly ignored directories and binary file types.
|
||||
* These are compatible with glob ignore patterns.
|
||||
@@ -413,66 +434,124 @@ Use this tool when the user's query implies needing the content of several files
|
||||
|
||||
const sortedFiles = Array.from(filesToConsider).sort();
|
||||
|
||||
for (const filePath of sortedFiles) {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
const fileProcessingPromises = sortedFiles.map(
|
||||
async (filePath): Promise<FileProcessingResult> => {
|
||||
try {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
const fileType = await detectFileType(filePath);
|
||||
const fileType = await 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 (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;
|
||||
}
|
||||
}
|
||||
if (!requestedExplicitly) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason:
|
||||
'asset file (image/pdf) was not explicitly requested by name or extension',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
|
||||
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}',
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
contentParts.push(`${separator}\n\n${fileReadResult.llmContent}\n\n`);
|
||||
} else {
|
||||
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
|
||||
|
||||
if (fileReadResult.error) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason: `Read error: ${fileReadResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
fileReadResult,
|
||||
};
|
||||
} catch (error) {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
processedFilesRelativePaths.push(relativePathForDisplay);
|
||||
const lines =
|
||||
typeof fileReadResult.llmContent === 'string'
|
||||
? fileReadResult.llmContent.split('\n').length
|
||||
: undefined;
|
||||
const mimetype = getSpecificMimeType(filePath);
|
||||
recordFileOperationMetric(
|
||||
this.config,
|
||||
FileOperation.READ,
|
||||
lines,
|
||||
mimetype,
|
||||
path.extname(filePath),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(fileProcessingPromises);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const fileResult = result.value;
|
||||
|
||||
if (!fileResult.success) {
|
||||
// Handle skipped files (images/PDFs not requested or read errors)
|
||||
skippedFiles.push({
|
||||
path: fileResult.relativePathForDisplay,
|
||||
reason: fileResult.reason,
|
||||
});
|
||||
} else {
|
||||
// Handle successfully processed files
|
||||
const { filePath, relativePathForDisplay, fileReadResult } =
|
||||
fileResult;
|
||||
|
||||
if (typeof fileReadResult.llmContent === 'string') {
|
||||
const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
|
||||
'{filePath}',
|
||||
filePath,
|
||||
);
|
||||
contentParts.push(
|
||||
`${separator}\n\n${fileReadResult.llmContent}\n\n`,
|
||||
);
|
||||
} else {
|
||||
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
|
||||
}
|
||||
|
||||
processedFilesRelativePaths.push(relativePathForDisplay);
|
||||
|
||||
const lines =
|
||||
typeof fileReadResult.llmContent === 'string'
|
||||
? fileReadResult.llmContent.split('\n').length
|
||||
: undefined;
|
||||
const mimetype = getSpecificMimeType(filePath);
|
||||
recordFileOperationMetric(
|
||||
this.config,
|
||||
FileOperation.READ,
|
||||
lines,
|
||||
mimetype,
|
||||
path.extname(filePath),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Handle Promise rejection (unexpected errors)
|
||||
skippedFiles.push({
|
||||
path: 'unknown',
|
||||
reason: `Unexpected error: ${result.reason}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -543,3 +543,37 @@ describe('validateToolParams', () => {
|
||||
expect(result).toContain('is not a registered workspace directory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should return null for valid directory', () => {
|
||||
const config = {
|
||||
getCoreTools: () => undefined,
|
||||
getExcludeTools: () => undefined,
|
||||
getTargetDir: () => '/root',
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext('/root', ['/users/test']),
|
||||
} as unknown as Config;
|
||||
const shellTool = new ShellTool(config);
|
||||
const result = shellTool.validateToolParams({
|
||||
command: 'ls',
|
||||
directory: 'test',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for directory outside workspace', () => {
|
||||
const config = {
|
||||
getCoreTools: () => undefined,
|
||||
getExcludeTools: () => undefined,
|
||||
getTargetDir: () => '/root',
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext('/root', ['/users/test']),
|
||||
} as unknown as Config;
|
||||
const shellTool = new ShellTool(config);
|
||||
const result = shellTool.validateToolParams({
|
||||
command: 'ls',
|
||||
directory: 'test2',
|
||||
});
|
||||
expect(result).toContain('is not a registered workspace directory');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Schema,
|
||||
} from '@google/genai';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs');
|
||||
@@ -140,7 +139,6 @@ const baseConfigParams: ConfigParameters = {
|
||||
geminiMdFileCount: 0,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
sessionId: 'test-session-id',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
};
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
@@ -172,6 +170,10 @@ describe('ToolRegistry', () => {
|
||||
);
|
||||
vi.spyOn(config, 'getMcpServers');
|
||||
vi.spyOn(config, 'getMcpServerCommand');
|
||||
vi.spyOn(config, 'getPromptRegistry').mockReturnValue({
|
||||
clear: vi.fn(),
|
||||
removePromptsByServer: vi.fn(),
|
||||
} as any);
|
||||
mockDiscoverMcpTools.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -353,7 +355,7 @@ describe('ToolRegistry', () => {
|
||||
mcpServerConfigVal,
|
||||
undefined,
|
||||
toolRegistry,
|
||||
undefined,
|
||||
config.getPromptRegistry(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -376,7 +378,7 @@ describe('ToolRegistry', () => {
|
||||
mcpServerConfigVal,
|
||||
undefined,
|
||||
toolRegistry,
|
||||
undefined,
|
||||
config.getPromptRegistry(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -150,6 +150,14 @@ export class ToolRegistry {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
private removeDiscoveredTools(): void {
|
||||
for (const tool of this.tools.values()) {
|
||||
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
|
||||
this.tools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers tools from project (if available and configured).
|
||||
* Can be called multiple times to update discovered tools.
|
||||
@@ -157,11 +165,9 @@ export class ToolRegistry {
|
||||
*/
|
||||
async discoverAllTools(): 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);
|
||||
}
|
||||
}
|
||||
this.removeDiscoveredTools();
|
||||
|
||||
this.config.getPromptRegistry().clear();
|
||||
|
||||
await this.discoverAndRegisterToolsFromCommand();
|
||||
|
||||
@@ -182,11 +188,9 @@ export class ToolRegistry {
|
||||
*/
|
||||
async discoverMcpTools(): Promise<void> {
|
||||
// remove any previously discovered tools
|
||||
for (const tool of this.tools.values()) {
|
||||
if (tool instanceof DiscoveredMCPTool) {
|
||||
this.tools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
this.removeDiscoveredTools();
|
||||
|
||||
this.config.getPromptRegistry().clear();
|
||||
|
||||
// discover tools using MCP servers, if configured
|
||||
await discoverMcpTools(
|
||||
@@ -210,6 +214,8 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
this.config.getPromptRegistry().removePromptsByServer(serverName);
|
||||
|
||||
const mcpServers = this.config.getMcpServers() ?? {};
|
||||
const serverConfig = mcpServers[serverName];
|
||||
if (serverConfig) {
|
||||
|
||||
Reference in New Issue
Block a user