diff --git a/.changeset/4912-filesystem-effective-directories-log.md b/.changeset/4912-filesystem-effective-directories-log.md new file mode 100644 index 0000000000..7883dde3a4 --- /dev/null +++ b/.changeset/4912-filesystem-effective-directories-log.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +Log the effective allowed directories to stderr, with their source, at startup and whenever they change: from the command line, or from the client's Roots (saying whether those replaced the command-line directories), or none, with why every path is now refused. Previously the server logged only a count of valid Roots. diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c08589e917..ed95e0f5a2 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -80,6 +80,11 @@ The server's directory access control follows this flow: 5. **Access Control** - All filesystem operations are restricted to allowed directories - Use `list_allowed_directories` tool to see current directories + - The server also logs the effective allowed directories to stderr, with where they came from (the command line, or the client's Roots and whether those replaced the command-line directories), at startup and whenever they change: after `initialize`, after each Roots update, and when a withdrawal leaves none. For example: + ``` + Effective allowed directories (from the client's Roots, which replaced the command-line directories): + /Users/username/project + ``` - Server requires at least ONE allowed directory to operate **Note**: The server will only allow operations within directories specified either via `args` or, for a 2025-era client, via Roots. diff --git a/src/filesystem/__tests__/allowed-directories.test.ts b/src/filesystem/__tests__/allowed-directories.test.ts new file mode 100644 index 0000000000..59e376e317 --- /dev/null +++ b/src/filesystem/__tests__/allowed-directories.test.ts @@ -0,0 +1,207 @@ +// The effective allowed-directories summary (#4912): one stderr message naming +// the directories in force and where they came from, printed at startup and +// after every change to them. The message itself is pinned through +// describeAllowedDirectories; the protocol tests drive a real Client over the +// in-memory transport and assert the server prints it at each point an +// operator needs it: startup, after initialize (with and without Roots), after +// a Roots update, after a withdrawal, and when the initial roots/list fails. + +import fs from "fs/promises"; +import path from "path"; +import { PassThrough } from "node:stream"; +import { pathToFileURL } from "url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Root } from "@modelcontextprotocol/server"; +import { describeAllowedDirectories } from "../allowed-directories.js"; +import { main } from "../index.js"; +import { + allowedDirectoriesOf, + connect, + makeTempDir, + quietStderr, + type Connected, +} from "./helpers.js"; + +const ROOTS = { roots: { listChanged: true } }; + +let cliDir: string; +let rootDir: string; +let stderr: ReturnType; +let closers: Array<() => Promise>; + +beforeEach(async () => { + stderr = quietStderr(); + cliDir = await makeTempDir("mcp-fs-summary-cli-"); + rootDir = await makeTempDir("mcp-fs-summary-root-"); + closers = []; +}); + +afterEach(async () => { + for (const close of closers.reverse()) await close(); + vi.restoreAllMocks(); + await fs.rm(cliDir, { recursive: true, force: true }); + await fs.rm(rootDir, { recursive: true, force: true }); +}); + +async function connectTracked( + ...args: Parameters +): Promise { + const conn = await connect(...args); + closers.push(conn.close); + return conn; +} + +function rootOf(dir: string): Root { + return { uri: pathToFileURL(dir).href, name: path.basename(dir) }; +} + +describe("describeAllowedDirectories", () => { + it("lists command-line directories one per line", () => { + expect( + describeAllowedDirectories(["/a", "/b,c"], { from: "command-line" }), + ).toBe( + "Effective allowed directories (from the command line):\n /a\n /b,c", + ); + }); + + it("says when the client's Roots replaced the command-line directories", () => { + expect( + describeAllowedDirectories(["/r"], { + from: "roots", + replacedCommandLine: true, + }), + ).toBe( + "Effective allowed directories (from the client's Roots, which replaced the command-line directories):\n /r", + ); + expect( + describeAllowedDirectories(["/r"], { + from: "roots", + replacedCommandLine: false, + }), + ).toBe("Effective allowed directories (from the client's Roots):\n /r"); + }); + + it("says why there are none, and that every path is refused", () => { + expect(describeAllowedDirectories([], { from: "command-line" })).toBe( + "Effective allowed directories: none. No command-line directories and no valid client Roots, so every path is refused", + ); + expect( + describeAllowedDirectories([], { + from: "roots", + replacedCommandLine: true, + }), + ).toBe( + "Effective allowed directories: none. The client's Roots name no valid directory, so every path is refused until it exposes one again", + ); + }); +}); + +describe("the server logs the summary", () => { + it("at startup, for the command-line directories", async () => { + const handle = await main([cliDir], new PassThrough(), new PassThrough()); + closers.push(() => handle.close()); + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([cliDir], { from: "command-line" }), + ); + }); + + it("after initialize, when the client does not support Roots", async () => { + await connectTracked([cliDir]); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([cliDir], { from: "command-line" }), + ), + ); + }); + + it("after initialize, when the client's Roots replace the command-line directories", async () => { + await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => [rootOf(rootDir)], + }); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([rootDir], { + from: "roots", + replacedCommandLine: true, + }), + ), + ); + }); + + it("after initialize, when Roots supply the only directories", async () => { + await connectTracked([], { + capabilities: ROOTS, + listRoots: () => [rootOf(rootDir)], + }); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([rootDir], { + from: "roots", + replacedCommandLine: false, + }), + ), + ); + }); + + it("after initialize, keeping the command-line directories when no root is valid", async () => { + await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => [rootOf(path.join(rootDir, "missing"))], + }); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([cliDir], { from: "command-line" }), + ), + ); + }); + + it("after initialize, when the initial roots/list fails", async () => { + await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => { + throw new Error("client has no roots today"); + }, + }); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([cliDir], { from: "command-line" }), + ), + ); + }); + + it("after a Roots update, and after the client withdraws its last root", async () => { + const other = await makeTempDir("mcp-fs-summary-other-"); + closers.push(() => fs.rm(other, { recursive: true, force: true })); + let roots = [rootOf(rootDir)]; + const { client } = await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => roots, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + roots = [rootOf(other)]; + await client.sendRootsListChanged(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([other], { + from: "roots", + replacedCommandLine: true, + }), + ), + ); + + roots = []; + await client.sendRootsListChanged(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + describeAllowedDirectories([], { + from: "roots", + replacedCommandLine: true, + }), + ), + ); + }); +}); diff --git a/src/filesystem/allowed-directories.ts b/src/filesystem/allowed-directories.ts new file mode 100644 index 0000000000..e902b33613 --- /dev/null +++ b/src/filesystem/allowed-directories.ts @@ -0,0 +1,44 @@ +// How the filesystem server reports its allow-list to the operator (#4912). +// The allowed directories in force can come from the command line or, on a +// 2025-era connection, from the client's Roots, and they change at runtime as +// the client's Roots do. index.ts logs them at startup and server.ts after +// every change, both through describeAllowedDirectories, so the message reads +// the same wherever it is printed and an operator can tell the effective +// access state and its source from stderr alone, without calling a tool. + +/** + * Where the allowed directories in force came from (#4912): the command line, + * or the client's Roots, which on a 2025-era connection replace the + * command-line directories (`replacedCommandLine` says whether there were any + * to replace). + */ +export type AllowedDirectoriesSource = + | { from: "command-line" } + | { from: "roots"; replacedCommandLine: boolean }; + +/** + * The one-message summary of the allowed directories in force, logged to + * stderr at startup and after every change, so an operator can read the + * effective access state and its source without calling a tool (#4912). Each + * directory is on a line of its own, since a path may contain a comma. + */ +export function describeAllowedDirectories( + directories: string[], + source: AllowedDirectoriesSource, +): string { + if (directories.length === 0) { + return source.from === "roots" + ? "Effective allowed directories: none. The client's Roots name no valid directory, so every path is refused until it exposes one again" + : "Effective allowed directories: none. No command-line directories and no valid client Roots, so every path is refused"; + } + const origin = + source.from === "command-line" + ? "from the command line" + : source.replacedCommandLine + ? "from the client's Roots, which replaced the command-line directories" + : "from the client's Roots"; + return [ + `Effective allowed directories (${origin}):`, + ...directories.map((dir) => ` ${dir}`), + ].join("\n"); +} diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 0cb961cb37..5beb1a6beb 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -22,6 +22,7 @@ import { } from "@modelcontextprotocol/server/stdio"; import { normalizePath, expandHome, realpathWithRetry } from "./path-utils.js"; import { createServer } from "./server.js"; +import { describeAllowedDirectories } from "./allowed-directories.js"; /** * Resolve the allowed directories given on the command line, warning about and @@ -172,6 +173,12 @@ export async function main( console.error( "Started without allowed directories - waiting for a 2025-era client to provide roots via MCP protocol", ); + } else { + // The directories in force until a 2025-era client's Roots replace them; + // on 2026-07-28 they are final (#4912). + console.error( + describeAllowedDirectories(allowedDirectories, { from: "command-line" }), + ); } return handle; } diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 713237c7ee..ac6c896efc 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -13,6 +13,7 @@ import { pathToFileURL } from "url"; import { z } from "zod"; import { minimatch } from "minimatch"; import { getValidRootDirectories } from "./roots-utils.js"; +import { describeAllowedDirectories } from "./allowed-directories.js"; import { formatSize, validatePath, @@ -877,6 +878,22 @@ export function createServer( } else { console.error("No valid root directories provided by client"); } + logAllowedDirectories(); + } + + // Log the allowed directories now in force and where they came from (#4912). + function logAllowedDirectories(): void { + console.error( + describeAllowedDirectories( + allowedDirectories, + rootsInForce + ? { + from: "roots", + replacedCommandLine: initialAllowedDirectories.length > 0, + } + : { from: "command-line" }, + ), + ); } // Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. @@ -935,6 +952,7 @@ export function createServer( "Failed to request initial roots from client:", error instanceof Error ? error.message : String(error), ); + logAllowedDirectories(); } } else { if (allowedDirectories.length > 0) { @@ -942,6 +960,7 @@ export function createServer( "Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories, ); + logAllowedDirectories(); } else { // Nothing could ever be allowed in this session, so fail visibly // rather than serve every call with "Access denied" (#4992): log the