330 lines
12 KiB
Python
330 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Web Search MCP Server
|
|
|
|
This advanced MCP server demonstrates integration with SerpAPI to provide
|
|
real-time web data to LLMs through four specialized tools:
|
|
- general_search: For broad web search results
|
|
- news_search: For recent news articles
|
|
- product_search: For e-commerce product information
|
|
- qna: For direct question-answer snippets
|
|
|
|
The server is built using FastMCP and showcases advanced concepts
|
|
like external API integration, structured data parsing, and
|
|
multi-tool orchestration.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import httpx
|
|
import logging
|
|
from typing import Dict, Any
|
|
from dotenv import load_dotenv
|
|
from mcp.server.fastmcp import FastMCP, Context
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
SERPAPI_KEY = os.getenv("SERPAPI_KEY")
|
|
if not SERPAPI_KEY:
|
|
logger.error("SERPAPI_KEY environment variable not found. Please set it in .env file.")
|
|
raise EnvironmentError("SERPAPI_KEY environment variable is required")
|
|
|
|
# API configuration
|
|
SERPAPI_BASE_URL = "https://serpapi.com/search"
|
|
DEFAULT_TIMEOUT = 10.0 # seconds
|
|
DEFAULT_RESULTS_LIMIT = 5
|
|
|
|
# Initialize FastMCP server
|
|
mcp = FastMCP("WebSearchServer")
|
|
|
|
async def make_serpapi_request(ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Make a request to SerpAPI with the given parameters.
|
|
|
|
Args:
|
|
ctx: MCP context object for logging
|
|
params: Dictionary of parameters to send to SerpAPI
|
|
|
|
Returns:
|
|
Dict containing the API response
|
|
|
|
Raises:
|
|
Exception: If the API request fails
|
|
"""
|
|
# Ensure API key is included
|
|
request_params = {**params, "api_key": SERPAPI_KEY}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
|
|
await ctx.info(f"Making SerpAPI request with engine: {params.get('engine', 'google')}")
|
|
response = await client.get(SERPAPI_BASE_URL, params=request_params)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
await ctx.info("SerpAPI request successful")
|
|
return data
|
|
except httpx.TimeoutException:
|
|
await ctx.error("SerpAPI request timed out")
|
|
raise Exception("Search request timed out. Please try again.")
|
|
except httpx.RequestError as e:
|
|
await ctx.error(f"SerpAPI request error: {e}")
|
|
raise Exception(f"Failed to fetch data from search API: {e}")
|
|
except httpx.HTTPStatusError as e:
|
|
await ctx.error(f"SerpAPI HTTP error: {e.response.status_code} - {e.response.text}")
|
|
raise Exception(f"Search API returned error status: {e.response.status_code}")
|
|
except json.JSONDecodeError:
|
|
await ctx.error("Failed to parse SerpAPI response as JSON")
|
|
raise Exception("Failed to parse search results")
|
|
|
|
# Tool for general web search
|
|
@mcp.tool()
|
|
async def general_search(query: str, num_results: int = DEFAULT_RESULTS_LIMIT, ctx: Context = None) -> str:
|
|
"""
|
|
Perform a general web search and return formatted results.
|
|
|
|
Args:
|
|
query: The search query
|
|
num_results: Number of results to return (default: 5)
|
|
ctx: MCP context object
|
|
|
|
Returns:
|
|
Formatted search results as a string
|
|
"""
|
|
await ctx.info(f"Performing general search for: {query}")
|
|
|
|
try:
|
|
# Prepare parameters for SerpAPI
|
|
params = {
|
|
"q": query,
|
|
"num": num_results,
|
|
"engine": "google",
|
|
}
|
|
|
|
# Make the API request
|
|
response_data = await make_serpapi_request(ctx, params)
|
|
|
|
# Extract organic results
|
|
organic_results = response_data.get("organic_results", [])
|
|
if not organic_results:
|
|
await ctx.info("No general search results found")
|
|
return "No search results found."
|
|
|
|
# Format results for return
|
|
formatted_results = []
|
|
for i, result in enumerate(organic_results[:num_results]):
|
|
formatted_results.append(
|
|
f"## {i+1}. {result.get('title', 'No title')}\n"
|
|
f"**Link**: {result.get('link', 'No link')}\n"
|
|
f"**Snippet**: {result.get('snippet', 'No description')}\n"
|
|
)
|
|
|
|
await ctx.info(f"Returning {len(formatted_results)} general search results")
|
|
return "\n\n".join(formatted_results)
|
|
except Exception as e:
|
|
await ctx.error(f"General search failed: {str(e)}")
|
|
return f"Error: Unable to fetch results. {str(e)}"
|
|
|
|
# Tool for news search
|
|
@mcp.tool()
|
|
async def news_search(query: str, num_results: int = DEFAULT_RESULTS_LIMIT, ctx: Context = None) -> str:
|
|
"""
|
|
Search for recent news articles related to a query.
|
|
|
|
Args:
|
|
query: The search query
|
|
num_results: Number of news articles to return (default: 5)
|
|
ctx: MCP context object
|
|
|
|
Returns:
|
|
Formatted news search results as a string
|
|
"""
|
|
await ctx.info(f"Performing news search for: {query}")
|
|
|
|
try:
|
|
# Prepare parameters for SerpAPI
|
|
params = {
|
|
"q": query,
|
|
"num": num_results,
|
|
"engine": "google_news",
|
|
}
|
|
|
|
# Make the API request
|
|
response_data = await make_serpapi_request(ctx, params)
|
|
|
|
# Extract news results
|
|
news_results = response_data.get("news_results", [])
|
|
if not news_results:
|
|
await ctx.info("No news articles found")
|
|
return "No news articles found."
|
|
|
|
# Format results for return
|
|
formatted_results = []
|
|
for i, result in enumerate(news_results[:num_results]):
|
|
formatted_results.append(
|
|
f"## {i+1}. {result.get('title', 'No title')}\n"
|
|
f"**Source**: {result.get('source', 'Unknown source')} | "
|
|
f"**Date**: {result.get('date', 'Unknown date')}\n"
|
|
f"**Link**: {result.get('link', 'No link')}\n"
|
|
f"**Snippet**: {result.get('snippet', 'No description')}\n"
|
|
)
|
|
|
|
await ctx.info(f"Returning {len(formatted_results)} news results")
|
|
return "\n\n".join(formatted_results)
|
|
except Exception as e:
|
|
await ctx.error(f"News search failed: {str(e)}")
|
|
return f"Error: Unable to fetch news. {str(e)}"
|
|
|
|
# Tool for product search
|
|
@mcp.tool()
|
|
async def product_search(query: str, num_results: int = DEFAULT_RESULTS_LIMIT, ctx: Context = None) -> str:
|
|
"""
|
|
Search for products matching a query.
|
|
|
|
Args:
|
|
query: The product search query
|
|
num_results: Number of product results to return (default: 5)
|
|
ctx: MCP context object
|
|
|
|
Returns:
|
|
Formatted product search results as a string
|
|
"""
|
|
await ctx.info(f"Performing product search for: {query}")
|
|
|
|
try:
|
|
# Prepare parameters for SerpAPI
|
|
params = {
|
|
"q": query,
|
|
"engine": "google_shopping",
|
|
"shopping_intent": "high",
|
|
"num": num_results
|
|
}
|
|
|
|
# Make the API request
|
|
response_data = await make_serpapi_request(ctx, params)
|
|
|
|
# Extract shopping results
|
|
shopping_results = response_data.get("shopping_results", [])
|
|
if not shopping_results:
|
|
await ctx.info("No product results found")
|
|
return "No product results found."
|
|
|
|
# Format results for return
|
|
formatted_results = []
|
|
for i, result in enumerate(shopping_results[:num_results]):
|
|
formatted_results.append(
|
|
f"## {i+1}. {result.get('title', 'No title')}\n"
|
|
f"**Price**: {result.get('price', 'Unknown price')}\n"
|
|
f"**Rating**: {result.get('rating', 'No rating')} "
|
|
f"({result.get('reviews', 'No')} reviews)\n"
|
|
f"**Source**: {result.get('source', 'Unknown source')}\n"
|
|
f"**Link**: {result.get('link', 'No link')}\n"
|
|
)
|
|
|
|
await ctx.info(f"Returning {len(formatted_results)} product results")
|
|
return "\n\n".join(formatted_results)
|
|
except Exception as e:
|
|
await ctx.error(f"Product search failed: {str(e)}")
|
|
return f"Error: Unable to fetch products. {str(e)}"
|
|
|
|
# Tool for Q&A search
|
|
@mcp.tool()
|
|
async def qna(question: str, ctx: Context = None) -> str:
|
|
"""
|
|
Get direct answers to questions from search engines.
|
|
|
|
Args:
|
|
question: The question to find an answer for
|
|
ctx: MCP context object
|
|
|
|
Returns:
|
|
Answer snippet as a string
|
|
"""
|
|
await ctx.info(f"Searching for answer to: {question}")
|
|
|
|
try:
|
|
# Prepare parameters for SerpAPI
|
|
params = {
|
|
"q": question,
|
|
"engine": "google",
|
|
}
|
|
|
|
# Make the API request
|
|
response_data = await make_serpapi_request(ctx, params)
|
|
|
|
# Try to extract answer box first (direct answer)
|
|
answer_box = response_data.get("answer_box", {})
|
|
if answer_box:
|
|
await ctx.info("Found answer in answer box")
|
|
if "answer" in answer_box:
|
|
return f"**Answer**: {answer_box['answer']}"
|
|
elif "snippet" in answer_box:
|
|
return f"**Answer**: {answer_box['snippet']}"
|
|
elif "snippet_highlighted_words" in answer_box:
|
|
return f"**Answer**: {' '.join(answer_box['snippet_highlighted_words'])}"
|
|
|
|
# Try knowledge graph if no answer box
|
|
knowledge_graph = response_data.get("knowledge_graph", {})
|
|
if knowledge_graph and "description" in knowledge_graph:
|
|
await ctx.info("Found answer in knowledge graph")
|
|
return f"**Answer**: {knowledge_graph['description']}"
|
|
|
|
# Try featured snippet
|
|
if "featured_snippet" in response_data:
|
|
await ctx.info("Found answer in featured snippet")
|
|
snippet = response_data["featured_snippet"]
|
|
if "snippet" in snippet:
|
|
return f"**Answer**: {snippet['snippet']}"
|
|
|
|
# Try related questions
|
|
related_questions = response_data.get("related_questions", [])
|
|
if related_questions:
|
|
await ctx.info("Found answer in related questions")
|
|
formatted = []
|
|
for i, question in enumerate(related_questions[:3]):
|
|
formatted.append(
|
|
f"**Question**: {question.get('question', 'Unknown question')}\n"
|
|
f"**Answer**: {question.get('snippet', 'No answer available')}\n"
|
|
f"**Source**: {question.get('source', {}).get('link', 'No source')}"
|
|
)
|
|
return "\n\n".join(formatted)
|
|
|
|
# Fallback to first organic result snippet
|
|
organic_results = response_data.get("organic_results", [])
|
|
if organic_results and "snippet" in organic_results[0]:
|
|
await ctx.info("No direct answer found, using first organic result")
|
|
return f"**Possible answer**: {organic_results[0]['snippet']}"
|
|
|
|
await ctx.info("No answer found")
|
|
return "No direct answer found for your question."
|
|
except Exception as e:
|
|
await ctx.error(f"Q&A search failed: {str(e)}")
|
|
return f"Error: Unable to find an answer. {str(e)}"
|
|
|
|
@mcp.resource("readme://")
|
|
async def get_readme() -> str:
|
|
"""Get README information for the Web Search MCP Server"""
|
|
return """
|
|
# Web Search MCP Server
|
|
|
|
This MCP server provides tools for integrating web search capabilities into LLMs using SerpAPI.
|
|
|
|
## Available Tools:
|
|
|
|
1. `general_search(query, num_results=5)` - Perform a general web search
|
|
2. `news_search(query, num_results=5)` - Search for recent news articles
|
|
3. `product_search(query, num_results=5)` - Search for products
|
|
4. `qna(question)` - Get direct answers to questions
|
|
|
|
## Usage:
|
|
|
|
Call these tools from an MCP client to retrieve real-time web data.
|
|
"""
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run() |