Files
2026-07-13 13:31:35 +08:00

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()