@zxkane/quip-mcp-server
Version:
MCP server for interacting with Quip spreadsheets
665 lines • 28.1 kB
JavaScript
;
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