Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ A Model Context Protocol (MCP) server implementation that integrates with [SerpA
- **Stock Market Data**: Company financials and market data through search integration
- **Dynamic Result Processing**: Automatically detects and formats different result types
- **Flexible Response Modes**: Complete or compact JSON responses
- **JSON Responses**: Structured JSON output with complete or compact modes
- **JSON Responses (default)**: Structured JSON output with complete or compact modes
- **Markdown Responses**: Cut token usage by 50% on average and by more than 90% for APIs with complex nested JSON.
- **Interactive UI (MCP Apps)**: Opt-in `search_table` and `search_dashboard` tools that render results as an interactive UI in supporting hosts

## Quick Start
Expand Down Expand Up @@ -103,7 +104,8 @@ The parameters you can provide are specific for each API engine. Some sample par
- `params.q` (required): Search query
- `params.engine`: Search engine (default: "google_light")
- `params.location`: Geographic filter
- `mode`: Response mode - "complete" (default) or "compact"
- `params.output`: Response format; omit for JSON (default), or set to `"md"` for Markdown
- `mode`: Response mode; `"compact"` removes metadata from JSON, while Markdown is returned unchanged
- ...see other parameters on the [SerpApi API reference](https://serpapi.com/search-api)

**Examples:**
Expand All @@ -114,6 +116,12 @@ The parameters you can provide are specific for each API engine. Some sample par
{"name": "search", "arguments": {"params": {"q": "AAPL stock"}}}
{"name": "search", "arguments": {"params": {"q": "news"}, "mode": "compact"}}
{"name": "search", "arguments": {"params": {"q": "detailed search"}, "mode": "complete"}}
{"name": "search", "arguments": {"params": {"q": "news", "output": "md"}}}
{"name": "search", "arguments": {"params": {"engine": "amazon", "k": "mechanical keyboards", "amazon_domain": "amazon.com", "output": "md"}}}
{"name": "search", "arguments": {"params": {"engine": "google_scholar", "q": "retrieval augmented generation"}}}
{"name": "search", "arguments": {"params": {"engine": "youtube", "search_query": "how to make espresso"}}}
{"name": "search", "arguments": {"params": {"engine": "apple_app_store", "term": "habit tracker"}}}
{"name": "search", "arguments": {"params": {"engine": "ebay", "_nkw": "vintage mechanical keyboard"}}}
```

**Supported Engines:** Google, Bing, Yahoo, DuckDuckGo, YouTube, eBay, and more (see `serpapi://engines`).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "serpapi-mcp-server"
version = "0.5.0"
version = "0.6.0"
description = "A Model Context Protocol (MCP) server implementation that integrates with SerpApi for comprehensive search engine results and data extraction"
requires-python = ">=3.12"
dependencies = [
Expand Down
42 changes: 32 additions & 10 deletions src/mcp_components/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastmcp.server.dependencies import get_http_request
from fastmcp.tools import tool
from mcp.types import ToolAnnotations
from serpapi.models import SerpResults


def extract_error_response(exception) -> str:
Expand Down Expand Up @@ -84,23 +85,25 @@ def map_search_error(exception) -> str:
- q: Search query. Required for most engines.
- engine: SerpApi engine name. Defaults to "google_light".
- location: Optional geographic location for localized results.
- output: Optional response format. Omit for JSON (default), or set to "md" for Markdown.

Engine-specific parameters are available via MCP resources:
- serpapi://engines lists all supported engines.
- serpapi://engines/<engine> provides parameters and options for one engine.

mode: Response mode. Defaults to "complete".
- "complete": Return the full SerpApi JSON response.
- "compact": Return a reduced response with metadata removed.
- "complete": Return the full SerpApi response.
- "compact": Remove metadata fields from JSON responses. Markdown is returned unchanged.

Output schema:
JSON string containing search results, structured engine output, or an error message.
Markdown when params.output is "md"; otherwise a JSON string or an error message.

Examples:
Weather: {"params": {"q": "weather in London", "engine": "google"}, "mode": "complete"}
Stock: {"params": {"q": "AAPL stock", "engine": "google"}, "mode": "complete"}
General: {"params": {"q": "coffee shops", "engine": "google_light", "location": "Austin, TX"}, "mode": "complete"}
Compact: {"params": {"q": "news"}, "mode": "compact"}
Markdown: {"params": {"q": "news", "output": "md"}}

Supported engines include (not limited to):
- google
Expand Down Expand Up @@ -139,21 +142,34 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str:
- q: Search query (required for most engines)
- engine: Search engine to use (default: "google_light")
- location: Geographic location filter
- output: Response format; omit for JSON or set to "md" for Markdown

mode: Response mode (default: "complete")
- "complete": Returns full JSON response with all fields
- "compact": Returns JSON response with metadata fields removed
- "complete": Returns the full response
- "compact": Removes metadata fields from JSON responses; Markdown is unchanged

Returns:
A JSON string containing search results or an error message.
A Markdown or JSON string containing search results, or an error message.
"""

# Validate mode parameter
if mode not in ["complete", "compact"]:
return "Error: Invalid mode. Must be 'complete' or 'compact'"

output = (params or {}).get("output", "json")
if output not in {"json", "md"}:
return (
"Error: Invalid output. Use either 'md' or 'json' for the output parameter."
)

try:
data = fetch_search_data(params)
response = fetch_search_response(params)
if isinstance(response, str):
if output == "md":
return response
return "Error: SerpApi returned text when JSON output was requested."

data = response.as_dict()

# Apply mode-specific filtering
if mode == "compact":
Expand All @@ -168,7 +184,6 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str:
for field in fields_to_remove:
data.pop(field, None)

# Return JSON response for both modes
return json.dumps(data, indent=2, ensure_ascii=False)

except RuntimeError as e:
Expand All @@ -177,7 +192,7 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str:
return map_search_error(e)


def fetch_search_data(params: dict[str, Any] | None) -> dict[str, Any]:
def fetch_search_response(params: dict[str, Any] | None) -> SerpResults | str:
"""Run a SerpApi search using the request's API key. Raises on failure."""
request = get_http_request()
api_key = getattr(getattr(request, "state", None), "api_key", None)
Expand All @@ -190,4 +205,11 @@ def fetch_search_data(params: dict[str, Any] | None) -> dict[str, Any]:
**(params or {}),
"api_key": api_key,
}
return serpapi.search(search_params).as_dict()
# The SDK returns raw text for non-JSON responses, including Markdown.
return serpapi.search(search_params)


def fetch_search_data(params: dict[str, Any] | None) -> dict[str, Any]:
"""Return structured JSON data for MCP Apps, regardless of text output params."""
json_params = {**(params or {}), "output": "json"}
return fetch_search_response(json_params).as_dict()
84 changes: 84 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,19 @@ async def test_search_rejects_invalid_mode():
assert out == "Error: Invalid mode. Must be 'complete' or 'compact'"


async def test_search_rejects_unsupported_output_before_search(monkeypatch):
def should_not_search(params):
raise AssertionError("SerpApi should not be called for invalid output")

use_search(monkeypatch, should_not_search)

out = await mcp_tools.search(params={"q": "x", "output": "html"})

assert out == (
"Error: Invalid output. Use either 'md' or 'json' for the output parameter."
)


async def test_search_without_api_key_returns_graceful_error(monkeypatch):
# A real starlette Request with empty state: request.state.api_key would raise
# AttributeError, so the guard must use getattr, not attribute access.
Expand All @@ -223,6 +236,51 @@ async def test_search_complete_returns_full_payload(monkeypatch):
assert json.loads(await mcp_tools.search(params={"q": "x"})) == payload


async def test_search_returns_markdown_response_unchanged(monkeypatch):
markdown = (
"## Organic Results\n\n| Position | Title |\n| --- | --- |\n| 1 | Hit |\n"
)
captured = {}

def capture(params):
captured.update(params)
return markdown

use_request(monkeypatch, real_request(state={"api_key": "KEY"}))
use_search(monkeypatch, capture)

assert await mcp_tools.search(params={"q": "x", "output": "md"}) == markdown
assert captured["output"] == "md"


async def test_search_explicit_json_output_returns_json(monkeypatch):
payload = {"organic_results": [{"title": "hit"}]}
captured = {}

def capture(params):
captured.update(params)
return serp_results(payload)

use_request(monkeypatch, real_request(state={"api_key": "KEY"}))
use_search(monkeypatch, capture)

assert (
json.loads(await mcp_tools.search(params={"q": "x", "output": "json"}))
== payload
)
assert captured["output"] == "json"


async def test_search_json_request_rejects_unexpected_text_response(monkeypatch):
use_request(monkeypatch, real_request(state={"api_key": "KEY"}))
use_search(monkeypatch, lambda params: "<html>unexpected response</html>")

out = await mcp_tools.search(params={"q": "x", "output": "json"})

assert out == "Error: SerpApi returned text when JSON output was requested."
assert "<html>" not in out


async def test_search_compact_strips_serpapi_metadata(monkeypatch):
payload = {
"search_metadata": {},
Expand All @@ -238,6 +296,17 @@ async def test_search_compact_strips_serpapi_metadata(monkeypatch):
assert out == {"organic_results": [{"title": "hit"}]}


async def test_search_compact_returns_markdown_unchanged(monkeypatch):
markdown = "## Organic Results\n\n- Hit\n"
use_request(monkeypatch, real_request(state={"api_key": "KEY"}))
use_search(monkeypatch, lambda params: markdown)

assert (
await mcp_tools.search(params={"q": "x", "output": "md"}, mode="compact")
== markdown
)


async def test_search_compact_does_not_mutate_the_live_result(monkeypatch):
payload = {"search_metadata": {"id": "1"}, "organic_results": [{"title": "hit"}]}
results = serp_results(payload)
Expand All @@ -260,6 +329,7 @@ def capture(params):
assert captured["api_key"] == "KEY"
assert captured["engine"] == "google_light"
assert captured["q"] == "x"
assert "output" not in captured


async def test_search_caller_overrides_default_engine(monkeypatch):
Expand Down Expand Up @@ -304,6 +374,20 @@ def capture(params):
assert captured["api_key"] == "TRUSTED"


async def test_search_apps_force_json_output(monkeypatch):
captured = {}

def capture(params):
captured.update(params)
return serp_results(_SAMPLE_PAYLOAD)

use_request(monkeypatch, real_request(state={"api_key": "KEY"}))
use_search(monkeypatch, capture)
await mcp_apps.search_table(params={"q": "x", "output": "md"})

assert captured["output"] == "json"


@pytest.mark.parametrize(
"status, fragment",
[
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading