mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 04:10:35 -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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user