UNPKG

mcp-quickbase

Version:

Work with Quickbase via Model Context Protocol

261 lines 11.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.QuickbaseClient = void 0; const cache_1 = require("../utils/cache"); const logger_1 = require("../utils/logger"); const retry_1 = require("../utils/retry"); const logger = (0, logger_1.createLogger)('QuickbaseClient'); /** * Thread-safe rate limiter to prevent API overload */ class RateLimiter { constructor(maxRequests = 10, windowMs = 1000) { this.requests = []; this.pending = Promise.resolve(); this.maxRequests = maxRequests; this.windowMs = windowMs; } async wait() { // Serialize all rate limit checks to prevent race conditions this.pending = this.pending.then(() => this.checkRateLimit()); return this.pending; } async checkRateLimit() { const now = Date.now(); // Remove requests outside the current window this.requests = this.requests.filter(time => now - time < this.windowMs); if (this.requests.length >= this.maxRequests) { // Calculate wait time until oldest request expires const oldestRequest = Math.min(...this.requests); const waitTime = this.windowMs - (now - oldestRequest) + 10; // +10ms buffer if (waitTime > 0) { logger.debug(`Rate limiting: waiting ${waitTime}ms`); await new Promise(resolve => setTimeout(resolve, waitTime)); // Re-check after waiting (recursive but bounded by maxRequests) return this.checkRateLimit(); } } // Add this request to the window this.requests.push(Date.now()); } } /** * Client for interacting with the Quickbase API */ class QuickbaseClient { /** * Creates a new Quickbase client * @param config Client configuration */ constructor(config) { // Validate and sanitize configuration const rateLimit = config.rateLimit !== undefined ? config.rateLimit : 10; const cacheTtl = config.cacheTtl !== undefined ? config.cacheTtl : 3600; const maxRetries = config.maxRetries !== undefined ? config.maxRetries : 3; const retryDelay = config.retryDelay !== undefined ? config.retryDelay : 1000; const requestTimeout = config.requestTimeout !== undefined ? config.requestTimeout : 30000; // Validate numeric values if (rateLimit < 1 || rateLimit > 100) { throw new Error('Rate limit must be between 1 and 100 requests per second'); } if (cacheTtl < 0 || cacheTtl > 86400) { // Max 24 hours throw new Error('Cache TTL must be between 0 and 86400 seconds (24 hours)'); } if (maxRetries < 0 || maxRetries > 10) { throw new Error('Max retries must be between 0 and 10'); } if (retryDelay < 100 || retryDelay > 60000) { throw new Error('Retry delay must be between 100ms and 60 seconds'); } if (requestTimeout < 1000 || requestTimeout > 300000) { // 1s to 5 minutes throw new Error('Request timeout must be between 1 second and 5 minutes'); } this.config = { userAgent: 'QuickbaseMCPConnector/2.0', cacheEnabled: true, debug: false, ...config, // Override with validated values cacheTtl, maxRetries, retryDelay, requestTimeout, rateLimit }; if (!this.config.realmHost) { throw new Error('Realm hostname is required'); } if (!this.config.userToken) { throw new Error('User token is required'); } this.baseUrl = `https://api.quickbase.com/v1`; this.headers = { 'QB-Realm-Hostname': this.config.realmHost, 'Authorization': `QB-USER-TOKEN ${this.config.userToken}`, 'Content-Type': 'application/json', 'User-Agent': this.config.userAgent || 'QuickbaseMCPConnector/2.0' }; this.cache = new cache_1.CacheService(this.config.cacheTtl, this.config.cacheEnabled); // Initialize rate limiter (10 requests per second by default) this.rateLimiter = new RateLimiter(this.config.rateLimit || 10, 1000); logger.info('Quickbase client initialized', { realmHost: this.config.realmHost, appId: this.config.appId, cacheEnabled: this.config.cacheEnabled, rateLimit: this.config.rateLimit || 10 }); } /** * Get the client configuration * @returns Current configuration */ getConfig() { return { ...this.config }; } /** * Sends a request to the Quickbase API with retry logic * @param options Request options * @returns API response */ async request(options) { const makeRequest = async () => { const { method, path, body, params, headers = {}, skipCache = false } = options; // Build full URL with query parameters let url = `${this.baseUrl}${path}`; if (params && Object.keys(params).length > 0) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { searchParams.append(key, value); }); url += `?${searchParams.toString()}`; } // Check cache for GET requests const cacheKey = `${method}:${url}`; if (method === 'GET' && !skipCache) { const cachedResponse = this.cache.get(cacheKey); if (cachedResponse) { logger.debug('Returning cached response', { url, method }); return cachedResponse; } } // Apply rate limiting before making the request await this.rateLimiter.wait(); // Combine default headers with request-specific headers const requestHeaders = { ...this.headers, ...headers }; // Log request (with redacted sensitive info) const redactedHeaders = { ...requestHeaders }; if (redactedHeaders.Authorization) { redactedHeaders.Authorization = '***REDACTED***'; } if (redactedHeaders['QB-Realm-Hostname']) { // Keep realm hostname structure for debugging but redact sensitive parts // Example: "company-name.quickbase.com" becomes "***.quickbase.com" redactedHeaders['QB-Realm-Hostname'] = redactedHeaders['QB-Realm-Hostname'].replace(/^[^.]+/, '***'); } logger.debug('Sending API request', { url: url.replace(/[?&]userToken=[^&]*/g, '&userToken=***REDACTED***'), // Redact tokens in URL too method, headers: redactedHeaders, body: body ? JSON.stringify(body) : undefined }); // Send request with timeout protection const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.requestTimeout || 30000); let response; try { response = await fetch(url, { method, headers: requestHeaders, body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); } finally { clearTimeout(timeoutId); } // Parse response safely let responseData; try { responseData = await response.json(); } catch (error) { throw new Error(`Invalid JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Ensure responseData is an object if (typeof responseData !== 'object' || responseData === null) { throw new Error('API response is not a valid object'); } const data = responseData; // Check for error response if (!response.ok) { const errorMessage = typeof data.message === 'string' ? data.message : response.statusText; const error = { message: errorMessage, code: response.status, details: data }; logger.error('API request failed', { status: response.status, error }); // Create error with proper metadata for retry logic const httpError = new Error(`HTTP Error ${response.status}: ${errorMessage}`); Object.assign(httpError, { status: response.status, data: responseData }); // Always throw HTTP errors - let retry logic determine if they're retryable // The retry logic will check the status code and decide whether to retry throw httpError; } // Successful response const result = { success: true, data: responseData }; // Cache successful GET responses if (method === 'GET' && !skipCache) { this.cache.set(cacheKey, result); } return result; }; // Retry configuration const retryOptions = { maxRetries: this.config.maxRetries || 3, baseDelay: this.config.retryDelay || 1000, isRetryable: (error) => { // Only retry certain HTTP errors and network errors if (!error) return false; // Handle HTTP errors if (typeof error === 'object' && error !== null && 'status' in error) { const httpError = error; return httpError.status === 429 || // Too Many Requests httpError.status === 408 || // Request Timeout (httpError.status >= 500 && httpError.status < 600); // Server errors } // Handle network errors if (error instanceof Error) { return error.message.includes('network') || error.message.includes('timeout') || error.message.includes('connection'); } return false; } }; try { // Use withRetry to add retry logic to the request return await (0, retry_1.withRetry)(makeRequest, retryOptions)(); } catch (error) { // Handle errors that weren't handled by the retry logic logger.error('Request failed after retries', { error }); return { success: false, error: { message: error instanceof Error ? error.message : 'Unknown error', type: 'NetworkError' } }; } } } exports.QuickbaseClient = QuickbaseClient; //# sourceMappingURL=quickbase.js.map