import { channel } from 'node:diagnostics_channel';
import type {
ContentBlock,
Icon,
ProgressNotificationParams,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { FileInfo } from '../config.js';
import { parseTrueEnvFlag } from '../lib/constants.js';
import {
createDetailedError,
ErrorCode,
formatDetailedError,
getSuggestion,
McpError,
} from '../lib/errors.js';
import { createTimedAbortSignal } from '../lib/fs-helpers.js';
import { withToolDiagnostics } from '../lib/observability.js';
import { getAllowedDirectories } from '../lib/path-validation.js';
import type { ResourceStore } from '../lib/resource-store.js';
import type { ToolErrorResponseSchema } from '../schemas.js';
export { type ToolContract } from './contract.js';
const MAX_INLINE_CONTENT_CHARS =
parseInt(process.env['FS_CONTEXT_MAX_INLINE_CHARS'] ?? '', 10) || 20_000;
const MAX_INLINE_PREVIEW_CHARS = 4_000;
const PROGRESS_RATE_LIMIT_MS = 50;
interface ContextDiagnosticsEvent {
phase: 'externalize_text';
name: string;
mimeType?: string;
chars: number;
uri: string;
}
const CONTEXT_DIAGNOSTICS_CHANNEL = channel('filesystem-mcp:context');
function publishContextDiagnostics(event: ContextDiagnosticsEvent): void {
if (!CONTEXT_DIAGNOSTICS_CHANNEL.hasSubscribers) return;
CONTEXT_DIAGNOSTICS_CHANNEL.publish(event);
}
export const READ_ONLY_TOOL_ANNOTATIONS = {
readOnlyHint: true,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
} as const;
export const DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
} as const;
export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
readOnlyHint: false,
idempotentHint: true,
destructiveHint: false,
openWorldHint: false,
} as const;
export function shouldStripStructuredOutput(): boolean {
return parseTrueEnvFlag(process.env['FS_CONTEXT_STRIP_STRUCTURED']);
}
export function maybeStripStructuredContentFromResult<T extends object>(
result: T
): T {
if (!shouldStripStructuredOutput()) return result;
if (!Object.hasOwn(result, 'structuredContent')) return result;
const rest = { ...(result as Record<string, unknown>) };
delete rest['structuredContent'];
return rest as T;
}
function maybeStripOutputSchema<T extends object>(tool: T): T {
if (!shouldStripStructuredOutput()) return tool;
if (!Object.hasOwn(tool, 'outputSchema')) return tool;
const mutable = { ...(tool as Record<string, unknown>) };
delete mutable['outputSchema'];
return mutable as T;
}
type ResourceEntry = ReturnType<ResourceStore['putText']>;
function buildTextPreview(text: string): string {
if (text.length <= MAX_INLINE_PREVIEW_CHARS) return text;
return `${text.slice(0, MAX_INLINE_PREVIEW_CHARS)}\n… [truncated preview]`;
}
export function maybeExternalizeTextContent(
resourceStore: ResourceStore | undefined,
content: string,
params: { name: string; mimeType?: string }
): { entry: ResourceEntry; preview: string } | undefined {
if (!resourceStore) return undefined;
if (content.length <= MAX_INLINE_CONTENT_CHARS) return undefined;
const entry = resourceStore.putText({
name: params.name,
...(params.mimeType !== undefined ? { mimeType: params.mimeType } : {}),
text: content,
});
publishContextDiagnostics({
phase: 'externalize_text',
name: params.name,
...(params.mimeType !== undefined ? { mimeType: params.mimeType } : {}),
chars: content.length,
uri: entry.uri,
});
return {
entry,
preview: buildTextPreview(content),
};
}
export function buildResourceLink(params: {
uri: string;
name: string;
mimeType?: string;
description?: string;
}): ContentBlock {
return {
type: 'resource_link',
uri: params.uri,
name: params.name,
...(params.description ? { description: params.description } : {}),
...(params.mimeType ? { mimeType: params.mimeType } : {}),
};
}
function buildContentBlock<T>(
text: string,
structuredContent: T,
extraContent: ContentBlock[] = []
): { content: ContentBlock[]; structuredContent: T } {
return {
content: [{ type: 'text', text }, ...extraContent],
structuredContent,
};
}
function resolveDetailedError(
error: unknown,
defaultCode: ErrorCode,
path?: string
): {
code: ErrorCode;
message: string;
path?: string;
suggestion?: string;
details?: Record<string, unknown>;
} {
const detailed = createDetailedError(error, path);
if (detailed.code === ErrorCode.E_UNKNOWN) {
detailed.code = defaultCode;
detailed.suggestion = getSuggestion(defaultCode);
}
return detailed;
}
export function buildToolResponse<T>(
text: string,
structuredContent: T,
extraContent: ContentBlock[] = []
): {
content: ContentBlock[];
structuredContent: T;
} {
return buildContentBlock(text, structuredContent, extraContent);
}
export type ToolResponse<T> = ReturnType<typeof buildToolResponse<T>> &
Record<string, unknown>;
type ToolErrorStructuredContent = z.infer<typeof ToolErrorResponseSchema>;
interface ToolErrorResponse extends Record<string, unknown> {
content: ContentBlock[];
structuredContent: ToolErrorStructuredContent;
isError: true;
}
export type ToolResult<T> = ToolResponse<T> | ToolErrorResponse;
function parseToolArgs<Schema extends z.ZodType>(
schema: Schema,
args: unknown
): z.infer<Schema> {
const candidate = args === undefined ? {} : args;
const parsed = schema.safeParse(candidate);
if (parsed.success) {
return parsed.data;
}
throw new McpError(
ErrorCode.E_INVALID_INPUT,
`Invalid tool arguments: ${parsed.error.message}`,
undefined,
{ errors: z.treeifyError(parsed.error) }
);
}
export function withValidatedArgs<Args, Result>(
schema: z.ZodType<Args>,
handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>
): (args: unknown, extra: ToolExtra) => Promise<ToolResult<Result>> {
return async (args, extra) => {
const normalizedArgs = parseToolArgs(schema, args);
return handler(normalizedArgs, extra);
};
}
type ProgressToken = string | number;
export interface ToolExtra {
signal?: AbortSignal;
_meta?: {
progressToken?: ProgressToken | undefined;
};
sendNotification?: (notification: {
method: 'notifications/progress';
params: ProgressNotificationParams;
}) => Promise<void>;
}
function canSendProgress(extra: ToolExtra): extra is ToolExtra & {
_meta: { progressToken: ProgressToken };
sendNotification: NonNullable<ToolExtra['sendNotification']>;
} {
return (
extra._meta?.progressToken !== undefined &&
extra.sendNotification !== undefined
);
}
function canReportProgress(extra: ToolExtra): boolean {
const taskExtra = extra as Record<string, unknown>;
const hasTask =
taskExtra.taskId !== undefined && taskExtra.taskStore !== undefined;
return canSendProgress(extra) || hasTask;
}
export interface IconInfo {
src: string;
mimeType: string;
}
export function withDefaultIcons<T extends object>(
tool: T,
iconInfo: IconInfo | undefined
): T & { icons?: Icon[] } {
if (!iconInfo) {
return maybeStripOutputSchema(tool) as T & { icons?: Icon[] };
}
const existingIcons = (tool as { icons?: Icon[] }).icons;
if (existingIcons && existingIcons.length > 0) {
return maybeStripOutputSchema(tool) as T & { icons?: Icon[] };
}
const withIcons = {
...tool,
icons: [
{
src: iconInfo.src,
mimeType: iconInfo.mimeType,
},
],
};
return maybeStripOutputSchema(withIcons) as T & { icons?: Icon[] };
}
export interface ToolRegistrationOptions {
resourceStore?: ResourceStore;
isInitialized?: () => boolean;
serverIcon?: string;
iconInfo?: IconInfo;
}
interface FileInfoPayload {
name: string;
path: string;
type: FileInfo['type'];
size: number;
tokenEstimate?: number;
created: string;
modified: string;
accessed: string;
permissions: string;
isHidden: boolean;
mimeType?: string;
symlinkTarget?: string;
}
export function buildFileInfoPayload(info: FileInfo): FileInfoPayload {
return {
name: info.name,
path: info.path,
type: info.type,
size: info.size,
...(info.tokenEstimate !== undefined
? { tokenEstimate: info.tokenEstimate }
: {}),
created: info.created.toISOString(),
modified: info.modified.toISOString(),
accessed: info.accessed.toISOString(),
permissions: info.permissions,
isHidden: info.isHidden,
...(info.mimeType !== undefined ? { mimeType: info.mimeType } : {}),
...(info.symlinkTarget !== undefined
? { symlinkTarget: info.symlinkTarget }
: {}),
};
}
const NOT_INITIALIZED_ERROR = new McpError(
ErrorCode.E_INVALID_INPUT,
'Client not initialized; wait for notifications/initialized'
);
async function withToolErrorHandling<T>(
run: () => Promise<ToolResponse<T>>,
onError: (error: unknown) => ToolResult<T>
): Promise<ToolResult<T>> {
try {
return await run();
} catch (error) {
return onError(error);
}
}
interface ToolExecutionOptions<T> {
toolName: string;
extra: ToolExtra;
run: (
signal: AbortSignal | undefined
) => ToolResponse<T> | Promise<ToolResponse<T>>;
onError: (error: unknown) => ToolResult<T>;
context?: Record<string, unknown>;
timedSignal?: {
timeoutMs?: number;
};
}
function getToolSignal(
extraSignal: AbortSignal | undefined,
timedSignal: ToolExecutionOptions<unknown>['timedSignal']
): { signal: AbortSignal | undefined; cleanup: () => void } {
if (!timedSignal) {
return { signal: extraSignal, cleanup: () => {} };
}
const { signal, cleanup } = createTimedAbortSignal(
extraSignal,
timedSignal.timeoutMs
);
return { signal, cleanup };
}
export async function executeToolWithDiagnostics<T>(
options: ToolExecutionOptions<T>
): Promise<ToolResult<T>> {
return withToolDiagnostics(
options.toolName,
() =>
withToolErrorHandling(async () => {
const { signal, cleanup } = getToolSignal(
options.extra.signal,
options.timedSignal
);
try {
return await options.run(signal);
} finally {
cleanup();
}
}, options.onError),
options.context
);
}
export function buildToolErrorResponse(
error: unknown,
defaultCode: ErrorCode,
path?: string
): ToolErrorResponse {
const detailed = resolveDetailedError(error, defaultCode, path);
const text = formatDetailedError(detailed);
const errorContent: ToolErrorStructuredContent['error'] = {
code: detailed.code,
message: detailed.message,
};
if (detailed.path !== undefined) {
errorContent.path = detailed.path;
}
if (detailed.suggestion !== undefined) {
errorContent.suggestion = detailed.suggestion;
}
const structuredContent: ToolErrorStructuredContent = {
ok: false,
error: errorContent,
};
return {
...buildContentBlock(text, structuredContent),
isError: true,
};
}
function buildNotInitializedResult<T>(): ToolResult<T> {
return buildToolErrorResponse(
NOT_INITIALIZED_ERROR,
ErrorCode.E_INVALID_INPUT
);
}
async function reportProgress(
extra: ToolExtra,
progress: { current: number; total?: number; message?: string }
): Promise<void> {
const taskExtra = extra as Record<string, unknown>;
if (
typeof taskExtra.taskId === 'string' &&
taskExtra.taskStore !== undefined &&
taskExtra.taskStore !== null
) {
const store = taskExtra.taskStore as Record<string, unknown>;
if (typeof store.updateTaskStatus === 'function') {
try {
let statusMessage = progress.message;
if (progress.total !== undefined) {
statusMessage = statusMessage
? `${statusMessage} (${progress.current}/${progress.total})`
: `${progress.current}/${progress.total}`;
} else {
statusMessage ??= `${progress.current}`;
}
await (
store.updateTaskStatus as (
taskId: string,
status: string,
message?: string
) => Promise<void>
)(taskExtra.taskId, 'working', statusMessage);
} catch (error) {
console.error('Failed to update task status message:', error);
}
}
}
if (canSendProgress(extra)) {
try {
await extra.sendNotification({
method: 'notifications/progress',
params: {
progressToken: extra._meta.progressToken,
progress: progress.current,
...(progress.total !== undefined ? { total: progress.total } : {}),
...(progress.message !== undefined
? { message: progress.message }
: {}),
},
});
} catch (error) {
console.error('Failed to send progress notification:', error);
}
}
}
export function createProgressReporter(
extra: ToolExtra
): (progress: { total?: number; current: number; message?: string }) => void {
if (!canReportProgress(extra)) {
return () => {};
}
// State for monotonic enforcement and rate-limiting.
let lastProgress = -1;
let lastSentMs = 0;
return (progress) => {
const { current, total, message } = progress;
// Enforce monotonic progress to prevent client confusion. Client behavior on
// out-of-order progress is undefined in the MCP spec.
if (current <= lastProgress) return;
// Enforce rate-limiting to prevent client flooding. Progress updates faster
// than PROGRESS_RATE_LIMIT_MS are silently dropped.
const now = Date.now();
if (now - lastSentMs < PROGRESS_RATE_LIMIT_MS) return;
lastProgress = current;
lastSentMs = now;
void reportProgress(extra, {
current,
...(total !== undefined ? { total } : {}),
...(message !== undefined ? { message } : {}),
});
};
}
export function notifyProgress(
extra: ToolExtra,
progress: { current: number; total?: number; message?: string }
): void {
if (!canReportProgress(extra)) return;
void reportProgress(extra, progress);
}
async function withProgress<T>(
message: string,
extra: ToolExtra,
run: () => Promise<T>,
getCompletionMessage?: (result: T) => string | undefined
): Promise<T> {
if (!canReportProgress(extra)) {
return run();
}
const total = 1;
await reportProgress(extra, {
current: 0,
total,
message,
});
try {
const result = await run();
const endMessage = getCompletionMessage?.(result) ?? message;
await reportProgress(extra, {
current: total,
total,
message: endMessage,
});
return result;
} catch (error) {
void reportProgress(extra, {
current: total,
total,
message: `${message} • failed`,
});
throw error;
}
}
export function wrapToolHandler<Args, Result>(
handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>,
options: {
guard?: (() => boolean) | undefined;
progressMessage?: (args: Args) => string;
completionMessage?: (
args: Args,
result: ToolResult<Result>
) => string | undefined;
}
): (args: Args, extra?: ToolExtra) => Promise<ToolResult<Result>> {
return async (args: Args, extra?: ToolExtra) => {
const resolvedExtra = extra ?? {};
if (options.guard && !options.guard()) {
return maybeStripStructuredContentFromResult(buildNotInitializedResult());
}
if (options.progressMessage) {
const message = options.progressMessage(args);
const { completionMessage } = options;
const completionFn = completionMessage
? (result: ToolResult<Result>) => completionMessage(args, result)
: undefined;
const result = await withProgress(
message,
resolvedExtra,
() => handler(args, resolvedExtra),
completionFn
);
return maybeStripStructuredContentFromResult(result);
}
const result = await handler(args, resolvedExtra);
return maybeStripStructuredContentFromResult(result);
};
}
export function resolvePathOrRoot(pathValue: string | undefined): string {
if (pathValue && pathValue.trim().length > 0) return pathValue;
const roots = getAllowedDirectories();
if (roots.length === 0) {
throw new McpError(
ErrorCode.E_ACCESS_DENIED,
'No workspace roots configured. Use the roots tool to check, or configure roots via the MCP Roots protocol (or start with --allow-cwd / CLI directories).'
);
}
if (roots.length > 1) {
throw new McpError(
ErrorCode.E_INVALID_INPUT,
'Multiple workspace roots configured. Provide an explicit path to disambiguate.'
);
}
const root = roots[0];
if (!root) {
throw new McpError(
ErrorCode.E_ACCESS_DENIED,
'Workspace root is unexpectedly undefined'
);
}
return root;
}