mcp-quickbase
Version:
Work with Quickbase via Model Context Protocol
274 lines • 11.6 kB
JavaScript
;
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 };
}
/**
* Invalidate a cache entry
* @param key Cache key to invalidate
*/
invalidateCache(key) {
this.cache.del(key);
logger.debug(`Cache invalidated for key: ${key}`);
}
/**
* 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