From 4e8765bce56eeeebe530a6091987e2646cb2c281 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Sat, 15 Aug 2026 00:30:11 +0530 Subject: [PATCH 1/6] 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/6] 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/6] 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 ) From 18a74a3cb11a0589578ffd472a900c0b5467c91b Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Tue, 1 Sep 2026 14:49:05 +0530 Subject: [PATCH 4/6] fix: Correct MCP config values in server.json, add a new action to publish server.json updates to MCP registry --- .github/workflows/publish-mcp-registry.yml | 42 ++++++++++++++++++++++ server.json | 17 ++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish-mcp-registry.yml diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml new file mode 100644 index 0000000..f0f7339 --- /dev/null +++ b/.github/workflows/publish-mcp-registry.yml @@ -0,0 +1,42 @@ +name: Publish MCP Registry + +on: + push: + branches: [ main ] + paths: + - server.json + workflow_dispatch: + +concurrency: + group: publish-mcp-registry + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install MCP publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + run: | + curl --fail --location --silent --show-error \ + --output mcp-publisher.tar.gz \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum --check - + tar -xzf mcp-publisher.tar.gz mcp-publisher + chmod +x mcp-publisher + + - name: Authenticate to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish server metadata + run: ./mcp-publisher publish server.json diff --git a/server.json b/server.json index e9d06c3..9518f37 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,8 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.serpapi/serpapi-mcp", - "version": "1.0.1", + "title": "SerpApi", + "version": "1.0.2", "description": "Official SerpApi MCP server for Google, Bing, and other search engines.", "repository": { "url": "https://github.com/serpapi/serpapi-mcp", @@ -9,8 +10,16 @@ }, "remotes": [ { - "type": "http", - "url": "https://mcp.serpapi.com/mcp" + "type": "streamable-http", + "url": "https://mcp.serpapi.com/{SERPAPI_API_KEY}/mcp", + "variables": { + "SERPAPI_API_KEY": { + "description": "Your SerpApi API key", + "isRequired": true, + "isSecret": true, + "placeholder": "Get your API key at https://serpapi.com/manage-api-key" + } + } } ] -} \ No newline at end of file +} From b1a2cd5092f58bcddb4cae06ce909e4a7ff8fc4e Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Tue, 1 Sep 2026 15:41:01 +0530 Subject: [PATCH 5/6] fix: Correct MCP config values in server.json - sync proejct version with server.json config --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23fe7a9..9a6a9fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "serpapi-mcp-server" -version = "0.6.0" +version = "1.0.2" 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 553e7f8..8bec5eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1389,7 +1389,7 @@ wheels = [ [[package]] name = "serpapi-mcp-server" -version = "0.6.0" +version = "1.0.2" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, From cb7a31052dbf2202952f8311486ccb8cd35dfe97 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Tue, 1 Sep 2026 15:41:59 +0530 Subject: [PATCH 6/6] fix: Correct MCP config values in server.json - add tests and CI check for MCP version match --- .github/workflows/publish-mcp-registry.yml | 37 ++++++++++++++++++++++ .github/workflows/tests.yml | 9 ++++++ server.json | 5 +-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index f0f7339..a8a21b8 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -23,6 +23,43 @@ jobs: - name: Check out repository uses: actions/checkout@v5 + - name: Check registry version alignment + run: | + PROJECT_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + REGISTRY_VERSION="$(jq -r '.version' server.json)" + if [[ "${PROJECT_VERSION}" != "${REGISTRY_VERSION}" ]]; then + echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but server.json is ${REGISTRY_VERSION}. Keep both versions aligned." + exit 1 + fi + + # Registry versions are immutable. Every server.json metadata update must + # use a version that has not previously been published. + - name: Check registry version is unpublished + run: | + SERVER_NAME="$(jq -r '.name' server.json)" + SERVER_VERSION="$(jq -r '.version' server.json)" + ENCODED_NAME="$(jq -rn --arg value "${SERVER_NAME}" '$value | @uri')" + ENCODED_VERSION="$(jq -rn --arg value "${SERVER_VERSION}" '$value | @uri')" + REGISTRY_URL="https://registry.modelcontextprotocol.io/v0.1/servers/${ENCODED_NAME}/versions/${ENCODED_VERSION}" + HTTP_STATUS="$(curl --silent --show-error --location \ + --output registry-version.json \ + --write-out '%{http_code}' \ + "${REGISTRY_URL}")" + + case "${HTTP_STATUS}" in + 404) + ;; + 200) + echo "::error title=Registry version already exists::${SERVER_NAME} ${SERVER_VERSION} is already published. Bump version in server.json and pyproject.toml before publishing metadata changes." + exit 1 + ;; + *) + echo "::error title=Registry lookup failed::The registry returned HTTP ${HTTP_STATUS} while checking ${SERVER_NAME} ${SERVER_VERSION}." + cat registry-version.json + exit 1 + ;; + esac + - name: Install MCP publisher env: MCP_PUBLISHER_VERSION: v1.8.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8488255..9dd8f1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,5 +30,14 @@ jobs: - name: Install dependencies run: uv sync --group dev --frozen + - name: Check registry version alignment + run: | + PROJECT_VERSION="$(uv run --no-sync python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + REGISTRY_VERSION="$(jq -r '.version' server.json)" + if [[ "${PROJECT_VERSION}" != "${REGISTRY_VERSION}" ]]; then + echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but server.json is ${REGISTRY_VERSION}. Keep both versions aligned." + exit 1 + fi + - name: Run tests run: uv run pytest -q diff --git a/server.json b/server.json index 9518f37..dabd494 100644 --- a/server.json +++ b/server.json @@ -4,6 +4,7 @@ "title": "SerpApi", "version": "1.0.2", "description": "Official SerpApi MCP server for Google, Bing, and other search engines.", + "websiteUrl": "https://serpapi.com/", "repository": { "url": "https://github.com/serpapi/serpapi-mcp", "source": "github" @@ -14,10 +15,10 @@ "url": "https://mcp.serpapi.com/{SERPAPI_API_KEY}/mcp", "variables": { "SERPAPI_API_KEY": { - "description": "Your SerpApi API key", + "description": "Your SerpApi API key. Get one at https://serpapi.com/manage-api-key", "isRequired": true, "isSecret": true, - "placeholder": "Get your API key at https://serpapi.com/manage-api-key" + "placeholder": "your-serpapi-api-key" } } }