Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/4912-filesystem-effective-directories-log.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
207 changes: 207 additions & 0 deletions src/filesystem/__tests__/allowed-directories.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof quietStderr>;
let closers: Array<() => Promise<void>>;

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<typeof connect>
): Promise<Connected> {
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,
}),
),
);
});
});
44 changes: 44 additions & 0 deletions src/filesystem/allowed-directories.ts
Original file line number Diff line number Diff line change
@@ -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");
}
7 changes: 7 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 19 additions & 0 deletions src/filesystem/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -935,13 +952,15 @@ export function createServer(
"Failed to request initial roots from client:",
error instanceof Error ? error.message : String(error),
);
logAllowedDirectories();
}
} else {
if (allowedDirectories.length > 0) {
console.error(
"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
Expand Down
Loading