mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
feat: Add support for MCP Resources (#13178)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import type { Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { Config, DiscoveredMCPResource } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FileDiscoveryService,
|
||||
GlobTool,
|
||||
@@ -86,6 +86,13 @@ describe('handleAtCommand', () => {
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getResourceRegistry: () => ({
|
||||
findResourceByUri: () => undefined,
|
||||
getAllResources: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getClient: () => undefined,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const registry = new ToolRegistry(mockConfig);
|
||||
@@ -1241,4 +1248,98 @@ describe('handleAtCommand', () => {
|
||||
);
|
||||
expect(userTurnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('MCP resource attachments', () => {
|
||||
it('attaches MCP resource content when @serverName:uri matches registry', async () => {
|
||||
const serverName = 'server-1';
|
||||
const resourceUri = 'resource://server-1/logs';
|
||||
const prefixedUri = `${serverName}:${resourceUri}`;
|
||||
const resource = {
|
||||
serverName,
|
||||
uri: resourceUri,
|
||||
name: 'logs',
|
||||
discoveredAt: Date.now(),
|
||||
} as DiscoveredMCPResource;
|
||||
|
||||
vi.spyOn(mockConfig, 'getResourceRegistry').mockReturnValue({
|
||||
findResourceByUri: (identifier: string) =>
|
||||
identifier === prefixedUri ? resource : undefined,
|
||||
getAllResources: () => [],
|
||||
} as never);
|
||||
|
||||
const readResource = vi.fn().mockResolvedValue({
|
||||
contents: [{ text: 'mcp resource body' }],
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
|
||||
getClient: () => ({ readResource }),
|
||||
} as never);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query: `@${prefixedUri}`,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 42,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(readResource).toHaveBeenCalledWith(resourceUri);
|
||||
const processedParts = Array.isArray(result.processedQuery)
|
||||
? result.processedQuery
|
||||
: [];
|
||||
const containsResourceText = processedParts.some((part) => {
|
||||
const text = typeof part === 'string' ? part : part?.text;
|
||||
return typeof text === 'string' && text.includes('mcp resource body');
|
||||
});
|
||||
expect(containsResourceText).toBe(true);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'tool_group' }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an error if MCP client is unavailable', async () => {
|
||||
const serverName = 'server-1';
|
||||
const resourceUri = 'resource://server-1/logs';
|
||||
const prefixedUri = `${serverName}:${resourceUri}`;
|
||||
vi.spyOn(mockConfig, 'getResourceRegistry').mockReturnValue({
|
||||
findResourceByUri: (identifier: string) =>
|
||||
identifier === prefixedUri
|
||||
? ({
|
||||
serverName,
|
||||
uri: resourceUri,
|
||||
discoveredAt: Date.now(),
|
||||
} as DiscoveredMCPResource)
|
||||
: undefined,
|
||||
getAllResources: () => [],
|
||||
} as never);
|
||||
vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
|
||||
getClient: () => undefined,
|
||||
} as never);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query: `@${prefixedUri}`,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 42,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.shouldProceed).toBe(false);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'tool_group',
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resultDisplay: expect.stringContaining(
|
||||
"MCP client for server 'server-1' is not available or not connected.",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { PartListUnion, PartUnion } from '@google/genai';
|
||||
import type { AnyToolInvocation, Config } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnyToolInvocation,
|
||||
Config,
|
||||
DiscoveredMCPResource,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
unescapePath,
|
||||
ReadManyFilesTool,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -113,13 +118,14 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes user input potentially containing one or more '@<path>' commands.
|
||||
* If found, it attempts to read the specified files/directories using the
|
||||
* 'read_many_files' tool. The user query is modified to include resolved paths,
|
||||
* and the content of the files is appended in a structured block.
|
||||
* Processes user input containing one or more '@<path>' commands.
|
||||
* - Workspace paths are read via the 'read_many_files' tool.
|
||||
* - MCP resource URIs are read via each server's `resources/read`.
|
||||
* The user query is updated with inline content blocks so the LLM receives the
|
||||
* referenced context directly.
|
||||
*
|
||||
* @returns An object indicating whether the main hook should proceed with an
|
||||
* LLM call and the processed query parts (including file content).
|
||||
* LLM call and the processed query parts (including file/resource content).
|
||||
*/
|
||||
export async function handleAtCommand({
|
||||
query,
|
||||
@@ -129,6 +135,9 @@ export async function handleAtCommand({
|
||||
messageId: userMessageTimestamp,
|
||||
signal,
|
||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||
const resourceRegistry = config.getResourceRegistry();
|
||||
const mcpClientManager = config.getMcpClientManager();
|
||||
|
||||
const commandParts = parseAllAtCommands(query);
|
||||
const atPathCommandParts = commandParts.filter(
|
||||
(part) => part.type === 'atPath',
|
||||
@@ -144,8 +153,9 @@ export async function handleAtCommand({
|
||||
const respectFileIgnore = config.getFileFilteringOptions();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const resourceAttachments: DiscoveredMCPResource[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const fileLabelsForDisplay: string[] = [];
|
||||
const absoluteToRelativePathMap = new Map<string, string>();
|
||||
const ignoredByReason: Record<string, string[]> = {
|
||||
git: [],
|
||||
@@ -191,7 +201,13 @@ export async function handleAtCommand({
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
|
||||
// Check if path should be ignored based on filtering options
|
||||
// Check if this is an MCP resource reference (serverName:uri format)
|
||||
const resourceMatch = resourceRegistry.findResourceByUri(pathName);
|
||||
if (resourceMatch) {
|
||||
resourceAttachments.push(resourceMatch);
|
||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(pathName)) {
|
||||
@@ -324,7 +340,7 @@ export async function handleAtCommand({
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
|
||||
const displayPath = path.isAbsolute(pathName) ? relativePath : pathName;
|
||||
contentLabelsForDisplay.push(displayPath);
|
||||
fileLabelsForDisplay.push(displayPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -397,7 +413,7 @@ export async function handleAtCommand({
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
if (pathSpecsToRead.length === 0) {
|
||||
if (pathSpecsToRead.length === 0 && resourceAttachments.length === 0) {
|
||||
onDebugMessage('No valid file paths found in @ commands to read.');
|
||||
if (initialQueryText === '@' && query.trim() === '@') {
|
||||
// If the only thing was a lone @, pass original query (which might have spaces)
|
||||
@@ -413,7 +429,86 @@ export async function handleAtCommand({
|
||||
};
|
||||
}
|
||||
|
||||
const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
|
||||
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
|
||||
|
||||
const resourcePromises = resourceAttachments.map(async (resource) => {
|
||||
const uri = resource.uri!;
|
||||
const client = mcpClientManager?.getClient(resource.serverName);
|
||||
try {
|
||||
if (!client) {
|
||||
throw new Error(
|
||||
`MCP client for server '${resource.serverName}' is not available or not connected.`,
|
||||
);
|
||||
}
|
||||
const response = await client.readResource(uri);
|
||||
const parts = convertResourceContentsToParts(response);
|
||||
return {
|
||||
success: true,
|
||||
parts,
|
||||
uri,
|
||||
display: {
|
||||
callId: `mcp-resource-${resource.serverName}-${uri}`,
|
||||
name: `resources/read (${resource.serverName})`,
|
||||
description: uri,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: `Successfully read resource ${uri}`,
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
parts: [],
|
||||
uri,
|
||||
display: {
|
||||
callId: `mcp-resource-${resource.serverName}-${uri}`,
|
||||
name: `resources/read (${resource.serverName})`,
|
||||
description: uri,
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: `Error reading resource ${uri}: ${getErrorMessage(error)}`,
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const resourceResults = await Promise.all(resourcePromises);
|
||||
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
||||
let resourceErrorOccurred = false;
|
||||
|
||||
for (const result of resourceResults) {
|
||||
resourceReadDisplays.push(result.display);
|
||||
if (result.success) {
|
||||
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
||||
processedQueryParts.push(...result.parts);
|
||||
} else {
|
||||
resourceErrorOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceErrorOccurred) {
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: resourceReadDisplays } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
|
||||
if (pathSpecsToRead.length === 0) {
|
||||
if (resourceReadDisplays.length > 0) {
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: resourceReadDisplays } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
return { processedQuery: processedQueryParts, shouldProceed: true };
|
||||
}
|
||||
|
||||
const toolArgs = {
|
||||
include: pathSpecsToRead,
|
||||
@@ -423,20 +518,20 @@ export async function handleAtCommand({
|
||||
},
|
||||
// Use configuration setting
|
||||
};
|
||||
let toolCallDisplay: IndividualToolCallDisplay;
|
||||
let readManyFilesDisplay: IndividualToolCallDisplay | undefined;
|
||||
|
||||
let invocation: AnyToolInvocation | undefined = undefined;
|
||||
try {
|
||||
invocation = readManyFilesTool.build(toolArgs);
|
||||
const result = await invocation.execute(signal);
|
||||
toolCallDisplay = {
|
||||
readManyFilesDisplay = {
|
||||
callId: `client-read-${userMessageTimestamp}`,
|
||||
name: readManyFilesTool.displayName,
|
||||
description: invocation.getDescription(),
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay:
|
||||
result.returnDisplay ||
|
||||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||
`Successfully read: ${fileLabelsForDisplay.join(', ')}`,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
|
||||
@@ -486,32 +581,67 @@ export async function handleAtCommand({
|
||||
);
|
||||
}
|
||||
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
if (resourceReadDisplays.length > 0 || readManyFilesDisplay) {
|
||||
addItem(
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
...resourceReadDisplays,
|
||||
...(readManyFilesDisplay ? [readManyFilesDisplay] : []),
|
||||
],
|
||||
} as Omit<HistoryItem, 'id'>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
return { processedQuery: processedQueryParts, shouldProceed: true };
|
||||
} catch (error: unknown) {
|
||||
toolCallDisplay = {
|
||||
readManyFilesDisplay = {
|
||||
callId: `client-read-${userMessageTimestamp}`,
|
||||
name: readManyFilesTool.displayName,
|
||||
description:
|
||||
invocation?.getDescription() ??
|
||||
'Error attempting to execute tool to read files',
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [...resourceReadDisplays, readManyFilesDisplay],
|
||||
} as Omit<HistoryItem, 'id'>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
}
|
||||
|
||||
function convertResourceContentsToParts(response: {
|
||||
contents?: Array<{
|
||||
text?: string;
|
||||
blob?: string;
|
||||
mimeType?: string;
|
||||
resource?: {
|
||||
text?: string;
|
||||
blob?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
}>;
|
||||
}): PartUnion[] {
|
||||
const parts: PartUnion[] = [];
|
||||
for (const content of response.contents ?? []) {
|
||||
const candidate = content.resource ?? content;
|
||||
if (candidate.text) {
|
||||
parts.push({ text: candidate.text });
|
||||
continue;
|
||||
}
|
||||
if (candidate.blob) {
|
||||
const sizeBytes = Buffer.from(candidate.blob, 'base64').length;
|
||||
const mimeType = candidate.mimeType ?? 'application/octet-stream';
|
||||
parts.push({
|
||||
text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ describe('useAtCompletion', () => {
|
||||
})),
|
||||
getEnableRecursiveFileSearch: () => true,
|
||||
getFileFilteringDisableFuzzySearch: () => false,
|
||||
getResourceRegistry: vi.fn().mockReturnValue({
|
||||
getAllResources: () => [],
|
||||
}),
|
||||
} as unknown as Config;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -174,6 +177,34 @@ describe('useAtCompletion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP resource suggestions', () => {
|
||||
it('should include MCP resources in the suggestion list using fuzzy matching', async () => {
|
||||
mockConfig.getResourceRegistry = vi.fn().mockReturnValue({
|
||||
getAllResources: () => [
|
||||
{
|
||||
serverName: 'server-1',
|
||||
uri: 'file:///tmp/server-1/logs.txt',
|
||||
name: 'logs',
|
||||
discoveredAt: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, 'logs', mockConfig, '/tmp'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
result.current.suggestions.some(
|
||||
(suggestion) =>
|
||||
suggestion.value === 'server-1:file:///tmp/server-1/logs.txt',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI State and Loading Behavior', () => {
|
||||
it('should be in a loading state during initial file system crawl', async () => {
|
||||
testRootDir = await createTmpDir({});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Config, FileSearch } from '@google/gemini-cli-core';
|
||||
import { FileSearchFactory, escapePath } from '@google/gemini-cli-core';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export enum AtCompletionStatus {
|
||||
IDLE = 'idle',
|
||||
@@ -97,6 +98,61 @@ export interface UseAtCompletionProps {
|
||||
setIsLoadingSuggestions: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
interface ResourceSuggestionCandidate {
|
||||
searchKey: string;
|
||||
suggestion: Suggestion;
|
||||
}
|
||||
|
||||
function buildResourceCandidates(
|
||||
config?: Config,
|
||||
): ResourceSuggestionCandidate[] {
|
||||
const registry = config?.getResourceRegistry?.();
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resources = registry.getAllResources().map((resource) => {
|
||||
// Use serverName:uri format to disambiguate resources from different MCP servers
|
||||
const prefixedUri = `${resource.serverName}:${resource.uri}`;
|
||||
return {
|
||||
// Include prefixedUri in searchKey so users can search by the displayed format
|
||||
searchKey: `${prefixedUri} ${resource.name ?? ''}`.toLowerCase(),
|
||||
suggestion: {
|
||||
label: prefixedUri,
|
||||
value: prefixedUri,
|
||||
},
|
||||
} satisfies ResourceSuggestionCandidate;
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
async function searchResourceCandidates(
|
||||
pattern: string,
|
||||
candidates: ResourceSuggestionCandidate[],
|
||||
): Promise<Suggestion[]> {
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedPattern = pattern.toLowerCase();
|
||||
if (!normalizedPattern) {
|
||||
return candidates
|
||||
.slice(0, MAX_SUGGESTIONS_TO_SHOW)
|
||||
.map((candidate) => candidate.suggestion);
|
||||
}
|
||||
|
||||
const fzf = new AsyncFzf(candidates, {
|
||||
selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey,
|
||||
});
|
||||
const results = await fzf.find(normalizedPattern, {
|
||||
limit: MAX_SUGGESTIONS_TO_SHOW * 3,
|
||||
});
|
||||
return results.map(
|
||||
(result: { item: ResourceSuggestionCandidate }) => result.item.suggestion,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
const {
|
||||
enabled,
|
||||
@@ -210,11 +266,28 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = results.map((p) => ({
|
||||
const fileSuggestions = results.map((p) => ({
|
||||
label: p,
|
||||
value: escapePath(p),
|
||||
}));
|
||||
dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
|
||||
|
||||
const resourceCandidates = buildResourceCandidates(config);
|
||||
const resourceSuggestions = (
|
||||
await searchResourceCandidates(
|
||||
state.pattern ?? '',
|
||||
resourceCandidates,
|
||||
)
|
||||
).map((suggestion) => ({
|
||||
...suggestion,
|
||||
label: suggestion.label.replace(/^@/, ''),
|
||||
value: suggestion.value.replace(/^@/, ''),
|
||||
}));
|
||||
|
||||
const combinedSuggestions = [
|
||||
...fileSuggestions,
|
||||
...resourceSuggestions,
|
||||
];
|
||||
dispatch({ type: 'SEARCH_SUCCESS', payload: combinedSuggestions });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||
dispatch({ type: 'ERROR' });
|
||||
|
||||
Reference in New Issue
Block a user