engram-notion-mcp 0.1.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.
Files changed (2) hide show
  1. package/package.json +19 -0
  2. package/src/index.js +427 -0
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "engram-notion-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Node.js implementation of the Engram Notion MCP server",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node src/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.0.1",
12
+ "@notionhq/client": "^2.2.14",
13
+ "sqlite3": "^5.1.7",
14
+ "dotenv": "^16.4.5"
15
+ },
16
+ "bin": {
17
+ "engram-mcp": "src/index.js"
18
+ }
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import { Client } from "@notionhq/client";
9
+ import sqlite3 from "sqlite3";
10
+ import dotenv from "dotenv";
11
+ import path from "path";
12
+ import { fileURLToPath } from "url";
13
+ import fs from "fs";
14
+ import os from "os";
15
+ import https from "https";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ // Load .env from parent directory (monorepo structure implication or consistent with original script location expectation)
19
+ dotenv.config({ path: path.join(__dirname, "../.env") });
20
+
21
+ // Initialize Notion Client
22
+ const notion = new Client({ auth: process.env.NOTION_API_KEY });
23
+
24
+ // Database Initialization
25
+ const get_default_db_path = () => {
26
+ const system = os.platform();
27
+ const home = os.homedir();
28
+ let basePath;
29
+
30
+ if(system === "win32") {
31
+ basePath = path.join(home, ".engram", "data");
32
+ } else if(system === "darwin") {
33
+ basePath = path.join(home, "Library", ".engram", "data");
34
+ } else {
35
+ basePath = path.join(home, ".engram", "data");
36
+ }
37
+
38
+ return path.join(basePath, "agent_memory.db");
39
+ };
40
+
41
+ let DB_PATH = process.env.AGENT_MEMORY_PATH || get_default_db_path();
42
+
43
+ // Ensure directory exists
44
+ try {
45
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
46
+ } catch(e) {
47
+ console.error(`Warning: Could not create database directory ${path.dirname(DB_PATH)}: ${e}`);
48
+ DB_PATH = "agent_memory.db";
49
+ }
50
+
51
+ const db = new sqlite3.Database(DB_PATH);
52
+
53
+ const init_db = () => {
54
+ db.serialize(() => {
55
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS memory_index USING fts5(content, metadata, tokenize='porter')`, (err) => {
56
+ if(err) {
57
+ // Fallback
58
+ db.run(`CREATE TABLE IF NOT EXISTS memory_index (content TEXT, metadata TEXT)`);
59
+ }
60
+ });
61
+ });
62
+ };
63
+
64
+ init_db();
65
+
66
+ const _save_to_db = (content, metadata = null) => {
67
+ return new Promise((resolve, reject) => {
68
+ try {
69
+ // Create a fresh connection or use the existing one? Python code creates a new connection every time.
70
+ // SQLite in Node usually handles concurrency better with a single connection in WAL mode, but standard is single connection object.
71
+ // We will use the global `db` object here, assuming single-threaded event loop access.
72
+ const meta_str = metadata ? JSON.stringify(metadata) : "{}";
73
+ const stmt = db.prepare("INSERT INTO memory_index (content, metadata) VALUES (?, ?)");
74
+ stmt.run(content, meta_str, function(err) {
75
+ if(err) {
76
+ console.error(`Error saving to DB: ${err}`);
77
+ reject(err);
78
+ } else {
79
+ resolve();
80
+ }
81
+ });
82
+ stmt.finalize();
83
+ } catch(e) {
84
+ console.error(`Error saving to DB: ${e}`);
85
+ reject(e);
86
+ }
87
+ });
88
+ };
89
+
90
+ // Tools implementation
91
+ const tools = {
92
+ remember_fact: async ({ fact }) => {
93
+ await _save_to_db(fact, { type: "manual_fact", timestamp: new Date().toISOString() });
94
+ return `Remembered: ${fact}`;
95
+ },
96
+
97
+ create_page: async ({ title, content = "", parent_id }) => {
98
+ const target_parent = parent_id || process.env.NOTION_PAGE_ID;
99
+ if(!target_parent) {
100
+ return "Error: No parent_id provided and NOTION_PAGE_ID not set. Please specify where to create this page.";
101
+ }
102
+
103
+ try {
104
+ const children = [];
105
+ if(content) {
106
+ children.push({
107
+ object: "block",
108
+ type: "paragraph",
109
+ paragraph: {
110
+ rich_text: [ { type: "text", text: { content: content } } ]
111
+ }
112
+ });
113
+ }
114
+
115
+ const response = await notion.pages.create({
116
+ parent: { page_id: target_parent },
117
+ properties: {
118
+ title: [
119
+ {
120
+ text: {
121
+ content: title
122
+ }
123
+ }
124
+ ]
125
+ },
126
+ children: children
127
+ });
128
+
129
+ const page_url = response.url || "URL not found";
130
+
131
+ // Spy Logging
132
+ const log_content = `Created Page: ${title}. Content snippet: ${content.substring(0, 100)}`;
133
+ const meta = {
134
+ type: "create_page",
135
+ title: title,
136
+ url: page_url,
137
+ timestamp: new Date().toISOString()
138
+ };
139
+ await _save_to_db(log_content, meta);
140
+
141
+ return `Successfully created page '${title}'. URL: ${page_url}`;
142
+ } catch(e) {
143
+ return `Error creating page: ${e.message}`;
144
+ }
145
+ },
146
+
147
+ update_page: async ({ page_id, title, content, type = "paragraph", language = "plain text" }) => {
148
+ // Spy Logging
149
+ const log_content = `Updated Page ${page_id} with section '${title}'. Content: ${content.substring(0, 100)}...`;
150
+ const meta = {
151
+ type: "update_page",
152
+ page_id: page_id,
153
+ section_title: title,
154
+ timestamp: new Date().toISOString()
155
+ };
156
+ await _save_to_db(log_content, meta);
157
+
158
+ const validTypes = [ "paragraph", "bulleted_list_item", "code", "table" ];
159
+ if(!validTypes.includes(type)) {
160
+ return `Error: Invalid type '${type}'. Must be 'paragraph', 'bulleted_list_item', 'code', or 'table'.`;
161
+ }
162
+
163
+ const children = [
164
+ {
165
+ object: "block",
166
+ type: "heading_2",
167
+ heading_2: {
168
+ rich_text: [ { type: "text", text: { content: title } } ]
169
+ }
170
+ }
171
+ ];
172
+
173
+ if(type === "code") {
174
+ let cleaned_content = content.trim().replace(/^```(?:[\w\+\-]+)?\n?/, "").replace(/\n?```$/, "");
175
+ children.push({
176
+ object: "block",
177
+ type: "code",
178
+ code: {
179
+ rich_text: [ { type: "text", text: { content: cleaned_content } } ],
180
+ language: language
181
+ }
182
+ });
183
+ } else if(type === "table") {
184
+ const rows = [];
185
+ const lines = content.trim().split('\n');
186
+ let has_header = false;
187
+
188
+ for(let i = 0; i < lines.length; i++) {
189
+ const line = lines[ i ];
190
+ if(/^\s*\|?[\s\-:|]+\|?\s*$/.test(line)) {
191
+ if(i === 1) has_header = true;
192
+ continue;
193
+ }
194
+
195
+ let cells = line.split('|').map(c => c.trim());
196
+ if(line.trim().startsWith('|') && cells.length > 0) cells.shift();
197
+ if(line.trim().endsWith('|') && cells.length > 0) cells.pop();
198
+
199
+ if(cells.length > 0) {
200
+ rows.push(cells);
201
+ }
202
+ }
203
+
204
+ if(rows.length === 0) return "Error: Could not parse table content.";
205
+
206
+ const table_width = rows[ 0 ].length;
207
+ const table_children = rows.map(row => {
208
+ while(row.length < table_width) row.push("");
209
+ return {
210
+ object: "block",
211
+ type: "table_row",
212
+ table_row: {
213
+ cells: row.map(cell => [ { type: "text", text: { content: cell } } ])
214
+ }
215
+ };
216
+ });
217
+
218
+ children.push({
219
+ object: "block",
220
+ type: "table",
221
+ table: {
222
+ table_width: table_width,
223
+ has_column_header: has_header,
224
+ has_row_header: false,
225
+ children: table_children
226
+ }
227
+ });
228
+
229
+ } else {
230
+ children.push({
231
+ object: "block",
232
+ type: type,
233
+ [ type ]: {
234
+ rich_text: [ { type: "text", text: { content: content } } ]
235
+ }
236
+ });
237
+ }
238
+
239
+ try {
240
+ await notion.blocks.children.append({ block_id: page_id, children: children });
241
+ return `Successfully updated page ${page_id}: ${title}`;
242
+ } catch(e) {
243
+ return `Error updating page: ${e.message}`;
244
+ }
245
+ },
246
+
247
+ log_to_notion: async ({ title, content, type = "paragraph", language = "plain text", page_id }) => {
248
+ const target_page = page_id || process.env.NOTION_PAGE_ID;
249
+ if(!target_page) {
250
+ return "Error: No page_id provided and NOTION_PAGE_ID not set.";
251
+ }
252
+ // Re-use update_page logic
253
+ return tools.update_page({ page_id: target_page, title, content, type, language });
254
+ },
255
+
256
+ list_sub_pages: async ({ parent_id }) => {
257
+ let target_id = parent_id;
258
+ if(!target_id) {
259
+ target_id = process.env.NOTION_PAGE_ID;
260
+ if(!target_id) return "Error: NOTION_PAGE_ID not set and no parent_id provided.";
261
+ }
262
+
263
+ try {
264
+ const response = await notion.blocks.children.list({ block_id: target_id });
265
+ const pages = [];
266
+ for(const block of response.results) {
267
+ if(block.type === "child_page") {
268
+ pages.push(`- ${block.child_page.title} (ID: ${block.id})`);
269
+ }
270
+ }
271
+
272
+ if(pages.length === 0) return "No sub-pages found.";
273
+ return pages.join("\n");
274
+ } catch(e) {
275
+ return `Error listing sub-pages: ${e.message}`;
276
+ }
277
+ },
278
+
279
+ send_alert: async ({ message }) => {
280
+ const bot_token = process.env.TELEGRAM_BOT_TOKEN;
281
+ const chat_id = process.env.TELEGRAM_CHAT_ID;
282
+
283
+ if(!bot_token || !chat_id) {
284
+ return "Error: Telegram credentials not set.";
285
+ }
286
+
287
+ try {
288
+ // Using built-in https request for zero dependency http, or we could use fetch if node 18+
289
+ // Using fetch is cleaner.
290
+ const response = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
291
+ method: 'POST',
292
+ headers: {
293
+ 'Content-Type': 'application/json'
294
+ },
295
+ body: JSON.stringify({ chat_id: chat_id, text: message })
296
+ });
297
+
298
+ if(!response.ok) {
299
+ throw new Error(`HTTP error! status: ${response.status}`);
300
+ }
301
+ return "Alert sent successfully.";
302
+ } catch(e) {
303
+ return `Failed to send alert: ${e.message}`;
304
+ }
305
+ }
306
+ };
307
+
308
+ const server = new Server(
309
+ {
310
+ name: "engram-mcp",
311
+ version: "0.1.0",
312
+ },
313
+ {
314
+ capabilities: {
315
+ tools: {},
316
+ },
317
+ }
318
+ );
319
+
320
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
321
+ return {
322
+ tools: [
323
+ {
324
+ name: "remember_fact",
325
+ description: "Stores a fact in the agent's internal SQLite memory.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ fact: { type: "string" },
330
+ },
331
+ required: [ "fact" ],
332
+ },
333
+ },
334
+ {
335
+ name: "create_page",
336
+ description: "Creates a new sub-page in Notion.",
337
+ inputSchema: {
338
+ type: "object",
339
+ properties: {
340
+ title: { type: "string" },
341
+ content: { type: "string" },
342
+ parent_id: { type: "string" }
343
+ },
344
+ required: [ "title" ],
345
+ },
346
+ },
347
+ {
348
+ name: "update_page",
349
+ description: "Appends content to a specific Notion page.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ page_id: { type: "string" },
354
+ title: { type: "string" },
355
+ content: { type: "string" },
356
+ type: { type: "string", enum: [ "paragraph", "bulleted_list_item", "code", "table" ], default: "paragraph" },
357
+ language: { type: "string", default: "plain text" }
358
+ },
359
+ required: [ "page_id", "title", "content" ],
360
+ },
361
+ },
362
+ {
363
+ name: "log_to_notion",
364
+ description: "Logs an entry to a Notion page.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ title: { type: "string" },
369
+ content: { type: "string" },
370
+ type: { type: "string", enum: [ "paragraph", "bulleted_list_item", "code", "table" ], default: "paragraph" },
371
+ language: { type: "string", default: "plain text" },
372
+ page_id: { type: "string" }
373
+ },
374
+ required: [ "title", "content" ],
375
+ },
376
+ },
377
+ {
378
+ name: "list_sub_pages",
379
+ description: "Lists sub-pages under a parent page.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ parent_id: { type: "string" }
384
+ },
385
+ },
386
+ },
387
+ {
388
+ name: "send_alert",
389
+ description: "Sends a push notification via Telegram.",
390
+ inputSchema: {
391
+ type: "object",
392
+ properties: {
393
+ message: { type: "string" }
394
+ },
395
+ required: [ "message" ],
396
+ },
397
+ }
398
+ ],
399
+ };
400
+ });
401
+
402
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
403
+ const { name, arguments: args } = request.params;
404
+
405
+ if(tools[ name ]) {
406
+ return {
407
+ content: [
408
+ {
409
+ type: "text",
410
+ text: await tools[ name ](args)
411
+ }
412
+ ]
413
+ };
414
+ } else {
415
+ throw new Error(`Tool ${name} not found`);
416
+ }
417
+ });
418
+
419
+ async function main() {
420
+ const transport = new StdioServerTransport();
421
+ await server.connect(transport);
422
+ }
423
+
424
+ main().catch((error) => {
425
+ console.error("Server error:", error);
426
+ process.exit(1);
427
+ });