UNPKG

@zxkane/quip-mcp-server

Version:

MCP server for interacting with Quip spreadsheets

334 lines 13.4 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; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.QuipClient = void 0; exports.findSheetByName = findSheetByName; exports.extractSheetData = extractSheetData; exports.convertXLSXToCSV = convertXLSXToCSV; /** * Quip API client implementation */ const axios_1 = __importDefault(require("axios")); const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const XLSX = __importStar(require("xlsx")); const cheerio = __importStar(require("cheerio")); const logger_1 = require("./logger"); /** * Simple Quip API client implementation for the MCP server */ class QuipClient { /** * Initialize the Quip client with the given access token and base URL * * @param accessToken Quip API access token * @param baseUrl Base URL for the Quip API (default: https://platform.quip.com) */ constructor(accessToken, baseUrl = "https://platform.quip.com") { this.accessToken = accessToken; this.baseUrl = baseUrl.replace(/\/$/, ""); this.axiosInstance = axios_1.default.create({ headers: { 'Authorization': `Bearer ${this.accessToken}` }, // Add timeout configuration to prevent hanging requests timeout: 30000 // 30 seconds timeout }); logger_1.logger.info(`QuipClient initialized with base URL: ${this.baseUrl}`); } /** * Get a thread by ID * * @param threadId ID of the thread to retrieve * @returns Promise resolving to thread information * @throws Error if the request fails */ async getThread(threadId) { logger_1.logger.info(`Getting thread: ${threadId}`); try { const response = await this.axiosInstance.get(`${this.baseUrl}/1/threads/${threadId}`); return response.data; } catch (error) { logger_1.logger.error(`Error getting thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`); // Check for timeout error based on error message or properties const err = error; if (err.code === 'ECONNABORTED' || (err.message && err.message.includes('timeout'))) { throw new Error(`Request timed out when trying to get thread ${threadId}`); } throw error; } } /** * Export a thread to XLSX format and save it locally * * @param threadId ID of the thread to export * @param outputPath Local file path where the XLSX file should be saved * @returns Promise resolving to path to the saved XLSX file * @throws Error if the request fails */ async exportThreadToXLSX(threadId, outputPath) { logger_1.logger.info(`Exporting thread ${threadId} to XLSX`); try { const response = await this.axiosInstance.get(`${this.baseUrl}/1/threads/${threadId}/export/xlsx`, { responseType: 'arraybuffer', // Get binary data timeout: 45000 // 45 seconds timeout for export operation which may take longer }); // Ensure the directory exists await fs.mkdirp(path.dirname(path.resolve(outputPath))); // Write the file await fs.writeFile(outputPath, Buffer.from(response.data)); logger_1.logger.info(`Successfully exported XLSX to ${outputPath}`); return outputPath; } catch (error) { logger_1.logger.error(`Error exporting thread ${threadId} to XLSX: ${error instanceof Error ? error.message : String(error)}`); // Check for timeout error based on error message or properties const err = error; if (err.code === 'ECONNABORTED' || (err.message && err.message.includes('timeout'))) { throw new Error(`Request timed out when trying to export thread ${threadId} to XLSX`); } throw error; } } /** * Export a thread to CSV format using HTML parsing as fallback method * * @param threadId ID of the thread to export * @param sheetName Name of the sheet to extract (optional) * @returns Promise resolving to CSV data as string * @throws Error if the thread is not found or does not contain a spreadsheet */ async exportThreadToCSVFallback(threadId, sheetName) { logger_1.logger.info(`Using fallback method to export thread ${threadId} to CSV`); // Get thread data const thread = await this.getThread(threadId); if (!thread || !thread.html) { throw new Error("Could not retrieve thread or thread has no HTML content"); } // Find and extract sheet data const sheet = findSheetByName(thread.html, sheetName); if (!sheet) { if (sheetName) { throw new Error(`Could not find sheet '${sheetName}' in the document`); } else { throw new Error("Could not find any spreadsheet in the document"); } } // Extract and process data const data = extractSheetData(sheet); if (!data || data.length === 0) { throw new Error(`No data found in sheet '${sheetName || 'default'}'`); } // Convert to CSV string let csvContent = ''; for (const row of data) { csvContent += row.map(cell => { // Escape quotes and wrap in quotes if needed if (cell.includes('"') || cell.includes(',') || cell.includes('\n')) { return `"${cell.replace(/"/g, '""')}"`; } return cell; }).join(',') + '\n'; } return csvContent; } /** * Check if a thread is a spreadsheet * * @param threadId ID of the thread to check * @returns Promise resolving to true if the thread is a spreadsheet, false otherwise */ async isSpreadsheet(threadId) { try { const thread = await this.getThread(threadId); if (!thread || !thread.thread) { return false; } // Check if the thread type is 'spreadsheet' const threadType = thread.thread.type?.toLowerCase() || ''; return threadType === 'spreadsheet'; } catch (error) { logger_1.logger.error(`Error checking if thread is spreadsheet: ${error instanceof Error ? error.message : String(error)}`); return false; } } } exports.QuipClient = QuipClient; /** * Find a spreadsheet with the given name in the document HTML * * @param documentHtml HTML content of the document * @param sheetName Name of the sheet to find (optional) * @returns Cheerio element or null if not found */ function findSheetByName(documentHtml, sheetName) { const $ = cheerio.load(documentHtml); // First try to find a table with the specified title attribute let table = null; if (sheetName) { table = $(`table[title="${sheetName}"]`).get(0) || null; } if (table) { return table; } // If not found and sheet_name is provided, look for a heading with the sheet name if (sheetName) { for (const heading of $('h1, h2, h3').get()) { if ($(heading).text().trim() === sheetName) { const nextTable = $(heading).nextAll('table').first().get(0); if (nextTable) { return nextTable; } } } } // If still not found or no sheet_name provided, return the first table return $('table').get(0) || null; } /** * Extract data from a sheet element * * @param sheet Cheerio element * @returns Array of rows, where each row is an array of cell values */ function extractSheetData(sheet) { if (!sheet) { return []; } const $ = cheerio.load(sheet); const rows = []; // Process rows $('tr').each((_, tr) => { const rowData = []; $(tr).find('td').each((_, td) => { rowData.push($(td).text().trim()); }); // Skip empty rows if (rowData.some(text => text)) { rows.push(rowData); } }); return rows; } /** * Convert XLSX file to CSV format, optionally extracting a specific sheet * * @param xlsxPath Path to the XLSX file * @param sheetName Name of the sheet to extract (optional) * @returns CSV data as string * @throws Error if the sheet is not found */ function convertXLSXToCSV(xlsxPath, sheetName) { logger_1.logger.info(`Reading XLSX file from ${xlsxPath}`); // Load the workbook const workbook = XLSX.readFile(xlsxPath); // Get available sheet names const sheetNames = workbook.SheetNames; logger_1.logger.info(`Available sheets: ${sheetNames.join(', ')}`); // Determine which sheet to use let sheetIndex = 0; if (sheetName) { // Try exact match first sheetIndex = sheetNames.findIndex(s => s === sheetName); // If not found, try case-insensitive match if (sheetIndex === -1) { const sheetLower = sheetName.toLowerCase(); sheetIndex = sheetNames.findIndex(s => s.toLowerCase() === sheetLower); } if (sheetIndex === -1) { throw new Error(`Sheet '${sheetName}' not found. Available sheets: ${sheetNames.join(', ')}`); } } // Get the sheet const sheet = workbook.Sheets[sheetNames[sheetIndex]]; // BUGFIX: Manually scan for all cell keys to determine the true sheet range // This fixes an issue where the sheet's !ref property might incorrectly report // a smaller range than the actual data in the file const cellKeys = Object.keys(sheet).filter(key => key[0] !== '!'); let maxCol = 0; let maxRow = 0; // Find the maximum column and row by examining all cell keys cellKeys.forEach(key => { // Parse cell address (e.g., "A1", "B2", etc.) const match = key.match(/^([A-Z]+)(\d+)$/); if (match) { const colStr = match[1]; const rowIdx = parseInt(match[2], 10); const colIdx = XLSX.utils.decode_col(colStr); if (colIdx > maxCol) maxCol = colIdx; if (rowIdx > maxRow) maxRow = rowIdx; } }); // Create a custom range that includes all cells const customRange = { s: { c: 0, r: 0 }, // Start at A1 e: { c: maxCol, r: maxRow - 1 } // End at the furthest cell }; // Log the detected range logger_1.logger.info(`Detected full sheet range: from A1 to ${XLSX.utils.encode_col(maxCol)}${maxRow}`); logger_1.logger.info(`Total columns: ${maxCol + 1}`); // Convert to JSON using our custom range to include all columns const data = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '', // Ensure empty cells are included range: customRange // Use our custom range instead of sheet['!ref'] }); // Process data and build CSV with proper escaping let csvContent = ''; for (const row of data) { if (!row || row.length === 0) continue; // Skip empty rows csvContent += row.map((cell) => { // Handle null or undefined cells const cellValue = cell === null || cell === undefined ? '' : String(cell); // If cell contains commas, quotes, or newlines, wrap in quotes and escape any quotes if (cellValue.includes(',') || cellValue.includes('"') || cellValue.includes('\n') || cellValue.includes('\r')) { return `"${cellValue.replace(/"/g, '""')}"`; } return cellValue; }).join(',') + '\n'; } return csvContent; } //# sourceMappingURL=quipClient.js.map