UNPKG

@bdmarvin/mcp-server-memory

Version:

MCP Server for LLM Long-Term Memory using KG and Google Drive

247 lines 14.6 kB
import { google } from 'googleapis'; import * as kgService from './kgService.js'; import { Readable } from 'stream'; // import logger from '../utils/logger.js'; // Removed pino const DEFAULT_TARGET_BASE_FOLDER_NAME = "llm-memory-store"; export const TARGET_BASE_FOLDER_NAME_EXPORTED = process.env.MCP_MEMORY_DRIVE_BASE_FOLDER_NAME || DEFAULT_TARGET_BASE_FOLDER_NAME; // Simple console logging replacements const log = { info: (...args) => console.error("INFO:", ...args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg)), debug: (...args) => { }, // Comment out debug by default warn: (...args) => console.error("WARN:", ...args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg)), error: (...args) => console.error("ERROR:", ...args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg)), }; if (process.env.MCP_MEMORY_DRIVE_BASE_FOLDER_NAME) { log.info(`Using custom Drive base folder name from MCP_MEMORY_DRIVE_BASE_FOLDER_NAME: "${TARGET_BASE_FOLDER_NAME_EXPORTED}"`); } else { log.info(`Using default Drive base folder name: "${TARGET_BASE_FOLDER_NAME_EXPORTED}"`); } let llmMemoryBaseFolderIdCache = null; export async function getOauth2ClientInstance(accessToken) { const oauth2Client = new google.auth.OAuth2(); oauth2Client.setCredentials({ access_token: accessToken }); return oauth2Client; } async function findOrCreateLlmMemoryBaseFolder(drive) { if (llmMemoryBaseFolderIdCache) { log.debug(`Using cached LLM Memory Base Folder ID: ${llmMemoryBaseFolderIdCache}`); return llmMemoryBaseFolderIdCache; } const q = `mimeType='application/vnd.google-apps.folder' and name='${TARGET_BASE_FOLDER_NAME_EXPORTED}' and 'root' in parents and trashed=false`; log.debug(`Searching for LLM Memory Base Folder. Query: ${q}`); try { const res = await drive.files.list({ q: q, fields: 'files(id, name)', spaces: 'drive' }); if (res.data.files && res.data.files.length > 0 && res.data.files[0].id) { llmMemoryBaseFolderIdCache = res.data.files[0].id; log.info(`Found base LLM memory folder. Name: '${TARGET_BASE_FOLDER_NAME_EXPORTED}', ID: ${llmMemoryBaseFolderIdCache}`); return llmMemoryBaseFolderIdCache; } else { log.info(`Base LLM memory folder not found, creating... Name: '${TARGET_BASE_FOLDER_NAME_EXPORTED}'`); const fileMetadata = { name: TARGET_BASE_FOLDER_NAME_EXPORTED, mimeType: 'application/vnd.google-apps.folder', parents: ['root'], }; const folderResponse = await drive.files.create({ requestBody: fileMetadata, fields: 'id', }); if (folderResponse.data.id) { llmMemoryBaseFolderIdCache = folderResponse.data.id; log.info(`Created base LLM memory folder. Name: '${TARGET_BASE_FOLDER_NAME_EXPORTED}', ID: ${llmMemoryBaseFolderIdCache}`); return llmMemoryBaseFolderIdCache; } else { log.error(`Folder creation did not return an ID for base folder. Name: '${TARGET_BASE_FOLDER_NAME_EXPORTED}'`); throw new Error('Base folder creation did not return an ID.'); } } } catch (error) { log.error(`Error finding or creating base LLM memory folder. Name: '${TARGET_BASE_FOLDER_NAME_EXPORTED}', Error: ${error.message}`); throw new Error(`Failed to find or create base LLM memory folder: ${error.message}`); } } export async function findOrCreateProjectFolder(drive, projectId) { if (!projectId.match(/^[a-zA-Z0-9_.-]+$/)) { log.warn(`Invalid project_id format for Drive folder name: ${projectId}`); throw new Error('Invalid project_id format for Drive folder.'); } const baseLlmFolderId = await findOrCreateLlmMemoryBaseFolder(drive); const projectFolderName = `Project_${projectId}`; const q = `mimeType='application/vnd.google-apps.folder' and name='${projectFolderName}' and '${baseLlmFolderId}' in parents and trashed=false`; log.debug(`Searching for project folder. Query: ${q}, ProjectId: ${projectId}`); try { const res = await drive.files.list({ q: q, fields: 'files(id, name)', spaces: 'drive' }); if (res.data.files && res.data.files.length > 0 && res.data.files[0].id) { log.info(`Found project folder. Name: '${projectFolderName}', ID: ${res.data.files[0].id}, ParentFolderId: ${baseLlmFolderId}`); return res.data.files[0].id; } else { log.info(`Project folder not found, creating... Name: '${projectFolderName}', ParentFolderId: ${baseLlmFolderId}`); const fileMetadata = { name: projectFolderName, mimeType: 'application/vnd.google-apps.folder', parents: [baseLlmFolderId], }; const folderResponse = await drive.files.create({ requestBody: fileMetadata, fields: 'id' }); if (folderResponse.data.id) { log.info(`Created project folder. Name: '${projectFolderName}', ID: ${folderResponse.data.id}, ParentFolderId: ${baseLlmFolderId}`); return folderResponse.data.id; } else { log.error(`Project folder creation did not return an ID. Name: '${projectFolderName}', ParentFolderId: ${baseLlmFolderId}`); throw new Error('Project folder creation did not return an ID.'); } } } catch (error) { log.error(`Error finding or creating project folder. Name: '${projectFolderName}', ProjectId: ${projectId}, Error: ${error.message}`); throw new Error(`Failed to find or create project folder for ${projectId}: ${error.message}`); } } export async function storeDocument(accessToken, args) { log.info(`storeDocument called. ProjectId: ${args.project_id}, DocumentName: ${args.document_name}`); const oauth2Client = await getOauth2ClientInstance(accessToken); const drive = google.drive({ version: 'v3', auth: oauth2Client }); try { const projectFolderId = await findOrCreateProjectFolder(drive, args.project_id); log.debug(`Ensured project folder for document storage. ProjectId: ${args.project_id}, FolderId: ${projectFolderId}`); const fileMetadata = { name: args.document_name, parents: [projectFolderId], }; if (args.document_description) { fileMetadata.description = args.document_description; } let media = {}; if (typeof args.document_content === 'string') { media = { body: Readable.from([args.document_content]) }; } else if (args.document_content instanceof Uint8Array) { media = { body: Readable.from([Buffer.from(args.document_content)]) }; } else { log.warn(`Unsupported document_content type in storeDocument. Type: ${typeof args.document_content}`); throw new Error("Unsupported document_content type. Must be string or Uint8Array."); } const fileResponse = await drive.files.create({ requestBody: fileMetadata, media: media, fields: 'id, name, webViewLink, mimeType, description', }); const fileData = fileResponse.data; log.info(`File uploaded to Drive. FileName: ${fileData.name}, FileId: ${fileData.id}, ProjectId: ${args.project_id}`); const documentNodeId = `doc_${fileData.id}`; const kgNodeAttributes = { type: 'document_reference', name: fileData.name, drive_file_id: fileData.id, drive_folder_id: projectFolderId, mime_type: fileData.mimeType, description: args.document_description || fileData.description, tags: args.tags || [], web_view_link: fileData.webViewLink, linked_at: new Date().toISOString(), }; await kgService.updateKgNode(accessToken, { project_id: args.project_id, node_id: documentNodeId, attributes: kgNodeAttributes, }); log.info(`Document linked in Knowledge Graph. KGNodeId: ${documentNodeId}, DriveFileId: ${fileData.id}, ProjectId: ${args.project_id}`); return { status: 'success', drive_file_id: fileData.id, file_name: fileData.name, web_view_link: fileData.webViewLink, kg_node_id: documentNodeId, message: `Document stored in Drive (Project: ${args.project_id}, Folder: ${projectFolderId}) and linked in KG.` }; } catch (error) { log.error(`Error storing document in Drive. Args: ${JSON.stringify(args)}, Error: ${error.message}`); // Removed stack for brevity if (error.errors) log.error(`Google API Errors during storeDocument: ${JSON.stringify(error.errors)}`); throw new Error(`Failed to store document: ${error.message}`); } } export async function getDocumentContent(accessToken, args) { log.info(`getDocumentContent called. DriveFileId: ${args.drive_file_id}`); const oauth2Client = await getOauth2ClientInstance(accessToken); const drive = google.drive({ version: 'v3', auth: oauth2Client }); try { const fileMetadataResponse = await drive.files.get({ fileId: args.drive_file_id, fields: 'id, name, mimeType' }); const fileMetadata = fileMetadataResponse.data; const mimeType = fileMetadata.mimeType || 'application/octet-stream'; log.debug(`Fetched file metadata. DriveFileId: ${args.drive_file_id}, MimeType: ${mimeType}, FileName: ${fileMetadata.name}`); if (mimeType === 'application/vnd.google-apps.document') { log.info(`Exporting Google Doc as text/plain. DriveFileId: ${args.drive_file_id}`); const exportedResponse = await drive.files.export({ fileId: args.drive_file_id, mimeType: 'text/plain' }, { responseType: 'stream' }); const chunks = []; for await (const chunk of exportedResponse.data) { chunks.push(Buffer.from(chunk)); } return { file_id: args.drive_file_id, name: fileMetadata.name, content_type: 'text/plain', content: Buffer.concat(chunks).toString('utf-8') }; } else if (mimeType.startsWith('application/vnd.google-apps')) { log.warn(`Direct content retrieval for GSuite type '${mimeType}' might require specific export. Returning metadata only. DriveFileId: ${args.drive_file_id}`); return { file_id: args.drive_file_id, name: fileMetadata.name, content_type: mimeType, content: null, message: "Direct content for this Google Workspace file type needs specific export handling (e.g., PDF for slides, CSV for sheets). This tool currently exports Google Docs as text/plain." }; } log.debug(`Fetching file content via alt=media. DriveFileId: ${args.drive_file_id}`); const res = await drive.files.get({ fileId: args.drive_file_id, alt: 'media' }, { responseType: 'arraybuffer' }); let content_string; if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml' || mimeType === 'application/javascript') { content_string = Buffer.from(res.data).toString('utf-8'); } else { content_string = Buffer.from(res.data).toString('base64'); log.warn(`Binary content returned as base64. DriveFileId: ${args.drive_file_id}, MimeType: ${mimeType}, FileName: ${fileMetadata.name}`); } return { file_id: args.drive_file_id, name: fileMetadata.name, content_type: mimeType, content: content_string }; } catch (error) { log.error(`Error getting document content. DriveFileId: ${args.drive_file_id}, Error: ${error.message}`); // Removed stack for brevity if (error.errors) log.error(`Google API Errors during getDocumentContent: ${JSON.stringify(error.errors)}`); throw new Error(`Failed to get document content for ${args.drive_file_id}: ${error.message}`); } } export async function getDocumentSummary(accessToken, args) { log.info(`getDocumentSummary called. DriveFileId: ${args.drive_file_id}, SummaryLength: ${args.summary_length}`); const contentResult = await getDocumentContent(accessToken, { drive_file_id: args.drive_file_id }); if (contentResult.content_type && contentResult.content_type.startsWith('application/vnd.google-apps') && !contentResult.content) { log.warn(`Cannot summarize GSuite file without text export. DriveFileId: ${args.drive_file_id}, ContentType: ${contentResult.content_type}`); throw new Error(contentResult.message || `Cannot summarize Google Workspace file ${args.drive_file_id} without specific export to text.`); } if (!contentResult.content || typeof contentResult.content !== 'string') { const isLikelyBase64ForBinary = contentResult.content && typeof contentResult.content === 'string' && contentResult.content.length > 20 && !contentResult.content_type?.startsWith('text/'); if (isLikelyBase64ForBinary) { log.warn(`Cannot summarize binary content (returned as base64). DriveFileId: ${args.drive_file_id}, ContentType: ${contentResult.content_type}`); throw new Error(`Cannot summarize document ${args.drive_file_id} (type: ${contentResult.content_type}): Content appears to be binary (returned as base64).`); } log.warn(`Cannot summarize, content not available as plain text. DriveFileId: ${args.drive_file_id}, ContentType: ${contentResult.content_type}`); throw new Error(`Cannot summarize document ${args.drive_file_id}: Content is not available as plain text.`); } let summary = contentResult.content; const maxLength = args.summary_length === 'short' ? 150 : args.summary_length === 'medium' ? 500 : 1500; if (summary.length > maxLength) { summary = summary.substring(0, maxLength) + '... (truncated)'; } log.info(`Document summary generated. DriveFileId: ${args.drive_file_id}, SummaryLength: ${args.summary_length}, GeneratedSummaryLength: ${summary.length}`); return { file_id: args.drive_file_id, name: contentResult.name, summary: summary, original_content_type: contentResult.content_type }; } //# sourceMappingURL=driveService.js.map