5 Commits

Author SHA1 Message Date
e9584929a4 docs: add CHANGELOG entry for version 1.3.0 2026-02-06 20:51:12 -05:00
e91b9f6025 feat: add configurable IP allow-list for remote MCP access
Enable non-localhost connections by specifying allowed IPs/CIDRs in
settings (e.g., 100.64.0.0/10 for Tailscale). Server auto-binds to
0.0.0.0 when remote IPs are configured, with three-layer validation
(source IP, CORS, host header) plus mandatory Bearer token auth.
2026-02-06 20:35:49 -05:00
8e97c2fef0 docs: add design for remote IP access feature
Allows configuring non-localhost IPs/CIDRs for remote MCP access,
enabling use cases like Obsidian in Docker over Tailscale.
2026-02-06 20:35:49 -05:00
b1701865ab 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.
2026-01-31 22:07:58 -05:00
edb29a9376 docs: add design for read_note line numbers by default
Changes withLineNumbers default from false to true so AI assistants
can reference specific line numbers when discussing notes.
2026-01-31 22:05:00 -05:00
16 changed files with 572 additions and 30 deletions

View File

@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
---
## [1.3.0] - 2026-02-06
### Added
- **Remote IP access**: New `allowedIPs` setting accepts comma-separated IPs and CIDR ranges (e.g., `100.64.0.0/10` for Tailscale) to allow non-localhost connections
- Server automatically binds to `0.0.0.0` when remote IPs are configured, otherwise stays on `127.0.0.1`
- Three-layer network validation: source IP check, CORS origin check, and host header validation
- Bearer token authentication remains mandatory for all connections
- Localhost is always implicitly allowed — cannot lock out local access
- IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) handled transparently
- New `network-utils` module with CIDR parsing and IP matching (no external dependencies)
- Security warning displayed in settings when remote access is enabled
---
## [1.2.0] - 2026-01-31
### Added

View File

@@ -0,0 +1,87 @@
# Design: Line Numbers by Default in `read_note`
**Date:** 2026-01-31
**Version:** 1.2.1
## Summary
Change `read_note` to return line-numbered content by default (e.g., `1→First line`) to help AI assistants reference specific locations when discussing notes. Add `withLineNumbers: false` to get raw content.
## Motivation
AI assistants can give much more precise references like "line 42 has a typo" rather than vague "in the section about X". Line numbers make file discussions unambiguous.
## Changes
### 1. Default Behavior Change
```typescript
// Before
const withLineNumbers = options?.withLineNumbers ?? false;
// After
const withLineNumbers = options?.withLineNumbers ?? true;
```
### 2. Apply to Parsed Path
Currently the `parseFrontmatter: true` path ignores line numbers. Add line numbering to the `content` field (and `contentWithoutFrontmatter`) when enabled.
### 3. Schema Update
Update the tool description to say "Default: true" and clarify opt-out with `withLineNumbers: false`.
## Files to Modify
### `src/tools/note-tools.ts`
- Line 48: Change default from `false` to `true`
- Lines 125-155: Add line numbering logic to the `parseFrontmatter` path for `content` and `contentWithoutFrontmatter` fields
- Add `totalLines` to parsed response when line numbers enabled
### `src/tools/index.ts`
- Lines 51-54: Update schema description to reflect new default
### `tests/note-tools.test.ts`
- Update existing tests that expect raw content to either:
- Explicitly pass `withLineNumbers: false`, or
- Update assertions to expect numbered content
## Response Format Examples
### Before (current default)
```json
{
"content": "# Title\nSome content",
"wordCount": 3,
"versionId": "abc123"
}
```
### After (new default)
```json
{
"content": "1→# Title\n2→Some content",
"totalLines": 2,
"wordCount": 3,
"versionId": "abc123"
}
```
### Opt-out (`withLineNumbers: false`)
```json
{
"content": "# Title\nSome content",
"wordCount": 3,
"versionId": "abc123"
}
```
## Breaking Change
This changes the default response format. MCP clients that parse `content` expecting raw text will need to either:
- Update their parsing to handle line-numbered format
- Explicitly pass `withLineNumbers: false`
## Version
Bump to 1.2.1.

View File

@@ -0,0 +1,87 @@
# Remote IP access for MCP server
Date: 2026-02-06
## Problem
The MCP server is hardcoded to localhost-only access (bind address, CORS, host header validation). This prevents use cases where Obsidian runs in a Docker container or remote machine and MCP clients connect over a Tailscale VPN or other private network.
## Design
### New setting: `allowedIPs`
A comma-separated string of IPs and CIDR ranges. Default: `""` (empty).
- When empty: server behaves exactly as today (binds to `127.0.0.1`, localhost-only)
- When populated: server binds to `0.0.0.0` and allows connections from listed IPs/CIDRs
- Localhost (`127.0.0.1`) is always implicitly allowed regardless of the setting
- Examples: `100.64.0.0/10`, `192.168.1.50`, `10.0.0.0/8`
### Middleware changes (src/server/middleware.ts)
Three layers are updated:
1. **Source IP validation (new)** - Checks `req.socket.remoteAddress` against the allow-list before auth. Rejects connections from unlisted IPs with 403. Localhost always passes.
2. **CORS policy update** - Extends the origin check to allow origins whose hostname matches the allow-list, in addition to the existing localhost regex.
3. **Host header validation update** - Extends to accept Host headers matching allowed IPs, in addition to localhost.
All three use a shared `isIPAllowed()` utility.
### Server bind (src/server/mcp-server.ts)
The `start()` method computes bind address dynamically:
- `allowedIPs` non-empty (trimmed) -> bind `0.0.0.0`
- `allowedIPs` empty -> bind `127.0.0.1` (current behavior)
### Network utilities (src/utils/network-utils.ts)
New file (~40 lines) exporting:
- `parseAllowedIPs(setting: string): AllowedIPEntry[]` - Parses comma-separated string into structured list of individual IPs and CIDR ranges
- `isIPAllowed(ip: string, allowList: AllowedIPEntry[]): boolean` - Checks if an IP matches any entry. Handles IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) that Node.js uses for `req.socket.remoteAddress`
CIDR matching is standard bit arithmetic, no external dependencies needed.
### Settings UI (src/settings.ts)
New text field below the Port setting:
- Name: "Allowed IPs"
- Description: "Comma-separated IPs or CIDR ranges allowed to connect (e.g., 100.64.0.0/10, 192.168.1.50). Leave empty for localhost only. Restart required."
- Placeholder: `100.64.0.0/10, 192.168.1.0/24`
- Shows restart warning when changed while server is running
- Shows security note when non-empty: "Server is accessible from non-localhost IPs. Ensure your API key is kept secure."
Status display updates to show actual bind address (`0.0.0.0` vs `127.0.0.1`).
Generated client configs (Windsurf/Claude Code) stay as `127.0.0.1` - users adjust manually for remote access.
### Settings type (src/types/settings-types.ts)
Add `allowedIPs: string` to `MCPServerSettings` with default `""`.
## Security model
- **Auth is still mandatory.** IP allow-list is defense-in-depth, not a replacement for Bearer token authentication.
- **Localhost always allowed.** Cannot accidentally lock out local access.
- **Empty default = current behavior.** Zero-change upgrade for existing users. Feature is opt-in.
- **Three-layer validation:** Source IP check + CORS + Host header validation + Bearer auth.
## Testing
New file `tests/network-utils.test.ts`:
- Individual IP match/mismatch
- CIDR range matching (e.g., `100.64.0.0/10` matches `100.100.1.1`)
- IPv4-mapped IPv6 handling (`::ffff:192.168.1.1`)
- Edge cases: empty string, malformed entries, extra whitespace
- Localhost always allowed regardless of list contents
## Files changed
1. `src/types/settings-types.ts` - Add `allowedIPs` field
2. `src/utils/network-utils.ts` - New file: CIDR parsing + IP matching
3. `src/server/middleware.ts` - Update CORS, host validation, add source IP check
4. `src/server/mcp-server.ts` - Dynamic bind address
5. `src/settings.ts` - New text field + security note
6. `tests/network-utils.test.ts` - New test file

View File

@@ -1,7 +1,7 @@
{
"id": "mcp-server",
"name": "MCP Server",
"version": "1.2.0",
"version": "1.2.1",
"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.1.4",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-server",
"version": "1.1.4",
"version": "1.2.1",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",

View File

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

View File

@@ -101,7 +101,8 @@ export class MCPServer {
public async start(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.server = this.app.listen(this.settings.port, '127.0.0.1', () => {
const bindAddress = this.settings.allowedIPs?.trim() ? '0.0.0.0' : '127.0.0.1';
this.server = this.app.listen(this.settings.port, bindAddress, () => {
resolve();
});

View File

@@ -3,12 +3,24 @@ import express from 'express';
import cors from 'cors';
import { MCPServerSettings } from '../types/settings-types';
import { ErrorCodes, JSONRPCResponse } from '../types/mcp-types';
import { parseAllowedIPs, isIPAllowed } from '../utils/network-utils';
export function setupMiddleware(app: Express, settings: MCPServerSettings, createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse): void {
const allowList = parseAllowedIPs(settings.allowedIPs);
// Parse JSON bodies
app.use(express.json());
// CORS configuration - Always enabled with fixed localhost-only policy
// Source IP validation - reject connections from unlisted IPs before any other checks
app.use((req: Request, res: Response, next: NextFunction) => {
const remoteAddress = req.socket.remoteAddress;
if (remoteAddress && !isIPAllowed(remoteAddress, allowList)) {
return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Connection from this IP is not allowed'));
}
next();
});
// CORS configuration
const corsOptions = {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// Allow requests with no origin (like CLI clients, curl, MCP SDKs)
@@ -19,10 +31,22 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat
// Allow localhost and 127.0.0.1 on any port, both HTTP and HTTPS
const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
if (localhostRegex.test(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
return callback(null, true);
}
// Check if origin hostname is in the allow-list
if (allowList.length > 0) {
try {
const url = new URL(origin);
if (isIPAllowed(url.hostname, allowList)) {
return callback(null, true);
}
} catch {
// Invalid origin URL, fall through to reject
}
}
callback(new Error('Not allowed by CORS'));
},
credentials: true
};
@@ -44,15 +68,27 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat
next();
});
// Origin validation for security (DNS rebinding protection)
// Host header validation for security (DNS rebinding protection)
app.use((req: Request, res: Response, next: NextFunction) => {
const host = req.headers.host;
// Only allow localhost connections
if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Only localhost connections allowed'));
if (!host) {
return next();
}
next();
// Strip port from host header
const hostname = host.split(':')[0];
// Always allow localhost
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return next();
}
// Check against allow-list
if (allowList.length > 0 && isIPAllowed(hostname, allowList)) {
return next();
}
return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Connection from this host is not allowed'));
});
}

View File

@@ -138,9 +138,10 @@ export class MCPServerSettingTab extends PluginSettingTab {
const statusEl = containerEl.createEl('div', {cls: 'mcp-server-status'});
const isRunning = this.plugin.mcpServer?.isRunning() ?? false;
const bindAddress = this.plugin.settings.allowedIPs?.trim() ? '0.0.0.0' : '127.0.0.1';
statusEl.createEl('p', {
text: isRunning
? `✅ Running on http://127.0.0.1:${this.plugin.settings.port}/mcp`
? `✅ Running on http://${bindAddress}:${this.plugin.settings.port}/mcp`
: '⭕ Stopped'
});
@@ -203,6 +204,29 @@ export class MCPServerSettingTab extends PluginSettingTab {
}
}));
// Allowed IPs setting
new Setting(containerEl)
.setName('Allowed IPs')
.setDesc('Comma-separated IPs or CIDR ranges allowed to connect remotely (e.g., 100.64.0.0/10, 192.168.1.50). Leave empty for localhost only. Restart required.')
.addText(text => text
.setPlaceholder('100.64.0.0/10, 192.168.1.0/24')
.setValue(this.plugin.settings.allowedIPs)
.onChange(async (value) => {
this.plugin.settings.allowedIPs = value;
await this.plugin.saveSettings();
if (this.plugin.mcpServer?.isRunning()) {
new Notice('⚠️ Server restart required for allowed IPs changes to take effect');
}
}));
// Security note when remote access is enabled
if (this.plugin.settings.allowedIPs?.trim()) {
const securityNote = containerEl.createEl('div', {cls: 'mcp-security-note'});
securityNote.createEl('p', {
text: '⚠️ Server is accessible from non-localhost IPs. Ensure your API key is kept secure.'
});
}
// Authentication (Always Enabled)
const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'});

View File

@@ -50,7 +50,7 @@ export class ToolRegistry {
},
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"
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"]

View File

@@ -45,7 +45,7 @@ export class NoteTools {
/* istanbul ignore next */
const parseFrontmatter = options?.parseFrontmatter ?? false;
/* istanbul ignore next */
const withLineNumbers = options?.withLineNumbers ?? false;
const withLineNumbers = options?.withLineNumbers ?? true;
// Validate path
if (!path || path.trim() === '') {
@@ -125,13 +125,38 @@ export class NoteTools {
// Parse frontmatter if requested
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 = {
path: file.path,
hasFrontmatter: extracted.hasFrontmatter,
/* 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
/* istanbul ignore next - Response building branches tested via integration tests */
if (withFrontmatter && extracted.hasFrontmatter) {
@@ -142,7 +167,7 @@ export class NoteTools {
// Include content without frontmatter if parsing
/* istanbul ignore next */
if (withContent && extracted.hasFrontmatter) {
result.contentWithoutFrontmatter = extracted.contentWithoutFrontmatter;
result.contentWithoutFrontmatter = resultContentWithoutFrontmatter;
}
// Add word count when content is included

View File

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

View File

@@ -3,6 +3,7 @@ export interface MCPServerSettings {
port: number;
apiKey: string; // Now required, not optional
enableAuth: boolean; // Will be removed in future, kept for migration
allowedIPs: string; // Comma-separated IPs/CIDRs allowed to connect remotely
}
export interface NotificationSettings {
@@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: MCPPluginSettings = {
port: 3000,
apiKey: '', // Will be auto-generated on first load
enableAuth: true, // Always true now
allowedIPs: '', // Empty = localhost only
autoStart: false,
// Notification defaults
notificationsEnabled: false,

View File

@@ -0,0 +1,84 @@
export interface AllowedIPEntry {
type: 'ip' | 'cidr';
ip: number; // 32-bit numeric IPv4
mask: number; // 32-bit subnet mask (only for CIDR)
}
/**
* Convert dotted IPv4 string to 32-bit number.
* Returns null if invalid.
*/
function ipToNumber(ip: string): number | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
let num = 0;
for (const part of parts) {
const octet = parseInt(part, 10);
if (isNaN(octet) || octet < 0 || octet > 255) return null;
num = (num << 8) | octet;
}
return num >>> 0; // Ensure unsigned
}
/**
* Strip IPv4-mapped IPv6 prefix (::ffff:) if present.
*/
function normalizeIP(ip: string): string {
if (ip.startsWith('::ffff:')) {
return ip.slice(7);
}
return ip;
}
/**
* Parse a comma-separated string of IPs and CIDRs into structured entries.
* Invalid entries are silently skipped.
*/
export function parseAllowedIPs(setting: string): AllowedIPEntry[] {
if (!setting || !setting.trim()) return [];
const entries: AllowedIPEntry[] = [];
for (const raw of setting.split(',')) {
const trimmed = raw.trim();
if (!trimmed) continue;
if (trimmed.includes('/')) {
const [ipStr, prefixStr] = trimmed.split('/');
const ip = ipToNumber(ipStr);
const prefix = parseInt(prefixStr, 10);
if (ip === null || isNaN(prefix) || prefix < 0 || prefix > 32) continue;
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
entries.push({ type: 'cidr', ip: (ip & mask) >>> 0, mask });
} else {
const ip = ipToNumber(trimmed);
if (ip === null) continue;
entries.push({ type: 'ip', ip, mask: 0xFFFFFFFF });
}
}
return entries;
}
/**
* Check if an IP address is allowed by the given allow-list.
* Localhost (127.0.0.1) is always allowed.
*/
export function isIPAllowed(ip: string, allowList: AllowedIPEntry[]): boolean {
const normalized = normalizeIP(ip);
// Localhost is always allowed
if (normalized === '127.0.0.1' || normalized === 'localhost') return true;
if (allowList.length === 0) return false;
const num = ipToNumber(normalized);
if (num === null) return false;
for (const entry of allowList) {
if (entry.type === 'ip') {
if (num === entry.ip) return true;
} else {
if (((num & entry.mask) >>> 0) === entry.ip) return true;
}
}
return false;
}

141
tests/network-utils.test.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Unit tests for network-utils
*/
import { parseAllowedIPs, isIPAllowed, AllowedIPEntry } from '../src/utils/network-utils';
describe('parseAllowedIPs', () => {
test('should return empty array for empty string', () => {
expect(parseAllowedIPs('')).toEqual([]);
});
test('should return empty array for whitespace-only string', () => {
expect(parseAllowedIPs(' ')).toEqual([]);
});
test('should parse a single IP', () => {
const result = parseAllowedIPs('192.168.1.1');
expect(result).toHaveLength(1);
expect(result[0].type).toBe('ip');
});
test('should parse multiple comma-separated IPs', () => {
const result = parseAllowedIPs('192.168.1.1, 10.0.0.5');
expect(result).toHaveLength(2);
});
test('should parse CIDR notation', () => {
const result = parseAllowedIPs('100.64.0.0/10');
expect(result).toHaveLength(1);
expect(result[0].type).toBe('cidr');
});
test('should parse mixed IPs and CIDRs', () => {
const result = parseAllowedIPs('192.168.1.1, 10.0.0.0/8, 172.16.0.5');
expect(result).toHaveLength(3);
expect(result[0].type).toBe('ip');
expect(result[1].type).toBe('cidr');
expect(result[2].type).toBe('ip');
});
test('should handle extra whitespace', () => {
const result = parseAllowedIPs(' 192.168.1.1 , 10.0.0.5 ');
expect(result).toHaveLength(2);
});
test('should skip invalid entries', () => {
const result = parseAllowedIPs('192.168.1.1, invalid, 10.0.0.5');
expect(result).toHaveLength(2);
});
test('should skip invalid CIDR prefix', () => {
const result = parseAllowedIPs('10.0.0.0/33');
expect(result).toHaveLength(0);
});
test('should skip entries with invalid octets', () => {
const result = parseAllowedIPs('256.0.0.1');
expect(result).toHaveLength(0);
});
test('should handle trailing commas', () => {
const result = parseAllowedIPs('192.168.1.1,');
expect(result).toHaveLength(1);
});
});
describe('isIPAllowed', () => {
test('should always allow 127.0.0.1 with empty list', () => {
expect(isIPAllowed('127.0.0.1', [])).toBe(true);
});
test('should always allow localhost with empty list', () => {
expect(isIPAllowed('localhost', [])).toBe(true);
});
test('should always allow IPv4-mapped localhost', () => {
expect(isIPAllowed('::ffff:127.0.0.1', [])).toBe(true);
});
test('should reject non-localhost with empty list', () => {
expect(isIPAllowed('192.168.1.1', [])).toBe(false);
});
test('should match exact IP', () => {
const allowList = parseAllowedIPs('192.168.1.50');
expect(isIPAllowed('192.168.1.50', allowList)).toBe(true);
expect(isIPAllowed('192.168.1.51', allowList)).toBe(false);
});
test('should match CIDR range', () => {
const allowList = parseAllowedIPs('10.0.0.0/8');
expect(isIPAllowed('10.0.0.1', allowList)).toBe(true);
expect(isIPAllowed('10.255.255.255', allowList)).toBe(true);
expect(isIPAllowed('11.0.0.1', allowList)).toBe(false);
});
test('should match Tailscale CGNAT range (100.64.0.0/10)', () => {
const allowList = parseAllowedIPs('100.64.0.0/10');
expect(isIPAllowed('100.64.0.1', allowList)).toBe(true);
expect(isIPAllowed('100.100.50.25', allowList)).toBe(true);
expect(isIPAllowed('100.127.255.255', allowList)).toBe(true);
expect(isIPAllowed('100.128.0.0', allowList)).toBe(false);
expect(isIPAllowed('100.63.255.255', allowList)).toBe(false);
});
test('should handle IPv4-mapped IPv6 addresses', () => {
const allowList = parseAllowedIPs('192.168.1.50');
expect(isIPAllowed('::ffff:192.168.1.50', allowList)).toBe(true);
expect(isIPAllowed('::ffff:192.168.1.51', allowList)).toBe(false);
});
test('should handle IPv4-mapped IPv6 with CIDR', () => {
const allowList = parseAllowedIPs('10.0.0.0/8');
expect(isIPAllowed('::ffff:10.5.3.1', allowList)).toBe(true);
expect(isIPAllowed('::ffff:11.0.0.1', allowList)).toBe(false);
});
test('should match against multiple entries', () => {
const allowList = parseAllowedIPs('192.168.1.0/24, 10.0.0.5');
expect(isIPAllowed('192.168.1.100', allowList)).toBe(true);
expect(isIPAllowed('10.0.0.5', allowList)).toBe(true);
expect(isIPAllowed('10.0.0.6', allowList)).toBe(false);
});
test('should handle /32 CIDR as single IP', () => {
const allowList = parseAllowedIPs('192.168.1.1/32');
expect(isIPAllowed('192.168.1.1', allowList)).toBe(true);
expect(isIPAllowed('192.168.1.2', allowList)).toBe(false);
});
test('should handle /0 CIDR as allow-all', () => {
const allowList = parseAllowedIPs('0.0.0.0/0');
expect(isIPAllowed('1.2.3.4', allowList)).toBe(true);
expect(isIPAllowed('255.255.255.255', allowList)).toBe(true);
});
test('should return false for invalid IP input', () => {
const allowList = parseAllowedIPs('10.0.0.0/8');
expect(isIPAllowed('not-an-ip', allowList)).toBe(false);
});
});

View File

@@ -52,7 +52,7 @@ describe('NoteTools', () => {
});
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 content = '# Test Note\n\nThis is test content.';
@@ -62,9 +62,10 @@ describe('NoteTools', () => {
const result = await noteTools.readNote('test.md');
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);
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(mockVault.read).toHaveBeenCalledWith(mockFile);
});
@@ -124,7 +125,7 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
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();
const parsed = JSON.parse(result.content[0].text);
@@ -188,23 +189,23 @@ describe('NoteTools', () => {
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 content = '# Test Note\n\nContent here.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
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();
// Now returns JSON even with default options
// Returns JSON with raw content when line numbers disabled
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content);
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', {
ctime: 1000,
mtime: 2000,
@@ -215,7 +216,7 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
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();
const parsed = JSON.parse(result.content[0].text);
@@ -225,7 +226,7 @@ describe('NoteTools', () => {
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', {
ctime: 1000,
mtime: 2000,
@@ -236,13 +237,52 @@ describe('NoteTools', () => {
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
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();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe('# Test');
expect(parsed.totalLines).toBeUndefined();
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', () => {