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
@@ -63,6 +63,7 @@ describe('mcpCommand', () => {
getPromptRegistry: ReturnType<typeof vi.fn>;
getGeminiClient: ReturnType<typeof vi.fn>;
getMcpClientManager: ReturnType<typeof vi.fn>;
getResourceRegistry: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -93,6 +94,9 @@ describe('mcpCommand', () => {
getBlockedMcpServers: vi.fn(),
getMcpServers: vi.fn(),
})),
getResourceRegistry: vi.fn().mockReturnValue({
getAllResources: vi.fn().mockReturnValue([]),
}),
};
mockContext = createMockCommandContext({
@@ -141,6 +145,10 @@ describe('mcpCommand', () => {
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
mockConfig.getMcpClientManager = vi.fn().mockReturnValue({
getMcpServers: vi.fn().mockReturnValue(mockMcpServers),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
});
});
it('should display configured MCP servers with status indicators and their tools', async () => {
@@ -169,6 +177,30 @@ describe('mcpCommand', () => {
getAllTools: vi.fn().mockReturnValue(allTools),
});
const resourcesByServer: Record<
string,
Array<{ name: string; uri: string }>
> = {
server1: [
{
name: 'Server1 Resource',
uri: 'file:///server1/resource1.txt',
},
],
server2: [],
server3: [],
};
mockConfig.getResourceRegistry = vi.fn().mockReturnValue({
getAllResources: vi.fn().mockReturnValue(
Object.entries(resourcesByServer).flatMap(([serverName, resources]) =>
resources.map((entry) => ({
serverName,
...entry,
})),
),
),
});
await mcpCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -180,6 +212,12 @@ describe('mcpCommand', () => {
description: tool.description,
schema: tool.schema,
})),
resources: expect.arrayContaining([
expect.objectContaining({
serverName: 'server1',
uri: 'file:///server1/resource1.txt',
}),
]),
}),
expect.any(Number),
);
@@ -12,6 +12,7 @@ import type {
import { CommandKind } from './types.js';
import type {
DiscoveredMCPPrompt,
DiscoveredMCPResource,
MessageActionReturn,
} from '@google/gemini-cli-core';
import {
@@ -230,6 +231,13 @@ const listAction = async (
serverNames.includes(prompt.serverName as string),
) as DiscoveredMCPPrompt[];
const resourceRegistry = config.getResourceRegistry();
const mcpResources = resourceRegistry
.getAllResources()
.filter((entry) =>
serverNames.includes(entry.serverName),
) as DiscoveredMCPResource[];
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
const tokenStorage = new MCPOAuthTokenStorage();
for (const serverName of serverNames) {
@@ -265,6 +273,13 @@ const listAction = async (
name: prompt.name,
description: prompt.description,
})),
resources: mcpResources.map((resource) => ({
serverName: resource.serverName,
name: resource.name,
uri: resource.uri,
mimeType: resource.mimeType,
description: resource.description,
})),
authStatus,
blockedServers: blockedMcpServers,
discoveryInProgress,
@@ -36,6 +36,7 @@ describe('McpStatus', () => {
},
],
prompts: [],
resources: [],
blockedServers: [],
serverStatus: () => MCPServerStatus.CONNECTED,
authStatus: {},
@@ -147,6 +148,24 @@ describe('McpStatus', () => {
unmount();
});
it('renders correctly with resources', () => {
const { lastFrame, unmount } = render(
<McpStatus
{...baseProps}
resources={[
{
serverName: 'server-1',
name: 'resource-1',
uri: 'file:///tmp/resource-1.txt',
description: 'A test resource',
},
]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with a blocked server', () => {
const { lastFrame, unmount } = render(
<McpStatus
@@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js';
import type {
HistoryItemMcpStatus,
JsonMcpPrompt,
JsonMcpResource,
JsonMcpTool,
} from '../../types.js';
@@ -19,6 +20,7 @@ interface McpStatusProps {
servers: Record<string, MCPServerConfig>;
tools: JsonMcpTool[];
prompts: JsonMcpPrompt[];
resources: JsonMcpResource[];
blockedServers: Array<{ name: string; extensionName: string }>;
serverStatus: (serverName: string) => MCPServerStatus;
authStatus: HistoryItemMcpStatus['authStatus'];
@@ -32,6 +34,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
servers,
tools,
prompts,
resources,
blockedServers,
serverStatus,
authStatus,
@@ -83,9 +86,14 @@ export const McpStatus: React.FC<McpStatusProps> = ({
const serverPrompts = prompts.filter(
(prompt) => prompt.serverName === serverName,
);
const serverResources = resources.filter(
(resource) => resource.serverName === serverName,
);
const originalStatus = serverStatus(serverName);
const hasCachedItems =
serverTools.length > 0 || serverPrompts.length > 0;
serverTools.length > 0 ||
serverPrompts.length > 0 ||
serverResources.length > 0;
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
@@ -121,6 +129,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
const toolCount = serverTools.length;
const promptCount = serverPrompts.length;
const resourceCount = serverResources.length;
const parts = [];
if (toolCount > 0) {
parts.push(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`);
@@ -130,6 +139,11 @@ export const McpStatus: React.FC<McpStatusProps> = ({
`${promptCount} ${promptCount === 1 ? 'prompt' : 'prompts'}`,
);
}
if (resourceCount > 0) {
parts.push(
`${resourceCount} ${resourceCount === 1 ? 'resource' : 'resources'}`,
);
}
const serverAuthStatus = authStatus[serverName];
let authStatusNode: React.ReactNode = null;
@@ -233,6 +247,34 @@ export const McpStatus: React.FC<McpStatusProps> = ({
))}
</Box>
)}
{serverResources.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary}>Resources:</Text>
{serverResources.map((resource, index) => {
const label = resource.name || resource.uri || 'resource';
return (
<Box
key={`${resource.serverName}-resource-${index}`}
flexDirection="column"
>
<Text>
- <Text color={theme.text.primary}>{label}</Text>
{resource.uri ? ` (${resource.uri})` : ''}
{resource.mimeType ? ` [${resource.mimeType}]` : ''}
</Text>
{showDescriptions && resource.description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{resource.description.trim()}
</Text>
</Box>
)}
</Box>
);
})}
</Box>
)}
</Box>
);
})}
@@ -116,6 +116,20 @@ A test server
"
`;
exports[`McpStatus > renders correctly with resources 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool, 1 resource)
A test server
Tools:
- tool-1
A test tool
Resources:
- resource-1 (file:///tmp/resource-1.txt)
A test resource
"
`;
exports[`McpStatus > renders correctly with schema enabled 1`] = `
"Configured MCP servers:
@@ -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),
);
});
});
});
+157 -27
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;
}
@@ -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({});
+75 -2
View File
@@ -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' });
+9
View File
@@ -224,11 +224,20 @@ export interface JsonMcpPrompt {
description?: string;
}
export interface JsonMcpResource {
serverName: string;
name?: string;
uri?: string;
mimeType?: string;
description?: string;
}
export type HistoryItemMcpStatus = HistoryItemBase & {
type: 'mcp_status';
servers: Record<string, MCPServerConfig>;
tools: JsonMcpTool[];
prompts: JsonMcpPrompt[];
resources: JsonMcpResource[];
authStatus: Record<
string,
'authenticated' | 'expired' | 'unauthenticated' | 'not-configured'
+5 -1
View File
@@ -11,7 +11,11 @@ export type HighlightToken = {
type: 'default' | 'command' | 'file';
};
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g;
// Matches slash commands (e.g., /help) and @ references (files or MCP resource URIs).
// The @ pattern uses a negated character class to support URIs like `@file:///example.txt`
// which contain colons. It matches any character except delimiters: comma, whitespace,
// semicolon, common punctuation, and brackets.
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
export function parseInputForHighlighting(
text: string,