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/5226-filesystem-hard-link-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-filesystem": minor
---

`get_file_info` now reports a file's hard-link count as a `hardLinks: <n>` line, after `permissions`, so a client can see that a file has other names before changing it. The README now says that allowed-directory checks are path-based, and that overwriting or editing a file that is hard-linked from elsewhere changes it at every name ([#5226](https://github.com/modelcontextprotocol/servers/issues/5226)).
11 changes: 11 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ The server's directory access control follows this flow:
time, hard links and permissions (`edit_file` writes the same way). The
overwrite is not crash-atomic: a crash mid-write can leave the file partly
written. A read-only file is refused rather than replaced
- Allowed-directory checks are path-based: they check the name you pass,
not every name the file has. A hard link is a real name of the same file,
so when a file inside an allowed directory is also hard-linked from
somewhere else (pnpm, for example, hard-links package files into
`node_modules` from its store), overwriting or editing it changes the
content at every name, inside the allowed directories or not.
`get_file_info` reports a file's `hardLinks` count; more than 1 means
the file has other names

- **edit_file**
- Make selective edits using advanced pattern matching and formatting
Expand Down Expand Up @@ -185,6 +193,9 @@ The server's directory access control follows this flow:
- Access time
- Type (file/directory)
- Permissions
- Hard-link count (`hardLinks`): the number of names the file has. More
than 1 means a write through this name changes the file at its other
names too (see `write_file`)

- **list_allowed_directories**
- List all directories the server is allowed to access
Expand Down
2 changes: 2 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ describe("Lib Functions", () => {
isDirectory: () => false,
isFile: () => true,
mode: 0o644,
nlink: 2,
};

mockFs.stat.mockResolvedValueOnce(mockStats as Stats);
Expand All @@ -457,6 +458,7 @@ describe("Lib Functions", () => {
isDirectory: false,
isFile: true,
permissions: "644",
hardLinks: 2,
});
});

Expand Down
11 changes: 11 additions & 0 deletions src/filesystem/__tests__/server-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1888,6 +1888,7 @@ describe("get_file_info", () => {
"isDirectory",
"isFile",
"permissions",
"hardLinks",
]);
expect(lines[0]).toBe("size: 5");
expect(lines[4]).toBe("isDirectory: false");
Expand All @@ -1900,6 +1901,16 @@ describe("get_file_info", () => {
expect(lines[2]).toMatch(/^modified: \w{3} \w{3} \d{2} \d{4} /);
});

it("reports a file's hard-link count (#5226)", async () => {
const file = path.join(dir, "linked.txt");
await fs.writeFile(file, "shared");
const info = async () =>
textOf(await call(client, "get_file_info", { path: file }));
expect(await info()).toContain("hardLinks: 1");
await fs.link(file, path.join(dir, "hardlink.txt"));
expect(await info()).toContain("hardLinks: 2");
});

it("reports a directory", async () => {
const text = textOf(await call(client, "get_file_info", { path: dir }));
expect(text).toContain("isDirectory: true");
Expand Down
4 changes: 4 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface FileInfo {
isDirectory: boolean;
isFile: boolean;
permissions: string;
hardLinks: number;
}

export interface SearchOptions {
Expand Down Expand Up @@ -312,6 +313,9 @@ export async function getFileStats(filePath: string): Promise<FileInfo> {
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
// The link count: more than 1 means a file has other names, possibly
// outside the allowed directories, that a write here also changes.
hardLinks: stats.nlink,
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/filesystem/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ export function createServer(
description:
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"type and hard-link count. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: jsonSchema2020(GetFileInfoArgsSchema.shape),
outputSchema: jsonSchema2020({ content: z.string() }),
Expand Down
Loading