UNPKG

@rhofkens/mcp-quotes-server-claude-code

Version:

Model Context Protocol (MCP) server for managing and serving quotes

203 lines 8.49 kB
/** * Serper API Client * * Service for searching quotes using the Serper.dev search API */ import axios, { AxiosError } from 'axios'; import { APIError, ErrorCode, withRetry, CircuitBreaker, withTimeout, logError, ErrorContextBuilder, generateRequestId, } from '../utils/errorHandling.js'; import { logger } from '../utils/logger.js'; import { validateEnvVar } from '../utils/validation.js'; /** * Client for interacting with Serper.dev API */ export class SerperClient { apiKey; baseUrl; timeout; circuitBreaker; constructor(config) { // Get API key from environment or config this.apiKey = config?.apiKey || validateEnvVar('SERPER_API_KEY', process.env['SERPER_API_KEY']); this.baseUrl = config?.baseUrl || 'https://google.serper.dev'; this.timeout = config?.timeout || 10000; // 10 seconds default // Initialize circuit breaker for API protection this.circuitBreaker = new CircuitBreaker(5, // Open after 5 failures 60000, // Stay open for 1 minute 30000 // Try half-open after 30 seconds ); } /** * Search for quotes using Serper API with enhanced error handling */ async searchQuotes(params) { const requestId = generateRequestId(); const context = new ErrorContextBuilder() .setOperation('searchQuotes') .setInput({ query: params.query, num: params.num }) .setEnvironment() .build(); logger.info('Starting quote search', { requestId, params }); try { // Execute with circuit breaker protection return await this.circuitBreaker.execute(async () => { // Wrap with retry logic for transient failures return await withRetry(async () => { // Add timeout protection const response = await withTimeout(axios.post(`${this.baseUrl}/search`, { q: params.query, num: params.num || 10, }, { headers: { 'X-API-KEY': this.apiKey, 'Content-Type': 'application/json', 'X-Request-ID': requestId, }, timeout: this.timeout, }), this.timeout, 'Serper API request timed out'); // Check for API errors in response if (response.data.error) { throw new APIError(`Serper API error: ${response.data.error}`, ErrorCode.API_ERROR, 'serper', { error: response.data.error, requestId, }); } // Log successful response logger.info('Quote search completed', { requestId, resultsCount: response.data.organic?.length || 0, }); // Return organic results or empty array return response.data.organic || []; }, { maxRetries: 3, initialDelay: 1000, maxDelay: 10000, backoffMultiplier: 2, retryableErrors: [ErrorCode.API_TIMEOUT, ErrorCode.API_RATE_LIMIT], onRetry: (error, attempt) => { logger.warn('Retrying Serper API request', { requestId, attempt, error: error.message, }); }, }); }); } catch (error) { // Log the error with context logError(error, context); if (error instanceof APIError) { throw error; } if (error instanceof AxiosError) { // Handle specific Axios errors with enhanced context if (error.code === 'ECONNABORTED') { throw new APIError('Serper API request timed out', ErrorCode.API_TIMEOUT, 'serper', { timeout: this.timeout, requestId, query: params.query, }); } if (error.response) { const status = error.response.status; const retryAfter = error.response.headers['retry-after']; if (status === 401) { throw new APIError('Invalid Serper API key', ErrorCode.API_UNAUTHORIZED, 'serper', { requestId, }); } if (status === 429) { throw new APIError('Serper API rate limit exceeded', ErrorCode.API_RATE_LIMIT, 'serper', { retryAfter: retryAfter ? parseInt(retryAfter) : 60, requestId, }); } throw new APIError(`Serper API error: ${error.response.statusText}`, ErrorCode.API_ERROR, 'serper', { status, statusText: error.response.statusText, data: error.response.data, requestId, }); } // Network errors throw new APIError(`Network error connecting to Serper API: ${error.message}`, ErrorCode.API_ERROR, 'serper', { originalError: error.message, requestId, code: error.code, }); } // Unknown errors throw new APIError('Unexpected error while searching quotes', ErrorCode.API_ERROR, 'serper', { originalError: String(error), requestId, }); } } /** * Build a search query for finding quotes */ buildQuoteSearchQuery(person, topic) { // Build a targeted search query for quotes const baseQuery = `"${person}" quotes`; if (topic) { // Add topic filter to the query return `${baseQuery} about "${topic}"`; } return baseQuery; } /** * Get circuit breaker status */ getCircuitBreakerStatus() { const state = this.circuitBreaker.getState(); return { state, canExecute: state !== 'open', }; } /** * Reset circuit breaker (for manual intervention) */ resetCircuitBreaker() { this.circuitBreaker.reset(); logger.info('Circuit breaker manually reset'); } /** * Extract quote text from search result snippet * Attempts to find quoted text within the snippet */ extractQuoteFromSnippet(snippet) { // Try to find text within quotation marks const doubleQuoteMatch = snippet.match(/"([^"]+)"/); if (doubleQuoteMatch && doubleQuoteMatch[1] && doubleQuoteMatch[1].length > 20) { return doubleQuoteMatch[1]; } // Try single quotes const singleQuoteMatch = snippet.match(/'([^']+)'/); if (singleQuoteMatch && singleQuoteMatch[1] && singleQuoteMatch[1].length > 20) { return singleQuoteMatch[1]; } // Try to find text after common quote indicators const indicators = ['said:', 'wrote:', 'stated:', 'declared:', 'remarked:']; for (const indicator of indicators) { const index = snippet.toLowerCase().indexOf(indicator); if (index !== -1) { const afterIndicator = snippet.substring(index + indicator.length).trim(); // Take the first sentence or up to 200 characters const sentenceEnd = afterIndicator.search(/[.!?]/); if (sentenceEnd > 20) { return afterIndicator.substring(0, sentenceEnd + 1).trim(); } } } // If no quote markers found, return the snippet itself if it's reasonable length if (snippet.length > 20 && snippet.length < 300) { return snippet; } return null; } } // Export a singleton instance with default configuration export const serperClient = new SerperClient(); //# sourceMappingURL=serperClient.js.map