Skip to content
Open
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
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"}}}
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/mcp_components/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)",
Expand Down
12 changes: 7 additions & 5 deletions src/mcp_components/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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."
Expand Down Expand Up @@ -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,
}
Expand Down
37 changes: 28 additions & 9 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"}]}


Expand All @@ -347,18 +367,15 @@ 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):
payload = {"search_metadata": {"id": "1"}, "organic_results": [{"title": "hit"}]}
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()


Expand All @@ -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):
Expand Down