@bdmarvin/mcp-server-memory
Version:
MCP Server for LLM Long-Term Memory using KG and Google Drive
247 lines • 14.6 kB
JavaScript
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