From 6edf3bc8cc79736c035992d1c4e5cac18054fc00 Mon Sep 17 00:00:00 2001 From: vm-serpapi <223280037+vm-serpapi@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:53:38 +0200 Subject: [PATCH 1/2] [MCP] Add interactive UI search tools via MCP Apps Adds two opt-in MCP Apps (SEP-1865) tools alongside the unchanged text `search` tool, so app-aware hosts can render results as interactive UI without the bulk SERP JSON entering the model context window: - search_table: organic results as a sortable/searchable table - search_dashboard: summary metrics, source-breakdown chart, and a results table with a click-to-expand detail panel Both reuse the existing API-key resolution and a shared error-mapping helper, and surface upstream errors as an in-UI alert. Pure view-model helpers (organic_rows, source_breakdown, dashboard_summary) are unit tested offline with the existing mock style; tools assert build + error behavior. Also bumps fastmcp to >=3.4.2 with the [apps] extra, pins prefab-ui (<0.21, frequent breaking changes), and floors starlette at >=1.0.1 (CVE-2026-48710). --- README.md | 16 +++ pyproject.toml | 10 +- src/server.py | 294 ++++++++++++++++++++++++++++++++++++++----- tests/test_server.py | 145 +++++++++++++++++++++ uv.lock | 111 ++++++++++++---- 5 files changed, 523 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 37fc856..7c5c147 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Model Context Protocol (MCP) server implementation that integrates with [SerpA - **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 +- **Interactive UI (MCP Apps)**: Opt-in `search_table` and `search_dashboard` tools that render results as an interactive UI in supporting hosts ## Quick Start @@ -97,6 +98,21 @@ The parameters you can provide are specific for each API engine. Some sample par **Result Types:** Answer boxes, organic results, news, images, shopping - automatically detected and formatted. +## Interactive UI (MCP Apps) + +The default `search` tool returns JSON and is unchanged. For hosts that support the [MCP Apps extension](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp) (SEP-1865), two opt-in tools render results as an interactive UI directly in the conversation, so the bulk SERP JSON never enters the model's context window: + +- `search_table`: organic results as a sortable, searchable table. +- `search_dashboard`: summary metrics, a source-breakdown chart, and a results table with a click-to-expand detail panel. + +Both accept the same `params` as `search`. Hosts that don't support MCP Apps simply ignore these tools. + +Preview them locally without an MCP host: + +```bash +uv run fastmcp dev apps src/server.py +``` + ## Development ```bash diff --git a/pyproject.toml b/pyproject.toml index 6874703..06c74df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "serpapi-mcp-server" -version = "0.3.0" +version = "0.4.0" description = "A Model Context Protocol (MCP) server implementation that integrates with SerpApi for comprehensive search engine results and data extraction" readme = "README.md" license = "MIT" @@ -23,11 +23,15 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ - "fastmcp>=3.2.0", + "fastmcp[apps]>=3.4.2", + # Prefab (the MCP Apps UI library) ships frequent breaking changes and + # fastmcp does not pin an upper bound, so pin it explicitly here. + "prefab-ui>=0.20.2,<0.21", "python-dotenv>=1.0.0", "httpx>=0.25.0", "uvicorn>=0.38.0", - "starlette>=0.50.0", + # >=1.0.1 avoids CVE-2026-48710 (fastmcp 3.4.1 floors this transitively). + "starlette>=1.0.1", "serpapi>=0.1.5", "beautifulsoup4>=4.12.0", "markdownify>=0.14.1", diff --git a/src/server.py b/src/server.py index cf766e5..3151a4b 100644 --- a/src/server.py +++ b/src/server.py @@ -1,25 +1,49 @@ -import uvicorn +import json +import logging +import os +import re import time +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlparse -from fastmcp.resources import ResourceResult, ResourceContent -from starlette.middleware import Middleware -from starlette.middleware.cors import CORSMiddleware -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import JSONResponse -from starlette.requests import Request +import serpapi +import uvicorn +from dotenv import load_dotenv from fastmcp import FastMCP from fastmcp.exceptions import NotFoundError +from fastmcp.resources import ResourceContent, ResourceResult from fastmcp.server.dependencies import get_http_request -from dotenv import load_dotenv -import os -import json -from typing import Any -import serpapi -import logging -from datetime import datetime -from pathlib import Path -import re from mcp.types import Annotations, ToolAnnotations +from prefab_ui.actions import SetState +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + H3, + Alert, + AlertDescription, + AlertTitle, + Card, + CardContent, + CardHeader, + Column, + DataTable, + DataTableColumn, + Grid, + If, + Link, + Metric, + Small, + Text, +) +from prefab_ui.components.charts import PieChart +from prefab_ui.rx import STATE, Rx +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse load_dotenv() @@ -165,6 +189,29 @@ def extract_error_response(exception) -> str: return str(exception) +def map_search_error(exception) -> str: + """Map a SerpApi/transport exception to a user-facing 'Error: ...' string. + + Shared by the text `search` tool and the App tools so all entry points + surface identical messages for the same upstream failure. + """ + if isinstance(exception, serpapi.exceptions.HTTPError): + text = str(exception) + if "429" in text: + return "Error: Rate limit exceeded. Please try again later." + if "401" in text: + return ( + "Error: Invalid SerpApi API key. " + "Check your API key in the path or Authorization header." + ) + if "403" in text: + return ( + "Error: SerpApi API key forbidden. " + "Verify your subscription and key validity." + ) + return f"Error: {extract_error_response(exception)}" + + class ApiKeyMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Skip authentication for healthcheck endpoint @@ -342,19 +389,210 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str: # Return JSON response for both modes return json.dumps(data, indent=2, ensure_ascii=False) - except serpapi.exceptions.HTTPError as e: - if "429" in str(e): - return f"Error: Rate limit exceeded. Please try again later." - elif "401" in str(e): - return f"Error: Invalid SerpApi API key. Check your API key in the path or Authorization header." - elif "403" in str(e): - return f"Error: SerpApi API key forbidden. Verify your subscription and key validity." - else: - error_msg = extract_error_response(e) - return f"Error: {error_msg}" except Exception as e: - error_msg = extract_error_response(e) - return f"Error: {error_msg}" + return map_search_error(e) + + +# --------------------------------------------------------------------------- +# MCP Apps (SEP-1865): interactive UI variants of `search`. +# +# These are opt-in: the plain-text `search` tool above is unchanged and stays +# the default. App-aware hosts can call `search_table` / `search_dashboard` +# to get an interactive UI rendered in the conversation; the bulk SERP JSON +# never enters the model context window. Hosts that don't support the Apps +# extension simply ignore these tools. +# --------------------------------------------------------------------------- + + +def _result_source(result: dict[str, Any]) -> str: + """Best-effort source label for an organic result (explicit source or host).""" + source = result.get("source") + if source: + return str(source) + host = urlparse(result.get("link", "") or "").netloc + return host[4:] if host.startswith("www.") else host + + +def organic_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + """Flatten SerpApi organic_results into compact, table-ready rows.""" + rows: list[dict[str, Any]] = [] + for index, result in enumerate(data.get("organic_results") or [], start=1): + rows.append( + { + "position": result.get("position", index), + "title": result.get("title", ""), + "link": result.get("link", ""), + "source": _result_source(result), + "snippet": result.get("snippet", ""), + } + ) + return rows + + +def source_breakdown( + rows: list[dict[str, Any]], limit: int = 8 +) -> list[dict[str, Any]]: + """Count results per source for the dashboard pie chart (top `limit`).""" + counts = Counter(row["source"] for row in rows if row["source"]) + return [ + {"source": source, "count": count} + for source, count in counts.most_common(limit) + ] + + +def dashboard_summary(data: dict[str, Any]) -> dict[str, Any]: + """Derive the dashboard view-model from a SerpApi response.""" + params = data.get("search_parameters") or {} + info = data.get("search_information") or {} + rows = organic_rows(data) + return { + "query": params.get("q", ""), + "engine": params.get("engine", ""), + "total_results": info.get("total_results"), + "result_count": len(rows), + "rows": rows, + "sources": source_breakdown(rows), + } + + +def fetch_search_data(params: dict[str, Any] | None) -> dict[str, Any]: + """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) + if not api_key: + raise RuntimeError("Error: Unable to access API key from request context") + + search_params = { + "api_key": api_key, + "engine": "google_light", + **(params or {}), + } + return serpapi.search(search_params).as_dict() + + +def _error_app(message: str) -> PrefabApp: + """Render an upstream/search error as an Apps alert instead of raw text.""" + with PrefabApp(title="Search error") as app: + with Alert(variant="destructive"): + AlertTitle(content="Search failed") + AlertDescription(content=message) + return app + + +_ORGANIC_COLUMNS = [ + DataTableColumn(key="position", header="#", sortable=True, width="64px"), + DataTableColumn(key="title", header="Title", sortable=True), + DataTableColumn(key="source", header="Source", sortable=True), + DataTableColumn(key="snippet", header="Snippet"), +] + + +@mcp.tool( + app=True, + description=( + "Interactive UI variant of `search`: returns organic results as a " + "sortable, searchable table rendered in the conversation. Same params " + "as `search`. Use when the host supports MCP Apps and the user wants " + "to browse results visually rather than read JSON." + ), + annotations=ToolAnnotations( + title="SerpApi search (table)", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), +) +async def search_table(params: dict[str, Any] = None) -> PrefabApp: + try: + data = fetch_search_data(params) + except Exception as exc: + return _error_app( + str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) + ) + + rows = organic_rows(data) + with PrefabApp(title="Search results") as app: + with Column(gap=4, css_class="p-4"): + DataTable( + columns=_ORGANIC_COLUMNS, + rows=rows, + search=True, + paginated=True, + page_size=10, + ) + return app + + +@mcp.tool( + app=True, + description=( + "Interactive dashboard variant of `search`: returns summary metrics, a " + "source breakdown chart, and a results table with a click-to-expand " + "detail panel, all rendered in the conversation. Same params as " + "`search`. Use for a richer visual overview of a query's results." + ), + annotations=ToolAnnotations( + title="SerpApi search (dashboard)", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), +) +async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: + try: + data = fetch_search_data(params) + except Exception as exc: + return _error_app( + str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) + ) + + summary = dashboard_summary(data) + total = summary["total_results"] + + with PrefabApp(title="Search dashboard", state={"selected": None}) as app: + with Column(gap=4, css_class="p-4"): + with Grid(columns=[1, 1, 1], gap=4): + Metric(label="Query", value=summary["query"] or "—") + Metric(label="Engine", value=summary["engine"] or "—") + Metric( + label="Results shown", + value=str(summary["result_count"]), + description=( + f"of ~{total:,} total" if isinstance(total, int) else None + ), + ) + + with Grid(columns=[1, 2], gap=4): + if summary["sources"]: + PieChart( + data=summary["sources"], + data_key="count", + name_key="source", + show_legend=True, + ) + DataTable( + columns=_ORGANIC_COLUMNS, + rows=summary["rows"], + search=True, + on_row_click=SetState("selected", Rx("$event")), + ) + + with If(STATE.selected): + with Card(): + with CardHeader(): + H3(Rx("selected.title")) + Small(content=Rx("selected.source")) + with CardContent(): + with Column(gap=2): + Text(content=Rx("selected.snippet")) + Link( + content=Rx("selected.link"), + href=Rx("selected.link"), + target="_blank", + ) + return app async def healthcheck_handler(request): diff --git a/tests/test_server.py b/tests/test_server.py index 8fee7a6..33c00a8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -302,3 +302,148 @@ async def test_healthcheck_returns_healthy_with_utc_timestamp(): assert body["service"] == "SerpApi MCP Server" # timezone-aware UTC, Z-suffixed (utcnow() was deprecated on 3.12+). assert body["timestamp"].endswith("Z") + + +# --- MCP Apps: shared error mapping ---------------------------------------- + + +@pytest.mark.parametrize( + "status, fragment", + [ + (429, "Rate limit exceeded"), + (401, "Invalid SerpApi API key"), + (403, "forbidden"), + ], +) +def test_map_search_error_maps_known_statuses(status, fragment): + out = server.map_search_error(make_serpapi_http_error(status, {"error": "x"})) + assert out.startswith("Error:") + assert fragment in out + + +def test_map_search_error_falls_back_to_json_body(): + out = server.map_search_error( + make_serpapi_http_error(500, {"error": "server boom"}) + ) + assert "server boom" in out + + +def test_map_search_error_handles_generic_exception(): + assert server.map_search_error(ValueError("weird")) == "Error: weird" + + +# --- MCP Apps: pure view-model helpers ------------------------------------- + + +def test_organic_rows_flattens_results(): + data = { + "organic_results": [ + { + "position": 1, + "title": "A", + "link": "https://a.com/x", + "source": "A Co", + "snippet": "s1", + }, + {"position": 2, "title": "B", "link": "https://b.com/y", "snippet": "s2"}, + ] + } + rows = server.organic_rows(data) + assert rows[0] == { + "position": 1, + "title": "A", + "link": "https://a.com/x", + "source": "A Co", + "snippet": "s1", + } + # source falls back to the link host (www stripped) when not provided. + assert rows[1]["source"] == "b.com" + + +def test_organic_rows_strips_www_from_derived_source(): + data = {"organic_results": [{"title": "x", "link": "https://www.example.com/p"}]} + assert server.organic_rows(data)[0]["source"] == "example.com" + + +def test_organic_rows_empty_without_results(): + assert server.organic_rows({}) == [] + assert server.organic_rows({"organic_results": None}) == [] + + +def test_source_breakdown_counts_and_limits(): + rows = [{"source": "a"}, {"source": "a"}, {"source": "b"}, {"source": ""}] + breakdown = server.source_breakdown(rows, limit=1) + assert breakdown == [{"source": "a", "count": 2}] + + +def test_dashboard_summary_shape(): + data = { + "search_parameters": {"q": "coffee", "engine": "google_light"}, + "search_information": {"total_results": 999}, + "organic_results": [{"title": "A", "link": "https://a.com", "source": "A"}], + } + summary = server.dashboard_summary(data) + assert summary["query"] == "coffee" + assert summary["engine"] == "google_light" + assert summary["total_results"] == 999 + assert summary["result_count"] == 1 + assert summary["sources"] == [{"source": "A", "count": 1}] + + +# --- MCP Apps: tool behavior ----------------------------------------------- + + +def ui_json(app): + """Serialize a Prefab app via its canonical serializer for assertions.""" + return json.dumps(app.to_json()) + + +_SAMPLE_PAYLOAD = { + "search_parameters": {"q": "coffee", "engine": "google_light"}, + "search_information": {"total_results": 12345}, + "organic_results": [ + { + "position": 1, + "title": "Best Coffee", + "link": "https://example.com/a", + "snippet": "beans", + }, + ], +} + + +async def test_search_table_returns_results_app(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_PAYLOAD)) + app = await server.search_table(params={"q": "coffee"}) + assert app.title == "Search results" + body = ui_json(app) + assert "DataTable" in body + assert "Best Coffee" in body + + +async def test_search_dashboard_returns_dashboard_app(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_PAYLOAD)) + app = await server.search_dashboard(params={"q": "coffee"}) + assert app.title == "Search dashboard" + # click-to-expand detail panel starts collapsed. + assert app.state == {"selected": None} + body = ui_json(app) + assert "Best Coffee" in body + assert "coffee" in body # query surfaced in a metric + + +async def test_search_table_without_api_key_renders_error_app(monkeypatch): + use_request(monkeypatch, real_request(state={})) + app = await server.search_table(params={"q": "x"}) + assert app.title == "Search error" + assert "Unable to access API key" in ui_json(app) + + +async def test_search_dashboard_maps_http_error_to_error_app(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, raiser(make_serpapi_http_error(429, {"error": "x"}))) + app = await server.search_dashboard(params={"q": "x"}) + assert app.title == "Search error" + assert "Rate limit exceeded" in ui_json(app) diff --git a/uv.lock b/uv.lock index 9d0726f..9297ab1 100644 --- a/uv.lock +++ b/uv.lock @@ -48,14 +48,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.5" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -408,36 +409,74 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.2.4" +version = "3.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, +] + +[package.optional-dependencies] +apps = [ + { name = "fastmcp-slim", extra = ["apps"] }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, +] + +[package.optional-dependencies] +apps = [ + { name = "prefab-ui" }, +] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, { name = "httpx" }, + { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, { name = "opentelemetry-api" }, { name = "packaging" }, - { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, - { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, - { name = "rich" }, + { name = "starlette" }, { name = "uncalled-for" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, -] [[package]] name = "flake8" @@ -589,6 +628,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + [[package]] name = "jsonref" version = "1.1.0" @@ -845,6 +896,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prefab-ui" +version = "0.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cyclopts" }, + { name = "pydantic" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/99/4e61eb3d3f8b09bdaa28bda3c99971264555f486a485333cb8e6f56c7d8c/prefab_ui-0.20.2.tar.gz", hash = "sha256:4ac17ebf8ec1c5a918a188625837fc608157907430a59c4feb8adac80e01262b", size = 4118979, upload-time = "2026-06-03T02:13:49.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/d0/59b697dd5a44e632fcc4aba42ff5f88224df672ffea1f5e9a5a9e9e50698/prefab_ui-0.20.2-py3-none-any.whl", hash = "sha256:861d4914e4d9120996b4d5c6753788beeca433754d7bb3cfbd13f9dba7ea8e85", size = 1852274, upload-time = "2026-06-03T02:13:47.539Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.4" @@ -1074,11 +1139,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -1324,13 +1389,14 @@ wheels = [ [[package]] name = "serpapi-mcp-server" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, - { name = "fastmcp" }, + { name = "fastmcp", extra = ["apps"] }, { name = "httpx" }, { name = "markdownify" }, + { name = "prefab-ui" }, { name = "python-dotenv" }, { name = "serpapi" }, { name = "starlette" }, @@ -1352,18 +1418,19 @@ dev = [ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, - { name = "fastmcp", specifier = ">=3.2.0" }, + { name = "fastmcp", extras = ["apps"], specifier = ">=3.4.2" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "markdownify", specifier = ">=0.14.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.5.0" }, + { name = "prefab-ui", specifier = ">=0.20.2,<0.21" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", marker = "extra == 'dev'", specifier = ">=2" }, { name = "serpapi", specifier = ">=0.1.5" }, - { name = "starlette", specifier = ">=0.50.0" }, + { name = "starlette", specifier = ">=1.0.1" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] provides-extras = ["dev"] @@ -1409,15 +1476,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] From 2c33f503a13ab0e1d32812d82c5f809af23c2867 Mon Sep 17 00:00:00 2001 From: vm-serpapi <223280037+vm-serpapi@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:15:14 +0200 Subject: [PATCH 2/2] Extract app builders and set pie chart height Split the UI composition out of the search_table / search_dashboard tools into pure build_table_app / build_dashboard_app helpers so the tools are thin (fetch + build) and the views can be rendered without a request context. Give the dashboard PieChart an explicit height so it renders at a stable size. --- src/server.py | 108 +++++++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/src/server.py b/src/server.py index 3151a4b..ff302c1 100644 --- a/src/server.py +++ b/src/server.py @@ -487,36 +487,13 @@ def _error_app(message: str) -> PrefabApp: ] -@mcp.tool( - app=True, - description=( - "Interactive UI variant of `search`: returns organic results as a " - "sortable, searchable table rendered in the conversation. Same params " - "as `search`. Use when the host supports MCP Apps and the user wants " - "to browse results visually rather than read JSON." - ), - annotations=ToolAnnotations( - title="SerpApi search (table)", - readOnlyHint=True, - destructiveHint=False, - idempotentHint=False, - openWorldHint=True, - ), -) -async def search_table(params: dict[str, Any] = None) -> PrefabApp: - try: - data = fetch_search_data(params) - except Exception as exc: - return _error_app( - str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) - ) - - rows = organic_rows(data) +def build_table_app(data: dict[str, Any]) -> PrefabApp: + """Compose the results-table UI from a SerpApi response.""" with PrefabApp(title="Search results") as app: with Column(gap=4, css_class="p-4"): DataTable( columns=_ORGANIC_COLUMNS, - rows=rows, + rows=organic_rows(data), search=True, paginated=True, page_size=10, @@ -524,30 +501,8 @@ async def search_table(params: dict[str, Any] = None) -> PrefabApp: return app -@mcp.tool( - app=True, - description=( - "Interactive dashboard variant of `search`: returns summary metrics, a " - "source breakdown chart, and a results table with a click-to-expand " - "detail panel, all rendered in the conversation. Same params as " - "`search`. Use for a richer visual overview of a query's results." - ), - annotations=ToolAnnotations( - title="SerpApi search (dashboard)", - readOnlyHint=True, - destructiveHint=False, - idempotentHint=False, - openWorldHint=True, - ), -) -async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: - try: - data = fetch_search_data(params) - except Exception as exc: - return _error_app( - str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) - ) - +def build_dashboard_app(data: dict[str, Any]) -> PrefabApp: + """Compose the dashboard UI (metrics + chart + table + detail) from a response.""" summary = dashboard_summary(data) total = summary["total_results"] @@ -571,6 +526,7 @@ async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: data_key="count", name_key="source", show_legend=True, + height=260, ) DataTable( columns=_ORGANIC_COLUMNS, @@ -595,6 +551,58 @@ async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: return app +@mcp.tool( + app=True, + description=( + "Interactive UI variant of `search`: returns organic results as a " + "sortable, searchable table rendered in the conversation. Same params " + "as `search`. Use when the host supports MCP Apps and the user wants " + "to browse results visually rather than read JSON." + ), + annotations=ToolAnnotations( + title="SerpApi search (table)", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), +) +async def search_table(params: dict[str, Any] = None) -> PrefabApp: + try: + data = fetch_search_data(params) + except Exception as exc: + return _error_app( + str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) + ) + return build_table_app(data) + + +@mcp.tool( + app=True, + description=( + "Interactive dashboard variant of `search`: returns summary metrics, a " + "source breakdown chart, and a results table with a click-to-expand " + "detail panel, all rendered in the conversation. Same params as " + "`search`. Use for a richer visual overview of a query's results." + ), + annotations=ToolAnnotations( + title="SerpApi search (dashboard)", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), +) +async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: + try: + data = fetch_search_data(params) + except Exception as exc: + return _error_app( + str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) + ) + return build_dashboard_app(data) + + async def healthcheck_handler(request): return JSONResponse( {