feat(read_note): return line numbers by default

Change withLineNumbers default from false to true so AI assistants
can reference specific line numbers when discussing notes.

- Apply line numbers to both simple and parseFrontmatter paths
- Add totalLines to ParsedNote type
- Update schema description to document new default
- Update tests to expect line-numbered content by default

BREAKING CHANGE: read_note now returns line-numbered content by default.
Pass withLineNumbers: false to get raw content.
This commit is contained in:
2026-01-31 22:07:58 -05:00
parent edb29a9376
commit b1701865ab
7 changed files with 85 additions and 19 deletions

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

@@ -50,7 +50,7 @@ export class ToolRegistry {
}, },
withLineNumbers: { withLineNumbers: {
type: "boolean", 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" description: "If true (default), prefix each line with its line number (e.g., '1→content'). This helps AI assistants reference specific line numbers when discussing notes. Returns totalLines count and versionId for use with ifMatch parameter. Set to false to get raw content without line prefixes. Default: true"
} }
}, },
required: ["path"] required: ["path"]

View File

@@ -45,7 +45,7 @@ export class NoteTools {
/* istanbul ignore next */ /* istanbul ignore next */
const parseFrontmatter = options?.parseFrontmatter ?? false; const parseFrontmatter = options?.parseFrontmatter ?? false;
/* istanbul ignore next */ /* istanbul ignore next */
const withLineNumbers = options?.withLineNumbers ?? false; const withLineNumbers = options?.withLineNumbers ?? true;
// Validate path // Validate path
if (!path || path.trim() === '') { if (!path || path.trim() === '') {
@@ -125,13 +125,38 @@ export class NoteTools {
// Parse frontmatter if requested // Parse frontmatter if requested
const extracted = FrontmatterUtils.extractFrontmatter(content); const extracted = FrontmatterUtils.extractFrontmatter(content);
// Apply line numbers if requested
let resultContent = withContent ? content : '';
let resultContentWithoutFrontmatter = extracted.contentWithoutFrontmatter;
let totalLines: number | undefined;
if (withLineNumbers && withContent) {
const lines = content.split('\n');
resultContent = lines.map((line, idx) => `${idx + 1}${line}`).join('\n');
totalLines = lines.length;
if (extracted.hasFrontmatter && extracted.contentWithoutFrontmatter) {
const contentLines = extracted.contentWithoutFrontmatter.split('\n');
// Calculate the offset: frontmatter lines + 1 for the empty line after ---
const frontmatterLineCount = extracted.frontmatter ? extracted.frontmatter.split('\n').length + 2 : 0;
resultContentWithoutFrontmatter = contentLines
.map((line, idx) => `${frontmatterLineCount + idx + 1}${line}`)
.join('\n');
}
}
const result: ParsedNote = { const result: ParsedNote = {
path: file.path, path: file.path,
hasFrontmatter: extracted.hasFrontmatter, hasFrontmatter: extracted.hasFrontmatter,
/* istanbul ignore next - Conditional content inclusion tested via integration tests */ /* istanbul ignore next - Conditional content inclusion tested via integration tests */
content: withContent ? content : '' content: resultContent
}; };
// Add totalLines when line numbers are enabled
if (totalLines !== undefined) {
result.totalLines = totalLines;
}
// Include frontmatter if requested // Include frontmatter if requested
/* istanbul ignore next - Response building branches tested via integration tests */ /* istanbul ignore next - Response building branches tested via integration tests */
if (withFrontmatter && extracted.hasFrontmatter) { if (withFrontmatter && extracted.hasFrontmatter) {
@@ -142,7 +167,7 @@ export class NoteTools {
// Include content without frontmatter if parsing // Include content without frontmatter if parsing
/* istanbul ignore next */ /* istanbul ignore next */
if (withContent && extracted.hasFrontmatter) { if (withContent && extracted.hasFrontmatter) {
result.contentWithoutFrontmatter = extracted.contentWithoutFrontmatter; result.contentWithoutFrontmatter = resultContentWithoutFrontmatter;
} }
// Add word count when content is included // Add word count when content is included

View File

@@ -218,6 +218,7 @@ export interface ParsedNote {
content: string; content: string;
contentWithoutFrontmatter?: string; contentWithoutFrontmatter?: string;
wordCount?: number; wordCount?: number;
totalLines?: number;
} }
/** /**

View File

@@ -52,7 +52,7 @@ describe('NoteTools', () => {
}); });
describe('readNote', () => { describe('readNote', () => {
it('should read note content successfully', async () => { it('should read note content successfully with line numbers by default', async () => {
const mockFile = createMockTFile('test.md'); const mockFile = createMockTFile('test.md');
const content = '# Test Note\n\nThis is test content.'; const content = '# Test Note\n\nThis is test content.';
@@ -62,9 +62,10 @@ describe('NoteTools', () => {
const result = await noteTools.readNote('test.md'); const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
// Now returns JSON with content and wordCount // Now returns JSON with content (line-numbered by default) and wordCount
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content); expect(parsed.content).toBe('1→# Test Note\n2→\n3→This is test content.');
expect(parsed.totalLines).toBe(3);
expect(parsed.wordCount).toBe(7); // Test Note This is test content expect(parsed.wordCount).toBe(7); // Test Note This is test content
expect(mockVault.read).toHaveBeenCalledWith(mockFile); expect(mockVault.read).toHaveBeenCalledWith(mockFile);
}); });
@@ -124,7 +125,7 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content); mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withContent: true }); const result = await noteTools.readNote('test.md', { withContent: true, withLineNumbers: false });
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
@@ -188,23 +189,23 @@ describe('NoteTools', () => {
expect(parsed.wordCount).toBe(0); expect(parsed.wordCount).toBe(0);
}); });
it('should return JSON format even with default options', async () => { it('should return JSON format with raw content when withLineNumbers is false', async () => {
const mockFile = createMockTFile('test.md'); const mockFile = createMockTFile('test.md');
const content = '# Test Note\n\nContent here.'; const content = '# Test Note\n\nContent here.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content); mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md'); const result = await noteTools.readNote('test.md', { withLineNumbers: false });
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
// Now returns JSON even with default options // Returns JSON with raw content when line numbers disabled
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content); expect(parsed.content).toBe(content);
expect(parsed.wordCount).toBe(5); // Test Note Content here expect(parsed.wordCount).toBe(5); // Test Note Content here
}); });
it('should return numbered lines when withLineNumbers is true', async () => { it('should return numbered lines by default', async () => {
const mockFile = createMockTFile('test.md', { const mockFile = createMockTFile('test.md', {
ctime: 1000, ctime: 1000,
mtime: 2000, mtime: 2000,
@@ -215,7 +216,7 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content); mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withLineNumbers: true }); const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
@@ -225,7 +226,7 @@ describe('NoteTools', () => {
expect(parsed.wordCount).toBe(6); // # Title Paragraph text More text expect(parsed.wordCount).toBe(6); // # Title Paragraph text More text
}); });
it('should return versionId even without withLineNumbers', async () => { it('should return raw content when withLineNumbers is false', async () => {
const mockFile = createMockTFile('test.md', { const mockFile = createMockTFile('test.md', {
ctime: 1000, ctime: 1000,
mtime: 2000, mtime: 2000,
@@ -236,13 +237,52 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content); mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md'); const result = await noteTools.readNote('test.md', { withLineNumbers: false });
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('# Test'); expect(parsed.content).toBe('# Test');
expect(parsed.totalLines).toBeUndefined();
expect(parsed.versionId).toBe('AXrGSV5GxqntccmzWCNwe7'); // SHA-256 hash of "2000-100" expect(parsed.versionId).toBe('AXrGSV5GxqntccmzWCNwe7'); // SHA-256 hash of "2000-100"
}); });
it('should return numbered lines in parseFrontmatter path by default', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '---\ntitle: Test\n---\n\nContent here';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { parseFrontmatter: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('1→---\n2→title: Test\n3→---\n4→\n5→Content here');
expect(parsed.totalLines).toBe(5);
});
it('should return raw content in parseFrontmatter path when withLineNumbers is false', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 100
});
const content = '---\ntitle: Test\n---\n\nContent here';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { parseFrontmatter: true, withLineNumbers: false });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content);
expect(parsed.totalLines).toBeUndefined();
});
}); });
describe('createNote', () => { describe('createNote', () => {