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
11 changes: 4 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "mcp-tools",
"name": "MCP Tools",
"version": "0.2.27",
"version": "0.2.28",
"minAppVersion": "0.15.0",
"description": "Securely connect Claude Desktop to your vault with semantic search, templates, and file management capabilities.",
"author": "Jack Steam",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-tools-for-obsidian",
"version": "0.2.27",
"version": "0.2.28",
"private": true,
"description": "Securely connect Claude Desktop to your Obsidian vault with semantic search, templates, and file management capabilities.",
"tags": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/**
* BDD specs for buildPatchHeaders.
*
* Covers: TestBuildPatchHeadersRequiredFields,
* TestBuildPatchHeadersOptionalFields,
* TestBuildPatchHeadersCreateTargetIfMissing
*/

// Public API surface (from src/features/local-rest-api/buildPatchHeaders.ts):
// buildPatchHeaders(args: ApiPatchParameters) -> Record<string, string>
// Input: parsed ApiPatchParameters (operation, targetType, target, and optionals)
// Output: Record<string, string> suitable for HTTP headers

import { describe, expect, test } from "bun:test";
import { buildPatchHeaders } from "./buildPatchHeaders";

/**
* REQUIREMENT: Required patch parameters are always mapped to HTTP headers.
*
* WHO: The local-rest-api patch handlers (patch_active_file, patch_vault_file)
* WHAT: The three required fields (operation, targetType, target) are always
* present in the returned headers under their HTTP header names
* WHY: The Obsidian Local REST API requires Operation, Target-Type, and Target
* headers on every PATCH request; omitting any causes a silent failure
*
* MOCK BOUNDARY:
* Mock: nothing β€” this function is pure computation
* Real: buildPatchHeaders
* Never: construct headers directly β€” always obtain via buildPatchHeaders()
*/
describe("buildPatchHeaders β€” required fields", () => {
test("maps operation, targetType, and target to HTTP headers", () => {
/**
* Given required fields for a patch operation
* When buildPatchHeaders is called with those fields
* Then the returned headers contain Operation, Target-Type, and Target
*/

// Given: a minimal set of required patch parameters
const args = {
operation: "append" as const,
targetType: "heading" as const,
target: "Section Title",
content: "New content paragraph",
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: all three required headers are present with correct values
// Operation header should map directly from args.operation
expect(headers["Operation"]).toBe("append");
// Target-Type header should map directly from args.targetType
expect(headers["Target-Type"]).toBe("heading");
// Target header should map directly from args.target
expect(headers["Target"]).toBe("Section Title");
});

test("does not include optional headers when optional fields are omitted", () => {
/**
* Given only required fields are provided
* When buildPatchHeaders is called
* Then optional headers (Target-Delimiter, Trim-Target-Whitespace,
* Content-Type, Create-Target-If-Missing) are absent
*/

// Given: only required fields
const args = {
operation: "replace" as const,
targetType: "block" as const,
target: "block-ref-id",
content: "Replacement text",
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: no optional headers are present
// Target-Delimiter should be absent when targetDelimiter is not provided
expect(headers).not.toHaveProperty("Target-Delimiter");
// Trim-Target-Whitespace should be absent when trimTargetWhitespace is not provided
expect(headers).not.toHaveProperty("Trim-Target-Whitespace");
// Content-Type should be absent when contentType is not provided
expect(headers).not.toHaveProperty("Content-Type");
// Create-Target-If-Missing should be absent when createTargetIfMissing is not provided
expect(headers).not.toHaveProperty("Create-Target-If-Missing");
});
});

/**
* REQUIREMENT: Optional patch parameters are conditionally mapped to HTTP headers.
*
* WHO: The local-rest-api patch handlers
* WHAT: When targetDelimiter, trimTargetWhitespace, or contentType are provided,
* they appear in the headers under the correct HTTP header names;
* boolean values are serialized as strings
* WHY: The Obsidian Local REST API uses custom HTTP headers for patch options;
* incorrect or missing mapping causes the API to use wrong defaults
*
* MOCK BOUNDARY:
* Mock: nothing β€” this function is pure computation
* Real: buildPatchHeaders
* Never: construct headers directly β€” always obtain via buildPatchHeaders()
*/
describe("buildPatchHeaders β€” optional fields", () => {
test("includes Target-Delimiter header when targetDelimiter is provided", () => {
/**
* Given a targetDelimiter value is specified
* When buildPatchHeaders is called
* Then the Target-Delimiter header is present with that value
*/

// Given: args with a custom delimiter
const args = {
operation: "append" as const,
targetType: "heading" as const,
target: "Parent > Child",
content: "Content under child heading",
targetDelimiter: ">",
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Target-Delimiter is set to the provided value
expect(headers["Target-Delimiter"]).toBe(">");
});

test("includes Trim-Target-Whitespace header as string when trimTargetWhitespace is true", () => {
/**
* Given trimTargetWhitespace is set to true
* When buildPatchHeaders is called
* Then the Trim-Target-Whitespace header is "true" (string, not boolean)
*/

// Given: args with trimTargetWhitespace enabled
const args = {
operation: "replace" as const,
targetType: "heading" as const,
target: " Heading With Spaces ",
content: "Trimmed content",
trimTargetWhitespace: true,
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Trim-Target-Whitespace is the string "true", not boolean true
expect(headers["Trim-Target-Whitespace"]).toBe("true");
});

test("includes Trim-Target-Whitespace header as string when trimTargetWhitespace is false", () => {
/**
* Given trimTargetWhitespace is explicitly set to false
* When buildPatchHeaders is called
* Then the Trim-Target-Whitespace header is "false" (string)
*/

// Given: args with trimTargetWhitespace explicitly disabled
const args = {
operation: "replace" as const,
targetType: "heading" as const,
target: "Heading",
content: "Content",
trimTargetWhitespace: false,
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Trim-Target-Whitespace is the string "false", not omitted
expect(headers["Trim-Target-Whitespace"]).toBe("false");
});

test("includes Content-Type header when contentType is provided", () => {
/**
* Given a contentType value is specified
* When buildPatchHeaders is called
* Then the Content-Type header is present with that value
*/

// Given: args with JSON content type
const args = {
operation: "replace" as const,
targetType: "frontmatter" as const,
target: "tags",
content: '["tag1", "tag2"]',
contentType: "application/json" as const,
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Content-Type is set to the provided value
expect(headers["Content-Type"]).toBe("application/json");
});
});

/**
* REQUIREMENT: createTargetIfMissing is conditionally mapped to the
* Create-Target-If-Missing HTTP header.
*
* WHO: The local-rest-api patch handlers
* WHAT: When createTargetIfMissing is true, the header is "true";
* when false, the header is "false";
* when omitted, the header is absent entirely
* WHY: Previously hardcoded to "true", which created targets unconditionally.
* Agents must explicitly opt in to target creation to avoid unintended
* modifications to vault structure.
*
* MOCK BOUNDARY:
* Mock: nothing β€” this function is pure computation
* Real: buildPatchHeaders
* Never: construct headers directly β€” always obtain via buildPatchHeaders()
*/
describe("buildPatchHeaders β€” createTargetIfMissing", () => {
test("includes Create-Target-If-Missing as 'true' when set to true", () => {
/**
* Given createTargetIfMissing is true
* When buildPatchHeaders is called
* Then the Create-Target-If-Missing header is the string "true"
*/

// Given: args opting in to target creation
const args = {
operation: "append" as const,
targetType: "heading" as const,
target: "New Section",
content: "Content for new section",
createTargetIfMissing: true,
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Create-Target-If-Missing is "true"
expect(headers["Create-Target-If-Missing"]).toBe("true");
});

test("includes Create-Target-If-Missing as 'false' when explicitly set to false", () => {
/**
* Given createTargetIfMissing is explicitly false
* When buildPatchHeaders is called
* Then the Create-Target-If-Missing header is the string "false"
*/

// Given: args explicitly opting out of target creation
const args = {
operation: "append" as const,
targetType: "heading" as const,
target: "Existing Section",
content: "Appended content",
createTargetIfMissing: false,
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Create-Target-If-Missing is "false"
expect(headers["Create-Target-If-Missing"]).toBe("false");
});

test("omits Create-Target-If-Missing header when createTargetIfMissing is not provided", () => {
/**
* Given createTargetIfMissing is omitted from args
* When buildPatchHeaders is called
* Then the Create-Target-If-Missing header is absent
*/

// Given: args without createTargetIfMissing
const args = {
operation: "prepend" as const,
targetType: "heading" as const,
target: "Existing Section",
content: "Prepended content",
};

// When: headers are built
const headers = buildPatchHeaders(args);

// Then: Create-Target-If-Missing is not in the headers β€” the old hardcoded 'true' behavior is the bug this fixes
expect(headers).not.toHaveProperty("Create-Target-If-Missing");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { LocalRestAPI } from "shared";

type ApiPatchArgs = typeof LocalRestAPI.ApiPatchParameters.infer;

export function buildPatchHeaders(args: ApiPatchArgs): Record<string, string> {
const headers: Record<string, string> = {
Operation: args.operation,
"Target-Type": args.targetType,
Target: args.target,
};

if (args.createTargetIfMissing !== undefined) {
headers["Create-Target-If-Missing"] = String(args.createTargetIfMissing);
}
if (args.targetDelimiter) {
headers["Target-Delimiter"] = args.targetDelimiter;
}
if (args.trimTargetWhitespace !== undefined) {
headers["Trim-Target-Whitespace"] = String(args.trimTargetWhitespace);
}
if (args.contentType) {
headers["Content-Type"] = args.contentType;
}

return headers;
}
Loading