UNPKG

@rhofkens/mcp-quotes-server

Version:

A Model Context Protocol (MCP) server that provides quotes based on user requests

201 lines 8.56 kB
import axios from "axios"; import axiosRetry from "axios-retry"; import { SerperApiError, SerperConfigurationError, } from "../types/serper-errors.js"; import { logger, logApiRequest, logApiResponse, logError, } from "../utils/logger.js"; export class SerperService { constructor(apiKey) { this.baseUrl = "https://google.serper.dev"; if (!apiKey || typeof apiKey !== "string" || apiKey.trim().length === 0) { throw new SerperConfigurationError("Serper API key is required and must be a non-empty string"); } this.apiKey = apiKey.trim(); this.axiosInstance = this.createAxiosInstance(); this.setupRetryLogic(); } createAxiosInstance() { const instance = axios.create({ baseURL: this.baseUrl, timeout: 10000, headers: { "X-API-KEY": this.apiKey, "Content-Type": "application/json", "User-Agent": "MCP-Quotes-Server/1.0.0", }, }); instance.interceptors.request.use((config) => { logApiRequest(`${config.baseURL}${config.url}`, config.method?.toUpperCase() || "UNKNOWN", config.data); return config; }, (error) => { logError("Request interceptor error", error); return Promise.reject(error); }); instance.interceptors.response.use((response) => { logApiResponse(response.config.url || "unknown", response.status, JSON.stringify(response.data).length); return response; }, (error) => { if (error.response) { logApiResponse(error.config?.url || "unknown", error.response.status); } return Promise.reject(error); }); return instance; } setupRetryLogic() { axiosRetry(this.axiosInstance, { retries: 3, retryDelay: axiosRetry.exponentialDelay, retryCondition: (error) => { return (axiosRetry.isNetworkOrIdempotentRequestError(error) || (error.response?.status ? error.response.status >= 500 : false)); }, onRetry: (retryCount, error, requestConfig) => { logger.warn("API request retry", { retryCount, url: requestConfig.url, error: error.message, }); }, }); } constructSearchQuery(params) { const { person, topic } = params; if (topic && topic.trim().length > 0) { return `"${person}" quotes about "${topic.trim()}"`; } return `"${person}" quotes famous sayings`; } parseQuoteFromResponse(response, searchParams, numberOfQuotes = 1) { const { person, topic } = searchParams; const foundQuotes = []; if (response.organic && response.organic.length > 0) { for (const result of response.organic) { if (foundQuotes.length >= numberOfQuotes) { break; } const snippet = result.snippet || ""; const quotePatterns = [ /"([^"]+)"/g, /([''])([^']+)\1/g, /^([^.!?]+[.!?])/, ]; for (const pattern of quotePatterns) { const matches = snippet.match(pattern); if (matches && matches.length > 0) { const potentialQuote = matches[0].replace(/['"]/g, "").trim(); if (potentialQuote.length > 10 && potentialQuote.length < 500) { if (!foundQuotes.some((existing) => existing.toLowerCase() === potentialQuote.toLowerCase())) { foundQuotes.push(potentialQuote); logger.debug("Quote extracted from search results", { person, topic, source: result.title, quoteLength: potentialQuote.length, quotesFound: foundQuotes.length, }); if (foundQuotes.length >= numberOfQuotes) { break; } } } } } } } if (foundQuotes.length > 0) { logger.info("Quotes extraction completed", { person, topic, requestedCount: numberOfQuotes, foundCount: foundQuotes.length, }); if (foundQuotes.length === 1) { return foundQuotes[0]; } else { return foundQuotes .map((quote, index) => `${index + 1}. "${quote}"`) .join("\n\n"); } } const requestedText = numberOfQuotes === 1 ? "a quote" : `${numberOfQuotes} quotes`; const fallbackMessage = topic ? `Unable to find ${requestedText} from ${person} about ${topic}. Try a different topic or check the spelling.` : `Unable to find ${requestedText} from ${person}. Please verify the person's name or try a more famous figure.`; logger.warn("No suitable quotes found in search results", { person, topic, numberOfQuotes, organicResultsCount: response.organic?.length || 0, }); return fallbackMessage; } async getQuote(params, numberOfQuotes = 1) { const { person, topic } = params; if (!person || typeof person !== "string" || person.trim().length === 0) { throw new SerperConfigurationError("Person parameter is required and must be a non-empty string"); } if (topic !== undefined && typeof topic !== "string") { throw new SerperConfigurationError("Topic parameter must be a string when provided"); } if (!Number.isInteger(numberOfQuotes) || numberOfQuotes < 1 || numberOfQuotes > 10) { throw new SerperConfigurationError("Number of quotes parameter must be an integer between 1 and 10"); } const searchQuery = this.constructSearchQuery({ person: person.trim(), topic, }); const requestPayload = { q: searchQuery, gl: "us", hl: "en", num: Math.max(10, numberOfQuotes * 2), }; try { logger.info("Starting quote search", { person: person.trim(), topic, numberOfQuotes, searchQuery, }); const response = await this.axiosInstance.post("/search", requestPayload); if (!response.data) { throw new SerperApiError("Empty response received from Serper API", 200); } const quote = this.parseQuoteFromResponse(response.data, { person: person.trim(), topic, }, numberOfQuotes); logger.info("Quote search completed successfully", { person: person.trim(), topic, numberOfQuotes, quoteLength: quote.length, successful: !quote.includes("Unable to find"), }); return quote; } catch (error) { if (axios.isAxiosError(error)) { const statusCode = error.response?.status || 0; const errorMessage = error.response?.data?.message || error.message; logError("Serper API request failed", error, { person: person.trim(), topic, numberOfQuotes, statusCode, apiErrorMessage: errorMessage, }); throw new SerperApiError(`Failed to retrieve quote from Serper API: ${errorMessage}`, statusCode); } logError("Unexpected error during quote retrieval", error, { person: person.trim(), topic, numberOfQuotes, }); throw new SerperApiError(`Unexpected error during quote retrieval: ${error.message}`, 0); } } } //# sourceMappingURL=serper-service.js.map