UNPKG

ai-knowledge-hub

Version:

MCP server that provides unified access to organizational knowledge across multiple platforms (local docs, Guru, Notion)

923 lines 39.2 kB
import { extractPageTitle, extractTitleFromMarkdown, markdownToNotion, notionToMarkdown } from '../utils/converters.js'; import { readMarkdownFile, validateFilePath } from '../utils/file-system.js'; import { basename } from 'path'; export class NotionService { config; baseUrl = 'https://api.notion.com/v1'; propertyTypeCache = new Map(); constructor(config) { this.config = config; } analyzeSearchMatch(page, searchTerm, searchMode) { const metadata = { searchMode: searchMode, matchLocation: 'content', matchedTerms: [], }; const term = searchTerm.toLowerCase(); // Check tags property - safely access the multi_select array const tagsProperty = page.properties.Tags; if (tagsProperty !== undefined && tagsProperty !== null && 'multi_select' in tagsProperty) { const multiSelect = tagsProperty.multi_select; if (Array.isArray(multiSelect)) { const matchingTags = multiSelect.filter(tag => tag?.name !== undefined && tag.name !== null && tag.name !== '' && tag.name.toLowerCase().includes(term)); if (matchingTags.length > 0) { metadata.matchLocation = 'tags'; metadata.matchedTerms = matchingTags.map(tag => tag.name); } } } // Check title - find the property with type 'title' const titleProperty = Object.values(page.properties).find(prop => prop !== undefined && prop !== null && typeof prop === 'object' && 'type' in prop && prop.type === 'title'); if (titleProperty !== undefined && titleProperty !== null && 'title' in titleProperty) { const titleArray = titleProperty.title; if (Array.isArray(titleArray) && titleArray[0]?.text?.content !== undefined && titleArray[0].text.content !== '') { const titleText = titleArray[0].text.content.toLowerCase(); if (titleText.includes(term)) { metadata.matchLocation = 'title'; metadata.matchedTerms = [searchTerm]; } } } // Check description property const descProperty = page.properties.Description; if (descProperty !== undefined && descProperty !== null && 'rich_text' in descProperty) { const richTextArray = descProperty.rich_text; if (Array.isArray(richTextArray) && richTextArray[0]?.text?.content !== undefined && richTextArray[0].text.content !== '') { const descText = richTextArray[0].text.content.toLowerCase(); if (descText.includes(term)) { metadata.matchLocation = 'description'; metadata.matchedTerms = [searchTerm]; } } } return metadata; } async getPropertyType(databaseId, propertyName) { // Check cache first const dbCache = this.propertyTypeCache.get(databaseId); if (dbCache?.has(propertyName) === true) { return dbCache.get(propertyName) ?? null; } try { const database = await this.getDatabase(databaseId); const propertyType = database.properties[propertyName]?.type ?? null; // Cache the result if (!this.propertyTypeCache.has(databaseId)) { this.propertyTypeCache.set(databaseId, new Map()); } this.propertyTypeCache.get(databaseId).set(propertyName, propertyType ?? ''); return propertyType; } catch { // Property type lookup failed, return null return null; } } async setPropertyValue(properties, propertyName, value, databaseId) { const propertyType = await this.getPropertyType(databaseId, propertyName); if (propertyType === null) { // Property not found in database schema, skip setting return; } switch (propertyType) { case 'select': properties[propertyName] = { select: { name: String(value) } }; break; case 'multi_select': properties[propertyName] = { multi_select: Array.isArray(value) ? value.map(v => ({ name: String(v) })) : [{ name: String(value) }], }; break; case 'title': properties[propertyName] = { title: [{ text: { content: String(value) } }], }; break; case 'rich_text': properties[propertyName] = { rich_text: [{ text: { content: String(value) } }], }; break; case 'checkbox': properties[propertyName] = { checkbox: Boolean(value) }; break; case 'number': properties[propertyName] = { number: Number(value) }; break; case 'url': properties[propertyName] = { url: String(value) }; break; default: // Unsupported property type, skip setting break; } } async makeRequest(endpoint, method = 'GET', body) { const url = `${this.baseUrl}${endpoint}`; const headers = { 'Authorization': `Bearer ${this.config.token}`, 'Notion-Version': this.config.version ?? '2022-06-28', 'Content-Type': 'application/json', }; const options = { method, headers, }; if (body !== undefined && (method === 'POST' || method === 'PATCH')) { options.body = JSON.stringify(body); } try { const response = await fetch(url, options); if (!response.ok) { const errorText = await response.text(); let errorData; try { errorData = JSON.parse(errorText); } catch { errorData = { message: errorText }; } throw new Error(`Notion API Error ${response.status}: ${errorData.message ?? response.statusText}`); } const data = await response.json(); return data; } catch (error) { if (error instanceof Error) { throw error; } throw new Error(`Request failed: ${String(error)}`); } } // Database operations (only for MCP Access Database) async getDatabase(databaseId) { return this.makeRequest(`/databases/${databaseId}`); } async queryDatabase(request) { const { database_id, ...queryBody } = request; return this.makeRequest(`/databases/${database_id}/query`, 'POST', queryBody); } async updateDatabase(databaseId, updateData) { return this.makeRequest(`/databases/${databaseId}`, 'PATCH', updateData); } // Page operations async getPage(pageId) { return this.makeRequest(`/pages/${pageId}`); } async createPage(request) { return this.makeRequest('/pages', 'POST', request); } async updatePage(pageId, request) { return this.makeRequest(`/pages/${pageId}`, 'PATCH', request); } async archivePage(pageId) { return this.makeRequest(`/pages/${pageId}`, 'PATCH', { archived: true, }); } // Block operations async getBlockChildren(blockId, startCursor) { const params = new URLSearchParams(); if (startCursor !== undefined) { params.append('start_cursor', startCursor); } const queryString = params.toString(); const url = `/blocks/${blockId}/children${queryString.length > 0 ? `?${queryString}` : ''}`; return this.makeRequest(url); } async appendBlockChildren(blockId, request) { return this.makeRequest(`/blocks/${blockId}/children`, 'PATCH', request); } /** * Append blocks to a page/block with automatic chunking for 100-block limit */ async appendBlockChildrenChunked(blockId, blocks, maxBlocksPerRequest = 100) { const results = []; // Split blocks into chunks of maxBlocksPerRequest for (let i = 0; i < blocks.length; i += maxBlocksPerRequest) { const chunk = blocks.slice(i, i + maxBlocksPerRequest); const result = await this.appendBlockChildren(blockId, { children: chunk, }); results.push(result); } return results; } async updateBlock(blockId, updateData) { return this.makeRequest(`/blocks/${blockId}`, 'PATCH', updateData); } async deleteBlock(blockId) { return this.makeRequest(`/blocks/${blockId}`, 'DELETE'); } // Comment operations async getComments(pageId) { return this.makeRequest(`/comments?block_id=${pageId}`); } async createComment(request) { return this.makeRequest('/comments', 'POST', request); } // Legacy sequential method - kept for internal use in parallel implementation async getAllBlocksRecursively(blockId) { const allBlocks = []; let hasMore = true; let startCursor; while (hasMore) { const response = await this.getBlockChildren(blockId, startCursor); for (const block of response.results) { allBlocks.push(block); // Recursively get children if they exist if (block.has_children) { const children = await this.getAllBlocksRecursively(block.id); // Add children property to the block block.children = children; } } hasMore = response.has_more; startCursor = response.next_cursor ?? undefined; } return allBlocks; } // Optimized breadth-first parallel version for fetching Notion blocks async getAllBlocksRecursivelyParallel(blockId, maxConcurrency = 5) { // Create a semaphore to limit concurrent API calls const { Sema } = await import('async-sema'); const semaphore = new Sema(maxConcurrency, { capacity: maxConcurrency, }); // Helper to fetch all pages for a single block (handles pagination) const fetchAllPagesForBlock = async (parentBlockId) => { const allBlocks = []; let hasMore = true; let startCursor; while (hasMore) { await semaphore.acquire(); try { const response = await this.getBlockChildren(parentBlockId, startCursor); allBlocks.push(...response.results); hasMore = response.has_more; startCursor = response.next_cursor ?? undefined; } finally { semaphore.release(); } } return allBlocks; }; // Breadth-first traversal - fetch level by level const allBlocks = []; let currentLevelBlocks = [{ id: blockId, has_children: true }]; let level = 0; while (currentLevelBlocks.length > 0) { level++; // Fetch children for all blocks at current level in parallel const fetchPromises = currentLevelBlocks .filter(block => block.has_children) .map(async (block) => { const children = await fetchAllPagesForBlock(block.id); return { parentId: block.id, children }; }); const levelResults = await Promise.all(fetchPromises); // Collect all children for the next level const nextLevelBlocks = []; for (const { parentId, children } of levelResults) { // Don't add to allBlocks if this is the root page call if (level > 1 || parentId !== blockId) { allBlocks.push(...children); } else { // For root level, just add to allBlocks without the page itself allBlocks.push(...children); } // Add children with children to next level nextLevelBlocks.push(...children.filter(child => child.has_children)); // Attach children to their parent blocks (for structure preservation) if (level > 1) { const parentBlock = allBlocks.find(b => b.id === parentId); if (parentBlock) { parentBlock.children = children; } } else { // For level 1, attach children to blocks in allBlocks for (const block of allBlocks) { if (block.id === parentId) { block.children = children; } } } } currentLevelBlocks = nextLevelBlocks; } return allBlocks; } // Helper method to create rich text (needed for comments) createRichText(content, annotations, link) { return { type: 'text', text: { content, link: link !== undefined ? { url: link } : null, }, annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default', ...annotations, }, plain_text: content, }; } // ======================================== // HIGH-LEVEL METHODS FOR MCP TOOLS // ======================================== /** * Create a Notion page from markdown content */ async createPageFromMarkdown(databaseId, options) { const debug = process.env.NODE_ENV === 'development'; try { let markdown; let { pageTitle } = options; // Determine which input to use if (options.markdown !== undefined && options.filePath !== undefined) { throw new Error('Cannot provide both markdown content and filePath. Please provide only one.'); } if (options.markdown !== undefined) { markdown = options.markdown; } else if (options.filePath !== undefined) { // Read file and extract title if not provided markdown = await readMarkdownFile(options.filePath); if (pageTitle === undefined) { const filename = basename(options.filePath, '.md'); pageTitle = filename.replace(/[-_]/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); } } else { throw new Error('Either markdown content or filePath must be provided.'); } // Convert markdown to blocks using utility function const conversionResult = markdownToNotion(markdown, options.conversionOptions ?? {}); // Conversion complete - warnings are handled internally const blocks = conversionResult.content; // Extract title from markdown if not provided if (pageTitle === undefined) { const extractedTitle = extractTitleFromMarkdown(markdown); pageTitle = extractedTitle ?? 'Untitled'; } // Get the correct title property name for this database const titlePropertyName = await this.getTitlePropertyName(databaseId); // Build page properties let properties = { [titlePropertyName]: { type: 'title', title: [ { type: 'text', text: { content: pageTitle ?? 'Untitled', }, }, ], }, }; // Ensure database has required properties if metadata is provided if (options.metadata !== undefined && Object.keys(options.metadata).length > 0) { await this.ensureDatabaseProperties(databaseId); } // Smart property setting for all metadata if (options.metadata !== undefined) { // Handle description if (options.metadata.description !== undefined) { await this.setPropertyValue(properties, 'Description', options.metadata.description, databaseId); } // Handle category with smart detection if (options.metadata.category !== undefined) { await this.setPropertyValue(properties, 'Category', options.metadata.category, databaseId); } // Handle tags if (options.metadata.tags !== undefined && Array.isArray(options.metadata.tags)) { await this.setPropertyValue(properties, 'Tags', options.metadata.tags, databaseId); } // Handle status if (options.metadata.status !== undefined) { await this.setPropertyValue(properties, 'Status', options.metadata.status, databaseId); } } // Create the page with retry logic for property type mismatches let page; let createdPageId = null; let retryCount = 0; const maxRetries = 1; while (retryCount <= maxRetries) { try { page = await this.createPage({ parent: { type: 'database_id', database_id: databaseId }, properties, }); createdPageId = page.id; if (debug) { // Page created successfully with ID: page.id } break; // Success, exit loop } catch (error) { if (retryCount < maxRetries && error instanceof Error && error.message?.includes('is expected to be')) { // Property type mismatch detected, clearing cache and retrying this.propertyTypeCache.delete(databaseId); retryCount++; // Rebuild properties with fresh type detection properties = { [titlePropertyName]: { type: 'title', title: [ { type: 'text', text: { content: pageTitle ?? 'Untitled', }, }, ], }, }; // Re-apply metadata with fresh detection if (options.metadata !== undefined) { if (options.metadata.description !== undefined) { await this.setPropertyValue(properties, 'Description', options.metadata.description, databaseId); } if (options.metadata.category !== undefined) { await this.setPropertyValue(properties, 'Category', options.metadata.category, databaseId); } if (options.metadata.tags !== undefined && Array.isArray(options.metadata.tags)) { await this.setPropertyValue(properties, 'Tags', options.metadata.tags, databaseId); } if (options.metadata.status !== undefined) { await this.setPropertyValue(properties, 'Status', options.metadata.status, databaseId); } } } else { // If we created a page but failed, clean it up if (createdPageId !== null) { let cleanupSuccessful = false; try { await this.archivePage(createdPageId); cleanupSuccessful = true; // Successfully cleaned up orphaned page after creation failure } catch { // Failed to cleanup orphaned page - manual cleanup may be required } // Enhance error message with cleanup status if (error instanceof Error) { if (cleanupSuccessful) { error.message += '\n\n✅ Cleanup: The partially created page has been archived.'; } else { error.message += `\n\n⚠️ Note: A partially created page may remain in your database. Page ID: ${createdPageId}`; } } } throw error; // Re-throw if not a type mismatch or max retries reached } } } // Add blocks to the page if any, using chunked method for large documents try { if (blocks.length > 0) { await this.appendBlockChildrenChunked(page.id, blocks); } } catch (error) { // If block addition fails, clean up the page let cleanupSuccessful = false; try { await this.archivePage(page.id); cleanupSuccessful = true; // Successfully cleaned up orphaned page after block creation failure } catch { // Failed to cleanup orphaned page - manual cleanup may be required } // Enhance error message based on failure type and cleanup result if (error instanceof Error) { let enhancedMessage = error.message; if (error.message.includes('block_validation_error') || error.message.includes('should be ≤')) { enhancedMessage = `❌ Block validation failed: ${error.message}`; } else if (error.message.includes('rate_limited')) { enhancedMessage = `⏱️ Rate limit exceeded: ${error.message}`; } if (cleanupSuccessful) { enhancedMessage += '\n\n✅ Cleanup: The partially created page has been archived.'; } else { enhancedMessage += `\n\n⚠️ Note: A partially created page may remain in your database. Page ID: ${page.id}`; } error.message = enhancedMessage; } throw error; } // Page created successfully with smart property detection return { page: page, conversionResult }; } catch (error) { throw new Error(`Failed to create page from markdown: ${String(error)}`); } } /** * Export a Notion page to markdown */ async exportPageToMarkdown(pageId, options = {}) { try { // Get the page details const page = await this.getPage(pageId); // Get all page blocks using optimized breadth-first parallel fetching const blocks = await this.getAllBlocksRecursivelyParallel(pageId, 8); // Convert to markdown using utility function const conversionResult = notionToMarkdown(blocks, options); const markdown = conversionResult.content; return { markdown, page, conversionResult }; } catch (error) { throw new Error(`Failed to export page to markdown: ${String(error)}`); } } /** * Update page metadata (properties only) */ async updatePageMetadata(pageId, metadata) { try { // Build update properties const properties = {}; if (metadata.category !== undefined) { properties.Category = { select: { name: metadata.category }, }; } if (metadata.tags !== undefined) { properties.Tags = { multi_select: metadata.tags.map(tag => ({ name: tag })), }; } if (metadata.description !== undefined) { properties.Description = { rich_text: [ { text: { content: metadata.description }, }, ], }; } if (metadata.status !== undefined) { properties.Status = { select: { name: metadata.status }, }; } return await this.updatePage(pageId, { properties }); } catch (error) { throw new Error(`Failed to update page metadata: ${String(error)}`); } } /** * Update page content (create new page, keep old page) */ async updatePageContent(pageId, options) { try { // Get markdown content let markdown; if (options.markdown === undefined && options.filePath === undefined) { throw new Error('Either markdown content or filePath must be provided'); } if (options.markdown !== undefined && options.filePath !== undefined) { throw new Error('Provide either markdown content or filePath, not both'); } if (options.filePath !== undefined) { if (!validateFilePath(options.filePath) || !options.filePath.endsWith('.md')) { throw new Error(`Invalid file path: ${options.filePath}. Must be a .md file with valid path.`); } markdown = await readMarkdownFile(options.filePath); } else { markdown = options.markdown; } // Get current page to preserve metadata const currentPage = await this.getPage(pageId); // Extract current metadata const properties = currentPage.properties; const _pageTitle = extractPageTitle(currentPage); // Get parent database const parent = currentPage.parent; if (parent.type !== 'database_id') { throw new Error('Can only update pages that are in a database'); } // Convert markdown to blocks const conversionResult = markdownToNotion(markdown, options.conversionOptions); const blocks = conversionResult.content; // Create new page with same properties const newPage = await this.createPage({ parent: { type: 'database_id', database_id: parent.database_id }, properties: properties, }); // Add blocks to new page using chunked method for large documents if (blocks.length > 0) { await this.appendBlockChildrenChunked(newPage.id, blocks); } // Archive the old page to complete the replacement await this.archivePage(pageId); return { conversionResult, newPageId: newPage.id }; } catch (error) { throw new Error(`Failed to update page content: ${String(error)}`); } } /** * List/query database pages with advanced filtering and sorting */ async listDatabasePages(databaseId, options = {}) { try { const { limit = 10, search, category, tags, status, sortBy = 'last_edited', sortOrder = 'descending', startCursor, searchMode = 'tags', } = options; // Get the title property name for this database const titlePropertyName = await this.getTitlePropertyName(databaseId); // Build filter conditions const filters = []; // Text search based on searchMode if (search !== undefined) { if (searchMode === 'tags') { // Search only in tags filters.push({ property: 'Tags', multi_select: { contains: search, }, }); } else if (searchMode === 'full-text') { // Search in title and description (current behavior) filters.push({ or: [ { property: titlePropertyName, title: { contains: search, }, }, { property: 'Description', rich_text: { contains: search, }, }, ], }); } else if (searchMode === 'combined') { // Search everywhere: tags, title, and description filters.push({ or: [ { property: 'Tags', multi_select: { contains: search, }, }, { property: titlePropertyName, title: { contains: search, }, }, { property: 'Description', rich_text: { contains: search, }, }, ], }); } } // Category filter if (category !== undefined) { filters.push({ property: 'Category', select: { equals: category, }, }); } // Status filter if (status !== undefined) { filters.push({ property: 'Status', select: { equals: status, }, }); } // Tags filter (any of the specified tags) if (tags && tags.length > 0) { if (tags.length === 1) { filters.push({ property: 'Tags', multi_select: { contains: tags[0], }, }); } else { // Multiple tags - find pages that contain any of these tags filters.push({ or: tags.map(tag => ({ property: 'Tags', multi_select: { contains: tag, }, })), }); } } // Build final filter let filter = undefined; if (filters.length === 1) { filter = filters[0]; } else if (filters.length > 1) { filter = { and: filters }; } // Build sort configuration const sorts = []; switch (sortBy) { case 'title': sorts.push({ property: titlePropertyName, direction: sortOrder, }); break; case 'category': sorts.push({ property: 'Category', direction: sortOrder, }); break; case 'status': sorts.push({ property: 'Status', direction: sortOrder, }); break; case 'created': sorts.push({ timestamp: 'created_time', direction: sortOrder, }); break; case 'last_edited': default: sorts.push({ timestamp: 'last_edited_time', direction: sortOrder, }); break; } // Execute query const queryRequest = { database_id: databaseId, page_size: Math.min(limit, 100), // Notion API max is 100 }; if (filter) { queryRequest.filter = filter; } if (sorts.length > 0) { queryRequest.sorts = sorts; } if (startCursor !== undefined) { queryRequest.start_cursor = startCursor; } return await this.queryDatabase(queryRequest); } catch (error) { throw new Error(`Failed to query database pages: ${String(error)}`); } } /** * Enhanced version of listDatabasePages that includes search match metadata */ async searchPagesWithMetadata(databaseId, options = {}) { const startTime = Date.now(); const searchMode = options.searchMode ?? 'tags'; const searchTerm = options.search ?? ''; // Get the regular results const response = await this.listDatabasePages(databaseId, options); // Analyze results const enhancedResults = []; const stats = { totalResults: 0, resultsByLocation: { tags: 0, title: 0, description: 0, content: 0 }, searchMode: searchMode, searchTerm: searchTerm, executionTime: 0, }; // Process each result to add metadata for (const page of response.results) { let metadata; if (searchTerm !== '') { metadata = this.analyzeSearchMatch(page, searchTerm, searchMode); } else { // No search term - default metadata metadata = { searchMode: searchMode, matchLocation: 'content', matchedTerms: [], }; } enhancedResults.push({ page, metadata }); stats.totalResults++; stats.resultsByLocation[metadata.matchLocation]++; } stats.executionTime = Date.now() - startTime; return { results: enhancedResults, statistics: stats, hasMore: response.has_more, nextCursor: response.next_cursor ?? undefined, }; } /** * Find the title property name in a database */ async getTitlePropertyName(databaseId) { try { const database = await this.getDatabase(databaseId); const properties = database.properties; // Find the property with type "title" for (const [propertyName, property] of Object.entries(properties)) { if (property.type === 'title') { return propertyName; } } // Fallback to common names if no title property found return 'title'; } catch { // Could not determine title property name, using fallback return 'title'; } } /** * Ensure database has required properties for metadata */ async ensureDatabaseProperties(databaseId) { try { const database = await this.getDatabase(databaseId); const existingProperties = database.properties; const propertiesToCreate = {}; // Helper to check if property exists with correct type const needsProperty = (name, expectedType) => { return existingProperties[name] === undefined || existingProperties[name].type !== expectedType; }; // Only add properties that don't exist or have wrong type if (needsProperty('Description', 'rich_text')) { propertiesToCreate['Description'] = { rich_text: {} }; } if (needsProperty('Tags', 'multi_select')) { propertiesToCreate['Tags'] = { multi_select: {} }; } if (needsProperty('Status', 'select')) { propertiesToCreate['Status'] = { select: { options: [ { name: 'published', color: 'green' }, { name: 'draft', color: 'yellow' }, { name: 'archived', color: 'gray' }, { name: 'review', color: 'blue' }, ], }, }; } // Don't create Category - respect existing configuration // Category property handling is done through smart detection // Only update if there are properties to add if (Object.keys(propertiesToCreate).length > 0) { await this.updateDatabase(databaseId, { properties: propertiesToCreate, }); // Database properties updated successfully } } catch { // Failed to ensure database properties, continue with existing schema } } } //# sourceMappingURL=notion.js.map