diff --git a/packages/mcp-server/src/features/local-rest-api/headerEncoding.test.ts b/packages/mcp-server/src/features/local-rest-api/headerEncoding.test.ts new file mode 100644 index 0000000..ac001f6 --- /dev/null +++ b/packages/mcp-server/src/features/local-rest-api/headerEncoding.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test"; + +/** + * Tests for header encoding logic used in patch_active_file and patch_vault_file tools + * to handle special characters, newlines, and multi-byte characters in header values + */ +describe("Header value encoding for PATCH operations", () => { + /** + * Helper function that replicates the header encoding logic + * This ensures special characters in Target and Target-Delimiter headers are properly encoded + */ + function encodeHeaderValue(value: string): string { + return encodeURIComponent(value); + } + + test("encodes special characters in target header", () => { + const target = "My Heading #1"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("My%20Heading%20%231"); + }); + + test("encodes newline characters in target header", () => { + const target = "Line 1\nLine 2"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Line%201%0ALine%202"); + }); + + test("encodes multi-byte Unicode characters", () => { + const target = "日本語のヘッダー"; + const encoded = encodeHeaderValue(target); + // Multi-byte characters should be percent-encoded + expect(encoded).toContain("%"); + expect(encoded).not.toBe(target); + }); + + test("encodes emoji characters", () => { + const target = "📝 Notes"; + const encoded = encodeHeaderValue(target); + expect(encoded).toContain("%"); + expect(encoded).not.toContain("📝"); + }); + + test("encodes forward slashes", () => { + const target = "Path/To/Heading"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Path%2FTo%2FHeading"); + }); + + test("encodes backslashes", () => { + const target = "Path\\To\\Heading"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Path%5CTo%5CHeading"); + }); + + test("encodes colons", () => { + const target = "Time: 12:00"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Time%3A%2012%3A00"); + }); + + test("encodes equals signs", () => { + const target = "key=value"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("key%3Dvalue"); + }); + + test("encodes ampersands", () => { + const target = "Tom & Jerry"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Tom%20%26%20Jerry"); + }); + + test("encodes question marks", () => { + const target = "Is this working?"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Is%20this%20working%3F"); + }); + + test("encodes carriage return characters", () => { + const target = "Line 1\r\nLine 2"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Line%201%0D%0ALine%202"); + }); + + test("handles empty strings", () => { + const target = ""; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe(""); + }); + + test("preserves alphanumeric characters", () => { + const target = "SimpleHeading123"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("SimpleHeading123"); + }); + + test("encodes spaces", () => { + const target = "Multiple Spaces"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Multiple%20%20%20Spaces"); + }); + + test("encodes tab characters", () => { + const target = "Tab\there"; + const encoded = encodeHeaderValue(target); + expect(encoded).toBe("Tab%09here"); + }); +}); + +describe("Header construction for PATCH operations", () => { + /** + * Simulates the full header construction as done in patch_active_file + * and patch_vault_file to ensure proper encoding + */ + interface PatchHeaders { + Operation: string; + "Target-Type": string; + Target: string; + "Create-Target-If-Missing": string; + "Target-Delimiter"?: string; + "Trim-Target-Whitespace"?: string; + "Content-Type"?: string; + } + + function constructPatchHeaders(args: { + operation: string; + targetType: string; + target: string; + targetDelimiter?: string; + trimTargetWhitespace?: boolean; + contentType?: string; + }): PatchHeaders { + const headers: PatchHeaders = { + Operation: args.operation, + "Target-Type": args.targetType, + Target: encodeURIComponent(args.target), + "Create-Target-If-Missing": "true", + }; + + if (args.targetDelimiter) { + headers["Target-Delimiter"] = encodeURIComponent(args.targetDelimiter); + } + if (args.trimTargetWhitespace !== undefined) { + headers["Trim-Target-Whitespace"] = String(args.trimTargetWhitespace); + } + if (args.contentType) { + headers["Content-Type"] = args.contentType; + } + + return headers; + } + + test("constructs headers with encoded target", () => { + const headers = constructPatchHeaders({ + operation: "insert-after", + targetType: "heading", + target: "My Heading #1", + }); + + expect(headers.Target).toBe("My%20Heading%20%231"); + expect(headers.Operation).toBe("insert-after"); + expect(headers["Target-Type"]).toBe("heading"); + }); + + test("constructs headers with encoded delimiter", () => { + const headers = constructPatchHeaders({ + operation: "insert-after", + targetType: "heading", + target: "Simple", + targetDelimiter: "\n---\n", + }); + + expect(headers["Target-Delimiter"]).toBe("%0A---%0A"); + }); + + test("handles multi-byte characters in target and delimiter", () => { + const headers = constructPatchHeaders({ + operation: "insert-after", + targetType: "heading", + target: "見出し", + targetDelimiter: "区切り文字", + }); + + expect(headers.Target).toContain("%"); + expect(headers["Target-Delimiter"]).toContain("%"); + }); + + test("preserves trimTargetWhitespace boolean as string", () => { + const headers = constructPatchHeaders({ + operation: "insert-after", + targetType: "heading", + target: "Simple", + trimTargetWhitespace: true, + }); + + expect(headers["Trim-Target-Whitespace"]).toBe("true"); + }); + + test("contentType header is not encoded", () => { + const headers = constructPatchHeaders({ + operation: "insert-after", + targetType: "heading", + target: "Simple", + contentType: "text/plain", + }); + + expect(headers["Content-Type"]).toBe("text/plain"); + }); +}); diff --git a/packages/mcp-server/src/features/local-rest-api/index.ts b/packages/mcp-server/src/features/local-rest-api/index.ts index 37a1424..050bd0d 100644 --- a/packages/mcp-server/src/features/local-rest-api/index.ts +++ b/packages/mcp-server/src/features/local-rest-api/index.ts @@ -95,15 +95,16 @@ export function registerLocalRestApiTools(tools: ToolRegistry, server: Server) { "Insert or modify content in the currently-open note relative to a heading, block reference, or frontmatter field.", ), async ({ arguments: args }) => { + // Encode header values to handle special characters, newlines, and multi-byte characters const headers: Record = { Operation: args.operation, "Target-Type": args.targetType, - Target: args.target, + Target: encodeURIComponent(args.target), "Create-Target-If-Missing": "true", }; if (args.targetDelimiter) { - headers["Target-Delimiter"] = args.targetDelimiter; + headers["Target-Delimiter"] = encodeURIComponent(args.targetDelimiter); } if (args.trimTargetWhitespace !== undefined) { headers["Trim-Target-Whitespace"] = String(args.trimTargetWhitespace); @@ -356,15 +357,16 @@ export function registerLocalRestApiTools(tools: ToolRegistry, server: Server) { "Insert or modify content in a file relative to a heading, block reference, or frontmatter field.", ), async ({ arguments: args }) => { + // Encode header values to handle special characters, newlines, and multi-byte characters const headers: HeadersInit = { Operation: args.operation, "Target-Type": args.targetType, - Target: args.target, + Target: encodeURIComponent(args.target), "Create-Target-If-Missing": "true", }; if (args.targetDelimiter) { - headers["Target-Delimiter"] = args.targetDelimiter; + headers["Target-Delimiter"] = encodeURIComponent(args.targetDelimiter); } if (args.trimTargetWhitespace !== undefined) { headers["Trim-Target-Whitespace"] = String(args.trimTargetWhitespace);