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 +7 -45
- package/dist/bear-urls.test.js +13 -0
- package/dist/config.js +1 -1
- package/dist/main.js +28 -6
- package/dist/notes.js +54 -24
- package/dist/tags.js +13 -5
- package/dist/utils.test.js +34 -0
- package/package.json +14 -14
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
162
|
+
innerQuery += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
|
|
153
163
|
queryParams.push(searchPattern, searchPattern, searchPattern);
|
|
154
164
|
}
|
|
155
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
+
innerQuery += ' AND note.ZMODIFICATIONDATE <= ?';
|
|
193
215
|
queryParams.push(timestamp);
|
|
194
216
|
}
|
|
195
217
|
}
|
|
196
|
-
//
|
|
197
|
-
query
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
21
23
|
"debug": "^4.4.3",
|
|
22
|
-
"zod": "^3.
|
|
23
|
-
"zod-to-json-schema": "^3.
|
|
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.
|
|
29
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
30
|
-
"@typescript-eslint/parser": "^8.
|
|
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.
|
|
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": ">=
|
|
39
|
-
},
|
|
40
|
-
"overrides": {
|
|
41
|
-
"body-parser": "2.2.1"
|
|
41
|
+
"node": ">=24.13.0"
|
|
42
42
|
},
|
|
43
43
|
"keywords": [
|
|
44
44
|
"bear app",
|