import * as path from 'node:path';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { z } from 'zod';
import {
DEFAULT_READ_MANY_MAX_TOTAL_SIZE,
DEFAULT_SEARCH_TIMEOUT_MS,
} from '../lib/constants.js';
import { ErrorCode } from '../lib/errors.js';
import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
import {
ReadMultipleFilesInputSchema,
ReadMultipleFilesOutputSchema,
} from '../schemas.js';
import {
buildResourceLink,
buildToolErrorResponse,
buildToolResponse,
createToolProgressSession,
executeToolWithDiagnostics,
maybeExternalizeTextContent,
READ_ONLY_TOOL_ANNOTATIONS,
type ToolContract,
type ToolExtra,
type ToolRegistrationOptions,
type ToolResponse,
type ToolResult,
withDefaultIcons,
withValidatedArgs,
wrapToolHandler,
} from './shared.js';
import { registerToolTaskIfAvailable } from './task-support.js';
export const READ_MULTIPLE_FILES_TOOL: ToolContract = {
name: 'read_many',
title: 'Read Multiple Files',
description:
'Read multiple text files in a single request. ' +
'Returns contents and metadata for each file. ' +
'For single file, use read for simpler output.',
inputSchema: ReadMultipleFilesInputSchema,
outputSchema: ReadMultipleFilesOutputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
taskSupport: 'optional',
nuances: ['Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.'],
gotchas: [
'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
],
} as const;
type ReadManyStructuredResult = z.infer<typeof ReadMultipleFilesOutputSchema>;
type ReadManyStructuredResultItem = NonNullable<
ReadManyStructuredResult['results']
>[number];
function toStructuredReadManyResult(
result: Awaited<ReturnType<typeof readMultipleFiles>>[number] & {
resourceUri?: string;
truncationReason?: 'head' | 'range' | 'externalized';
maxTotalSize?: number;
}
): ReadManyStructuredResultItem {
const structured: ReadManyStructuredResultItem = {
path: result.path,
};
if (result.content !== undefined) structured.content = result.content;
if (result.truncated) structured.truncated = result.truncated;
if (result.resourceUri) structured.resourceUri = result.resourceUri;
if (result.head !== undefined) structured.head = result.head;
if (result.startLine !== undefined) structured.startLine = result.startLine;
if (result.endLine !== undefined) structured.endLine = result.endLine;
if (result.hasMoreLines) structured.hasMoreLines = result.hasMoreLines;
if (result.totalLines !== undefined)
structured.totalLines = result.totalLines;
if (result.truncationReason) {
structured.truncationReason = result.truncationReason;
}
if (result.error) structured.error = result.error;
return structured;
}
async function handleReadMultipleFiles(
args: z.infer<typeof ReadMultipleFilesInputSchema>,
signal?: AbortSignal,
resourceStore?: ToolRegistrationOptions['resourceStore'],
onReadComplete?: () => void
): Promise<ToolResponse<z.infer<typeof ReadMultipleFilesOutputSchema>>> {
const options: Parameters<typeof readMultipleFiles>[1] = {
...(signal ? { signal } : {}),
...(args.head !== undefined ? { head: args.head } : {}),
...(args.startLine !== undefined ? { startLine: args.startLine } : {}),
...(args.endLine !== undefined ? { endLine: args.endLine } : {}),
...(onReadComplete ? { onReadComplete } : {}),
};
const results = await readMultipleFiles(args.paths, options);
const maxTotalSize = DEFAULT_READ_MANY_MAX_TOTAL_SIZE;
type ReadManyResult = Awaited<ReturnType<typeof readMultipleFiles>>[number];
type ReadManyResultWithResource = ReadManyResult & {
resourceUri?: string;
truncationReason?: 'head' | 'range' | 'externalized';
maxTotalSize?: number;
};
const mappedResults: ReadManyResultWithResource[] = results.map((result) => {
let baseTruncationReason: 'head' | 'range' | undefined;
if (result.truncated && result.readMode === 'head') {
baseTruncationReason = 'head';
} else if (result.truncated && result.readMode === 'range') {
baseTruncationReason = 'range';
}
const baseResult: ReadManyResultWithResource = {
...result,
maxTotalSize,
...(baseTruncationReason
? { truncationReason: baseTruncationReason }
: {}),
};
if (!result.content) {
return baseResult;
}
const externalized = maybeExternalizeTextContent(
resourceStore,
result.content,
{ name: `read:${path.basename(result.path)}`, mimeType: 'text/plain' }
);
if (!externalized) {
return baseResult;
}
return {
...baseResult,
content: externalized.preview,
truncated: true,
resourceUri: externalized.entry.uri,
truncationReason: 'externalized',
};
});
let succeeded = 0;
let failed = 0;
for (const result of mappedResults) {
if (result.error === undefined) succeeded += 1;
else failed += 1;
}
const structured: z.infer<typeof ReadMultipleFilesOutputSchema> = {
ok: true,
results: mappedResults.map((result) => toStructuredReadManyResult(result)),
summary: {
total: mappedResults.length,
succeeded,
failed,
},
};
const resourceLinks: ReturnType<typeof buildResourceLink>[] = [];
for (const result of mappedResults) {
if (!result.resourceUri) continue;
resourceLinks.push(
buildResourceLink({
uri: result.resourceUri,
name: `read:${path.basename(result.path)}`,
description: 'Full file contents',
})
);
}
const text = mappedResults
.map((result) => {
const header = `=== ${result.path} ===`;
if (result.error) {
return `${header}\nError: ${result.error}`;
}
return `${header}\n${result.content ?? ''}`;
})
.join('\n\n');
return buildToolResponse(text, structured, resourceLinks);
}
export function registerReadMultipleFilesTool(
server: McpServer,
options: ToolRegistrationOptions = {}
): void {
const handler = (
args: z.infer<typeof ReadMultipleFilesInputSchema>,
extra: ToolExtra
): Promise<ToolResult<z.infer<typeof ReadMultipleFilesOutputSchema>>> => {
const primaryPath = args.paths[0] ?? '';
return executeToolWithDiagnostics({
toolName: 'read_many',
extra,
timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
context: { path: primaryPath },
run: async (signal) => {
const first = path.basename(args.paths[0] ?? '');
const extraPaths =
args.paths.length > 1
? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
: '';
const context = `${args.paths.length} files [${first}${extraPaths}]`;
const progress = createToolProgressSession(
extra,
`🕮 read_many: ${context}`
);
const onReadComplete = (): void => {
progress.increment(
(current) =>
`🕮 read_many: ${context} [${current}/${args.paths.length} read]`
);
};
try {
const result = await handleReadMultipleFiles(
args,
signal,
options.resourceStore,
onReadComplete
);
const sc = result.structuredContent;
const total = sc.summary?.total ?? 0;
const failed = sc.summary?.failed ?? 0;
const succeeded = sc.summary?.succeeded ?? 0;
let suffix: string;
if (failed) {
suffix = `${succeeded}/${total} read, ${failed} failed`;
} else {
suffix = `${total} files read`;
}
const finalCurrent = Math.max(total, progress.getCurrent() + 1);
progress.complete(
`🕮 read_many: ${context} • ${suffix}`,
finalCurrent
);
return result;
} catch (error) {
progress.fail(`🕮 read_many: ${context} • failed`);
throw error;
}
},
onError: (error) =>
buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath),
});
};
const wrappedHandler = wrapToolHandler(handler, {
guard: options.isInitialized,
});
const validatedHandler = withValidatedArgs(
ReadMultipleFilesInputSchema,
wrappedHandler
);
if (
registerToolTaskIfAvailable(
server,
'read_many',
READ_MULTIPLE_FILES_TOOL,
validatedHandler,
options.iconInfo,
options.isInitialized
)
)
return;
server.registerTool(
'read_many',
withDefaultIcons({ ...READ_MULTIPLE_FILES_TOOL }, options.iconInfo),
validatedHandler
);
}