diff --git a/README.md b/README.md index a538c47..b617018 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ A Model Context Protocol (MCP) server implementation that integrates with [SerpA - **Real-time Weather Data**: Location-based weather with forecasts via search queries - **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 (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. +- **Flexible Response Modes**: Complete responses by default; compact filtering for JSON responses +- **Markdown Responses (default)**: Cut token usage by 50% on average and by more than 90% for APIs with complex nested JSON +- **JSON Responses**: Structured, machine-readable output when explicitly requested - **Interactive UI (MCP Apps)**: Opt-in `search_table` and `search_dashboard` tools that render results as an interactive UI in supporting hosts - **Claude Desktop Extension**: One-click local install from an [MCP Bundle](https://github.com/modelcontextprotocol/mcpb) (`.mcpb`), see below @@ -129,17 +129,31 @@ 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 -- `params.output`: Response format; omit for JSON (default), or set to `"md"` for Markdown +- `params.output`: Response format; omit for Markdown (default), set to `"md"` for Markdown, or set to `"json"` for JSON - `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:** +Markdown is returned by default when `params.output` is omitted: + +```json +{"name": "search", "arguments": {"params": {"q": "coffee shops"}}} +``` + +Request JSON explicitly for machine-readable results: + +```json +{"name": "search", "arguments": {"params": {"q": "coffee shops", "output": "json"}}} +``` + +More examples: + ```json {"name": "search", "arguments": {"params": {"q": "coffee shops", "location": "Austin, TX"}}} {"name": "search", "arguments": {"params": {"q": "weather in London"}}} {"name": "search", "arguments": {"params": {"q": "AAPL stock"}}} -{"name": "search", "arguments": {"params": {"q": "news"}, "mode": "compact"}} +{"name": "search", "arguments": {"params": {"q": "news", "output": "json"}, "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"}}} @@ -155,7 +169,7 @@ The parameters you can provide are specific for each API engine. Some sample par ## 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: +The default `search` tool returns Markdown; callers can request structured JSON with `params.output="json"`. The MCP App tools always request structured JSON internally. 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. diff --git a/src/mcp_components/apps.py b/src/mcp_components/apps.py index 7858e7b..354b7c1 100644 --- a/src/mcp_components/apps.py +++ b/src/mcp_components/apps.py @@ -36,8 +36,8 @@ # --------------------------------------------------------------------------- # 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` +# These are opt-in: the plain-text `search` tool remains 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. @@ -752,7 +752,7 @@ def build_shopping_app(data: dict[str, Any]) -> PrefabApp: "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." + "to browse results visually rather than read a text response." ), annotations=ToolAnnotations( title="SerpApi search (table)", diff --git a/src/mcp_components/tools.py b/src/mcp_components/tools.py index e7446bb..b227de5 100644 --- a/src/mcp_components/tools.py +++ b/src/mcp_components/tools.py @@ -87,7 +87,7 @@ 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. + - output: Optional response format. Omit for Markdown (default), set to "md" for Markdown, or set to "json" for JSON. Engine-specific parameters are available via MCP resources: - serpapi://engines lists all supported engines. @@ -98,13 +98,13 @@ def map_search_error(exception) -> str: - "compact": Remove metadata fields from JSON responses. Markdown is returned unchanged. Output schema: - Markdown when params.output is "md"; otherwise a JSON string or an error message. + Markdown by default or when params.output is "md"; a JSON string when params.output is "json"; 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"} + Compact JSON: {"params": {"q": "news", "output": "json"}, "mode": "compact"} Markdown: {"params": {"q": "news", "output": "md"}} Supported engines include (not limited to): @@ -144,7 +144,8 @@ 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 + - output: Response format; omit for Markdown (default), set to "md" for + Markdown, or set to "json" for JSON mode: Response mode (default: "complete") - "complete": Returns the full response @@ -158,7 +159,7 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str: if mode not in ["complete", "compact"]: return "Error: Invalid mode. Must be 'complete' or 'compact'" - output = (params or {}).get("output", "json") + output = (params or {}).get("output", "md") if output not in {"json", "md"}: return ( "Error: Invalid output. Use either 'md' or 'json' for the output parameter." @@ -222,6 +223,7 @@ def fetch_search_response(params: dict[str, Any] | None) -> SerpResults | str: # api_key set last so caller params can never override the trusted key. search_params = { "engine": "google_light", + "output": "md", **(params or {}), "api_key": api_key, } diff --git a/tests/test_server.py b/tests/test_server.py index b3a5307..74b2e18 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -279,7 +279,25 @@ async def test_search_complete_returns_full_payload(monkeypatch): payload = {"search_metadata": {"id": "1"}, "organic_results": [{"title": "hit"}]} use_request(monkeypatch, real_request(state={"api_key": "KEY"})) use_search(monkeypatch, lambda params: serp_results(payload)) - assert json.loads(await mcp_tools.search(params={"q": "x"})) == payload + assert ( + json.loads(await mcp_tools.search(params={"q": "x", "output": "json"})) + == payload + ) + + +async def test_search_defaults_to_markdown(monkeypatch): + markdown = "## Organic Results\n\n- 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"}) == markdown + assert captured["output"] == "md" async def test_search_returns_markdown_response_unchanged(monkeypatch): @@ -338,7 +356,9 @@ async def test_search_compact_strips_serpapi_metadata(monkeypatch): } use_request(monkeypatch, real_request(state={"api_key": "KEY"})) use_search(monkeypatch, lambda params: serp_results(payload)) - out = json.loads(await mcp_tools.search(params={"q": "x"}, mode="compact")) + out = json.loads( + await mcp_tools.search(params={"q": "x", "output": "json"}, mode="compact") + ) assert out == {"organic_results": [{"title": "hit"}]} @@ -347,10 +367,7 @@ async def test_search_compact_returns_markdown_unchanged(monkeypatch): 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 - ) + assert await mcp_tools.search(params={"q": "x"}, mode="compact") == markdown async def test_search_compact_does_not_mutate_the_live_result(monkeypatch): @@ -358,7 +375,7 @@ async def test_search_compact_does_not_mutate_the_live_result(monkeypatch): results = serp_results(payload) use_request(monkeypatch, real_request(state={"api_key": "KEY"})) use_search(monkeypatch, lambda params: results) - await mcp_tools.search(params={"q": "x"}, mode="compact") + await mcp_tools.search(params={"q": "x", "output": "json"}, mode="compact") assert "search_metadata" in results.as_dict() @@ -371,11 +388,13 @@ def capture(params): use_request(monkeypatch, real_request(state={"api_key": "KEY"})) use_search(monkeypatch, capture) - await mcp_tools.search(params={"q": "x"}) + params = {"q": "x"} + await mcp_tools.search(params=params) assert captured["api_key"] == "KEY" assert captured["engine"] == "google_light" + assert captured["output"] == "md" assert captured["q"] == "x" - assert "output" not in captured + assert params == {"q": "x"} async def test_search_caller_overrides_default_engine(monkeypatch):