bear-notes-mcp 2.0.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/dist/tags.js ADDED
@@ -0,0 +1,170 @@
1
+ import { convertCoreDataTimestamp, logAndThrow, logger } from './utils.js';
2
+ import { openBearDatabase } from './database.js';
3
+ /**
4
+ * Decodes and normalizes Bear tag names.
5
+ * - Replaces '+' with spaces (Bear's URL encoding)
6
+ * - Converts to lowercase (matches Bear UI behavior)
7
+ * - Trims whitespace
8
+ */
9
+ function decodeTagName(encodedName) {
10
+ return encodedName.replace(/\+/g, ' ').trim().toLowerCase();
11
+ }
12
+ /**
13
+ * Extracts the display name (leaf) from a full tag path.
14
+ * For "career/content/blog" returns "blog", for "career" returns "career".
15
+ */
16
+ function getTagDisplayName(fullPath) {
17
+ const parts = fullPath.split('/');
18
+ return parts[parts.length - 1];
19
+ }
20
+ /**
21
+ * Builds a hierarchical tree from a flat list of tags.
22
+ * Tags with paths like "career/content" become children of "career".
23
+ * Tags with 0 notes are excluded (matches Bear UI behavior).
24
+ */
25
+ function buildTagHierarchy(flatTags) {
26
+ // Filter out tags with no notes (hidden in Bear UI)
27
+ const activeTags = flatTags.filter((t) => t.noteCount > 0);
28
+ const tagMap = new Map();
29
+ // Two-pass approach: first create nodes, then link parent-child relationships
30
+ for (const tag of activeTags) {
31
+ tagMap.set(tag.name, {
32
+ name: tag.name,
33
+ displayName: tag.displayName,
34
+ noteCount: tag.noteCount,
35
+ children: [],
36
+ });
37
+ }
38
+ const roots = [];
39
+ // Build parent-child relationships
40
+ for (const tag of activeTags) {
41
+ const tagNode = tagMap.get(tag.name);
42
+ if (tag.isRoot) {
43
+ roots.push(tagNode);
44
+ }
45
+ else {
46
+ // Subtags use path notation (e.g., "career/content"), so extract parent path
47
+ const lastSlash = tag.name.lastIndexOf('/');
48
+ if (lastSlash > 0) {
49
+ const parentName = tag.name.substring(0, lastSlash);
50
+ const parent = tagMap.get(parentName);
51
+ if (parent) {
52
+ parent.children.push(tagNode);
53
+ }
54
+ else {
55
+ // Orphan subtag - parent has 0 notes or doesn't exist, treat as root
56
+ roots.push(tagNode);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // Sort children alphabetically at each level
62
+ const sortChildren = (tags) => {
63
+ tags.sort((a, b) => a.displayName.localeCompare(b.displayName));
64
+ for (const tag of tags) {
65
+ sortChildren(tag.children);
66
+ }
67
+ };
68
+ sortChildren(roots);
69
+ return roots;
70
+ }
71
+ /**
72
+ * Retrieves all tags from Bear database as a hierarchical tree.
73
+ * Each tag includes note count and nested children.
74
+ *
75
+ * @returns Object with tags array (tree structure) and total count
76
+ */
77
+ export function listTags() {
78
+ logger.info('listTags called');
79
+ const db = openBearDatabase();
80
+ try {
81
+ const query = `
82
+ SELECT t.ZTITLE as name,
83
+ t.ZISROOT as isRoot,
84
+ COUNT(nt.Z_5NOTES) as noteCount
85
+ FROM ZSFNOTETAG t
86
+ LEFT JOIN Z_5TAGS nt ON nt.Z_13TAGS = t.Z_PK
87
+ GROUP BY t.Z_PK
88
+ ORDER BY t.ZTITLE
89
+ `;
90
+ const stmt = db.prepare(query);
91
+ const rows = stmt.all();
92
+ if (!rows || rows.length === 0) {
93
+ logger.info('No tags found in database');
94
+ return { tags: [], totalCount: 0 };
95
+ }
96
+ // Transform rows: decode names and extract display names
97
+ const flatTags = rows.map((row) => {
98
+ const decodedName = decodeTagName(row.name);
99
+ return {
100
+ name: decodedName,
101
+ displayName: getTagDisplayName(decodedName),
102
+ noteCount: row.noteCount,
103
+ isRoot: row.isRoot === 1,
104
+ };
105
+ });
106
+ const hierarchy = buildTagHierarchy(flatTags);
107
+ logger.info(`Retrieved ${rows.length} tags, ${hierarchy.length} root tags`);
108
+ return { tags: hierarchy, totalCount: rows.length };
109
+ }
110
+ catch (error) {
111
+ logAndThrow(`Database error: Failed to retrieve tags: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ finally {
114
+ try {
115
+ db.close();
116
+ logger.debug('Database connection closed');
117
+ }
118
+ catch (closeError) {
119
+ logger.error(`Failed to close database connection: ${closeError}`);
120
+ }
121
+ }
122
+ return { tags: [], totalCount: 0 };
123
+ }
124
+ /**
125
+ * Finds notes that have no tags assigned.
126
+ *
127
+ * @param limit - Maximum number of results (default: 50)
128
+ * @returns Array of untagged notes
129
+ */
130
+ export function findUntaggedNotes(limit = 50) {
131
+ logger.info(`findUntaggedNotes called with limit: ${limit}`);
132
+ const db = openBearDatabase();
133
+ try {
134
+ const query = `
135
+ SELECT ZTITLE as title,
136
+ ZUNIQUEIDENTIFIER as identifier,
137
+ ZCREATIONDATE as creationDate,
138
+ ZMODIFICATIONDATE as modificationDate
139
+ FROM ZSFNOTE
140
+ WHERE ZARCHIVED = 0 AND ZTRASHED = 0 AND ZENCRYPTED = 0
141
+ AND Z_PK NOT IN (SELECT Z_5NOTES FROM Z_5TAGS)
142
+ ORDER BY ZMODIFICATIONDATE DESC
143
+ LIMIT ?
144
+ `;
145
+ const stmt = db.prepare(query);
146
+ const rows = stmt.all(limit);
147
+ const notes = rows.map((row) => ({
148
+ title: row.title || 'Untitled',
149
+ identifier: row.identifier,
150
+ creation_date: convertCoreDataTimestamp(row.creationDate),
151
+ modification_date: convertCoreDataTimestamp(row.modificationDate),
152
+ pin: 'no',
153
+ }));
154
+ logger.info(`Found ${notes.length} untagged notes`);
155
+ return notes;
156
+ }
157
+ catch (error) {
158
+ logAndThrow(`Database error: Failed to find untagged notes: ${error instanceof Error ? error.message : String(error)}`);
159
+ }
160
+ finally {
161
+ try {
162
+ db.close();
163
+ logger.debug('Database connection closed');
164
+ }
165
+ catch (closeError) {
166
+ logger.error(`Failed to close database connection: ${closeError}`);
167
+ }
168
+ }
169
+ return [];
170
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,184 @@
1
+ import createDebug from 'debug';
2
+ import { CORE_DATA_EPOCH_OFFSET, ERROR_MESSAGES } from './config.js';
3
+ import { getNoteContent } from './notes.js';
4
+ import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
5
+ export const logger = {
6
+ debug: createDebug('bear-notes-mcp:debug'),
7
+ info: createDebug('bear-notes-mcp:info'),
8
+ error: createDebug('bear-notes-mcp:error'),
9
+ };
10
+ // Convert UI_DEBUG_TOGGLE boolean set from UI to DEBUG string for debug package
11
+ // MCPB has no way to make this in one step with manifest.json
12
+ if (process.env.UI_DEBUG_TOGGLE === 'true') {
13
+ process.env.DEBUG = 'bear-notes-mcp:*';
14
+ logger.debug.enabled = true;
15
+ }
16
+ // Always enable error and info logs
17
+ logger.error.enabled = true;
18
+ logger.info.enabled = true;
19
+ /**
20
+ * Logs an error message and throws an Error to halt execution.
21
+ * Centralizes error handling to ensure consistent logging before failures.
22
+ *
23
+ * @param message - The error message to log and throw
24
+ * @throws Always throws Error with the provided message
25
+ */
26
+ export function logAndThrow(message) {
27
+ logger.error(message);
28
+ throw new Error(message);
29
+ }
30
+ /**
31
+ * Cleans base64 string by removing whitespace/newlines added by base64 command.
32
+ * URLSearchParams in buildBearUrl will handle URL encoding of special characters.
33
+ *
34
+ * @param base64String - Raw base64 string (may contain whitespace/newlines)
35
+ * @returns Cleaned base64 string without whitespace
36
+ */
37
+ export function cleanBase64(base64String) {
38
+ // Remove all whitespace/newlines from base64 (base64 command adds line breaks)
39
+ return base64String.trim().replace(/\s+/g, '');
40
+ }
41
+ /**
42
+ * Converts Bear's Core Data timestamp to ISO string format.
43
+ * Bear stores timestamps in seconds since Core Data epoch (2001-01-01).
44
+ *
45
+ * @param coreDataTimestamp - Timestamp in seconds since Core Data epoch
46
+ * @returns ISO string representation of the timestamp
47
+ */
48
+ export function convertCoreDataTimestamp(coreDataTimestamp) {
49
+ const unixTimestamp = coreDataTimestamp + CORE_DATA_EPOCH_OFFSET;
50
+ return new Date(unixTimestamp * 1000).toISOString();
51
+ }
52
+ /**
53
+ * Converts a JavaScript Date object to Bear's Core Data timestamp format.
54
+ * Core Data timestamps are in seconds since 2001-01-01 00:00:00 UTC.
55
+ *
56
+ * @param date - JavaScript Date object
57
+ * @returns Core Data timestamp in seconds
58
+ */
59
+ export function convertDateToCoreDataTimestamp(date) {
60
+ const unixTimestamp = Math.floor(date.getTime() / 1000);
61
+ return unixTimestamp - CORE_DATA_EPOCH_OFFSET;
62
+ }
63
+ /**
64
+ * Parses a date string and returns a JavaScript Date object.
65
+ * Supports relative dates ("today", "yesterday", "last week", "last month") and ISO date strings.
66
+ *
67
+ * @param dateString - Date string to parse (e.g., "today", "2024-01-15", "last week")
68
+ * @returns Parsed Date object
69
+ * @throws Error if the date string is invalid
70
+ */
71
+ export function parseDateString(dateString) {
72
+ const lowerDateString = dateString.trim().toLowerCase();
73
+ const now = new Date();
74
+ // Handle relative dates to provide user-friendly natural language date input
75
+ switch (lowerDateString) {
76
+ case 'today': {
77
+ const today = new Date(now);
78
+ today.setHours(0, 0, 0, 0);
79
+ return today;
80
+ }
81
+ case 'yesterday': {
82
+ const yesterday = new Date(now);
83
+ yesterday.setDate(yesterday.getDate() - 1);
84
+ yesterday.setHours(0, 0, 0, 0);
85
+ return yesterday;
86
+ }
87
+ case 'last week':
88
+ case 'week ago': {
89
+ const lastWeek = new Date(now);
90
+ lastWeek.setDate(lastWeek.getDate() - 7);
91
+ lastWeek.setHours(0, 0, 0, 0);
92
+ return lastWeek;
93
+ }
94
+ case 'last month':
95
+ case 'month ago':
96
+ case 'start of last month': {
97
+ // Calculate the first day of last month; month arithmetic handles year transitions correctly via JavaScript Date constructor
98
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
99
+ lastMonth.setHours(0, 0, 0, 0);
100
+ return lastMonth;
101
+ }
102
+ case 'end of last month': {
103
+ // Calculate the last day of last month; day 0 of current month equals last day of previous month
104
+ const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);
105
+ endOfLastMonth.setHours(23, 59, 59, 999);
106
+ return endOfLastMonth;
107
+ }
108
+ default: {
109
+ // Try parsing as ISO date or other standard formats as fallback for user-provided explicit dates
110
+ const parsed = new Date(dateString);
111
+ if (isNaN(parsed.getTime())) {
112
+ logAndThrow(`Invalid date format: "${dateString}". Use ISO format (YYYY-MM-DD) or relative dates (today, yesterday, last week, last month, start of last month, end of last month).`);
113
+ }
114
+ return parsed;
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Creates a standardized MCP tool response with consistent formatting.
120
+ * Centralizes response structure to follow DRY principles.
121
+ *
122
+ * @param text - The response text content
123
+ * @returns Formatted CallToolResult for MCP tools
124
+ */
125
+ export function createToolResponse(text) {
126
+ return {
127
+ content: [
128
+ {
129
+ type: 'text',
130
+ text,
131
+ annotations: { audience: ['user', 'assistant'] },
132
+ },
133
+ ],
134
+ };
135
+ }
136
+ /**
137
+ * Shared handler for adding text to Bear notes (append or prepend).
138
+ * Consolidates common validation, execution, and response logic.
139
+ *
140
+ * @param mode - Whether to append or prepend text
141
+ * @param params - Note ID, text content, and optional header
142
+ * @returns Formatted response indicating success or failure
143
+ */
144
+ export async function handleAddText(mode, { id, text, header }) {
145
+ const action = mode === 'append' ? 'appended' : 'prepended';
146
+ logger.info(`bear-add-text-${mode} called with id: ${id}, text length: ${text.length}, header: ${header || 'none'}`);
147
+ if (!id || !id.trim()) {
148
+ throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
149
+ }
150
+ if (!text || !text.trim()) {
151
+ throw new Error(ERROR_MESSAGES.MISSING_TEXT_PARAM);
152
+ }
153
+ try {
154
+ const existingNote = getNoteContent(id.trim());
155
+ if (!existingNote) {
156
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
157
+
158
+ Use bear-search-notes to find the correct note identifier.`);
159
+ }
160
+ // Strip markdown header syntax from header parameter for Bear API
161
+ const cleanHeader = header?.trim().replace(/^#+\s*/, '');
162
+ const url = buildBearUrl('add-text', {
163
+ id: id.trim(),
164
+ text: text.trim(),
165
+ header: cleanHeader,
166
+ mode,
167
+ });
168
+ logger.debug(`Executing Bear URL: ${url}`);
169
+ await executeBearXCallbackApi(url);
170
+ const responseLines = [`Text ${action} to note "${existingNote.title}" successfully!`, ''];
171
+ responseLines.push(`Text: ${text.trim().length} characters`);
172
+ if (header?.trim()) {
173
+ responseLines.push(`Section: ${header.trim()}`);
174
+ }
175
+ responseLines.push(`Note ID: ${id.trim()}`);
176
+ return createToolResponse(`${responseLines.join('\n')}
177
+
178
+ The text has been added to your Bear note.`);
179
+ }
180
+ catch (error) {
181
+ logger.error(`bear-add-text-${mode} failed: ${error}`);
182
+ throw error;
183
+ }
184
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "bear-notes-mcp",
3
+ "version": "2.0.0",
4
+ "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
+ "type": "module",
6
+ "main": "dist/main.js",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.build.json",
9
+ "dev": "tsx src/main.ts --debug",
10
+ "start": "node --experimental-sqlite dist/main.js",
11
+ "lint": "eslint src --fix",
12
+ "lint:check": "eslint src",
13
+ "format": "prettier --write src",
14
+ "format:check": "prettier --check src",
15
+ "check": "npm run lint:check && npm run format:check",
16
+ "fix": "npm run lint && npm run format"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.25.1",
20
+ "debug": "^4.4.3",
21
+ "zod": "^3.25.76",
22
+ "zod-to-json-schema": "^3.24.6"
23
+ },
24
+ "devDependencies": {
25
+ "@anthropic-ai/mcpb": "^2.1.2",
26
+ "@types/debug": "^4.1.12",
27
+ "@types/node": "^24.10.4",
28
+ "@typescript-eslint/eslint-plugin": "^8.46.3",
29
+ "@typescript-eslint/parser": "^8.46.3",
30
+ "eslint": "^9.39.2",
31
+ "eslint-plugin-import": "^2.32.0",
32
+ "prettier": "^3.7.4",
33
+ "tsx": "^4.21.0",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.5.0"
38
+ },
39
+ "overrides": {
40
+ "body-parser": "2.2.1"
41
+ },
42
+ "keywords": [
43
+ "bear app",
44
+ "bear notes",
45
+ "markdown notes",
46
+ "notes management",
47
+ "productivity",
48
+ "typescript",
49
+ "mcp"
50
+ ],
51
+ "author": {
52
+ "name": "Serhii Vasylenko",
53
+ "email": "serhii@vasylenko.info",
54
+ "url": "https://devdosvid.blog"
55
+ },
56
+ "license": "MIT",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://github.com/vasylenko/claude-desktop-extension-bear-notes.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/vasylenko/claude-desktop-extension-bear-notes/issues"
63
+ },
64
+ "homepage": "https://github.com/vasylenko/claude-desktop-extension-bear-notes#readme",
65
+ "bin": {
66
+ "bear-notes-mcp": "dist/main.js"
67
+ },
68
+ "files": [
69
+ "dist",
70
+ "README.md",
71
+ "LICENSE.md"
72
+ ],
73
+ "publishConfig": {
74
+ "access": "public"
75
+ }
76
+ }