10 Commits
1.1.4 ... 1.2.0

Author SHA1 Message Date
efbe6fe77f chore: bump version to 1.2.0
Breaking change: update_sections now requires ifMatch parameter
New features: withLineNumbers option, force parameter
2026-01-31 20:32:57 -05:00
3593291596 fix: address ObsidianReviewBot linting issues
- Add type guard for recursive parameter in notifications.ts to ensure
  only boolean values are stringified (prevents [object Object] output)
- Remove unused error variables from catch blocks across 5 files:
  - vault-tools.ts (5 instances)
  - frontmatter-utils.ts (3 instances)
  - search-utils.ts (2 instances)
  - waypoint-utils.ts (1 instance)
2026-01-31 20:32:24 -05:00
8c5ad5c401 docs(update_sections): update schema for required ifMatch and force opt-out 2026-01-31 17:16:55 -05:00
59433bc896 feat(update_sections): require ifMatch with force opt-out
Add `force` parameter to updateSections method that allows bypassing
the ifMatch version check. When neither ifMatch nor force is provided,
returns an error with guidance on how to properly use version control.

This implements the core safety feature: by default, update_sections
requires a versionId to prevent accidental overwrites. Callers must
either:
1. Pass a valid ifMatch parameter from read_note's versionId
2. Explicitly set force:true to bypass the check (not recommended)

Updated 8 existing tests to use force:true since they test behavior
other than the version checking feature.
2026-01-31 17:13:53 -05:00
abd712f694 test: add failing tests for updateSections force parameter
Add three tests for the upcoming force parameter feature:
1. Test that ifMatch is required when force is not set
2. Test that force=true bypasses ifMatch requirement
3. Test that valid ifMatch works without force

These tests are expected to fail until the force parameter
is implemented in updateSections.
2026-01-31 17:08:29 -05:00
a41ec656a0 docs(read_note): add withLineNumbers to tool schema
Add withLineNumbers parameter to read_note schema and call site:
- Schema property with description explaining line number format
- Updated tool description to mention versionId and line numbers
- Added withLineNumbers to args type and passed to readNote
2026-01-31 17:03:22 -05:00
a2e77586f3 feat(read_note): add withLineNumbers option and always return versionId
- Add withLineNumbers option to readNote that prefixes each line with
  its 1-indexed line number using → separator (e.g., "1→# Title")
- Include totalLines count in response when withLineNumbers is enabled
- Always return versionId in readNote response (not just when
  parseFrontmatter is true), enabling concurrency control for subsequent
  update_sections calls
- Fix test expectations to use actual SHA-256 hash format from VersionUtils
2026-01-31 16:59:06 -05:00
5f5a89512d test: add failing tests for withLineNumbers and versionId
Add tests for the new withLineNumbers option in readNote:
- Test that content returns numbered lines with arrow prefix (1→)
- Test that totalLines count is included in response
- Test that versionId is always included in read_note response

These tests are expected to fail until the feature is implemented.
2026-01-31 16:55:06 -05:00
4d707e1504 docs: add detailed implementation plan for update_sections safety
Task-by-task plan with TDD approach:
- Add withLineNumbers tests and implementation
- Add force parameter tests and implementation
- Update tool schemas
2026-01-31 16:53:15 -05:00
85bb6468d6 docs: revise update_sections safety plan
- Change withLineNumbers to use numbered string format (Option B)
- Remove expectedContent validation (unnecessary with ifMatch + line numbers)
- Use force:true instead of skipVersionCheck for opt-out
- Clarify breaking change impact
2026-01-31 16:49:40 -05:00
14 changed files with 934 additions and 35 deletions

View File

@@ -6,7 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
---
## [Unreleased]
## [1.2.0] - 2026-01-31
### Added
- **Line Numbers for `read_note`**: New `withLineNumbers` option prefixes each line with its 1-indexed line number (e.g., `1→# Title`, `2→Content`)
- Returns `totalLines` count for easy reference
- Designed for use with `update_sections` to ensure accurate line-based edits
- Example: `read_note("note.md", { withLineNumbers: true })` returns numbered content
- **Force Parameter for `update_sections`**: New `force` parameter allows bypassing the version check
- Use `force: true` to skip the `ifMatch` requirement (not recommended for normal use)
- Intended for scenarios where you intentionally want to overwrite without checking
### Changed
- **`read_note` Always Returns `versionId`**: The response now always includes `versionId` for concurrency control
- Previously only returned when `parseFrontmatter: true`
- Enables safe `update_sections` workflows by providing the ETag upfront
### Breaking Changes
- **`update_sections` Now Requires `ifMatch`**: The `ifMatch` parameter is now required by default
- Prevents accidental overwrites when file content has changed since reading
- Get `versionId` from `read_note` response and pass it as `ifMatch`
- To opt-out, pass `force: true` (not recommended)
- Error message guides users on proper usage workflow
---

View File

@@ -0,0 +1,516 @@
# update_sections Safety Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add line numbers to `read_note` and require version checking in `update_sections` to prevent line-based edit errors.
**Architecture:** Three focused changes: (1) `withLineNumbers` option on `read_note` returns numbered lines using `→` prefix, (2) `force` parameter on `update_sections` makes `ifMatch` required unless explicitly bypassed, (3) always return `versionId` from `read_note`.
**Tech Stack:** TypeScript, Jest, Obsidian API
---
## Task 1: Add `withLineNumbers` Tests
**Files:**
- Modify: `tests/note-tools.test.ts` (after line ~100, in the `readNote` describe block)
**Step 1: Write the failing tests**
Add these tests in the `describe('readNote', ...)` block:
```typescript
it('should return numbered lines when withLineNumbers is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '# Title\n\nParagraph text\nMore text';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withLineNumbers: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('1→# Title\n2→\n3→Paragraph text\n4→More text');
expect(parsed.totalLines).toBe(4);
expect(parsed.versionId).toBe('2000-100');
expect(parsed.wordCount).toBe(4); // Title Paragraph text More text
});
it('should return versionId even without withLineNumbers', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '# Test';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('# Test');
expect(parsed.versionId).toBe('2000-100');
});
```
**Step 2: Run tests to verify they fail**
Run: `npm test -- --testPathPattern=note-tools.test.ts --testNamePattern="withLineNumbers|versionId even without"`
Expected: FAIL - `versionId` undefined, content not numbered
**Step 3: Commit failing tests**
```bash
git add tests/note-tools.test.ts
git commit -m "test: add failing tests for withLineNumbers and versionId"
```
---
## Task 2: Implement `withLineNumbers` in `readNote`
**Files:**
- Modify: `src/tools/note-tools.ts:31-38` (options type)
- Modify: `src/tools/note-tools.ts:83-99` (implementation)
**Step 1: Update the options type (line 33-37)**
Change from:
```typescript
options?: {
withFrontmatter?: boolean;
withContent?: boolean;
parseFrontmatter?: boolean;
}
```
To:
```typescript
options?: {
withFrontmatter?: boolean;
withContent?: boolean;
parseFrontmatter?: boolean;
withLineNumbers?: boolean;
}
```
**Step 2: Add withLineNumbers handling (after line 45, before path validation)**
Add this line after the existing option destructuring:
```typescript
/* istanbul ignore next */
const withLineNumbers = options?.withLineNumbers ?? false;
```
**Step 3: Add numbered content logic (replace lines 83-99)**
Replace the existing `if (!parseFrontmatter)` block with:
```typescript
// If no special options, return simple content
if (!parseFrontmatter) {
// Compute word count when returning content
if (withContent) {
const wordCount = ContentUtils.countWords(content);
const versionId = VersionUtils.generateVersionId(file);
// If withLineNumbers, prefix each line with line number
if (withLineNumbers) {
const lines = content.split('\n');
const numberedContent = lines
.map((line, idx) => `${idx + 1}${line}`)
.join('\n');
const result = {
content: numberedContent,
totalLines: lines.length,
versionId,
wordCount
};
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
}
const result = {
content,
wordCount,
versionId
};
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
}
return {
content: [{ type: "text", text: content }]
};
}
```
**Step 4: Run tests to verify they pass**
Run: `npm test -- --testPathPattern=note-tools.test.ts --testNamePattern="withLineNumbers|versionId even without"`
Expected: PASS
**Step 5: Run full test suite**
Run: `npm test`
Expected: All 760+ tests pass
**Step 6: Commit implementation**
```bash
git add src/tools/note-tools.ts
git commit -m "feat(read_note): add withLineNumbers option and always return versionId"
```
---
## Task 3: Update `read_note` Schema
**Files:**
- Modify: `src/tools/index.ts:39-50` (read_note properties)
**Step 1: Add withLineNumbers to schema**
After the `parseFrontmatter` property (around line 49), add:
```typescript
withLineNumbers: {
type: "boolean",
description: "If true, prefix each line with its line number (e.g., '1→content'). Use this when you need to make line-based edits with update_sections. Returns totalLines count and versionId for use with ifMatch parameter. Default: false"
}
```
**Step 2: Update tool description (line 31)**
Update the description to mention line numbers:
```typescript
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Returns versionId for concurrency control. When withLineNumbers is true, prefixes each line with its number (e.g., '1→content') for use with update_sections. Returns word count (excluding frontmatter and Obsidian comments) when content is included. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path.",
```
**Step 3: Verify build passes**
Run: `npm run build`
Expected: Build succeeds with no type errors
**Step 4: Commit schema update**
```bash
git add src/tools/index.ts
git commit -m "docs(read_note): add withLineNumbers to tool schema"
```
---
## Task 4: Add `force` Parameter Tests for `updateSections`
**Files:**
- Modify: `tests/note-tools.test.ts` (after line ~960, in the `updateSections` describe block)
**Step 1: Write failing tests**
Add these tests in the `describe('updateSections', ...)` block:
```typescript
it('should return error when ifMatch not provided and force not set', async () => {
const mockFile = createMockTFile('test.md');
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
const result = await noteTools.updateSections('test.md', [
{ startLine: 1, endLine: 1, content: 'New' }
]);
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe('Version check required');
expect(parsed.message).toContain('ifMatch parameter is required');
expect(mockVault.modify).not.toHaveBeenCalled();
});
it('should proceed without ifMatch when force is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = 'Line 1\nLine 2';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections(
'test.md',
[{ startLine: 1, endLine: 1, content: 'New Line 1' }],
undefined, // no ifMatch
true, // validateLinks
true // force
);
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should proceed with valid ifMatch without force', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = 'Line 1\nLine 2';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections(
'test.md',
[{ startLine: 1, endLine: 1, content: 'New Line 1' }],
'2000-100' // valid ifMatch
);
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalled();
});
```
**Step 2: Run tests to verify they fail**
Run: `npm test -- --testPathPattern=note-tools.test.ts --testNamePattern="ifMatch not provided|force is true|valid ifMatch without force"`
Expected: FAIL - first test expects error but gets success, second test has wrong arity
**Step 3: Commit failing tests**
```bash
git add tests/note-tools.test.ts
git commit -m "test: add failing tests for updateSections force parameter"
```
---
## Task 5: Implement `force` Parameter in `updateSections`
**Files:**
- Modify: `src/tools/note-tools.ts:880-907` (method signature and validation)
**Step 1: Update method signature (lines 880-885)**
Change from:
```typescript
async updateSections(
path: string,
edits: SectionEdit[],
ifMatch?: string,
validateLinks: boolean = true
): Promise<CallToolResult> {
```
To:
```typescript
async updateSections(
path: string,
edits: SectionEdit[],
ifMatch?: string,
validateLinks: boolean = true,
force: boolean = false
): Promise<CallToolResult> {
```
**Step 2: Add ifMatch requirement check (after line 907, after edits validation)**
Insert after the "No edits provided" check:
```typescript
// Require ifMatch unless force is true
if (!ifMatch && !force) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: 'Version check required',
message: 'The ifMatch parameter is required to prevent overwriting concurrent changes. First call read_note with withLineNumbers:true to get the versionId, then pass it as ifMatch. To bypass this check, set force:true (not recommended).'
}, null, 2)
}],
isError: true
};
}
```
**Step 3: Run tests to verify they pass**
Run: `npm test -- --testPathPattern=note-tools.test.ts --testNamePattern="ifMatch not provided|force is true|valid ifMatch without force"`
Expected: PASS
**Step 4: Run full test suite**
Run: `npm test`
Expected: Some tests may fail (existing tests that don't pass ifMatch)
**Step 5: Fix existing tests that now fail**
Update existing `updateSections` tests to either:
- Pass a valid `ifMatch` value, OR
- Pass `force: true`
For the "should update sections successfully" test (around line 882), update to use force:
```typescript
const result = await noteTools.updateSections('test.md', [
{ startLine: 2, endLine: 3, content: 'New Line 2\nNew Line 3' }
], undefined, true, true); // validateLinks=true, force=true
```
Apply similar fixes to other affected tests in the `updateSections` block.
**Step 6: Run full test suite again**
Run: `npm test`
Expected: All tests pass
**Step 7: Commit implementation**
```bash
git add src/tools/note-tools.ts tests/note-tools.test.ts
git commit -m "feat(update_sections): require ifMatch with force opt-out"
```
---
## Task 6: Update `update_sections` Schema and Call Site
**Files:**
- Modify: `src/tools/index.ts:184-194` (update_sections schema)
- Modify: `src/tools/index.ts:529-537` (call site)
**Step 1: Update ifMatch description (line 184-187)**
Change from:
```typescript
ifMatch: {
type: "string",
description: "Optional ETag/versionId for concurrency control. If provided, update only proceeds if file hasn't been modified. Get versionId from read operations. Prevents conflicting edits in concurrent scenarios."
},
```
To:
```typescript
ifMatch: {
type: "string",
description: "Required: ETag/versionId for concurrency control. Get this from read_note response (always included). Update only proceeds if file hasn't changed since read. Omit only with force:true."
},
```
**Step 2: Add force property (after validateLinks, around line 191)**
Add:
```typescript
force: {
type: "boolean",
description: "If true, skip version check and apply edits without ifMatch. Use only when you intentionally want to overwrite without checking for concurrent changes. Not recommended. Default: false"
}
```
**Step 3: Update call site (lines 529-537)**
Change from:
```typescript
case "update_sections": {
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean };
result = await this.noteTools.updateSections(
a.path,
a.edits,
a.ifMatch,
a.validateLinks ?? true
);
break;
}
```
To:
```typescript
case "update_sections": {
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean; force?: boolean };
result = await this.noteTools.updateSections(
a.path,
a.edits,
a.ifMatch,
a.validateLinks ?? true,
a.force ?? false
);
break;
}
```
**Step 4: Verify build passes**
Run: `npm run build`
Expected: Build succeeds
**Step 5: Run full test suite**
Run: `npm test`
Expected: All tests pass
**Step 6: Commit schema and call site updates**
```bash
git add src/tools/index.ts
git commit -m "docs(update_sections): update schema for required ifMatch and force opt-out"
```
---
## Task 7: Final Verification
**Step 1: Run full test suite with coverage**
Run: `npm run test:coverage`
Expected: All tests pass, coverage maintained
**Step 2: Build for production**
Run: `npm run build`
Expected: Build succeeds with no errors
**Step 3: Manual verification checklist**
Verify these scenarios work correctly:
1. `read_note` without options → returns `content`, `wordCount`, `versionId`
2. `read_note` with `withLineNumbers: true` → returns numbered content, `totalLines`, `versionId`
3. `update_sections` without `ifMatch` → returns "Version check required" error
4. `update_sections` with `force: true` → proceeds without version check
5. `update_sections` with valid `ifMatch` → proceeds normally
6. `update_sections` with stale `ifMatch` → returns version mismatch error
**Step 4: Create summary commit**
```bash
git log --oneline -6
```
Verify commit history looks clean and logical.
---
## Summary of Changes
| File | Change |
|------|--------|
| `src/tools/note-tools.ts` | Add `withLineNumbers` option, add `force` parameter, always return `versionId` |
| `src/tools/index.ts` | Update schemas for both tools, update call site |
| `tests/note-tools.test.ts` | Add tests for new features, fix existing tests |
**Breaking Change:** `update_sections` now requires `ifMatch` parameter unless `force: true` is passed.

View File

@@ -0,0 +1,201 @@
# Plan: Fix update_sections Line Number Issue via MCP Server Changes
## Problem Analysis
When using `update_sections`, line number errors occur because:
1. **`read_note` doesn't return line numbers** - Returns content as a string, no line mapping
2. **`ifMatch` is optional** - No enforcement of version checking before edits
3. **`versionId` inconsistent** - Only returned when `parseFrontmatter: true`
### Root Cause
The `Read` tool shows line numbers (e.g., `1→content`) but `read_note` does not. When using `read_note` and later calling `update_sections`, line numbers are guessed based on stale content.
---
## Proposed Changes
### Change 1: Add `withLineNumbers` Option to `read_note`
**File:** `src/tools/note-tools.ts`
**Current behavior:** Returns `{ content: "...", wordCount: N }`
**New behavior with `withLineNumbers: true`:** Returns numbered lines using `→` prefix:
```json
{
"content": "1→---\n2→title: Example\n3→---\n4→\n5→## Overview\n6→Some text here",
"totalLines": 6,
"versionId": "abc123",
"wordCount": 42
}
```
**Implementation (add after existing options handling):**
```typescript
// If withLineNumbers requested, prefix each line with line number
if (options?.withLineNumbers && withContent) {
const lines = content.split('\n');
const numberedContent = lines
.map((line, idx) => `${idx + 1}${line}`)
.join('\n');
const result = {
content: numberedContent,
totalLines: lines.length,
versionId: VersionUtils.generateVersionId(file),
wordCount: ContentUtils.countWords(content)
};
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
}
```
**Schema update (in `index.ts`):**
```typescript
withLineNumbers: {
type: "boolean",
description: "If true, prefix each line with its line number (e.g., '1→content'). Use this when you need to make line-based edits with update_sections. Returns totalLines count and versionId for use with ifMatch parameter."
}
```
---
### Change 2: Require `ifMatch` for `update_sections`
**File:** `src/tools/note-tools.ts`
**Current behavior:** `ifMatch` is optional - edits proceed without version check.
**New behavior:** `ifMatch` is required unless `force: true` is passed.
**Method signature change:**
```typescript
async updateSections(
path: string,
edits: SectionEdit[],
ifMatch?: string, // Still optional in signature
validateLinks: boolean = true,
force?: boolean // NEW: explicit opt-out
): Promise<CallToolResult>
```
**Validation logic (early in method, after path/edits validation):**
```typescript
// Require ifMatch unless force is true
if (!ifMatch && !force) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: 'Version check required',
message: 'The ifMatch parameter is required to prevent overwriting concurrent changes. First call read_note with withLineNumbers:true to get the versionId, then pass it as ifMatch. To bypass this check, set force:true (not recommended).'
}, null, 2)
}],
isError: true
};
}
```
**Schema update (in `index.ts`):**
```typescript
ifMatch: {
type: "string",
description: "Required: ETag/versionId for concurrency control. Get this from read_note response. Update only proceeds if file hasn't changed since read. Omit only with force:true."
},
force: {
type: "boolean",
description: "If true, skip version check and apply edits without ifMatch. Use only when you intentionally want to overwrite without checking for concurrent changes. Default: false"
}
```
**Note:** Keep `required: ["path", "edits"]` in schema - we enforce `ifMatch` in code to provide a helpful error message.
---
### Change 3: Always Return `versionId` from `read_note`
**File:** `src/tools/note-tools.ts`
**Current behavior:** Only returns `versionId` when `parseFrontmatter: true`.
**New behavior:** Always include `versionId` in the response.
**Current code (around line 88):**
```typescript
const result = {
content,
wordCount
};
```
**Updated code:**
```typescript
const result = {
content,
wordCount,
versionId: VersionUtils.generateVersionId(file)
};
```
---
## Files to Modify
| File | Changes |
|------|---------|
| `src/tools/note-tools.ts` | Add `withLineNumbers`, add `force` parameter, always return `versionId` |
| `src/tools/index.ts` | Update schemas for `read_note` and `update_sections` |
---
## Implementation Steps
1. **Modify `readNote`** in `note-tools.ts`:
- Add `withLineNumbers` option handling
- Always return `versionId` when returning content
2. **Modify `updateSections`** in `note-tools.ts`:
- Add `force` parameter
- Add validation requiring `ifMatch` unless `force: true`
3. **Update tool schemas** in `index.ts`:
- Add `withLineNumbers` property to `read_note` schema
- Add `force` property to `update_sections` schema
- Update `ifMatch` description to indicate it's required
4. **Update call site** in `index.ts`:
- Pass `force` parameter through to `updateSections`
5. **Write tests** for new behaviors
6. **Build and test** in Obsidian
---
## Verification
1. **`read_note` with `withLineNumbers: true`** → returns numbered content, `totalLines`, `versionId`
2. **`read_note` without options** → returns content with `versionId` (new behavior)
3. **`update_sections` without `ifMatch`** → returns error with helpful message
4. **`update_sections` with `force: true`** → proceeds without version check
5. **`update_sections` with valid `ifMatch`** → proceeds normally
6. **`update_sections` with stale `ifMatch`** → returns version mismatch error
---
## Breaking Change
**Impact:** Callers that omit `ifMatch` from `update_sections` will receive an error unless they explicitly pass `force: true`.
**Mitigation:** The error message explains how to fix the issue and mentions the `force` option for those who intentionally want to skip version checking.

View File

@@ -1,7 +1,7 @@
{
"id": "mcp-server",
"name": "MCP Server",
"version": "1.1.4",
"version": "1.2.0",
"minAppVersion": "0.15.0",
"description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.",
"author": "William Ballou",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mcp-server",
"version": "1.0.1",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-server",
"version": "1.0.1",
"version": "1.1.4",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "mcp-server",
"version": "1.1.4",
"version": "1.2.0",
"description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP",
"main": "main.js",
"scripts": {

View File

@@ -28,7 +28,7 @@ export class ToolRegistry {
return [
{
name: "read_note",
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Returns word count (excluding frontmatter and Obsidian comments) when content is included in the response. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content with word count. Set parseFrontmatter to true to get structured data with separated frontmatter, content, and word count.",
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Returns versionId for concurrency control. When withLineNumbers is true, prefixes each line with its number (e.g., '1→content') for use with update_sections. Returns word count (excluding frontmatter and Obsidian comments) when content is included. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path.",
inputSchema: {
type: "object",
properties: {
@@ -47,6 +47,10 @@ export class ToolRegistry {
parseFrontmatter: {
type: "boolean",
description: "If true, parse and separate frontmatter from content, returning structured JSON. If false (default), return raw file content as plain text. Use true when you need to work with frontmatter separately."
},
withLineNumbers: {
type: "boolean",
description: "If true, prefix each line with its line number (e.g., '1→content'). Use this when you need to make line-based edits with update_sections. Returns totalLines count and versionId for use with ifMatch parameter. Default: false"
}
},
required: ["path"]
@@ -183,11 +187,15 @@ export class ToolRegistry {
},
ifMatch: {
type: "string",
description: "Optional ETag/versionId for concurrency control. If provided, update only proceeds if file hasn't been modified. Get versionId from read operations. Prevents conflicting edits in concurrent scenarios."
description: "Required: ETag/versionId for concurrency control. Get this from read_note response (always included). Update only proceeds if file hasn't changed since read. Omit only with force:true."
},
validateLinks: {
type: "boolean",
description: "If true (default), automatically validate all wikilinks and embeds in the entire note after applying section edits, returning detailed broken link information. If false, skip link validation for better performance. Link validation checks [[wikilinks]], [[note#heading]] links, and ![[embeds]]. Default: true"
},
force: {
type: "boolean",
description: "If true, skip version check and apply edits without ifMatch. Use only when you intentionally want to overwrite without checking for concurrent changes. Not recommended. Default: false"
}
},
required: ["path", "edits"]
@@ -488,11 +496,12 @@ export class ToolRegistry {
switch (name) {
case "read_note": {
const a = args as { path: string; withFrontmatter?: boolean; withContent?: boolean; parseFrontmatter?: boolean };
const a = args as { path: string; withFrontmatter?: boolean; withContent?: boolean; parseFrontmatter?: boolean; withLineNumbers?: boolean };
result = await this.noteTools.readNote(a.path, {
withFrontmatter: a.withFrontmatter,
withContent: a.withContent,
parseFrontmatter: a.parseFrontmatter
parseFrontmatter: a.parseFrontmatter,
withLineNumbers: a.withLineNumbers
});
break;
}
@@ -527,12 +536,13 @@ export class ToolRegistry {
break;
}
case "update_sections": {
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean };
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean; force?: boolean };
result = await this.noteTools.updateSections(
a.path,
a.edits,
a.ifMatch,
a.validateLinks ?? true
a.validateLinks ?? true,
a.force ?? false
);
break;
}

View File

@@ -34,6 +34,7 @@ export class NoteTools {
withFrontmatter?: boolean;
withContent?: boolean;
parseFrontmatter?: boolean;
withLineNumbers?: boolean;
}
): Promise<CallToolResult> {
// Default options
@@ -43,6 +44,8 @@ export class NoteTools {
const withContent = options?.withContent ?? true;
/* istanbul ignore next */
const parseFrontmatter = options?.parseFrontmatter ?? false;
/* istanbul ignore next */
const withLineNumbers = options?.withLineNumbers ?? false;
// Validate path
if (!path || path.trim() === '') {
@@ -85,9 +88,30 @@ export class NoteTools {
// Compute word count when returning content
if (withContent) {
const wordCount = ContentUtils.countWords(content);
const versionId = VersionUtils.generateVersionId(file);
// If withLineNumbers, prefix each line with line number
if (withLineNumbers) {
const lines = content.split('\n');
const numberedContent = lines
.map((line, idx) => `${idx + 1}${line}`)
.join('\n');
const result = {
content: numberedContent,
totalLines: lines.length,
versionId,
wordCount
};
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
}
const result = {
content,
wordCount
wordCount,
versionId
};
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -881,7 +905,8 @@ export class NoteTools {
path: string,
edits: SectionEdit[],
ifMatch?: string,
validateLinks: boolean = true
validateLinks: boolean = true,
force: boolean = false
): Promise<CallToolResult> {
// Validate path
if (!path || path.trim() === '') {
@@ -906,6 +931,20 @@ export class NoteTools {
};
}
// Require ifMatch unless force is true
if (!ifMatch && !force) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: 'Version check required',
message: 'The ifMatch parameter is required to prevent overwriting concurrent changes. First call read_note with withLineNumbers:true to get the versionId, then pass it as ifMatch. To bypass this check, set force:true (not recommended).'
}, null, 2)
}],
isError: true
};
}
// Resolve file
const file = PathUtils.resolveFile(this.app, path);

View File

@@ -286,7 +286,7 @@ export class VaultTools {
try {
const content = await this.vault.read(item);
fileMetadata.wordCount = ContentUtils.countWords(content);
} catch (error) {
} catch {
// Skip word count if file can't be read (binary file, etc.)
// wordCount field simply omitted for this file
}
@@ -356,7 +356,7 @@ export class VaultTools {
frontmatterSummary: summary
};
}
} catch (error) {
} catch {
// If frontmatter extraction fails, just return base metadata
}
@@ -390,7 +390,7 @@ export class VaultTools {
if (folderWithStat.stat && typeof folderWithStat.stat.mtime === 'number') {
modified = folderWithStat.stat.mtime;
}
} catch (error) {
} catch {
// Silently fail - modified will remain 0
}
@@ -442,7 +442,7 @@ export class VaultTools {
try {
const content = await this.vault.read(item);
metadata.wordCount = ContentUtils.countWords(content);
} catch (error) {
} catch {
// Skip word count if file can't be read (binary file, etc.)
}
}
@@ -712,7 +712,7 @@ export class VaultTools {
}
}
}
} catch (error) {
} catch {
// Skip files that can't be read
}
}

View File

@@ -162,8 +162,8 @@ export class NotificationManager {
if (args.folder && typeof args.folder === 'string') {
keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`);
}
if (args.recursive !== undefined) {
keyParams.push(`recursive: ${String(args.recursive)}`);
if (typeof args.recursive === 'boolean') {
keyParams.push(`recursive: ${args.recursive}`);
}
// If no key params, show first 50 chars of JSON

View File

@@ -73,7 +73,7 @@ export class FrontmatterUtils {
let parsedFrontmatter: Record<string, YAMLValue> | null = null;
try {
parsedFrontmatter = parseYaml(frontmatter) || {};
} catch (error) {
} catch {
// If parsing fails, return null for parsed frontmatter
parsedFrontmatter = null;
}
@@ -326,7 +326,7 @@ export class FrontmatterUtils {
compressed: true // Indicate data is compressed
}
};
} catch (decompressError) {
} catch {
// Decompression failed
return {
isExcalidraw: true,
@@ -355,9 +355,9 @@ export class FrontmatterUtils {
version: jsonData.version || 2
}
};
} catch (error) {
} catch {
// If parsing fails, return with default values
const isExcalidraw = content.includes('excalidraw-plugin') ||
const isExcalidraw = content.includes('excalidraw-plugin') ||
content.includes('"type":"excalidraw"');

View File

@@ -114,7 +114,7 @@ export class SearchUtils {
filesWithMatches.add(file.path);
matches.push(...filenameMatches);
}
} catch (error) {
} catch {
// Skip files that can't be read
}
}
@@ -323,7 +323,7 @@ export class SearchUtils {
waypointContent.push(line);
}
}
} catch (error) {
} catch {
// Skip files that can't be searched
}
}

View File

@@ -100,7 +100,7 @@ export class WaypointUtils {
try {
const content = await vault.read(file);
hasWaypoint = this.hasWaypointMarker(content);
} catch (error) {
} catch {
// If we can't read the file, we can't check for waypoints
}

View File

@@ -203,6 +203,46 @@ describe('NoteTools', () => {
expect(parsed.content).toBe(content);
expect(parsed.wordCount).toBe(5); // Test Note Content here
});
it('should return numbered lines when withLineNumbers is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '# Title\n\nParagraph text\nMore text';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withLineNumbers: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('1→# Title\n2→\n3→Paragraph text\n4→More text');
expect(parsed.totalLines).toBe(4);
expect(parsed.versionId).toBe('AXrGSV5GxqntccmzWCNwe7'); // SHA-256 hash of "2000-100"
expect(parsed.wordCount).toBe(6); // # Title Paragraph text More text
});
it('should return versionId even without withLineNumbers', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '# Test';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('# Test');
expect(parsed.versionId).toBe('AXrGSV5GxqntccmzWCNwe7'); // SHA-256 hash of "2000-100"
});
});
describe('createNote', () => {
@@ -893,7 +933,7 @@ Some text
const result = await noteTools.updateSections('test.md', [
{ startLine: 2, endLine: 3, content: 'New Line 2\nNew Line 3' }
]);
], undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalled();
@@ -918,7 +958,7 @@ Some text
const result = await noteTools.updateSections('test.md', [
{ startLine: 1, endLine: 10, content: 'New' }
]);
], undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid line range');
@@ -953,7 +993,7 @@ Some text
const result = await noteTools.updateSections('test.md', [
{ startLine: 1, endLine: 1, content: 'New' }
]);
], undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Update error');
@@ -965,7 +1005,7 @@ Some text
const result = await noteTools.updateSections('nonexistent.md', [
{ startLine: 1, endLine: 1, content: 'New' }
]);
], undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
@@ -977,11 +1017,82 @@ Some text
const result = await noteTools.updateSections('folder', [
{ startLine: 1, endLine: 1, content: 'New' }
]);
], undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not a file');
});
it('should return error when ifMatch not provided and force not set', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = 'Line 1\nLine 2';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('test.md', [
{ startLine: 1, endLine: 1, content: 'New' }
]);
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe('Version check required');
expect(parsed.message).toContain('ifMatch parameter is required');
expect(mockVault.modify).not.toHaveBeenCalled();
});
it('should proceed without ifMatch when force is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = 'Line 1\nLine 2';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections(
'test.md',
[{ startLine: 1, endLine: 1, content: 'New Line 1' }],
undefined, // no ifMatch
true, // validateLinks
true // force
);
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should proceed with valid ifMatch without force', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = 'Line 1\nLine 2';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections(
'test.md',
[{ startLine: 1, endLine: 1, content: 'New Line 1' }],
'AXrGSV5GxqntccmzWCNwe7' // valid ifMatch (SHA-256 hash of "2000-100")
);
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalled();
});
});
describe('path validation', () => {
@@ -1254,7 +1365,7 @@ Some text
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits);
const result = await noteTools.updateSections('sections-test.md', edits, undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
@@ -1270,7 +1381,7 @@ Some text
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits);
const result = await noteTools.updateSections('sections-test.md', edits, undefined, true, true); // validateLinks=true, force=true
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
@@ -1287,7 +1398,7 @@ Some text
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits, undefined, false);
const result = await noteTools.updateSections('sections-test.md', edits, undefined, false, true); // validateLinks=false, force=true
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);