From 4e8765bce56eeeebe530a6091987e2646cb2c281 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Sat, 15 Aug 2026 00:30:11 +0530 Subject: [PATCH 1/3] feat: output=md support --- README.md | 12 +++++- src/mcp_components/tools.py | 43 ++++++++++++++----- tests/test_server.py | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e7a97e5..1557e9d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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:** @@ -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`). diff --git a/src/mcp_components/tools.py b/src/mcp_components/tools.py index b709bc1..ed855f0 100644 --- a/src/mcp_components/tools.py +++ b/src/mcp_components/tools.py @@ -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: @@ -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/ 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 @@ -139,21 +142,35 @@ 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": @@ -168,7 +185,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: @@ -177,7 +193,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) @@ -190,4 +206,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() diff --git a/tests/test_server.py b/tests/test_server.py index fa0b9c5..4a9d8bd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -208,6 +208,20 @@ 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. @@ -223,6 +237,48 @@ 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: "unexpected response") + + out = await mcp_tools.search(params={"q": "x", "output": "json"}) + + assert out == "Error: SerpApi returned text when JSON output was requested." + assert "" not in out + + async def test_search_compact_strips_serpapi_metadata(monkeypatch): payload = { "search_metadata": {}, @@ -238,6 +294,19 @@ 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) @@ -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): @@ -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", [ From f3e154e81377e3f2a71be96e0f7b8bc1d9cba3a6 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Sat, 15 Aug 2026 00:30:28 +0530 Subject: [PATCH 2/3] chore: minor version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ad51120..23fe7a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/uv.lock b/uv.lock index 19fd518..553e7f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1389,7 +1389,7 @@ wheels = [ [[package]] name = "serpapi-mcp-server" -version = "0.5.0" +version = "0.6.0" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, From 830606c6737c1bb809bb9ad79d133e800fc3a07c Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Sat, 15 Aug 2026 13:52:57 +0530 Subject: [PATCH 3/3] lint: ruff format --- src/mcp_components/tools.py | 3 +-- tests/test_server.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/mcp_components/tools.py b/src/mcp_components/tools.py index ed855f0..acebafd 100644 --- a/src/mcp_components/tools.py +++ b/src/mcp_components/tools.py @@ -159,8 +159,7 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str: 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." + "Error: Invalid output. Use either 'md' or 'json' for the output parameter." ) try: diff --git a/tests/test_server.py b/tests/test_server.py index 4a9d8bd..dc7828f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -217,8 +217,7 @@ def should_not_search(params): out = await mcp_tools.search(params={"q": "x", "output": "html"}) assert out == ( - "Error: Invalid output. Use either 'md' or 'json' " - "for the output parameter." + "Error: Invalid output. Use either 'md' or 'json' for the output parameter." ) @@ -238,7 +237,9 @@ async def test_search_complete_returns_full_payload(monkeypatch): async def test_search_returns_markdown_response_unchanged(monkeypatch): - markdown = "## Organic Results\n\n| Position | Title |\n| --- | --- |\n| 1 | Hit |\n" + markdown = ( + "## Organic Results\n\n| Position | Title |\n| --- | --- |\n| 1 | Hit |\n" + ) captured = {} def capture(params): @@ -263,9 +264,10 @@ def capture(params): 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 ( + json.loads(await mcp_tools.search(params={"q": "x", "output": "json"})) + == payload + ) assert captured["output"] == "json" @@ -300,9 +302,7 @@ async def test_search_compact_returns_markdown_unchanged(monkeypatch): use_search(monkeypatch, lambda params: markdown) assert ( - await mcp_tools.search( - params={"q": "x", "output": "md"}, mode="compact" - ) + await mcp_tools.search(params={"q": "x", "output": "md"}, mode="compact") == markdown )