From bd8018ee13a4cba4d97fd262787468eb060019a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 17:00:18 +0000 Subject: [PATCH 1/2] fix: encode HTTP headers in patch operations to handle special characters (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch_vault_file and patch_active_file functions were failing with HTTP 400 errors when the Target header contained: - Multi-line text (newlines) - Special characters (accented letters like é, à, etc.) - Special Obsidian syntax (like [[wikilinks]]) This occurred because HTTP headers must be ASCII and cannot contain newlines or certain special characters. The fix applies URL encoding to the Target and Target-Delimiter header values using encodeURIComponent(), which: - Converts newlines and special characters to safe percent-encoded format - Maintains compatibility with standard HTTP header parsing - Allows the Local REST API plugin to properly decode and process the values This resolves the "Header 'Target' has invalid value" errors and allows patch operations to work correctly with complex target strings. Fixes #30 --- .../mcp-server/src/features/local-rest-api/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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); From 80431cb14a6037ff836af7e44b461bb1e88150cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 17:23:38 +0000 Subject: [PATCH 2/2] test: add tests for header encoding in PATCH operations (#30) - Test encoding of special characters (#, &, ?, =, etc.) - Test encoding of newline and carriage return characters - Test encoding of multi-byte Unicode and emoji characters - Test encoding of path separators (/ and \) - Test encoding of whitespace (spaces, tabs) - Test header construction with encoded target and delimiter - Verify preservation of alphanumeric characters - Verify trimTargetWhitespace boolean to string conversion --- .../local-rest-api/headerEncoding.test.ts | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 packages/mcp-server/src/features/local-rest-api/headerEncoding.test.ts 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"); + }); +});