@m2ai-mcp/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 (60) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -0
  4. package/dist/index.d.ts +10 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +374 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/tools/blocks.d.ts +43 -0
  9. package/dist/tools/blocks.d.ts.map +1 -0
  10. package/dist/tools/blocks.js +124 -0
  11. package/dist/tools/blocks.js.map +1 -0
  12. package/dist/tools/databases.d.ts +71 -0
  13. package/dist/tools/databases.d.ts.map +1 -0
  14. package/dist/tools/databases.js +121 -0
  15. package/dist/tools/databases.js.map +1 -0
  16. package/dist/tools/index.d.ts +9 -0
  17. package/dist/tools/index.d.ts.map +1 -0
  18. package/dist/tools/index.js +9 -0
  19. package/dist/tools/index.js.map +1 -0
  20. package/dist/tools/pages.d.ts +72 -0
  21. package/dist/tools/pages.d.ts.map +1 -0
  22. package/dist/tools/pages.js +153 -0
  23. package/dist/tools/pages.js.map +1 -0
  24. package/dist/tools/search.d.ts +28 -0
  25. package/dist/tools/search.d.ts.map +1 -0
  26. package/dist/tools/search.js +62 -0
  27. package/dist/tools/search.js.map +1 -0
  28. package/dist/tools/users.d.ts +33 -0
  29. package/dist/tools/users.d.ts.map +1 -0
  30. package/dist/tools/users.js +51 -0
  31. package/dist/tools/users.js.map +1 -0
  32. package/dist/utils/markdown-converter.d.ts +31 -0
  33. package/dist/utils/markdown-converter.d.ts.map +1 -0
  34. package/dist/utils/markdown-converter.js +355 -0
  35. package/dist/utils/markdown-converter.js.map +1 -0
  36. package/dist/utils/notion-client.d.ts +32 -0
  37. package/dist/utils/notion-client.d.ts.map +1 -0
  38. package/dist/utils/notion-client.js +111 -0
  39. package/dist/utils/notion-client.js.map +1 -0
  40. package/dist/utils/types.d.ts +212 -0
  41. package/dist/utils/types.d.ts.map +1 -0
  42. package/dist/utils/types.js +18 -0
  43. package/dist/utils/types.js.map +1 -0
  44. package/jest.config.cjs +33 -0
  45. package/package.json +53 -0
  46. package/server.json +92 -0
  47. package/src/index.ts +435 -0
  48. package/src/tools/blocks.ts +184 -0
  49. package/src/tools/databases.ts +216 -0
  50. package/src/tools/index.ts +9 -0
  51. package/src/tools/pages.ts +253 -0
  52. package/src/tools/search.ts +96 -0
  53. package/src/tools/users.ts +93 -0
  54. package/src/utils/markdown-converter.ts +408 -0
  55. package/src/utils/notion-client.ts +159 -0
  56. package/src/utils/types.ts +237 -0
  57. package/tests/markdown-converter.test.ts +252 -0
  58. package/tests/notion-client.test.ts +67 -0
  59. package/tests/tools.test.ts +448 -0
  60. package/tsconfig.json +20 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Markdown to Notion Block Converter
3
+ * Converts markdown text to Notion block format
4
+ */
5
+
6
+ export interface NotionRichText {
7
+ type: 'text';
8
+ text: {
9
+ content: string;
10
+ link?: { url: string } | null;
11
+ };
12
+ annotations?: {
13
+ bold?: boolean;
14
+ italic?: boolean;
15
+ strikethrough?: boolean;
16
+ underline?: boolean;
17
+ code?: boolean;
18
+ color?: string;
19
+ };
20
+ }
21
+
22
+ export interface NotionBlock {
23
+ object: 'block';
24
+ type: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ // Parse inline formatting (bold, italic, code, links, strikethrough)
29
+ export function parseInlineFormatting(text: string): NotionRichText[] {
30
+ const result: NotionRichText[] = [];
31
+ let remaining = text;
32
+
33
+ // Regex patterns for inline formatting
34
+ const patterns = [
35
+ { regex: /\*\*(.+?)\*\*/g, annotation: 'bold' },
36
+ { regex: /\*(.+?)\*/g, annotation: 'italic' },
37
+ { regex: /~~(.+?)~~/g, annotation: 'strikethrough' },
38
+ { regex: /`(.+?)`/g, annotation: 'code' },
39
+ { regex: /\[([^\]]+)\]\(([^)]+)\)/g, annotation: 'link' }
40
+ ];
41
+
42
+ // Simple approach: find the first match of any pattern
43
+ while (remaining.length > 0) {
44
+ let earliestMatch: { index: number; length: number; content: string; annotation: string; url?: string } | null = null;
45
+
46
+ for (const { regex, annotation } of patterns) {
47
+ regex.lastIndex = 0;
48
+ const match = regex.exec(remaining);
49
+ if (match && (!earliestMatch || match.index < earliestMatch.index)) {
50
+ if (annotation === 'link') {
51
+ earliestMatch = {
52
+ index: match.index,
53
+ length: match[0].length,
54
+ content: match[1],
55
+ annotation,
56
+ url: match[2]
57
+ };
58
+ } else {
59
+ earliestMatch = {
60
+ index: match.index,
61
+ length: match[0].length,
62
+ content: match[1],
63
+ annotation
64
+ };
65
+ }
66
+ }
67
+ }
68
+
69
+ if (earliestMatch) {
70
+ // Add plain text before the match
71
+ if (earliestMatch.index > 0) {
72
+ result.push({
73
+ type: 'text',
74
+ text: { content: remaining.substring(0, earliestMatch.index) }
75
+ });
76
+ }
77
+
78
+ // Add formatted text
79
+ const richText: NotionRichText = {
80
+ type: 'text',
81
+ text: {
82
+ content: earliestMatch.content,
83
+ link: earliestMatch.url ? { url: earliestMatch.url } : null
84
+ },
85
+ annotations: {}
86
+ };
87
+
88
+ if (earliestMatch.annotation !== 'link') {
89
+ richText.annotations = { [earliestMatch.annotation]: true };
90
+ }
91
+
92
+ result.push(richText);
93
+
94
+ remaining = remaining.substring(earliestMatch.index + earliestMatch.length);
95
+ } else {
96
+ // No more matches, add remaining text
97
+ if (remaining.length > 0) {
98
+ result.push({
99
+ type: 'text',
100
+ text: { content: remaining }
101
+ });
102
+ }
103
+ break;
104
+ }
105
+ }
106
+
107
+ return result.length > 0 ? result : [{ type: 'text', text: { content: text } }];
108
+ }
109
+
110
+ // Convert a single line to a Notion block
111
+ function lineToBlock(line: string): NotionBlock | null {
112
+ const trimmed = line.trim();
113
+
114
+ if (trimmed === '') {
115
+ return null;
116
+ }
117
+
118
+ // Heading 1
119
+ if (trimmed.startsWith('# ')) {
120
+ return {
121
+ object: 'block',
122
+ type: 'heading_1',
123
+ heading_1: {
124
+ rich_text: parseInlineFormatting(trimmed.substring(2))
125
+ }
126
+ };
127
+ }
128
+
129
+ // Heading 2
130
+ if (trimmed.startsWith('## ')) {
131
+ return {
132
+ object: 'block',
133
+ type: 'heading_2',
134
+ heading_2: {
135
+ rich_text: parseInlineFormatting(trimmed.substring(3))
136
+ }
137
+ };
138
+ }
139
+
140
+ // Heading 3
141
+ if (trimmed.startsWith('### ')) {
142
+ return {
143
+ object: 'block',
144
+ type: 'heading_3',
145
+ heading_3: {
146
+ rich_text: parseInlineFormatting(trimmed.substring(4))
147
+ }
148
+ };
149
+ }
150
+
151
+ // Checkbox / To-do
152
+ if (trimmed.startsWith('- [ ] ') || trimmed.startsWith('- [x] ')) {
153
+ const checked = trimmed.startsWith('- [x] ');
154
+ const content = trimmed.substring(6);
155
+ return {
156
+ object: 'block',
157
+ type: 'to_do',
158
+ to_do: {
159
+ rich_text: parseInlineFormatting(content),
160
+ checked
161
+ }
162
+ };
163
+ }
164
+
165
+ // Bullet list
166
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
167
+ return {
168
+ object: 'block',
169
+ type: 'bulleted_list_item',
170
+ bulleted_list_item: {
171
+ rich_text: parseInlineFormatting(trimmed.substring(2))
172
+ }
173
+ };
174
+ }
175
+
176
+ // Numbered list
177
+ const numberedMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
178
+ if (numberedMatch) {
179
+ return {
180
+ object: 'block',
181
+ type: 'numbered_list_item',
182
+ numbered_list_item: {
183
+ rich_text: parseInlineFormatting(numberedMatch[2])
184
+ }
185
+ };
186
+ }
187
+
188
+ // Blockquote
189
+ if (trimmed.startsWith('> ')) {
190
+ return {
191
+ object: 'block',
192
+ type: 'quote',
193
+ quote: {
194
+ rich_text: parseInlineFormatting(trimmed.substring(2))
195
+ }
196
+ };
197
+ }
198
+
199
+ // Horizontal rule
200
+ if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
201
+ return {
202
+ object: 'block',
203
+ type: 'divider',
204
+ divider: {}
205
+ };
206
+ }
207
+
208
+ // Default: paragraph
209
+ return {
210
+ object: 'block',
211
+ type: 'paragraph',
212
+ paragraph: {
213
+ rich_text: parseInlineFormatting(trimmed)
214
+ }
215
+ };
216
+ }
217
+
218
+ // Handle code blocks
219
+ function parseCodeBlocks(markdown: string): { content: string; isCode: boolean; language?: string }[] {
220
+ const parts: { content: string; isCode: boolean; language?: string }[] = [];
221
+ const lines = markdown.split('\n');
222
+ let inCodeBlock = false;
223
+ let codeContent: string[] = [];
224
+ let codeLanguage = '';
225
+ let textContent: string[] = [];
226
+
227
+ for (const line of lines) {
228
+ if (line.startsWith('```')) {
229
+ if (inCodeBlock) {
230
+ // End of code block
231
+ parts.push({
232
+ content: codeContent.join('\n'),
233
+ isCode: true,
234
+ language: codeLanguage || 'plain text'
235
+ });
236
+ codeContent = [];
237
+ codeLanguage = '';
238
+ inCodeBlock = false;
239
+ } else {
240
+ // Start of code block
241
+ if (textContent.length > 0) {
242
+ parts.push({
243
+ content: textContent.join('\n'),
244
+ isCode: false
245
+ });
246
+ textContent = [];
247
+ }
248
+ codeLanguage = line.substring(3).trim();
249
+ inCodeBlock = true;
250
+ }
251
+ } else if (inCodeBlock) {
252
+ codeContent.push(line);
253
+ } else {
254
+ textContent.push(line);
255
+ }
256
+ }
257
+
258
+ // Handle remaining content
259
+ if (textContent.length > 0) {
260
+ parts.push({
261
+ content: textContent.join('\n'),
262
+ isCode: false
263
+ });
264
+ }
265
+ if (codeContent.length > 0) {
266
+ parts.push({
267
+ content: codeContent.join('\n'),
268
+ isCode: true,
269
+ language: codeLanguage || 'plain text'
270
+ });
271
+ }
272
+
273
+ return parts;
274
+ }
275
+
276
+ // Main conversion function
277
+ export function markdownToBlocks(markdown: string): NotionBlock[] {
278
+ const blocks: NotionBlock[] = [];
279
+ const parts = parseCodeBlocks(markdown);
280
+
281
+ for (const part of parts) {
282
+ if (part.isCode) {
283
+ blocks.push({
284
+ object: 'block',
285
+ type: 'code',
286
+ code: {
287
+ rich_text: [{ type: 'text', text: { content: part.content } }],
288
+ language: part.language || 'plain text'
289
+ }
290
+ });
291
+ } else {
292
+ const lines = part.content.split('\n');
293
+ for (const line of lines) {
294
+ const block = lineToBlock(line);
295
+ if (block) {
296
+ blocks.push(block);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ return blocks;
303
+ }
304
+
305
+ // Convert Notion blocks to markdown
306
+ export function blocksToMarkdown(blocks: unknown[]): string {
307
+ const lines: string[] = [];
308
+
309
+ for (const block of blocks) {
310
+ const b = block as Record<string, unknown>;
311
+ const type = b.type as string;
312
+
313
+ switch (type) {
314
+ case 'paragraph':
315
+ lines.push(richTextToMarkdown((b.paragraph as Record<string, unknown>).rich_text as NotionRichText[]));
316
+ break;
317
+ case 'heading_1':
318
+ lines.push('# ' + richTextToMarkdown((b.heading_1 as Record<string, unknown>).rich_text as NotionRichText[]));
319
+ break;
320
+ case 'heading_2':
321
+ lines.push('## ' + richTextToMarkdown((b.heading_2 as Record<string, unknown>).rich_text as NotionRichText[]));
322
+ break;
323
+ case 'heading_3':
324
+ lines.push('### ' + richTextToMarkdown((b.heading_3 as Record<string, unknown>).rich_text as NotionRichText[]));
325
+ break;
326
+ case 'bulleted_list_item':
327
+ lines.push('- ' + richTextToMarkdown((b.bulleted_list_item as Record<string, unknown>).rich_text as NotionRichText[]));
328
+ break;
329
+ case 'numbered_list_item':
330
+ lines.push('1. ' + richTextToMarkdown((b.numbered_list_item as Record<string, unknown>).rich_text as NotionRichText[]));
331
+ break;
332
+ case 'to_do': {
333
+ const todo = b.to_do as Record<string, unknown>;
334
+ const checked = todo.checked ? 'x' : ' ';
335
+ lines.push(`- [${checked}] ` + richTextToMarkdown(todo.rich_text as NotionRichText[]));
336
+ break;
337
+ }
338
+ case 'quote':
339
+ lines.push('> ' + richTextToMarkdown((b.quote as Record<string, unknown>).rich_text as NotionRichText[]));
340
+ break;
341
+ case 'code': {
342
+ const code = b.code as Record<string, unknown>;
343
+ const lang = code.language || '';
344
+ lines.push('```' + lang);
345
+ lines.push(richTextToMarkdown(code.rich_text as NotionRichText[]));
346
+ lines.push('```');
347
+ break;
348
+ }
349
+ case 'divider':
350
+ lines.push('---');
351
+ break;
352
+ default:
353
+ // For unsupported blocks, try to extract any rich_text
354
+ if (b[type] && (b[type] as Record<string, unknown>).rich_text) {
355
+ lines.push(richTextToMarkdown((b[type] as Record<string, unknown>).rich_text as NotionRichText[]));
356
+ }
357
+ }
358
+ }
359
+
360
+ return lines.join('\n\n');
361
+ }
362
+
363
+ // Convert rich text array to markdown string
364
+ function richTextToMarkdown(richText: NotionRichText[]): string {
365
+ if (!richText || !Array.isArray(richText)) return '';
366
+
367
+ return richText.map(rt => {
368
+ let text = rt.text?.content || '';
369
+ const annotations = rt.annotations;
370
+
371
+ if (annotations?.code) {
372
+ text = `\`${text}\``;
373
+ }
374
+ if (annotations?.bold) {
375
+ text = `**${text}**`;
376
+ }
377
+ if (annotations?.italic) {
378
+ text = `*${text}*`;
379
+ }
380
+ if (annotations?.strikethrough) {
381
+ text = `~~${text}~~`;
382
+ }
383
+ if (rt.text?.link?.url) {
384
+ text = `[${text}](${rt.text.link.url})`;
385
+ }
386
+
387
+ return text;
388
+ }).join('');
389
+ }
390
+
391
+ // Convert blocks to plain text
392
+ export function blocksToPlainText(blocks: unknown[]): string {
393
+ const lines: string[] = [];
394
+
395
+ for (const block of blocks) {
396
+ const b = block as Record<string, unknown>;
397
+ const type = b.type as string;
398
+ const blockContent = b[type] as Record<string, unknown>;
399
+
400
+ if (blockContent?.rich_text) {
401
+ const richText = blockContent.rich_text as NotionRichText[];
402
+ const text = richText.map(rt => rt.text?.content || '').join('');
403
+ if (text) lines.push(text);
404
+ }
405
+ }
406
+
407
+ return lines.join('\n');
408
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Notion API Client
3
+ * Handles all HTTP communication with Notion API
4
+ */
5
+
6
+ const NOTION_API_BASE = 'https://api.notion.com/v1';
7
+ const NOTION_VERSION = '2022-06-28';
8
+
9
+ export interface NotionClientConfig {
10
+ apiKey: string;
11
+ }
12
+
13
+ export interface NotionResponse<T = unknown> {
14
+ success: boolean;
15
+ data?: T;
16
+ error?: string;
17
+ status?: number;
18
+ }
19
+
20
+ export interface RateLimitConfig {
21
+ requestsPerSecond: number;
22
+ minDelayMs: number;
23
+ }
24
+
25
+ export class NotionClient {
26
+ private apiKey: string;
27
+ private lastRequestTime: number = 0;
28
+ private rateLimitConfig: RateLimitConfig = {
29
+ requestsPerSecond: 3,
30
+ minDelayMs: 334 // ~3 requests per second
31
+ };
32
+
33
+ constructor(config: NotionClientConfig) {
34
+ this.apiKey = config.apiKey;
35
+ }
36
+
37
+ private async rateLimit(): Promise<void> {
38
+ const now = Date.now();
39
+ const timeSinceLastRequest = now - this.lastRequestTime;
40
+
41
+ if (timeSinceLastRequest < this.rateLimitConfig.minDelayMs) {
42
+ const delay = this.rateLimitConfig.minDelayMs - timeSinceLastRequest;
43
+ await new Promise(resolve => setTimeout(resolve, delay));
44
+ }
45
+
46
+ this.lastRequestTime = Date.now();
47
+ }
48
+
49
+ private getHeaders(): Record<string, string> {
50
+ return {
51
+ 'Authorization': `Bearer ${this.apiKey}`,
52
+ 'Notion-Version': NOTION_VERSION,
53
+ 'Content-Type': 'application/json'
54
+ };
55
+ }
56
+
57
+ async request<T>(
58
+ method: string,
59
+ endpoint: string,
60
+ body?: unknown
61
+ ): Promise<NotionResponse<T>> {
62
+ await this.rateLimit();
63
+
64
+ const url = `${NOTION_API_BASE}${endpoint}`;
65
+
66
+ try {
67
+ const options: RequestInit = {
68
+ method,
69
+ headers: this.getHeaders()
70
+ };
71
+
72
+ if (body && (method === 'POST' || method === 'PATCH')) {
73
+ options.body = JSON.stringify(body);
74
+ }
75
+
76
+ const response = await fetch(url, options);
77
+ const data = await response.json() as Record<string, unknown>;
78
+
79
+ if (!response.ok) {
80
+ return {
81
+ success: false,
82
+ error: (data.message as string) || `HTTP ${response.status}: ${response.statusText}`,
83
+ status: response.status
84
+ };
85
+ }
86
+
87
+ return {
88
+ success: true,
89
+ data: data as T,
90
+ status: response.status
91
+ };
92
+ } catch (error) {
93
+ return {
94
+ success: false,
95
+ error: error instanceof Error ? error.message : 'Unknown error occurred'
96
+ };
97
+ }
98
+ }
99
+
100
+ async get<T>(endpoint: string): Promise<NotionResponse<T>> {
101
+ return this.request<T>('GET', endpoint);
102
+ }
103
+
104
+ async post<T>(endpoint: string, body?: unknown): Promise<NotionResponse<T>> {
105
+ return this.request<T>('POST', endpoint, body);
106
+ }
107
+
108
+ async patch<T>(endpoint: string, body?: unknown): Promise<NotionResponse<T>> {
109
+ return this.request<T>('PATCH', endpoint, body);
110
+ }
111
+
112
+ async delete<T>(endpoint: string): Promise<NotionResponse<T>> {
113
+ return this.request<T>('DELETE', endpoint);
114
+ }
115
+ }
116
+
117
+ // Helper to format ID with dashes
118
+ function formatWithDashes(id: string): string {
119
+ const clean = id.toLowerCase().replace(/-/g, '');
120
+ return clean.replace(
121
+ /([a-f0-9]{8})([a-f0-9]{4})([a-f0-9]{4})([a-f0-9]{4})([a-f0-9]{12})/,
122
+ '$1-$2-$3-$4-$5'
123
+ );
124
+ }
125
+
126
+ // Extract page/database ID from URL if provided
127
+ export function extractNotionId(input: string): string {
128
+ // If it's already a UUID format (with or without dashes)
129
+ const uuidPattern = /^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$/i;
130
+
131
+ if (uuidPattern.test(input)) {
132
+ return formatWithDashes(input);
133
+ }
134
+
135
+ // Check if it's just a 32-char hex string
136
+ if (/^[a-f0-9]{32}$/i.test(input)) {
137
+ return formatWithDashes(input);
138
+ }
139
+
140
+ // Try to extract ID from Notion URL - look for 32 hex chars at end of path
141
+ // Pattern: matches ID that appears after a dash following text (like "Page-Title-abc123...")
142
+ const urlWithTitlePattern = /[/-]([a-f0-9]{32})(?:\?|$|#)/i;
143
+ const urlMatch = input.match(urlWithTitlePattern);
144
+
145
+ if (urlMatch) {
146
+ return formatWithDashes(urlMatch[1]);
147
+ }
148
+
149
+ // Try pattern with dashes in URL (like /12345678-90ab-cdef-1234-567890abcdef)
150
+ const urlPatternDashes = /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i;
151
+ const dashMatch = input.match(urlPatternDashes);
152
+
153
+ if (dashMatch) {
154
+ return formatWithDashes(dashMatch[1]);
155
+ }
156
+
157
+ // Return as-is if no pattern matches (will fail at API level)
158
+ return input;
159
+ }