@rhofkens/mcp-quotes-server
Version:
A Model Context Protocol (MCP) server that provides quotes based on user requests
201 lines • 8.56 kB
JavaScript
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