improved-notion-mcp 1.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/index.js ADDED
@@ -0,0 +1,693 @@
1
+ import { Client } from "@notionhq/client";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ const NOTION_TOKEN = process.env.NOTION_TOKEN || process.env.NOTION_API_KEY;
6
+ if (!NOTION_TOKEN) {
7
+ console.error("NOTION_TOKEN or NOTION_API_KEY environment variable is required");
8
+ process.exit(1);
9
+ }
10
+ const notion = new Client({ auth: NOTION_TOKEN });
11
+ function convertBlockToMarkdown(block) {
12
+ const type = block.type;
13
+ const data = block[type];
14
+ switch (type) {
15
+ case "paragraph":
16
+ return data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || "";
17
+ case "heading_1":
18
+ return `## ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
19
+ case "heading_2":
20
+ return `### ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
21
+ case "heading_3":
22
+ return `#### ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
23
+ case "bulleted_list_item":
24
+ return `- ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
25
+ case "numbered_list_item":
26
+ return `1. ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
27
+ case "to_do":
28
+ const checked = data.checked ? "[x]" : "[ ]";
29
+ return `${checked} ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
30
+ case "toggle":
31
+ return `> ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
32
+ case "code":
33
+ const lang = data.language || "";
34
+ const code = data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || "";
35
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
36
+ case "quote":
37
+ return `> ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
38
+ case "divider":
39
+ return "---";
40
+ case "image":
41
+ const imgUrl = data.file?.url || data.external?.url || "";
42
+ const imgCaption = data.caption?.map((t) => richTextToMarkdown(t)).join("") || "";
43
+ return imgCaption ? `![${imgCaption}](${imgUrl})` : `![](${imgUrl})`;
44
+ case "video":
45
+ const videoUrl = data.file?.url || data.external?.url || "";
46
+ return `[Video](${videoUrl})`;
47
+ case "embed":
48
+ return `[Embed](${data.url})`;
49
+ case "link_preview":
50
+ return `[Link Preview](${data.url})`;
51
+ case "bookmark":
52
+ return `[Bookmark](${data.url}) ${data.caption?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
53
+ case "callout":
54
+ const icon = data.icon?.emoji || "💡";
55
+ return `${icon} ${data.rich_text?.map((t) => richTextToMarkdown(t)).join("") || ""}`;
56
+ case "equation":
57
+ return `$$${data.expression}$$`;
58
+ default:
59
+ return `[${type}]`;
60
+ }
61
+ }
62
+ function richTextToMarkdown(richText) {
63
+ if (!richText)
64
+ return "";
65
+ let text = richText.plain_text || "";
66
+ if (richText.annotations) {
67
+ const { bold, italic, strikethrough, code, underline } = richText.annotations;
68
+ if (code)
69
+ text = `\`${text}\``;
70
+ if (bold)
71
+ text = `**${text}**`;
72
+ if (italic)
73
+ text = `*${text}*`;
74
+ if (strikethrough)
75
+ text = `~~${text}~~`;
76
+ if (underline)
77
+ text = `<u>${text}</u>`;
78
+ }
79
+ if (richText.href) {
80
+ text = `[${text}](${richText.href})`;
81
+ }
82
+ return text;
83
+ }
84
+ function convertPageToMarkdown(page) {
85
+ let md = "";
86
+ if (page.properties) {
87
+ const titleProp = Object.values(page.properties).find((p) => p.type === "title");
88
+ if (titleProp?.title?.length) {
89
+ md += `# ${titleProp.title.map((t) => t.plain_text).join("")}\n\n`;
90
+ }
91
+ md += `---\n`;
92
+ for (const [key, prop] of Object.entries(page.properties)) {
93
+ if (prop.type === "title")
94
+ continue;
95
+ const propValue = formatProperty(prop);
96
+ if (propValue) {
97
+ md += `**${key}**: ${propValue}\n`;
98
+ }
99
+ }
100
+ md += `---\n\n`;
101
+ }
102
+ return md;
103
+ }
104
+ function formatProperty(prop) {
105
+ switch (prop.type) {
106
+ case "rich_text":
107
+ return prop.rich_text?.map((t) => t.plain_text).join("") || null;
108
+ case "url":
109
+ return prop.url;
110
+ case "checkbox":
111
+ return prop.checkbox ? "✅" : "⬜";
112
+ case "date":
113
+ return prop.date?.start || null;
114
+ case "select":
115
+ return prop.select?.name || null;
116
+ case "multi_select":
117
+ return prop.multi_select?.map((s) => s.name).join(", ") || null;
118
+ case "status":
119
+ return prop.status?.name || null;
120
+ case "people":
121
+ return prop.people?.map((p) => p.name || p.person?.email).join(", ") || null;
122
+ case "email":
123
+ return prop.email;
124
+ case "phone_number":
125
+ return prop.phone_number;
126
+ case "number":
127
+ return prop.number?.toString() || null;
128
+ default:
129
+ return null;
130
+ }
131
+ }
132
+ function getTitle(item) {
133
+ if (item.properties) {
134
+ const titleProp = Object.values(item.properties).find((p) => p.type === "title");
135
+ if (titleProp?.title?.length) {
136
+ return titleProp.title.map((t) => t.plain_text).join("");
137
+ }
138
+ }
139
+ if (item.name) {
140
+ return item.name;
141
+ }
142
+ return "Untitled";
143
+ }
144
+ function markdownToBlocks(markdown) {
145
+ const lines = markdown.split("\n");
146
+ const blocks = [];
147
+ let currentParagraph = [];
148
+ let inCodeBlock = false;
149
+ let codeLanguage = "";
150
+ let codeContent = [];
151
+ const flushParagraph = () => {
152
+ if (currentParagraph.length > 0) {
153
+ const text = currentParagraph.join(" ").trim();
154
+ if (text) {
155
+ blocks.push({
156
+ object: "block",
157
+ type: "paragraph",
158
+ paragraph: {
159
+ rich_text: [{ type: "text", text: { content: text } }],
160
+ },
161
+ });
162
+ }
163
+ currentParagraph = [];
164
+ }
165
+ };
166
+ for (const line of lines) {
167
+ if (line.startsWith("```")) {
168
+ if (!inCodeBlock) {
169
+ flushParagraph();
170
+ inCodeBlock = true;
171
+ codeLanguage = line.slice(3).trim();
172
+ codeContent = [];
173
+ }
174
+ else {
175
+ blocks.push({
176
+ object: "block",
177
+ type: "code",
178
+ code: {
179
+ rich_text: [{ type: "text", text: { content: codeContent.join("\n") } }],
180
+ language: codeLanguage || "plain text",
181
+ },
182
+ });
183
+ inCodeBlock = false;
184
+ codeLanguage = "";
185
+ codeContent = [];
186
+ }
187
+ }
188
+ else if (inCodeBlock) {
189
+ codeContent.push(line);
190
+ }
191
+ else if (line.startsWith("## ")) {
192
+ flushParagraph();
193
+ blocks.push({
194
+ object: "block",
195
+ type: "heading_2",
196
+ heading_2: {
197
+ rich_text: [{ type: "text", text: { content: line.slice(3) } }],
198
+ },
199
+ });
200
+ }
201
+ else if (line.startsWith("### ")) {
202
+ flushParagraph();
203
+ blocks.push({
204
+ object: "block",
205
+ type: "heading_3",
206
+ heading_3: {
207
+ rich_text: [{ type: "text", text: { content: line.slice(4) } }],
208
+ },
209
+ });
210
+ }
211
+ else if (line.startsWith("# ")) {
212
+ flushParagraph();
213
+ blocks.push({
214
+ object: "block",
215
+ type: "heading_1",
216
+ heading_1: {
217
+ rich_text: [{ type: "text", text: { content: line.slice(2) } }],
218
+ },
219
+ });
220
+ }
221
+ else if (line.startsWith("- ")) {
222
+ flushParagraph();
223
+ blocks.push({
224
+ object: "block",
225
+ type: "bulleted_list_item",
226
+ bulleted_list_item: {
227
+ rich_text: [{ type: "text", text: { content: line.slice(2) } }],
228
+ },
229
+ });
230
+ }
231
+ else if (line.match(/^\d+\. /)) {
232
+ flushParagraph();
233
+ blocks.push({
234
+ object: "block",
235
+ type: "numbered_list_item",
236
+ numbered_list_item: {
237
+ rich_text: [{ type: "text", text: { content: line.replace(/^\d+\. /, "") } }],
238
+ },
239
+ });
240
+ }
241
+ else if (line.startsWith("> ")) {
242
+ flushParagraph();
243
+ blocks.push({
244
+ object: "block",
245
+ type: "quote",
246
+ quote: {
247
+ rich_text: [{ type: "text", text: { content: line.slice(2) } }],
248
+ },
249
+ });
250
+ }
251
+ else if (line === "---") {
252
+ flushParagraph();
253
+ blocks.push({ object: "block", type: "divider", divider: {} });
254
+ }
255
+ else if (line.trim()) {
256
+ currentParagraph.push(line);
257
+ }
258
+ }
259
+ flushParagraph();
260
+ return blocks;
261
+ }
262
+ class NotionMcpServer {
263
+ server;
264
+ constructor() {
265
+ this.server = new Server({
266
+ name: "improved-notion-mcp",
267
+ version: "1.0.0",
268
+ }, {
269
+ capabilities: {
270
+ tools: {},
271
+ resources: {},
272
+ },
273
+ });
274
+ this.setupTools();
275
+ this.setupResources();
276
+ }
277
+ setupTools() {
278
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
279
+ return {
280
+ tools: [
281
+ {
282
+ name: "notion_search",
283
+ description: "Search for pages and databases in Notion",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ query: { type: "string", description: "Search query" },
288
+ filter: {
289
+ type: "string",
290
+ enum: ["page", "database", "all"],
291
+ description: "Filter by type",
292
+ },
293
+ },
294
+ required: ["query"],
295
+ },
296
+ },
297
+ {
298
+ name: "notion_get_page",
299
+ description: "Get a page's content as markdown (token-optimized)",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {
303
+ pageId: { type: "string", description: "Page ID" },
304
+ },
305
+ required: ["pageId"],
306
+ },
307
+ },
308
+ {
309
+ name: "notion_create_page",
310
+ description: "Create a new page in Notion",
311
+ inputSchema: {
312
+ type: "object",
313
+ properties: {
314
+ parentId: { type: "string", description: "Parent page ID or database ID" },
315
+ title: { type: "string", description: "Page title" },
316
+ content: { type: "string", description: "Initial content in markdown" },
317
+ },
318
+ required: ["parentId", "title"],
319
+ },
320
+ },
321
+ {
322
+ name: "notion_create_database_entry",
323
+ description: "Create a new entry in a database",
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ databaseId: { type: "string", description: "Database ID" },
328
+ properties: { type: "object", description: "Database properties" },
329
+ },
330
+ required: ["databaseId", "properties"],
331
+ },
332
+ },
333
+ {
334
+ name: "notion_query_database",
335
+ description: "Query a database with filters",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {
339
+ databaseId: { type: "string", description: "Database ID" },
340
+ pageSize: { type: "number", description: "Number of results" },
341
+ },
342
+ required: ["databaseId"],
343
+ },
344
+ },
345
+ {
346
+ name: "notion_update_page",
347
+ description: "Update a page's properties or content",
348
+ inputSchema: {
349
+ type: "object",
350
+ properties: {
351
+ pageId: { type: "string", description: "Page ID" },
352
+ content: { type: "string", description: "Content to add (appended)" },
353
+ },
354
+ required: ["pageId"],
355
+ },
356
+ },
357
+ {
358
+ name: "notion_batch_create",
359
+ description: "Create multiple pages at once (batch operation)",
360
+ inputSchema: {
361
+ type: "object",
362
+ properties: {
363
+ parentId: { type: "string", description: "Parent page ID" },
364
+ pages: {
365
+ type: "array",
366
+ items: {
367
+ type: "object",
368
+ properties: {
369
+ title: { type: "string" },
370
+ content: { type: "string" },
371
+ },
372
+ required: ["title"],
373
+ },
374
+ description: "Array of pages to create",
375
+ },
376
+ },
377
+ required: ["parentId", "pages"],
378
+ },
379
+ },
380
+ {
381
+ name: "notion_append_content",
382
+ description: "Append content to a page (composite operation)",
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: {
386
+ pageId: { type: "string", description: "Page ID" },
387
+ markdown: { type: "string", description: "Markdown content to append" },
388
+ },
389
+ required: ["pageId", "markdown"],
390
+ },
391
+ },
392
+ {
393
+ name: "notion_delete_page",
394
+ description: "Delete (archive) a page",
395
+ inputSchema: {
396
+ type: "object",
397
+ properties: {
398
+ pageId: { type: "string", description: "Page ID to delete" },
399
+ },
400
+ required: ["pageId"],
401
+ },
402
+ },
403
+ {
404
+ name: "notion_list_databases",
405
+ description: "List all accessible databases",
406
+ inputSchema: {
407
+ type: "object",
408
+ properties: {},
409
+ },
410
+ },
411
+ {
412
+ name: "notion_get_database_schema",
413
+ description: "Get database schema (properties)",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: {
417
+ databaseId: { type: "string", description: "Database ID" },
418
+ },
419
+ required: ["databaseId"],
420
+ },
421
+ },
422
+ {
423
+ name: "notion_list_users",
424
+ description: "List users in the workspace",
425
+ inputSchema: {
426
+ type: "object",
427
+ properties: {},
428
+ },
429
+ },
430
+ ],
431
+ };
432
+ });
433
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
434
+ const { name, arguments: args } = request.params;
435
+ try {
436
+ switch (name) {
437
+ case "notion_search":
438
+ return await this.handleSearch(args);
439
+ case "notion_get_page":
440
+ return await this.handleGetPage(args);
441
+ case "notion_create_page":
442
+ return await this.handleCreatePage(args);
443
+ case "notion_create_database_entry":
444
+ return await this.handleCreateDatabaseEntry(args);
445
+ case "notion_query_database":
446
+ return await this.handleQueryDatabase(args);
447
+ case "notion_update_page":
448
+ return await this.handleUpdatePage(args);
449
+ case "notion_batch_create":
450
+ return await this.handleBatchCreate(args);
451
+ case "notion_append_content":
452
+ return await this.handleAppendContent(args);
453
+ case "notion_delete_page":
454
+ return await this.handleDeletePage(args);
455
+ case "notion_list_databases":
456
+ return await this.handleListDatabases();
457
+ case "notion_get_database_schema":
458
+ return await this.handleGetDatabaseSchema(args);
459
+ case "notion_list_users":
460
+ return await this.handleListUsers();
461
+ default:
462
+ return this.errorResult(`Unknown tool: ${name}`);
463
+ }
464
+ }
465
+ catch (error) {
466
+ return this.errorResult(error.message);
467
+ }
468
+ });
469
+ }
470
+ setupResources() {
471
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
472
+ return {
473
+ resources: [
474
+ {
475
+ uri: "notion://docs",
476
+ name: "Notion MCP documentation",
477
+ description: "Documentation for available tools",
478
+ mimeType: "text/markdown",
479
+ },
480
+ ],
481
+ };
482
+ });
483
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
484
+ const { uri } = request.params;
485
+ if (uri === "notion://docs") {
486
+ return {
487
+ contents: [
488
+ {
489
+ uri: "notion://docs",
490
+ mimeType: "text/markdown",
491
+ text: `# Notion MCP Server Tools
492
+
493
+ ## Available Tools
494
+
495
+ ### Search & Discovery
496
+ - \`notion_search\` - Search pages and databases
497
+ - \`notion_list_databases\` - List all databases
498
+ - \`notion_list_users\` - List workspace users
499
+
500
+ ### Read Operations
501
+ - \`notion_get_page\` - Get page as markdown (token-optimized ~80% reduction)
502
+ - \`notion_get_database_schema\` - Get database schema
503
+ - \`notion_query_database\` - Query database with filters
504
+
505
+ ### Write Operations
506
+ - \`notion_create_page\` - Create a new page
507
+ - \`notion_create_database_entry\` - Create database entry
508
+ - \`notion_update_page\` - Update page properties/content
509
+ - \`notion_append_content\` - Append markdown to page
510
+
511
+ ### Batch & Composite Operations
512
+ - \`notion_batch_create\` - Create multiple pages at once
513
+
514
+ ### Delete Operations
515
+ - \`notion_delete_page\` - Archive a page
516
+
517
+ ## Token Optimization
518
+ All read operations return markdown instead of raw JSON, reducing token usage by ~80%.
519
+ `,
520
+ },
521
+ ],
522
+ };
523
+ }
524
+ return { contents: [] };
525
+ });
526
+ }
527
+ async handleSearch(args) {
528
+ const response = await notion.search({
529
+ query: args.query,
530
+ // @ts-ignore - Notion API typing issue
531
+ filter: args.filter !== "all" && args.filter ? { value: args.filter, property: "object" } : undefined,
532
+ page_size: 20,
533
+ });
534
+ const results = response.results.map((item) => ({
535
+ id: item.id,
536
+ type: item.object,
537
+ title: getTitle(item),
538
+ url: item.url,
539
+ created_time: item.created_time,
540
+ }));
541
+ return this.successResult(JSON.stringify(results, null, 2));
542
+ }
543
+ async handleGetPage(args) {
544
+ const page = await notion.pages.retrieve({ page_id: args.pageId });
545
+ let md = convertPageToMarkdown(page);
546
+ const blocks = await notion.blocks.children.list({ block_id: args.pageId });
547
+ for (const block of blocks.results) {
548
+ md += convertBlockToMarkdown(block) + "\n";
549
+ }
550
+ return this.successResult(md);
551
+ }
552
+ async handleCreatePage(args) {
553
+ const blocks = args.content ? markdownToBlocks(args.content) : [];
554
+ const page = await notion.pages.create({
555
+ parent: { page_id: args.parentId },
556
+ properties: {
557
+ title: {
558
+ title: [{ text: { content: args.title } }],
559
+ },
560
+ },
561
+ });
562
+ if (blocks.length > 0) {
563
+ await notion.blocks.children.append({
564
+ block_id: page.id,
565
+ children: blocks,
566
+ });
567
+ }
568
+ const url = page.url || `https://notion.so/${page.id.replace(/-/g, '')}`;
569
+ return this.successResult(JSON.stringify({ id: page.id, url }, null, 2));
570
+ }
571
+ async handleCreateDatabaseEntry(args) {
572
+ const page = await notion.pages.create({
573
+ parent: { database_id: args.databaseId },
574
+ properties: args.properties,
575
+ });
576
+ const url = page.url || `https://notion.so/${page.id.replace(/-/g, '')}`;
577
+ return this.successResult(JSON.stringify({ id: page.id, url }, null, 2));
578
+ }
579
+ async handleQueryDatabase(args) {
580
+ // @ts-ignore - Notion SDK typing issue
581
+ const response = await notion.databases.query({
582
+ database_id: args.databaseId,
583
+ page_size: args.pageSize || 20,
584
+ });
585
+ const results = response.results.map((page) => ({
586
+ id: page.id,
587
+ title: getTitle(page),
588
+ url: page.url,
589
+ properties: page.properties,
590
+ }));
591
+ return this.successResult(JSON.stringify(results, null, 2));
592
+ }
593
+ async handleUpdatePage(args) {
594
+ if (args.content) {
595
+ const blocks = markdownToBlocks(args.content);
596
+ await notion.blocks.children.append({
597
+ block_id: args.pageId,
598
+ children: blocks,
599
+ });
600
+ }
601
+ return this.successResult("Page updated successfully");
602
+ }
603
+ async handleBatchCreate(args) {
604
+ const results = [];
605
+ for (const page of args.pages) {
606
+ const pageData = {
607
+ parent: { page_id: args.parentId },
608
+ properties: {
609
+ title: {
610
+ title: [{ text: { content: page.title } }],
611
+ },
612
+ },
613
+ };
614
+ const created = await notion.pages.create(pageData);
615
+ if (page.content) {
616
+ const blocks = markdownToBlocks(page.content);
617
+ await notion.blocks.children.append({
618
+ block_id: created.id,
619
+ children: blocks,
620
+ });
621
+ }
622
+ const url = created.url || `https://notion.so/${created.id.replace(/-/g, '')}`;
623
+ results.push({ id: created.id, title: page.title, url });
624
+ }
625
+ return this.successResult(JSON.stringify(results, null, 2));
626
+ }
627
+ async handleAppendContent(args) {
628
+ const blocks = markdownToBlocks(args.markdown);
629
+ await notion.blocks.children.append({
630
+ block_id: args.pageId,
631
+ children: blocks,
632
+ });
633
+ return this.successResult("Content appended successfully");
634
+ }
635
+ async handleDeletePage(args) {
636
+ await notion.pages.update({
637
+ page_id: args.pageId,
638
+ archived: true,
639
+ });
640
+ return this.successResult("Page archived successfully");
641
+ }
642
+ async handleListDatabases() {
643
+ const response = await notion.search({
644
+ filter: { value: "page", property: "object" },
645
+ page_size: 50,
646
+ });
647
+ const databases = response.results
648
+ .filter((item) => item.object === "page" && item.parent?.type === "workspace")
649
+ .map((db) => ({
650
+ id: db.id,
651
+ title: getTitle(db),
652
+ url: db.url,
653
+ }));
654
+ return this.successResult(JSON.stringify(databases, null, 2));
655
+ }
656
+ async handleGetDatabaseSchema(args) {
657
+ const db = await notion.databases.retrieve({ database_id: args.databaseId });
658
+ return this.successResult(JSON.stringify(db, null, 2));
659
+ }
660
+ async handleListUsers() {
661
+ // @ts-ignore - Notion SDK typing issue
662
+ const response = await notion.users.list({});
663
+ const users = response.results.map((user) => ({
664
+ id: user.id,
665
+ name: user.name,
666
+ type: user.type,
667
+ email: user.person?.email,
668
+ }));
669
+ return this.successResult(JSON.stringify(users, null, 2));
670
+ }
671
+ successResult(text) {
672
+ return {
673
+ content: [{ type: "text", text }],
674
+ };
675
+ }
676
+ errorResult(message) {
677
+ return {
678
+ content: [{ type: "text", text: `Error: ${message}` }],
679
+ isError: true,
680
+ };
681
+ }
682
+ async run(transport) {
683
+ await this.server.connect(transport);
684
+ await transport.start();
685
+ }
686
+ }
687
+ async function main() {
688
+ const transport = new StdioServerTransport();
689
+ const server = new NotionMcpServer();
690
+ await server.run(transport);
691
+ }
692
+ main().catch(console.error);
693
+ //# sourceMappingURL=index.js.map