feat: Add support for MCP Resources (#13178)

Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
Alex Gavrilescu
2025-12-09 03:43:12 +01:00
committed by GitHub
parent 720b31cb8b
commit 560550f5df
20 changed files with 1146 additions and 80 deletions

View File

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