import * as path from 'node:path';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { z } from 'zod';
import RE2 from 're2';
import { formatOperationSummary, joinLines } from '../config.js';
import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
import {
ErrorCode,
formatUnknownErrorMessage,
McpError,
} from '../lib/errors.js';
import { searchContent } from '../lib/file-operations/search-content.js';
import type { SearchContentOptions } from '../lib/file-operations/search-content.js';
import { withToolDiagnostics } from '../lib/observability.js';
import {
SearchContentInputSchema,
SearchContentOutputSchema,
} from '../schemas.js';
import {
buildResourceLink,
buildToolErrorResponse,
buildToolResponse,
createProgressReporter,
getExperimentalTaskRegistration,
notifyProgress,
resolvePathOrRoot,
type ToolExtra,
type ToolRegistrationOptions,
type ToolResponse,
type ToolResult,
withDefaultIcons,
withToolErrorHandling,
wrapToolHandler,
} from './shared.js';
import { createToolTaskHandler } from './task-support.js';
const MAX_INLINE_MATCHES = 50;
const SEARCH_CONTENT_TOOL = {
title: 'Search Content',
description:
'Search for text within file contents (grep-like). ' +
'Returns matching lines. ' +
'Path may be a directory or a single file. ' +
'Use includeHidden=true to include hidden files and directories.',
inputSchema: SearchContentInputSchema,
outputSchema: SearchContentOutputSchema,
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
},
} as const;
function assertValidRegexPattern(pattern: string): void {
try {
new RE2(pattern);
} catch (error) {
throw new McpError(
ErrorCode.E_INVALID_PATTERN,
`Invalid regex pattern: ${formatUnknownErrorMessage(error)}`
);
}
}
function buildSearchTextResult(
result: Awaited<ReturnType<typeof searchContent>>,
normalizedMatches: NormalizedSearchMatch[]
): string {
const { summary } = result;
if (normalizedMatches.length === 0) return 'No matches';
let truncatedReason: string | undefined;
if (summary.truncated) {
if (summary.stoppedReason === 'timeout') {
truncatedReason = 'timeout';
} else if (summary.stoppedReason === 'maxFiles') {
truncatedReason = `max files (${summary.filesScanned})`;
} else {
truncatedReason = `max results (${summary.matches})`;
}
}
const summaryOptions: Parameters<typeof formatOperationSummary>[0] = {
truncated: summary.truncated,
...(truncatedReason ? { truncatedReason } : {}),
};
const lines: string[] = [`Found ${normalizedMatches.length}:`];
for (const match of normalizedMatches) {
lines.push(formatSearchMatchLine(match));
}
return joinLines(lines) + formatOperationSummary(summaryOptions);
}
type SearchMatchPayload = NonNullable<
z.infer<typeof SearchContentOutputSchema>['matches']
>[number];
function buildSearchMatchPayload(
match: NormalizedSearchMatch
): SearchMatchPayload {
return {
file: match.relativeFile,
line: match.line,
content: match.content,
matchCount: match.matchCount,
...(match.contextBefore ? { contextBefore: [...match.contextBefore] } : {}),
...(match.contextAfter ? { contextAfter: [...match.contextAfter] } : {}),
};
}
function formatSearchMatchLine(match: NormalizedSearchMatch): string {
const lineNum = String(match.line).padStart(4);
return ` ${match.relativeFile}:${lineNum}: ${match.content}`;
}
function buildStructuredSearchResult(
result: Awaited<ReturnType<typeof searchContent>>,
normalizedMatches: NormalizedSearchMatch[],
options: { patternType: 'literal' | 'regex'; caseSensitive: boolean }
): z.infer<typeof SearchContentOutputSchema> {
const { summary } = result;
return {
ok: true,
patternType: options.patternType,
caseSensitive: options.caseSensitive,
matches: normalizedMatches.map(buildSearchMatchPayload),
totalMatches: summary.matches,
truncated: summary.truncated,
filesScanned: summary.filesScanned,
filesMatched: summary.filesMatched,
skippedTooLarge: summary.skippedTooLarge,
skippedBinary: summary.skippedBinary,
skippedInaccessible: summary.skippedInaccessible,
linesSkippedDueToRegexTimeout: summary.linesSkippedDueToRegexTimeout,
...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
};
}
type SearchContentResultValue = Awaited<ReturnType<typeof searchContent>>;
type NormalizedSearchMatch = SearchContentResultValue['matches'][number] & {
relativeFile: string;
index: number;
};
function normalizeSearchMatches(
result: SearchContentResultValue
): NormalizedSearchMatch[] {
const relativeByFile = new Map<string, string>();
const normalized = result.matches.map((match, index) => {
const cached = relativeByFile.get(match.file);
const relative = cached ?? path.relative(result.basePath, match.file);
if (!cached) relativeByFile.set(match.file, relative);
return {
...match,
relativeFile: relative,
index,
};
});
normalized.sort((a, b) => {
const fileCompare = a.relativeFile.localeCompare(b.relativeFile);
if (fileCompare !== 0) return fileCompare;
if (a.line !== b.line) return a.line - b.line;
return a.index - b.index;
});
return normalized;
}
async function handleSearchContent(
args: z.infer<typeof SearchContentInputSchema>,
signal?: AbortSignal,
resourceStore?: ToolRegistrationOptions['resourceStore'],
onProgress?: (progress: { total?: number; current: number }) => void
): Promise<ToolResponse<z.infer<typeof SearchContentOutputSchema>>> {
const basePath = resolvePathOrRoot(args.path);
const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
const patternType = args.isRegex ? 'regex' : 'literal';
if (args.isRegex) {
assertValidRegexPattern(args.pattern);
}
const options: SearchContentOptions = {
includeHidden: args.includeHidden,
excludePatterns,
filePattern: args.filePattern,
caseSensitive: args.caseSensitive,
wholeWord: args.wholeWord,
contextLines: args.contextLines,
maxResults: args.maxResults,
maxFilesScanned: args.maxFilesScanned,
isLiteral: !args.isRegex,
};
if (signal) {
options.signal = signal;
}
if (onProgress) {
options.onProgress = onProgress;
}
let result: Awaited<ReturnType<typeof searchContent>>;
try {
result = await searchContent(basePath, args.pattern, options);
} catch (error) {
if (error instanceof Error && /regular expression/i.test(error.message)) {
throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
}
throw error;
}
const normalizedMatches = normalizeSearchMatches(result);
const structuredFull = buildStructuredSearchResult(
result,
normalizedMatches,
{
patternType,
caseSensitive: args.caseSensitive,
}
);
const needsExternalize = normalizedMatches.length > MAX_INLINE_MATCHES;
if (!resourceStore || !needsExternalize) {
return buildToolResponse(
buildSearchTextResult(result, normalizedMatches),
structuredFull
);
}
const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
const previewStructured: z.infer<typeof SearchContentOutputSchema> = {
ok: true,
patternType,
caseSensitive: args.caseSensitive,
matches: previewMatches.map(buildSearchMatchPayload),
totalMatches: structuredFull.totalMatches,
truncated: true,
filesScanned: structuredFull.filesScanned,
filesMatched: structuredFull.filesMatched,
skippedTooLarge: structuredFull.skippedTooLarge,
skippedBinary: structuredFull.skippedBinary,
skippedInaccessible: structuredFull.skippedInaccessible,
linesSkippedDueToRegexTimeout: structuredFull.linesSkippedDueToRegexTimeout,
...(structuredFull.stoppedReason
? { stoppedReason: structuredFull.stoppedReason }
: {}),
resourceUri: undefined,
};
const entry = resourceStore.putText({
name: 'grep:matches',
mimeType: 'application/json',
text: JSON.stringify(structuredFull),
});
previewStructured.resourceUri = entry.uri;
const text = joinLines([
`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`,
...previewMatches.map(formatSearchMatchLine),
]);
return buildToolResponse(text, previewStructured, [
buildResourceLink({
uri: entry.uri,
name: entry.name,
mimeType: entry.mimeType,
description: 'Full grep results as JSON (structuredContent)',
}),
]);
}
export function registerSearchContentTool(
server: McpServer,
options: ToolRegistrationOptions = {}
): void {
const handler = (
args: z.infer<typeof SearchContentInputSchema>,
extra: ToolExtra
): Promise<ToolResult<z.infer<typeof SearchContentOutputSchema>>> =>
withToolDiagnostics(
'grep',
() =>
withToolErrorHandling(
async () => {
const normalizedArgs = SearchContentInputSchema.parse(args);
notifyProgress(extra, {
current: 0,
message: `🔎︎ grep: ${normalizedArgs.pattern}`,
});
const result = await handleSearchContent(
normalizedArgs,
extra.signal,
options.resourceStore,
createProgressReporter(extra)
);
const sc = result.structuredContent;
const suffix =
sc.ok && sc.totalMatches ? String(sc.totalMatches) : 'No matches';
const finalCurrent = (sc.filesScanned ?? 0) + 1;
notifyProgress(extra, {
current: finalCurrent,
message: `🔎︎ grep: ${normalizedArgs.pattern} ➟ ${suffix}`,
});
return result;
},
(error) =>
buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.')
),
{ path: args.path ?? '.' }
);
const { isInitialized } = options;
const wrappedHandler = wrapToolHandler(handler, {
guard: isInitialized,
});
const taskOptions = isInitialized ? { guard: isInitialized } : undefined;
const tasks = getExperimentalTaskRegistration(server);
if (tasks?.registerToolTask) {
tasks.registerToolTask(
'grep',
withDefaultIcons(
{
...SEARCH_CONTENT_TOOL,
execution: { taskSupport: 'optional' },
},
options.iconInfo
),
createToolTaskHandler(wrappedHandler, taskOptions)
);
return;
}
server.registerTool(
'grep',
withDefaultIcons({ ...SEARCH_CONTENT_TOOL }, options.iconInfo),
wrappedHandler
);
}