bear-notes-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,31 +11,25 @@ Want to use this Bear Notes MCP server with Claude Code, Cursor, Codex, or other
11
11
  - **`bear-search-notes`** - Find notes by text content or tags, returns list with IDs for further actions
12
12
  - **`bear-open-note`** - Read full content of a specific note including text, formatting, and metadata
13
13
  - **`bear-create-note`** - Create new notes with optional title, content, and tags
14
- - **`bear-add-text-append`** - Add text to the end of existing notes or specific sections
15
- - **`bear-add-text-prepend`** - Insert text at the beginning of existing notes or sections
14
+ - **`bear-add-text`** - Add text to an existing note at the beginning or end, optionally targeting a specific section
16
15
  - **`bear-add-file`** - Attach files (images, PDFs, spreadsheets, etc.) to existing notes
16
+ - **`bear-list-tags`** - List all tags in your Bear library as a hierarchical tree with note counts
17
+ - **`bear-find-untagged-notes`** - Find notes that have no tags assigned
18
+ - **`bear-add-tag`** - Add one or more tags to an existing note
17
19
 
18
20
  **Requirements**: Node.js 22.13.0+
19
21
 
20
22
  ## Quick Start - Claude Code (One Command)
21
23
 
22
- **For Node.js 22.13.0+ / 23.4.0+ / 24.x+ / 25.x+ (recommended):**
23
24
  ```bash
24
25
  claude mcp add bear-notes --transport stdio -- npx -y bear-notes-mcp@latest
25
26
  ```
26
27
 
27
- **For Node.js 22.5.0-22.12.x or 23.0.0-23.3.x (older versions):**
28
- ```bash
29
- claude mcp add bear-notes --transport stdio --env NODE_OPTIONS="--experimental-sqlite" -- npx -y bear-notes-mcp@latest
30
- ```
31
-
32
28
  That's it! The server will be downloaded from npm and configured automatically.
33
29
 
34
30
  ## Quick Start - Other AI Assistants
35
31
 
36
- **Check your Node.js version:** `node --version`
37
-
38
- **For Node.js 22.13.0+ / 23.4.0+ / 24.x+ / 25.x+ (recommended):**
32
+ Add to your MCP configuration file:
39
33
  ```json
40
34
  {
41
35
  "mcpServers": {
@@ -47,21 +41,6 @@ That's it! The server will be downloaded from npm and configured automatically.
47
41
  }
48
42
  ```
49
43
 
50
- **For Node.js 22.5.0-22.12.x or 23.0.0-23.3.x (older versions):**
51
- ```json
52
- {
53
- "mcpServers": {
54
- "bear-notes": {
55
- "command": "npx",
56
- "args": ["-y", "bear-notes-mcp@latest"],
57
- "env": {
58
- "NODE_OPTIONS": "--experimental-sqlite"
59
- }
60
- }
61
- }
62
- }
63
- ```
64
-
65
44
  ## Advanced: Local Development Build
66
45
 
67
46
  **Step 1: Clone and build**
@@ -74,17 +53,12 @@ npm run build
74
53
 
75
54
  **Step 2: Configure with local path**
76
55
 
77
- For Claude Code (Node.js 22.13.0+):
56
+ For Claude Code:
78
57
  ```bash
79
58
  claude mcp add bear-notes --transport stdio -- node /absolute/path/to/dist/main.js
80
59
  ```
81
60
 
82
- For Claude Code (Node.js 22.5.0-22.12.x):
83
- ```bash
84
- claude mcp add bear-notes --transport stdio -- node --experimental-sqlite /absolute/path/to/dist/main.js
85
- ```
86
-
87
- For other AI assistants (Node.js 22.13.0+):
61
+ For other AI assistants:
88
62
  ```json
89
63
  {
90
64
  "mcpServers": {
@@ -95,15 +69,3 @@ For other AI assistants (Node.js 22.13.0+):
95
69
  }
96
70
  }
97
71
  ```
98
-
99
- For other AI assistants (Node.js 22.5.0-22.12.x):
100
- ```json
101
- {
102
- "mcpServers": {
103
- "bear-notes": {
104
- "command": "node",
105
- "args": ["--experimental-sqlite", "/absolute/path/to/dist/main.js"]
106
- }
107
- }
108
- }
109
- ```
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildBearUrl } from './bear-urls.js';
3
+ describe('buildBearUrl', () => {
4
+ it('encodes spaces as %20, not +', () => {
5
+ const url = buildBearUrl('create', { title: 'Hello World' });
6
+ expect(url).toContain('Hello%20World');
7
+ expect(url).not.toContain('Hello+World');
8
+ });
9
+ it('preserves literal + by encoding as %2B', () => {
10
+ const url = buildBearUrl('create', { title: '1+1=2' });
11
+ expect(url).toContain('1%2B1%3D2');
12
+ });
13
+ });
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- export const APP_VERSION = '2.1.0';
1
+ export const APP_VERSION = '2.2.0';
2
2
  export const BEAR_URL_SCHEME = 'bear://x-callback-url/';
3
3
  export const CORE_DATA_EPOCH_OFFSET = 978307200; // 2001-01-01 to Unix epoch
4
4
  export const DEFAULT_SEARCH_LIMIT = 50;
package/dist/main.js CHANGED
@@ -117,14 +117,18 @@ server.registerTool('bear-search-notes', {
117
117
  .string()
118
118
  .optional()
119
119
  .describe('Filter notes modified on or before this date. Supports: relative dates ("today", "yesterday", "last week", "end of last month"), ISO format (YYYY-MM-DD). Use "end of last month" for the end of the previous month.'),
120
+ pinned: z
121
+ .boolean()
122
+ .optional()
123
+ .describe('Set to true to return only pinned notes: if combined with tag, will return pinned notes with that tag, otherwise only globally pinned notes.'),
120
124
  },
121
125
  annotations: {
122
126
  readOnlyHint: true,
123
127
  idempotentHint: true,
124
128
  openWorldHint: false,
125
129
  },
126
- }, async ({ term, tag, limit, createdAfter, createdBefore, modifiedAfter, modifiedBefore, }) => {
127
- logger.info(`bear-search-notes called with term: "${term || 'none'}", tag: "${tag || 'none'}", limit: ${limit || 'default'}, createdAfter: "${createdAfter || 'none'}", createdBefore: "${createdBefore || 'none'}", modifiedAfter: "${modifiedAfter || 'none'}", modifiedBefore: "${modifiedBefore || 'none'}", includeFiles: always`);
130
+ }, async ({ term, tag, limit, createdAfter, createdBefore, modifiedAfter, modifiedBefore, pinned, }) => {
131
+ logger.info(`bear-search-notes called with term: "${term || 'none'}", tag: "${tag || 'none'}", limit: ${limit || 'default'}, createdAfter: "${createdAfter || 'none'}", createdBefore: "${createdBefore || 'none'}", modifiedAfter: "${modifiedAfter || 'none'}", modifiedBefore: "${modifiedBefore || 'none'}", pinned: ${pinned ?? 'none'}, includeFiles: always`);
128
132
  try {
129
133
  const dateFilter = {
130
134
  ...(createdAfter && { createdAfter }),
@@ -132,7 +136,7 @@ server.registerTool('bear-search-notes', {
132
136
  ...(modifiedAfter && { modifiedAfter }),
133
137
  ...(modifiedBefore && { modifiedBefore }),
134
138
  };
135
- const notes = searchNotes(term, tag, limit, Object.keys(dateFilter).length > 0 ? dateFilter : undefined);
139
+ const { notes, totalCount } = searchNotes(term, tag, limit, Object.keys(dateFilter).length > 0 ? dateFilter : undefined, pinned);
136
140
  if (notes.length === 0) {
137
141
  const searchCriteria = [];
138
142
  if (term?.trim())
@@ -147,11 +151,18 @@ server.registerTool('bear-search-notes', {
147
151
  searchCriteria.push(`modified after "${modifiedAfter}"`);
148
152
  if (modifiedBefore)
149
153
  searchCriteria.push(`modified before "${modifiedBefore}"`);
154
+ if (pinned)
155
+ searchCriteria.push('pinned only');
150
156
  return createToolResponse(`No notes found matching ${searchCriteria.join(', ')}.
151
157
 
152
158
  Try different search criteria or check if notes exist in Bear Notes.`);
153
159
  }
154
- const resultLines = [`Found ${notes.length} note${notes.length === 1 ? '' : 's'}:`, ''];
160
+ // Show total count when results are truncated
161
+ const hasMore = totalCount > notes.length;
162
+ const countDisplay = hasMore
163
+ ? `${notes.length} notes (${totalCount} total matching)`
164
+ : `${notes.length} note${notes.length === 1 ? '' : 's'}`;
165
+ const resultLines = [`Found ${countDisplay}:`, ''];
155
166
  notes.forEach((note, index) => {
156
167
  const noteTitle = note.title || 'Untitled';
157
168
  const modifiedDate = new Date(note.modification_date).toLocaleDateString();
@@ -163,6 +174,9 @@ Try different search criteria or check if notes exist in Bear Notes.`);
163
174
  resultLines.push('');
164
175
  });
165
176
  resultLines.push('Use bear-open-note with an ID to read the full content of any note.');
177
+ if (hasMore) {
178
+ resultLines.push(`Use bear-search-notes with limit: ${totalCount} to get all results.`);
179
+ }
166
180
  return createToolResponse(resultLines.join('\n'));
167
181
  }
168
182
  catch (error) {
@@ -325,11 +339,16 @@ server.registerTool('bear-find-untagged-notes', {
325
339
  }, async ({ limit }) => {
326
340
  logger.info(`bear-find-untagged-notes called with limit: ${limit || 'default'}`);
327
341
  try {
328
- const notes = findUntaggedNotes(limit);
342
+ const { notes, totalCount } = findUntaggedNotes(limit);
329
343
  if (notes.length === 0) {
330
344
  return createToolResponse('No untagged notes found. All your notes have tags!');
331
345
  }
332
- const lines = [`Found ${notes.length} untagged note${notes.length === 1 ? '' : 's'}:`, ''];
346
+ // Show total count when results are truncated
347
+ const hasMore = totalCount > notes.length;
348
+ const countDisplay = hasMore
349
+ ? `${notes.length} untagged notes (${totalCount} total)`
350
+ : `${notes.length} untagged note${notes.length === 1 ? '' : 's'}`;
351
+ const lines = [`Found ${countDisplay}:`, ''];
333
352
  notes.forEach((note, index) => {
334
353
  const modifiedDate = new Date(note.modification_date).toLocaleDateString();
335
354
  lines.push(`${index + 1}. **${note.title}**`);
@@ -338,6 +357,9 @@ server.registerTool('bear-find-untagged-notes', {
338
357
  lines.push('');
339
358
  });
340
359
  lines.push('You can also use bear-list-tags to see available tags.');
360
+ if (hasMore) {
361
+ lines.push(`Use bear-find-untagged-notes with limit: ${totalCount} to get all results.`);
362
+ }
341
363
  return createToolResponse(lines.join('\n'));
342
364
  }
343
365
  catch (error) {
package/dist/notes.js CHANGED
@@ -116,32 +116,42 @@ export function getNoteContent(identifier) {
116
116
  * @param tag - Tag to filter notes by (optional)
117
117
  * @param limit - Maximum number of results to return (default from config)
118
118
  * @param dateFilter - Date range filters for creation and modification dates (optional)
119
- * @returns Array of matching notes without full text content
119
+ * @param pinned - Filter to only pinned notes (optional)
120
+ * @returns Object with matching notes and total count (before limit applied)
120
121
  * @throws Error if database access fails or no search criteria provided
121
122
  * Note: Always searches within text extracted from attached images and PDF files via OCR for comprehensive results
122
123
  */
123
- export function searchNotes(searchTerm, tag, limit, dateFilter) {
124
- logger.info(`searchNotes called with term: "${searchTerm || 'none'}", tag: "${tag || 'none'}", limit: ${limit || DEFAULT_SEARCH_LIMIT}, dateFilter: ${dateFilter ? JSON.stringify(dateFilter) : 'none'}, includeFiles: always`);
124
+ export function searchNotes(searchTerm, tag, limit, dateFilter, pinned) {
125
+ logger.info(`searchNotes called with term: "${searchTerm || 'none'}", tag: "${tag || 'none'}", limit: ${limit || DEFAULT_SEARCH_LIMIT}, dateFilter: ${dateFilter ? JSON.stringify(dateFilter) : 'none'}, pinned: ${pinned ?? 'none'}, includeFiles: always`);
125
126
  // Validate search parameters - at least one must be provided
126
127
  const hasSearchTerm = searchTerm && typeof searchTerm === 'string' && searchTerm.trim();
127
128
  const hasTag = tag && typeof tag === 'string' && tag.trim();
128
129
  const hasDateFilter = dateFilter && Object.keys(dateFilter).length > 0;
129
- if (!hasSearchTerm && !hasTag && !hasDateFilter) {
130
- logAndThrow('Search error: Please provide a search term, tag, or date filter to search for notes');
130
+ const hasPinnedFilter = pinned === true;
131
+ if (!hasSearchTerm && !hasTag && !hasDateFilter && !hasPinnedFilter) {
132
+ logAndThrow('Search error: Please provide a search term, tag, date filter, or pinned filter to search for notes');
131
133
  }
132
134
  const db = openBearDatabase();
133
135
  const queryLimit = limit || DEFAULT_SEARCH_LIMIT;
134
136
  try {
135
- let query;
136
137
  const queryParams = [];
137
- // Query with file search - uses LEFT JOIN to include OCR'd content for comprehensive search
138
- query = `
138
+ // Build inner query that handles filtering and DISTINCT
139
+ // CTE ensures window function counts distinct notes, not duplicated rows from JOIN
140
+ let innerQuery = `
139
141
  SELECT DISTINCT note.ZTITLE as title,
140
142
  note.ZUNIQUEIDENTIFIER as identifier,
141
143
  note.ZCREATIONDATE as creationDate,
142
- note.ZMODIFICATIONDATE as modificationDate
144
+ note.ZMODIFICATIONDATE as modificationDate,
145
+ note.ZPINNED as pinned
143
146
  FROM ZSFNOTE note
144
- LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK
147
+ LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK`;
148
+ // Tag-pinned search requires joining the pinned-in-tags relationship tables
149
+ if (hasPinnedFilter && hasTag) {
150
+ innerQuery += `
151
+ JOIN Z_5PINNEDINTAGS pt ON pt.Z_5PINNEDNOTES = note.Z_PK
152
+ JOIN ZSFNOTETAG t ON t.Z_PK = pt.Z_13PINNEDINTAGS`;
153
+ }
154
+ innerQuery += `
145
155
  WHERE note.ZARCHIVED = 0
146
156
  AND note.ZTRASHED = 0
147
157
  AND note.ZENCRYPTED = 0`;
@@ -149,13 +159,25 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
149
159
  if (hasSearchTerm) {
150
160
  const searchPattern = `%${searchTerm.trim()}%`;
151
161
  // Search in note title, text, and file OCR content
152
- query += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
162
+ innerQuery += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
153
163
  queryParams.push(searchPattern, searchPattern, searchPattern);
154
164
  }
155
- // Add tag filtering
156
- if (hasTag) {
165
+ // Pinned and tag filtering - behavior depends on combination
166
+ if (hasPinnedFilter && hasTag) {
167
+ // Notes pinned within specific tag view (via Z_5PINNEDINTAGS)
168
+ const tagPattern = `%${tag.trim()}%`;
169
+ innerQuery += ' AND t.ZTITLE LIKE ?';
170
+ queryParams.push(tagPattern);
171
+ }
172
+ else if (hasPinnedFilter) {
173
+ // All pinned notes: globally pinned OR pinned in any tag (matches Bear's "Pinned" section)
174
+ innerQuery +=
175
+ ' AND (note.ZPINNED = 1 OR EXISTS (SELECT 1 FROM Z_5PINNEDINTAGS pt WHERE pt.Z_5PINNEDNOTES = note.Z_PK))';
176
+ }
177
+ else if (hasTag) {
178
+ // Text-based tag search
157
179
  const tagPattern = `%#${tag.trim()}%`;
158
- query += ' AND note.ZTEXT LIKE ?';
180
+ innerQuery += ' AND note.ZTEXT LIKE ?';
159
181
  queryParams.push(tagPattern);
160
182
  }
161
183
  // Add date filtering
@@ -165,7 +187,7 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
165
187
  // Set to start of day (00:00:00) to include notes from the entire specified day onwards
166
188
  afterDate.setHours(0, 0, 0, 0);
167
189
  const timestamp = convertDateToCoreDataTimestamp(afterDate);
168
- query += ' AND note.ZCREATIONDATE >= ?';
190
+ innerQuery += ' AND note.ZCREATIONDATE >= ?';
169
191
  queryParams.push(timestamp);
170
192
  }
171
193
  if (dateFilter.createdBefore) {
@@ -173,7 +195,7 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
173
195
  // Set to end of day (23:59:59.999) to include notes through the entire specified day
174
196
  beforeDate.setHours(23, 59, 59, 999);
175
197
  const timestamp = convertDateToCoreDataTimestamp(beforeDate);
176
- query += ' AND note.ZCREATIONDATE <= ?';
198
+ innerQuery += ' AND note.ZCREATIONDATE <= ?';
177
199
  queryParams.push(timestamp);
178
200
  }
179
201
  if (dateFilter.modifiedAfter) {
@@ -181,7 +203,7 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
181
203
  // Set to start of day (00:00:00) to include notes from the entire specified day onwards
182
204
  afterDate.setHours(0, 0, 0, 0);
183
205
  const timestamp = convertDateToCoreDataTimestamp(afterDate);
184
- query += ' AND note.ZMODIFICATIONDATE >= ?';
206
+ innerQuery += ' AND note.ZMODIFICATIONDATE >= ?';
185
207
  queryParams.push(timestamp);
186
208
  }
187
209
  if (dateFilter.modifiedBefore) {
@@ -189,12 +211,17 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
189
211
  // Set to end of day (23:59:59.999) to include notes through the entire specified day
190
212
  beforeDate.setHours(23, 59, 59, 999);
191
213
  const timestamp = convertDateToCoreDataTimestamp(beforeDate);
192
- query += ' AND note.ZMODIFICATIONDATE <= ?';
214
+ innerQuery += ' AND note.ZMODIFICATIONDATE <= ?';
193
215
  queryParams.push(timestamp);
194
216
  }
195
217
  }
196
- // Add ordering and limit
197
- query += ' ORDER BY note.ZMODIFICATIONDATE DESC LIMIT ?';
218
+ // Wrap in CTE: inner query gets distinct notes, outer query adds total count and applies limit
219
+ const query = `
220
+ WITH filtered_notes AS (${innerQuery})
221
+ SELECT *, COUNT(*) OVER() as totalCount
222
+ FROM filtered_notes
223
+ ORDER BY modificationDate DESC
224
+ LIMIT ?`;
198
225
  queryParams.push(queryLimit);
199
226
  logger.debug(`Executing search query with ${queryParams.length} parameters`);
200
227
  // Use parameter binding to prevent SQL injection attacks
@@ -202,11 +229,14 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
202
229
  const rows = stmt.all(...queryParams);
203
230
  if (!rows || rows.length === 0) {
204
231
  logger.info('No notes found matching search criteria');
205
- return [];
232
+ return { notes: [], totalCount: 0 };
206
233
  }
234
+ // Extract totalCount from first row (window function adds same value to all rows)
235
+ const firstRow = rows[0];
236
+ const totalCount = firstRow.totalCount || rows.length;
207
237
  const notes = rows.map((row) => formatBearNote(row));
208
- logger.info(`Found ${notes.length} notes matching search criteria`);
209
- return notes;
238
+ logger.info(`Found ${notes.length} notes (${totalCount} total) matching search criteria`);
239
+ return { notes, totalCount };
210
240
  }
211
241
  catch (error) {
212
242
  logAndThrow(`SQLite search query failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -220,5 +250,5 @@ export function searchNotes(searchTerm, tag, limit, dateFilter) {
220
250
  logger.error(`Failed to close database connection: ${closeError}`);
221
251
  }
222
252
  }
223
- return [];
253
+ return { notes: [], totalCount: 0 };
224
254
  }
package/dist/tags.js CHANGED
@@ -125,17 +125,19 @@ export function listTags() {
125
125
  * Finds notes that have no tags assigned.
126
126
  *
127
127
  * @param limit - Maximum number of results (default: 50)
128
- * @returns Array of untagged notes
128
+ * @returns Object with untagged notes and total count (before limit applied)
129
129
  */
130
130
  export function findUntaggedNotes(limit = 50) {
131
131
  logger.info(`findUntaggedNotes called with limit: ${limit}`);
132
132
  const db = openBearDatabase();
133
133
  try {
134
+ // COUNT(*) OVER() calculates total matching rows BEFORE LIMIT is applied
134
135
  const query = `
135
136
  SELECT ZTITLE as title,
136
137
  ZUNIQUEIDENTIFIER as identifier,
137
138
  ZCREATIONDATE as creationDate,
138
- ZMODIFICATIONDATE as modificationDate
139
+ ZMODIFICATIONDATE as modificationDate,
140
+ COUNT(*) OVER() as totalCount
139
141
  FROM ZSFNOTE
140
142
  WHERE ZARCHIVED = 0 AND ZTRASHED = 0 AND ZENCRYPTED = 0
141
143
  AND Z_PK NOT IN (SELECT Z_5NOTES FROM Z_5TAGS)
@@ -144,6 +146,12 @@ export function findUntaggedNotes(limit = 50) {
144
146
  `;
145
147
  const stmt = db.prepare(query);
146
148
  const rows = stmt.all(limit);
149
+ if (rows.length === 0) {
150
+ logger.info('No untagged notes found');
151
+ return { notes: [], totalCount: 0 };
152
+ }
153
+ // Extract totalCount from first row (window function adds same value to all rows)
154
+ const totalCount = rows[0].totalCount || rows.length;
147
155
  const notes = rows.map((row) => ({
148
156
  title: row.title || 'Untitled',
149
157
  identifier: row.identifier,
@@ -151,8 +159,8 @@ export function findUntaggedNotes(limit = 50) {
151
159
  modification_date: convertCoreDataTimestamp(row.modificationDate),
152
160
  pin: 'no',
153
161
  }));
154
- logger.info(`Found ${notes.length} untagged notes`);
155
- return notes;
162
+ logger.info(`Found ${notes.length} untagged notes (${totalCount} total)`);
163
+ return { notes, totalCount };
156
164
  }
157
165
  catch (error) {
158
166
  logAndThrow(`Database error: Failed to find untagged notes: ${error instanceof Error ? error.message : String(error)}`);
@@ -166,5 +174,5 @@ export function findUntaggedNotes(limit = 50) {
166
174
  logger.error(`Failed to close database connection: ${closeError}`);
167
175
  }
168
176
  }
169
- return [];
177
+ return { notes: [], totalCount: 0 };
170
178
  }
@@ -0,0 +1,34 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { convertCoreDataTimestamp, parseDateString } from './utils.js';
3
+ describe('parseDateString', () => {
4
+ beforeEach(() => {
5
+ // Fix "now" to January 15, 2026 for predictable tests
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date(2026, 0, 15, 12, 0, 0));
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ it('"start of last month" in January returns December of previous year', () => {
13
+ const result = parseDateString('start of last month');
14
+ expect(result.getFullYear()).toBe(2025);
15
+ expect(result.getMonth()).toBe(11); // December (0-indexed)
16
+ expect(result.getDate()).toBe(1);
17
+ });
18
+ it('"end of last month" returns last day with end-of-day time', () => {
19
+ const result = parseDateString('end of last month');
20
+ expect(result.getFullYear()).toBe(2025);
21
+ expect(result.getMonth()).toBe(11); // December
22
+ expect(result.getDate()).toBe(31);
23
+ expect(result.getHours()).toBe(23);
24
+ expect(result.getMinutes()).toBe(59);
25
+ expect(result.getSeconds()).toBe(59);
26
+ });
27
+ });
28
+ describe('convertCoreDataTimestamp', () => {
29
+ it('converts Core Data timestamp to correct ISO string', () => {
30
+ // Core Data timestamp 0 = 2001-01-01 00:00:00 UTC
31
+ const result = convertCoreDataTimestamp(0);
32
+ expect(result).toBe('2001-01-01T00:00:00.000Z');
33
+ });
34
+ });
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "bear-notes-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
7
7
  "scripts": {
8
8
  "build": "tsc -p tsconfig.build.json",
9
9
  "dev": "tsx src/main.ts --debug",
10
- "start": "node --experimental-sqlite dist/main.js",
10
+ "start": "node dist/main.js",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
11
13
  "lint": "eslint src --fix",
12
14
  "lint:check": "eslint src",
13
15
  "format": "prettier --write src",
@@ -17,28 +19,26 @@
17
19
  "prepublishOnly": "npm run build && mv docs/NPM.md README.md"
18
20
  },
19
21
  "dependencies": {
20
- "@modelcontextprotocol/sdk": "^1.25.1",
22
+ "@modelcontextprotocol/sdk": "^1.25.2",
21
23
  "debug": "^4.4.3",
22
- "zod": "^3.25.76",
23
- "zod-to-json-schema": "^3.24.6"
24
+ "zod": "^4.3.5",
25
+ "zod-to-json-schema": "^3.25.1"
24
26
  },
25
27
  "devDependencies": {
26
28
  "@anthropic-ai/mcpb": "^2.1.2",
27
29
  "@types/debug": "^4.1.12",
28
- "@types/node": "^24.10.4",
29
- "@typescript-eslint/eslint-plugin": "^8.46.3",
30
- "@typescript-eslint/parser": "^8.46.3",
30
+ "@types/node": "^24.10.9",
31
+ "@typescript-eslint/eslint-plugin": "^8.53.0",
32
+ "@typescript-eslint/parser": "^8.53.0",
31
33
  "eslint": "^9.39.2",
32
34
  "eslint-plugin-import": "^2.32.0",
33
- "prettier": "^3.7.4",
35
+ "prettier": "^3.8.0",
34
36
  "tsx": "^4.21.0",
35
- "typescript": "^5.9.3"
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.0.17"
36
39
  },
37
40
  "engines": {
38
- "node": ">=22.5.0"
39
- },
40
- "overrides": {
41
- "body-parser": "2.2.1"
41
+ "node": ">=24.13.0"
42
42
  },
43
43
  "keywords": [
44
44
  "bear app",