diff --git a/.changeset/filesystem-append-tools.md b/.changeset/filesystem-append-tools.md new file mode 100644 index 0000000000..9b8269708f --- /dev/null +++ b/.changeset/filesystem-append-tools.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": minor +--- + +Add `append_file` for existing files and `create_or_append_file` for creation or append. Append at physical EOF without replacing files, reject missing files for `append_file`, and preserve file identity checks against symlink swaps. diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 35cbb2d567..5fac400eb5 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -7,6 +7,8 @@ Published on npm as [`@modelcontextprotocol/server-filesystem`](https://www.npmj ## Features - Read/write files +- Append to existing files +- Create files or append to existing ones - Create/list/delete directories - Move files/directories - Search files @@ -108,6 +110,25 @@ The server's directory access control follows this flow: overwrite is not crash-atomic: a crash mid-write can leave the file partly written. A read-only file is refused rather than replaced +- **append_file** + - Append content to the end of an existing file + - Inputs: + - `path` (string): File location (must exist) + - `content` (string): Content to append + - File must already exist; a missing file is rejected and stays missing + - Appends at physical EOF without replacing the file, preserving its inode and hard links + - Content is appended exactly as provided; include any desired newline in `content` + - Preserves existing content, adds new content at the end + +- **create_or_append_file** + - Create new file or append to existing file + - Inputs: + - `path` (string): File location + - `content` (string): Content to write or append + - If file doesn't exist: creates it with the provided content + - If file exists: appends new content to the end + - Useful when you want to add content while preserving existing data + - **edit_file** - Make selective edits using advanced pattern matching and formatting - Features: @@ -217,6 +238,8 @@ The mapping for filesystem tools is: | `list_allowed_directories` | `true` | – | – | Pure read | | `create_directory` | `false` | `true` | `false` | Re‑creating the same dir is a no‑op | | `write_file` | `false` | `true` | `true` | Overwrites existing files | +| `append_file` | `false` | `false` | `false` | Appends to existing files; not idempotent | +| `create_or_append_file` | `false` | `false` | `false` | Creates or appends; behavior depends on state | | `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply | | `move_file` | `false` | `false` | `true` | Deletes source file | diff --git a/src/filesystem/__tests__/eras.test.ts b/src/filesystem/__tests__/eras.test.ts index 90d6f43d4e..6a81c4a3ab 100644 --- a/src/filesystem/__tests__/eras.test.ts +++ b/src/filesystem/__tests__/eras.test.ts @@ -26,7 +26,7 @@ import { createServer, NO_ALLOWED_DIRECTORIES_MODERN_ERROR, } from "../server.js"; -import { makeTempDir, quietStderr } from "./helpers.js"; +import { makeTempDir, quietStderr, textOf } from "./helpers.js"; let allowed: string; let other: string; @@ -85,6 +85,42 @@ async function listAllowed(client: Client): Promise { return (result.content[0] as { text: string }).text; } +describe.each(["auto", "legacy"] as const)( + "append tools with %s negotiation", + (mode) => { + it("keeps missing files missing, appends at EOF, and creates only through create_or_append_file", async () => { + const { client } = await connect([allowed], mode); + // Legacy clients supply `other` through Roots; modern clients use argv. + const root = mode === "legacy" ? other : allowed; + expect(await listAllowed(client)).toBe(`Allowed directories:\n${root}`); + const missing = path.join(root, "new.txt"); + const rejected = (await client.callTool({ + name: "append_file", + arguments: { path: missing, content: "bad" }, + })) as CallToolResult; + expect(rejected.isError).toBe(true); + await expect(fs.lstat(missing)).rejects.toMatchObject({ code: "ENOENT" }); + + const created = (await client.callTool({ + name: "create_or_append_file", + arguments: { path: missing, content: "before" }, + })) as CallToolResult; + expect(created.isError).toBeFalsy(); + expect(textOf(created)).toBe( + `Successfully created ${missing} with content`, + ); + const before = await fs.stat(missing); + const appended = (await client.callTool({ + name: "append_file", + arguments: { path: missing, content: "after" }, + })) as CallToolResult; + expect(appended.isError).toBeFalsy(); + expect(await fs.readFile(missing, "utf-8")).toBe("beforeafter"); + expect((await fs.stat(missing)).ino).toBe(before.ino); + }); + }, +); + describe("on 2026-07-28", () => { it("serves only the command-line directories, and never asks for roots", async () => { const { client, rootsAsked } = await connect([allowed], "auto"); diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index fee6726488..8988eae535 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -22,6 +22,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + createOrAppendFileContent, moveFile, // Search & filtering functions searchFilesWithValidation, @@ -402,6 +404,155 @@ describe("Lib Functions", () => { }); describe("File Operations", () => { + describe("appendFileContent", () => { + it("reports a missing file without trying to create it", async () => { + mockFs.lstat.mockRejectedValueOnce( + Object.assign(new Error("missing"), { code: "ENOENT" }), + ); + await expect( + appendFileContent("/test/missing.txt", "text"), + ).rejects.toMatchObject({ + message: "File does not exist: /test/missing.txt", + code: "ENOENT", + }); + expect(mockFs.open).not.toHaveBeenCalled(); + }); + + it("reports a file removed between lstat and open as missing", async () => { + mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE); + mockFs.open.mockRejectedValueOnce( + Object.assign(new Error("missing"), { code: "ENOENT" }), + ); + await expect( + appendFileContent("/test/file.txt", "text"), + ).rejects.toThrow("File does not exist"); + }); + + it("refuses a non-regular path before opening it", async () => { + mockFs.lstat.mockResolvedValueOnce({ + ...REGULAR_FILE, + isFile: () => false, + } as Stats); + await expect(appendFileContent("/test/link", "text")).rejects.toThrow( + "not a regular file", + ); + expect(mockFs.open).not.toHaveBeenCalled(); + }); + + it.each([ + { ...REGULAR_FILE, ino: 43 } as Stats, + { ...REGULAR_FILE, dev: 2 } as Stats, + { ...REGULAR_FILE, isFile: () => false } as Stats, + ])("refuses a replaced file and closes its handle", async (stats) => { + mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE); + const handle = createMockWriteHandle(stats); + mockFs.open.mockResolvedValueOnce(asWriteHandle(handle)); + await expect( + appendFileContent("/test/file.txt", "text"), + ).rejects.toThrow("was replaced"); + expect(handle.writeFile).not.toHaveBeenCalled(); + expect(handle.close).toHaveBeenCalledOnce(); + }); + + it("retains the identity check without POSIX flags on Windows", async () => { + const constants = mockFs.constants as { + O_NOFOLLOW?: number; + O_NONBLOCK?: number; + }; + const noFollow = constants.O_NOFOLLOW; + const nonBlock = constants.O_NONBLOCK; + constants.O_NOFOLLOW = undefined; + constants.O_NONBLOCK = undefined; + mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE); + const handle = createMockWriteHandle({ + ...REGULAR_FILE, + ino: 43, + } as Stats); + mockFs.open.mockResolvedValueOnce(asWriteHandle(handle)); + try { + await expect( + appendFileContent("/test/file.txt", "text"), + ).rejects.toThrow("was replaced"); + expect(mockFs.open).toHaveBeenCalledWith( + "/test/file.txt", + fs.constants.O_WRONLY | fs.constants.O_APPEND, + ); + expect(handle.writeFile).not.toHaveBeenCalled(); + } finally { + constants.O_NOFOLLOW = noFollow; + constants.O_NONBLOCK = nonBlock; + } + }); + + it.each(["stat", "writeFile"] as const)( + "closes the handle when %s fails", + async (operation) => { + mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE); + const handle = createMockWriteHandle(); + const error = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + handle[operation].mockRejectedValueOnce(error); + mockFs.open.mockResolvedValueOnce(asWriteHandle(handle)); + await expect( + appendFileContent("/test/file.txt", "text"), + ).rejects.toBe(error); + expect(handle.close).toHaveBeenCalledOnce(); + }, + ); + + it("propagates an open error without creating a file", async () => { + mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE); + const error = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + mockFs.open.mockRejectedValueOnce(error); + await expect(appendFileContent("/test/file.txt", "text")).rejects.toBe( + error, + ); + }); + }); + + describe("createOrAppendFileContent", () => { + it("propagates append errors other than missing files", async () => { + const error = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + mockFs.lstat.mockRejectedValueOnce(error); + await expect( + createOrAppendFileContent("/test/file.txt", "text"), + ).rejects.toBe(error); + expect(mockFs.open).not.toHaveBeenCalled(); + }); + + it("propagates exclusive creation errors", async () => { + mockFs.lstat.mockRejectedValueOnce( + Object.assign(new Error("missing"), { code: "ENOENT" }), + ); + const error = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + mockFs.open.mockRejectedValueOnce(error); + await expect( + createOrAppendFileContent("/test/file.txt", "text"), + ).rejects.toBe(error); + }); + + it("closes a newly created handle when writing fails", async () => { + mockFs.lstat.mockRejectedValueOnce( + Object.assign(new Error("missing"), { code: "ENOENT" }), + ); + const handle = createMockWriteHandle(); + const error = Object.assign(new Error("disk full"), { code: "ENOSPC" }); + handle.writeFile.mockRejectedValueOnce(error); + mockFs.open.mockResolvedValueOnce(asWriteHandle(handle)); + await expect( + createOrAppendFileContent("/test/file.txt", "text"), + ).rejects.toBe(error); + expect(handle.close).toHaveBeenCalledOnce(); + }); + }); + describe("getFileStats", () => { it("returns file statistics", async () => { const mockStats = { diff --git a/src/filesystem/__tests__/server-append.test.ts b/src/filesystem/__tests__/server-append.test.ts new file mode 100644 index 0000000000..534b8b2bed --- /dev/null +++ b/src/filesystem/__tests__/server-append.test.ts @@ -0,0 +1,252 @@ +// Real-filesystem regressions for the append tools, called through the MCP +// client. Mocks alone missed the missing-file creation bug in PR #2816; +// controlled path swaps below exercise the validation/open boundary too. + +import fs from "fs/promises"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + call, + connect, + makeTempDir, + quietStderr, + textOf, + type Connected, +} from "./helpers.js"; + +let dir: string; +let outside: string; +let conn: Connected; + +beforeEach(async () => { + quietStderr(); + dir = await makeTempDir("mcp-fs-append-"); + outside = await makeTempDir("mcp-fs-append-outside-"); + conn = await connect([dir]); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await conn.close(); + await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); +}); + +describe("append_file", () => { + it("adds exact bytes at physical EOF without replacing the inode or hard links", async () => { + const file = path.join(dir, "existing.bin"); + const alias = path.join(dir, "hard-link.bin"); + const original = Buffer.from([0x00, 0xff, 0x41, 0x0d, 0x0a]); + await fs.writeFile(file, original); + await fs.link(file, alias); + const before = await fs.stat(file); + const held = await fs.open(file, "r"); + try { + const result = await call(conn.client, "append_file", { + path: file, + content: "héllo", + }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ content: textOf(result) }); + const expected = Buffer.concat([original, Buffer.from("héllo")]); + expect(await fs.readFile(file)).toEqual(expected); + expect(await fs.readFile(alias)).toEqual(expected); + const after = await fs.stat(file); + expect([after.dev, after.ino, after.mode]).toEqual([ + before.dev, + before.ino, + before.mode, + ]); + expect((await held.stat()).size).toBe(expected.length); + } finally { + await held.close(); + } + }); + + it.each(["hello", ""])( + "rejects a missing file and leaves it missing (content %j)", + async (content) => { + const file = path.join(dir, "missing.txt"); + const result = await call(conn.client, "append_file", { + path: file, + content, + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe(`File does not exist: ${file}`); + await expect(fs.lstat(file)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it("appends to an empty existing file", async () => { + const file = path.join(dir, "empty.txt"); + await fs.writeFile(file, ""); + expect( + (await call(conn.client, "append_file", { path: file, content: "first" })) + .isError, + ).toBeFalsy(); + expect(await fs.readFile(file, "utf-8")).toBe("first"); + }); + + it("preserves every concurrent append without replacing earlier content", async () => { + const file = path.join(dir, "log.txt"); + await fs.writeFile(file, "prefix\n"); + const records = Array.from({ length: 8 }, (_, i) => `entry-${i}\n`); + const results = await Promise.all( + records.map((content) => + call(conn.client, "append_file", { path: file, content }), + ), + ); + expect(results.every((result) => !result.isError)).toBe(true); + const lines = (await fs.readFile(file, "utf-8")).split("\n"); + expect(lines.shift()).toBe("prefix"); + expect(lines.pop()).toBe(""); + expect(lines.sort()).toEqual(records.map((record) => record.trim()).sort()); + }); + + it("allows a validated symlink to a file inside the allowed directory", async () => { + const file = path.join(dir, "target.txt"); + const link = path.join(dir, "link.txt"); + await fs.writeFile(file, "before"); + await fs.symlink(file, link, "file"); + expect( + (await call(conn.client, "append_file", { path: link, content: "after" })) + .isError, + ).toBeFalsy(); + expect(await fs.readFile(file, "utf-8")).toBe("beforeafter"); + expect((await fs.lstat(link)).isSymbolicLink()).toBe(true); + }); +}); + +describe("create_or_append_file", () => { + it("creates a missing file and reports the actual operation", async () => { + const file = path.join(dir, "new.txt"); + const result = await call(conn.client, "create_or_append_file", { + path: file, + content: "initial", + }); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toBe(`Successfully created ${file} with content`); + expect(result.structuredContent).toEqual({ content: textOf(result) }); + expect(await fs.readFile(file, "utf-8")).toBe("initial"); + }); + + it("appends to an existing file without replacing its inode", async () => { + const file = path.join(dir, "existing.txt"); + await fs.writeFile(file, "before"); + const before = await fs.stat(file); + const result = await call(conn.client, "create_or_append_file", { + path: file, + content: "after", + }); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toBe(`Successfully appended content to ${file}`); + expect(await fs.readFile(file, "utf-8")).toBe("beforeafter"); + expect((await fs.stat(file)).ino).toBe(before.ino); + }); + + it("appends rather than overwriting when another process wins creation", async () => { + const file = path.join(dir, "race.txt"); + const open = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementationOnce( + async (filePath, flags, mode) => { + expect(flags).toBe("wx"); + await fs.writeFile(file, "competitor"); + return open(filePath, flags, mode); + }, + ); + const result = await call(conn.client, "create_or_append_file", { + path: file, + content: "+ours", + }); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toBe(`Successfully appended content to ${file}`); + expect(await fs.readFile(file, "utf-8")).toBe("competitor+ours"); + }); +}); + +describe.each(["append_file", "create_or_append_file"])( + "%s validation", + (tool) => { + it("refuses an out-of-bounds symlink target", async () => { + const target = path.join(outside, "untouched.txt"); + const link = path.join(dir, "link.txt"); + await fs.writeFile(target, "outside"); + await fs.symlink(target, link, "file"); + const result = await call(conn.client, tool, { + path: link, + content: "bad", + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain( + "symlink target outside allowed directories", + ); + expect(await fs.readFile(target, "utf-8")).toBe("outside"); + }); + + it("refuses a symlink swapped in between validation and lstat", async () => { + const file = path.join(dir, "file.txt"); + const target = path.join(outside, "untouched.txt"); + await fs.writeFile(file, "before"); + await fs.writeFile(target, "outside"); + const lstat = fs.lstat.bind(fs); + vi.spyOn(fs, "lstat").mockImplementationOnce(async (filePath) => { + await fs.unlink(file); + await fs.symlink(target, file, "file"); + return lstat(filePath); + }); + const result = await call(conn.client, tool, { + path: file, + content: "bad", + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("not a regular file"); + expect(await fs.readFile(target, "utf-8")).toBe("outside"); + }); + + it("refuses a symlink swapped in between lstat and open", async () => { + const file = path.join(dir, "file.txt"); + const target = path.join(outside, "untouched.txt"); + await fs.writeFile(file, "before"); + await fs.writeFile(target, "outside"); + const open = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementationOnce( + async (filePath, flags, mode) => { + await fs.rename(file, path.join(dir, "original.txt")); + await fs.symlink(target, file, "file"); + return open(filePath, flags, mode); + }, + ); + const result = await call(conn.client, tool, { + path: file, + content: "bad", + }); + expect(result.isError).toBe(true); + expect(await fs.readFile(target, "utf-8")).toBe("outside"); + expect(await fs.readFile(path.join(dir, "original.txt"), "utf-8")).toBe( + "before", + ); + }); + + it("refuses a different regular file swapped in before open", async () => { + const file = path.join(dir, "file.txt"); + await fs.writeFile(file, "before"); + const open = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementationOnce( + async (filePath, flags, mode) => { + await fs.rename(file, path.join(dir, "original.txt")); + await fs.writeFile(file, "replacement"); + return open(filePath, flags, mode); + }, + ); + const result = await call(conn.client, tool, { + path: file, + content: "bad", + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain( + "was replaced while it was being opened", + ); + expect(await fs.readFile(file, "utf-8")).toBe("replacement"); + }); + }, +); diff --git a/src/filesystem/__tests__/server-tools.test.ts b/src/filesystem/__tests__/server-tools.test.ts index 9be4855672..08b1e04adc 100644 --- a/src/filesystem/__tests__/server-tools.test.ts +++ b/src/filesystem/__tests__/server-tools.test.ts @@ -30,6 +30,8 @@ const TOOL_NAMES = [ "read_media_file", "read_multiple_files", "write_file", + "append_file", + "create_or_append_file", "edit_file", "create_directory", "list_directory", @@ -88,7 +90,7 @@ describe("server identity and tool list", () => { }); }); - it("lists the 14 tools in registration order", async () => { + it("lists the 16 tools in registration order", async () => { const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toEqual(TOOL_NAMES); }); @@ -113,6 +115,20 @@ describe("server identity and tool list", () => { destructiveHint: true, openWorldHint: false, }, + append_file: { + title: "Append File", + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: false, + }, + create_or_append_file: { + title: "Create or Append File", + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: false, + }, edit_file: { title: "Edit File", readOnlyHint: false, @@ -276,6 +292,11 @@ describe("server identity and tool list", () => { ["read_text_file", { path: path.join(dir, "a.txt") }], ["read_multiple_files", { paths: [path.join(dir, "a.txt")] }], ["write_file", { path: path.join(dir, "b.txt"), content: "beta" }], + ["append_file", { path: path.join(dir, "a.txt"), content: " appended" }], + [ + "create_or_append_file", + { path: path.join(dir, "new.txt"), content: "new" }, + ], [ "edit_file", { diff --git a/src/filesystem/__tests__/stdio.test.ts b/src/filesystem/__tests__/stdio.test.ts index a3442132f5..9980df9bf7 100644 --- a/src/filesystem/__tests__/stdio.test.ts +++ b/src/filesystem/__tests__/stdio.test.ts @@ -210,7 +210,7 @@ describe("the stdio transport", () => { session.send(7, "tools/list"); const response = await session.response(7); expect(response.error).toBeUndefined(); - expect(response.result?.tools).toHaveLength(14); + expect(response.result?.tools).toHaveLength(16); }); // #4206: a malformed line is reported to the transport's onerror (unset, @@ -234,7 +234,7 @@ describe("the stdio transport", () => { message: "Method not found", }); session.send(3, "tools/list"); - expect((await session.response(3)).result?.tools).toHaveLength(14); + expect((await session.response(3)).result?.tools).toHaveLength(16); }); }); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index abb6b09cd4..72ff3ac681 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -1,4 +1,4 @@ -import fs from "fs/promises"; +import fs, { type FileHandle } from "fs/promises"; import path from "path"; import { StringDecoder } from "string_decoder"; import { createTwoFilesPatch } from "diff"; @@ -308,6 +308,90 @@ export async function writeFileContent( } } +/** Append at physical EOF without creating or replacing the validated file. */ +export async function appendFileContent( + filePath: string, + content: string, +): Promise { + try { + const expected = await fs.lstat(filePath); + if (!expected.isFile()) { + throw new Error(`Refusing to append: not a regular file: ${filePath}`); + } + // No O_CREAT or O_TRUNC: a missing file must stay missing. O_NONBLOCK + // prevents a FIFO swapped in after lstat from blocking the open. Neither + // it nor O_NOFOLLOW exists on Windows; the handle identity check below + // still refuses a replacement before any bytes are written there. + const handle = await fs.open( + filePath, + fs.constants.O_WRONLY | + fs.constants.O_APPEND | + (fs.constants.O_NOFOLLOW ?? 0) | + (fs.constants.O_NONBLOCK ?? 0), + ); + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.dev !== expected.dev || + opened.ino !== expected.ino + ) { + throw new Error( + `Refusing to append: ${filePath} was replaced while it was being opened`, + ); + } + // O_APPEND puts every write at EOF, retaining the inode and its links. + await handle.writeFile(content, "utf-8"); + } finally { + await handle.close(); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw Object.assign( + new Error(`File does not exist: ${filePath}`, { cause: error }), + { + code: "ENOENT", + }, + ); + } + throw error; + } +} + +/** Create exclusively, or append without overwriting a concurrently created file. */ +export async function createOrAppendFileContent( + filePath: string, + content: string, +): Promise<"created" | "appended"> { + try { + await appendFileContent(filePath, content); + return "appended"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + let handle: FileHandle; + try { + // Exclusive creation also refuses symlinks. If another process wins this + // race, append through the same checked path instead of overwriting it. + handle = await fs.open(filePath, "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + await appendFileContent(filePath, content); + return "appended"; + } + throw error; + } + try { + await handle.writeFile(content, "utf-8"); + return "created"; + } finally { + await handle.close(); + } +} + /** * Replace an existing file's content in place, through a single handle. * diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 8878b1ea68..82c8c2bd36 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -19,6 +19,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + createOrAppendFileContent, moveFile, searchFilesWithValidation, applyFileEdits, @@ -74,6 +76,21 @@ const WriteFileArgsSchema = z.object({ content: z.string(), }); +const AppendFileArgsSchema = z.object({ + path: z + .string() + .describe("Path to an existing file within allowed directories"), + content: z + .string() + .describe("Text to append exactly as provided, without adding a newline"), +}); + +const CreateOrAppendFileArgsSchema = AppendFileArgsSchema.extend({ + path: z + .string() + .describe("Path to a new or existing file within allowed directories"), +}); + const EditOperation = z.object({ oldText: z.string().describe("Text to search for - must match exactly"), newText: z.string().describe("Text to replace with"), @@ -456,6 +473,68 @@ export function createServer( }, ); + server.registerTool( + "append_file", + { + title: "Append File", + description: + "Append text to the physical end of an existing file without replacing it or its existing content. " + + "The file must already exist; use create_or_append_file to create it if needed. " + + "Content is appended exactly as provided, without adding a newline. Only works within allowed directories.", + inputSchema: jsonSchema2020(AppendFileArgsSchema.shape), + outputSchema: jsonSchema2020({ content: z.string() }), + annotations: { + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: false, + }, + }, + async (args: z.infer) => { + const validPath = await validatePath(args.path, allowedDirectories); + await appendFileContent(validPath, args.content); + const text = `Successfully appended content to ${args.path}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { content: text }, + }; + }, + ); + + server.registerTool( + "create_or_append_file", + { + title: "Create or Append File", + description: + "Create a missing file with the provided text, or append to the physical end of an existing file. " + + "Preserves existing content and appends text exactly as provided, without adding a newline. " + + "Only works within allowed directories.", + inputSchema: jsonSchema2020(CreateOrAppendFileArgsSchema.shape), + outputSchema: jsonSchema2020({ content: z.string() }), + annotations: { + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: false, + }, + }, + async (args: z.infer) => { + const validPath = await validatePath(args.path, allowedDirectories); + const operation = await createOrAppendFileContent( + validPath, + args.content, + ); + const text = + operation === "created" + ? `Successfully created ${args.path} with content` + : `Successfully appended content to ${args.path}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { content: text }, + }; + }, + ); + server.registerTool( "edit_file", {