UNPKG

@zxkane/quip-mcp-server

Version:

MCP server for interacting with Quip spreadsheets

665 lines 28.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.S3Storage = exports.LocalStorage = void 0; exports.truncateCSVContent = truncateCSVContent; exports.createStorage = createStorage; /** * Storage implementation for the Quip MCP Server */ const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const client_s3_1 = require("@aws-sdk/client-s3"); const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner"); const cache_1 = require("./cache"); const logger_1 = require("./logger"); const errors_1 = require("./errors"); /** * Local file system storage implementation */ class LocalStorage { /** * Initialize local storage * * @param storagePath Storage path * @param isFileProtocol Whether to use file protocol for resource URIs */ constructor(storagePath, isFileProtocol) { this.storagePath = storagePath; this.isFileProtocol = isFileProtocol; // Create the directory only if we're using local storage if (process.env.STORAGE_TYPE !== 's3') { try { fs.mkdirpSync(storagePath); logger_1.logger.info(`LocalStorage initialized with path: ${storagePath}, is_file_protocol: ${isFileProtocol}`); } catch (error) { logger_1.logger.warn(`Failed to create storage directory: ${error instanceof Error ? error.message : String(error)}`); } } else { logger_1.logger.info(`LocalStorage constructor called but not used (S3 storage mode)`); } } /** * Get file path for a thread and sheet * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns File path */ getFilePath(threadId, sheetName) { let fileName = `${threadId}`; if (sheetName) { // Replace invalid filename characters const safeSheetName = sheetName.replace(/[/\\]/g, '_'); fileName += `-${safeSheetName}`; } fileName += '.csv'; return path.join(this.storagePath, fileName); } /** * Save CSV content to local file * * @param threadId Quip document thread ID * @param csvContent CSV content * @param sheetName Sheet name (optional) * @returns Promise resolving to file path */ async saveCSV(threadId, csvContent, sheetName) { try { const filePath = this.getFilePath(threadId, sheetName); await fs.writeFile(filePath, csvContent, 'utf-8'); // Calculate and save metadata const metadata = { total_rows: csvContent.split('\n').length, total_size: csvContent.length, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: new Date().toISOString() }; // Save metadata to a separate file const metadataPath = `${filePath}.meta`; await fs.writeFile(metadataPath, JSON.stringify(metadata), 'utf-8'); // Update caches const cacheKey = this.getCacheKey(threadId, sheetName); cache_1.csvCache.set(cacheKey, csvContent); cache_1.metadataCache.set(cacheKey, metadata); logger_1.logger.info(`Saved CSV to ${filePath}`, { bytes: metadata.total_size, rows: metadata.total_rows }); return filePath; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to save CSV for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to save CSV: ${errorMessage}`); } } /** * Get CSV content from local file * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Promise resolving to CSV content, or null if file doesn't exist */ async getCSV(threadId, sheetName) { try { const cacheKey = this.getCacheKey(threadId, sheetName); // Try to get from cache first const cachedContent = cache_1.csvCache.get(cacheKey); if (cachedContent) { logger_1.logger.debug(`Retrieved CSV from cache for thread ${threadId}`, { sheetName: sheetName || 'default', bytes: cachedContent.length }); return cachedContent; } // If not in cache, read from file const filePath = this.getFilePath(threadId, sheetName); if (!await fs.pathExists(filePath)) { logger_1.logger.warn(`CSV file not found: ${filePath}`); return null; } const content = await fs.readFile(filePath, 'utf-8'); // Update cache cache_1.csvCache.set(cacheKey, content); logger_1.logger.info(`Retrieved CSV from ${filePath}`, { bytes: content.length }); return content; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to get CSV for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to get CSV: ${errorMessage}`); } } /** * Get resource URI * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Resource URI */ getResourceURI(threadId, sheetName) { if (this.isFileProtocol) { return `file://${this.getFilePath(threadId, sheetName)}`; } if (sheetName) { return `quip://${threadId}?sheet=${encodeURIComponent(sheetName)}`; } return `quip://${threadId}`; } /** * Get metadata for the stored CSV * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Promise resolving to metadata including total_rows, total_size, etc. */ async getMetadata(threadId, sheetName) { try { const cacheKey = this.getCacheKey(threadId, sheetName); // Try to get from cache first const cachedMetadata = cache_1.metadataCache.get(cacheKey); if (cachedMetadata) { logger_1.logger.debug(`Retrieved metadata from cache for thread ${threadId}`, { sheetName: sheetName || 'default' }); return cachedMetadata; } const filePath = this.getFilePath(threadId, sheetName); const metadataPath = `${filePath}.meta`; if (!await fs.pathExists(metadataPath)) { logger_1.logger.warn(`Metadata file not found: ${metadataPath}`); // If metadata file doesn't exist but CSV file does, generate metadata if (await fs.pathExists(filePath)) { const content = await fs.readFile(filePath, 'utf-8'); const metadata = { total_rows: content.split('\n').length, total_size: content.length, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: new Date().toISOString() }; // Save the generated metadata await fs.writeFile(metadataPath, JSON.stringify(metadata), 'utf-8'); // Update cache cache_1.metadataCache.set(cacheKey, metadata); return metadata; } // If neither file exists, return empty metadata const emptyMetadata = { total_rows: 0, total_size: 0, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: null }; return emptyMetadata; } // Read metadata from file const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); // Update cache cache_1.metadataCache.set(cacheKey, metadata); return metadata; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to get metadata for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to get metadata: ${errorMessage}`); } } /** * Get cache key for a thread and sheet * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Cache key */ getCacheKey(threadId, sheetName) { return sheetName ? `${threadId}:${sheetName}` : threadId; } } exports.LocalStorage = LocalStorage; /** * S3 storage implementation */ class S3Storage { /** * Initialize S3 storage * * @param bucket S3 bucket name * @param region S3 region * @param prefix S3 object key prefix (optional) * @param urlExpiration URL expiration in seconds (default: 3600) */ constructor(bucket, region, prefix = '', urlExpiration = 3600) { this.bucket = bucket; this.prefix = prefix.endsWith('/') || prefix === '' ? prefix : `${prefix}/`; this.urlExpiration = urlExpiration; this.s3Client = new client_s3_1.S3Client({ region }); // Check if presigned URLs should be used const usePresignedUrls = process.env.USE_PRESIGNED_URLS === 'true'; logger_1.logger.info(`S3Storage initialized with bucket: ${bucket}, region: ${region}, prefix: ${this.prefix}, use_presigned_urls: ${usePresignedUrls}`); } /** * Get S3 object key for a thread and sheet * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns S3 object key */ getObjectKey(threadId, sheetName) { let key = `${this.prefix}${threadId}`; if (sheetName) { // Replace invalid characters const safeSheetName = sheetName.replace(/[/\\]/g, '_'); key += `-${safeSheetName}`; } key += '.csv'; return key; } /** * Save CSV content to S3 * * @param threadId Quip document thread ID * @param csvContent CSV content * @param sheetName Sheet name (optional) * @returns Promise resolving to S3 object key */ async saveCSV(threadId, csvContent, sheetName) { try { const key = this.getObjectKey(threadId, sheetName); // Upload CSV content to S3 const putCommand = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: key, Body: csvContent, ContentType: 'text/csv', }); await this.sendCommand(putCommand); // Calculate and save metadata const metadata = { total_rows: csvContent.split('\n').length, total_size: csvContent.length, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: new Date().toISOString() }; // Save metadata as a separate object const metadataKey = `${key}.meta`; const metadataPutCommand = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: metadataKey, Body: JSON.stringify(metadata), ContentType: 'application/json', }); await this.sendCommand(metadataPutCommand); // Update caches const cacheKey = this.getCacheKey(threadId, sheetName); cache_1.csvCache.set(cacheKey, csvContent); cache_1.metadataCache.set(cacheKey, metadata); logger_1.logger.info(`Saved CSV to S3: ${this.bucket}/${key}`, { bytes: metadata.total_size, rows: metadata.total_rows }); return key; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to save CSV to S3 for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to save CSV to S3: ${errorMessage}`); } } /** * Get CSV content from S3 * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Promise resolving to CSV content, or null if object doesn't exist */ /** * Send a command to S3 * * @param command Command to send * @returns Promise resolving to command response */ async sendCommand(command) { return this.s3Client.send(command); } async getCSV(threadId, sheetName) { try { const cacheKey = this.getCacheKey(threadId, sheetName); // Try to get from cache first const cachedContent = cache_1.csvCache.get(cacheKey); if (cachedContent) { logger_1.logger.debug(`Retrieved CSV from cache for thread ${threadId}`, { sheetName: sheetName || 'default', bytes: cachedContent.length }); return cachedContent; } // If not in cache, get from S3 const key = this.getObjectKey(threadId, sheetName); // Check if object exists try { const headCommand = new client_s3_1.HeadObjectCommand({ Bucket: this.bucket, Key: key, }); await this.sendCommand(headCommand); } catch (error) { // Object doesn't exist logger_1.logger.warn(`CSV object not found in S3: ${this.bucket}/${key}`); return null; } // Get object const getCommand = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key, }); const response = await this.sendCommand(getCommand); if (!response.Body) { throw new Error('Response body is undefined'); } // Convert stream to string const chunks = []; for await (const chunk of response.Body) { chunks.push(Buffer.from(chunk)); } const content = Buffer.concat(chunks).toString('utf-8'); // Update cache cache_1.csvCache.set(cacheKey, content); logger_1.logger.info(`Retrieved CSV from S3: ${this.bucket}/${key}`, { bytes: content.length }); return content; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to get CSV from S3 for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to get CSV from S3: ${errorMessage}`); } } /** * Get resource URI * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Resource URI (presigned S3 URL or s3:// URI) */ getResourceURI(threadId, sheetName) { // Get the object key for the thread and sheet const key = this.getObjectKey(threadId, sheetName); // Check if we should generate a presigned URL const usePresignedUrl = process.env.USE_PRESIGNED_URLS === 'true'; if (usePresignedUrl) { // Since getResourceURI is not async, we can't generate a presigned URL here // Instead, we'll return a special URI format that can be resolved later // The actual presigned URL will be generated when needed using the generatePresignedUrl method logger_1.logger.debug(`Returning special URI format for presigned URL generation: ${threadId}, ${sheetName || 'default'}`); return `s3+https://${this.bucket}/${key}`; } // Fall back to s3:// URI format return `s3://${this.bucket}/${key}`; } /** * Generate a presigned URL for accessing the S3 object * This is a new method that can be used when an actual presigned URL is needed * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Promise resolving to a presigned URL */ async generatePresignedUrl(threadId, sheetName) { try { // Get the object key for the thread and sheet const key = this.getObjectKey(threadId, sheetName); // Create a GetObject command for the S3 object const command = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key, }); // Generate a presigned URL with the specified expiration time const presignedUrl = await (0, s3_request_presigner_1.getSignedUrl)(this.s3Client, command, { expiresIn: this.urlExpiration }); logger_1.logger.debug(`Generated presigned URL for ${key}, expires in ${this.urlExpiration} seconds`); return presignedUrl; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to generate presigned URL for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to generate presigned URL: ${errorMessage}`); } } /** * Get metadata for the stored CSV * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Promise resolving to metadata including total_rows, total_size, etc. */ async getMetadata(threadId, sheetName) { try { const cacheKey = this.getCacheKey(threadId, sheetName); // Try to get from cache first const cachedMetadata = cache_1.metadataCache.get(cacheKey); if (cachedMetadata) { logger_1.logger.debug(`Retrieved metadata from cache for thread ${threadId}`, { sheetName: sheetName || 'default' }); return cachedMetadata; } const key = this.getObjectKey(threadId, sheetName); const metadataKey = `${key}.meta`; // Try to get metadata object try { const getCommand = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: metadataKey, }); const response = await this.sendCommand(getCommand); if (!response.Body) { throw new Error('Response body is undefined'); } // Convert stream to string const chunks = []; for await (const chunk of response.Body) { chunks.push(Buffer.from(chunk)); } const metadataContent = Buffer.concat(chunks).toString('utf-8'); const metadata = JSON.parse(metadataContent); // Update cache cache_1.metadataCache.set(cacheKey, metadata); return metadata; } catch (metadataError) { // Metadata object doesn't exist or error occurred logger_1.logger.warn(`Metadata object not found in S3 or error: ${this.bucket}/${metadataKey}`); // Try to get the CSV object to generate metadata const csvContent = await this.getCSV(threadId, sheetName); if (csvContent) { const metadata = { total_rows: csvContent.split('\n').length, total_size: csvContent.length, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: new Date().toISOString() }; try { // Save the generated metadata const metadataPutCommand = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: metadataKey, Body: JSON.stringify(metadata), ContentType: 'application/json', }); await this.sendCommand(metadataPutCommand); } catch (putError) { // Log error but continue logger_1.logger.error(`Failed to save metadata to S3: ${this.bucket}/${metadataKey}`, { error: putError instanceof Error ? putError.message : String(putError) }); } // Update cache cache_1.metadataCache.set(cacheKey, metadata); return metadata; } // If neither object exists, return empty metadata const emptyMetadata = { total_rows: 0, total_size: 0, resource_uri: this.getResourceURI(threadId, sheetName), last_updated: null }; return emptyMetadata; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger_1.logger.error(`Failed to get metadata from S3 for thread ${threadId}`, { error: errorMessage }); throw new errors_1.StorageError(`Failed to get metadata from S3: ${errorMessage}`); } } /** * Get cache key for a thread and sheet * * @param threadId Quip document thread ID * @param sheetName Sheet name (optional) * @returns Cache key */ getCacheKey(threadId, sheetName) { return sheetName ? `${threadId}:${sheetName}` : threadId; } } exports.S3Storage = S3Storage; /** * Truncate CSV content to be under the specified maximum size while maintaining valid CSV structure * * @param csvContent Original CSV content * @param maxSize Maximum size in bytes (default: 10KB) * @returns Tuple of truncated CSV content and a boolean indicating if truncation occurred */ function truncateCSVContent(csvContent, maxSize = 10 * 1024) { if (csvContent.length <= maxSize) { return [csvContent, false]; // No truncation needed } // Parse the CSV properly to handle quoted fields with embedded newlines // First, identify the header row let headerEndIndex = 0; let inQuote = false; // Find where the header row ends by tracking quotes for (let i = 0; i < csvContent.length; i++) { if (csvContent[i] === '"') { // Toggle quote state (accounting for escaped quotes) if (i + 1 < csvContent.length && csvContent[i + 1] === '"') { i++; // Skip escaped quote } else { inQuote = !inQuote; } } else if (csvContent[i] === '\n' && !inQuote) { headerEndIndex = i; break; } } // Get header row const header = csvContent.substring(0, headerEndIndex); // Now parse the CSV to find complete rows let currentPos = headerEndIndex + 1; // Start after header const completedRows = [header]; // Start with header let rowStartPos = currentPos; let currentSize = header.length + 1; // +1 for newline inQuote = false; while (currentPos < csvContent.length) { const char = csvContent[currentPos]; // Handle quotes to properly track multi-line cells if (char === '"') { // Check for escaped quotes (two double quotes in a row) if (currentPos + 1 < csvContent.length && csvContent[currentPos + 1] === '"') { currentPos += 2; // Skip both quote characters } else { inQuote = !inQuote; currentPos++; } continue; } // If we find a row end (newline outside of quotes) if (char === '\n' && !inQuote) { const row = csvContent.substring(rowStartPos, currentPos); const rowSize = row.length + 1; // +1 for newline // Check if adding this row would exceed our size limit if (currentSize + rowSize > maxSize) { break; // Stop if adding this row would exceed maxSize } // Add the complete row completedRows.push(row); currentSize += rowSize; rowStartPos = currentPos + 1; // Start of next row } currentPos++; } const truncatedContent = completedRows.join('\n'); logger_1.logger.info(`Truncated CSV from ${csvContent.length} bytes to ${truncatedContent.length} bytes`); logger_1.logger.info(`Truncated from ${csvContent.split('\n').length} lines to ${truncatedContent.split('\n').length} lines`); return [truncatedContent, true]; } /** * Factory function to create storage instance * * @param storageType Storage type, supports "local" and "s3" * @param options Parameters passed to storage constructor * @returns Storage instance * @throws Error if storage type is not supported */ function createStorage(storageType, options) { // Export the storage type to environment for other components to check process.env.STORAGE_TYPE = storageType; logger_1.logger.info(`Creating storage implementation for type: ${storageType}`); if (storageType === 'local') { return new LocalStorage(options.storagePath, options.isFileProtocol); } else if (storageType === 's3') { if (!options.s3Bucket) { throw new Error('S3 bucket name is required for S3 storage'); } if (!options.s3Region) { throw new Error('S3 region is required for S3 storage'); } return new S3Storage(options.s3Bucket, options.s3Region, options.s3Prefix || '', options.s3UrlExpiration || 3600); } throw new Error(`Unsupported storage type: ${storageType}`); } //# sourceMappingURL=storage.js.map