@modelcontextprotocol/server-filesystem
Version:
MCP server for filesystem access
190 lines (189 loc) • 8.17 kB
JavaScript
// Files as MCP resources (#297). The server exposes one resource template,
// `file://{+path}`, so a client can read any file inside the allowed
// directories with resources/read and get it back as text or as a base64 blob
// with its MIME type, instead of only through the read tools. A resource is
// read by the client application, which can put it in model context or show it
// to the user, rather than returned inside a tool result the model must relay.
//
// The template has no list callback, on purpose: resources/list stays empty, so
// the server never enumerates the disk. A client reaches a file by building its
// URI from the template (or from a path a tool returned), and every read goes
// through the same validatePath as the tools, so a resource can reach nothing a
// tool cannot: no path outside the allowed directories, and no symlink whose
// target is outside them.
//
// It lives in a module of its own, registered from createServer() with one
// call, so the server factory's tool code is untouched. The factory hands it
// the live allow-list, the roots barrier and the reason a 2026-07-28
// connection with no directories cannot operate, so a read sees the same
// directories, at the same moment, as a tool call would.
import path from "path";
import { fileURLToPath } from "url";
import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate, } from "@modelcontextprotocol/server";
import { FsTimeoutError, validatePath } from "./lib.js";
import { readFileInDirectory } from "./pinned-directory.js";
/** The URI template every file resource matches (RFC 6570 reserved expansion). */
export const FILE_RESOURCE_TEMPLATE = "file://{+path}";
/** MIME types by lowercase extension; anything else is sniffed (see below). */
const MIME_TYPES = {
".txt": "text/plain",
".md": "text/markdown",
".csv": "text/csv",
".html": "text/html",
".htm": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".ts": "text/typescript",
".json": "application/json",
".xml": "application/xml",
".yaml": "application/yaml",
".yml": "application/yaml",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".ico": "image/x-icon",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".flac": "audio/flac",
".mp4": "video/mp4",
".pdf": "application/pdf",
".zip": "application/zip",
".gz": "application/gzip",
};
/** MIME types outside text/* whose content is still text. */
const TEXTUAL_MIME_TYPES = new Set([
"application/json",
"application/xml",
"application/yaml",
"image/svg+xml",
]);
function isTextualMimeType(mimeType) {
return mimeType.startsWith("text/") || TEXTUAL_MIME_TYPES.has(mimeType);
}
/** The bytes as UTF-8 text, or undefined when they are not valid UTF-8 text. */
function decodeText(bytes) {
// A NUL byte is valid UTF-8 but marks binary content in practice.
if (bytes.includes(0))
return undefined;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
catch {
return undefined;
}
}
/**
* The contents of one file resource: text when its type is textual (or, for an
* unknown extension, when its bytes decode as UTF-8), a base64 blob otherwise.
*/
export function fileResourceContents(uri, filePath, bytes) {
const known = MIME_TYPES[path.extname(filePath).toLowerCase()];
if (known === undefined || isTextualMimeType(known)) {
const text = decodeText(bytes);
if (text !== undefined) {
return { uri, mimeType: known ?? "text/plain", text };
}
}
return {
uri,
mimeType: known ?? "application/octet-stream",
blob: bytes.toString("base64"),
};
}
/** The errno codes that mean a path does not exist. */
const MISSING_PATH_CODES = new Set(["ENOENT", "ENOTDIR"]);
/**
* Whether `error` says the path does not exist, directly or through its
* cause: validatePath wraps a missing parent's ENOENT in an Error of its own.
*/
function isMissingPathError(error) {
for (let e = error; e instanceof Error; e = e.cause) {
const code = e.code;
if (code !== undefined && MISSING_PATH_CODES.has(code))
return true;
}
return false;
}
/**
* Whether `error` is the filesystem failing (an errno error, or a call that
* timed out) rather than validatePath refusing the path, which it does with a
* plain Error and no errno code.
*/
function isOperationalError(error) {
return (error instanceof FsTimeoutError ||
(error instanceof Error &&
error.code !== undefined));
}
/** Register the `file://{+path}` resource template on `server`. */
export function registerFileResources(server, context) {
server.registerResource("file", new ResourceTemplate(FILE_RESOURCE_TEMPLATE, { list: undefined }), {
title: "File",
description: "A file inside the allowed directories, by its absolute file:// URI " +
"(for example file:///home/user/project/README.md). Text files are " +
"returned as text, other files as a base64 blob with their MIME type. " +
"Directories are not resources; use the list_directory tool to find files.",
// A file can change at any time, and its contents belong to this user.
cacheHint: { ttlMs: 0, cacheScope: "private" },
}, async (uri) => {
// Wait out a pending roots refresh, as tool calls do, so a read is
// checked against the directories that refresh brings in.
let settled;
do {
settled = context.rootsSettled();
await settled;
} while (settled !== context.rootsSettled());
const unavailable = context.unavailableReason();
if (unavailable !== undefined) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, unavailable);
}
let requestedPath;
try {
requestedPath = fileURLToPath(uri);
}
catch (error) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid file URI ${uri.href}: ${error.message}`);
}
let validPath;
try {
validPath = await validatePath(requestedPath, context.allowedDirectories(), "read");
}
catch (error) {
// A path that does not exist (a dangling symlink, a missing parent, a
// path beneath a regular file) is a missing resource, so a client can
// recognise it by the URI in the error data.
if (isMissingPathError(error)) {
throw new ResourceNotFoundError(uri.href, error.message);
}
// The filesystem failed while resolving the path (an I/O error, a
// timeout): the request was fine, so it is the server's error.
if (isOperationalError(error)) {
throw new ProtocolError(ProtocolErrorCode.InternalError, `Failed to resolve ${uri.href}: ${error.message}`);
}
// Outside the allowed directories, or a symlink out of them: the
// message says which, as the tools do.
throw new ProtocolError(ProtocolErrorCode.InvalidParams, error.message);
}
let bytes;
try {
// Anchored to the validated file's directory, as the tools' reads
// are (pinned-directory.ts).
bytes = await readFileInDirectory(validPath);
}
catch (error) {
if (isMissingPathError(error)) {
throw new ResourceNotFoundError(uri.href);
}
if (error.code === "EISDIR") {
throw new ResourceNotFoundError(uri.href, `Not a file: ${uri.href} is a directory; directories are not resources`);
}
throw new ProtocolError(ProtocolErrorCode.InternalError, `Failed to read ${uri.href}: ${error.message}`);
}
return { contents: [fileResourceContents(uri.href, validPath, bytes)] };
});
}