Skip to content
Open
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
209 changes: 209 additions & 0 deletions packages/mcp-server/src/features/local-rest-api/headerEncoding.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 6 additions & 4 deletions packages/mcp-server/src/features/local-rest-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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);
Expand Down Expand Up @@ -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);
Expand Down