Files
skald-connectors/connectors/google-trends/google_trends_mcp.py
T
Daniele 5e4c41183c google-trends: fix broken MCP handshake and RSS articles (v2 / 1.1.0)
The server never implemented `initialize`, so every MCP client got
-32601 to its opening request and aborted before listing a tool. Also
missing: `notifications/initialized` and `ping`; `tools/list` returned a
bare array instead of {"tools": [...]}; notifications (no `id`) got a
full response written to stdout.

Rewritten to the shape the other local connectors already use (TOOLS
manifest + TOOL_DISPATCH, `_text_result` with isError, handle_request
returning None for notifications).

Also fixed:
- include_articles always returned nothing: trendspyg emits
  `news_articles`, the mapper read `articles`. explore_link kept too.
- fn(**arguments) turned a bad argument into -32603; handlers now take
  an args dict and coerce/clamp.
- errors were returned as successful results; now isError: true, with
  trendspyg's typed exceptions translated into actionable messages.
- browser calls could run ~100s (10 retries x 8s); capped at ~25s.
- verify.py shipped but connector.json declared no `verify` block; wired
  it and upgraded the script to a real RSS fetch.
- explore mutated trendspyg's ExploreEnvelope; dropped the no-op
  output_format param; trendspyg pinned >=1.6.0 and imported defensively.
2026-08-24 17:29:36 +01:00

423 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Google Trends MCP server (JSON-RPC 2.0 over stdio) using trendspyg.
Capabilities (callable as `mcp__google-trends__<tool>`):
status — self-check: confirms trendspyg is importable
get_trending — current trending topics per country (fast, RSS, no browser)
get_interest_over_time — keyword interest history (browser-based, ~10-25s)
explore — full keyword analysis: interest over time + related
queries + interest by region (browser-based, ~10-25s)
Data source: trendspyg (MIT) — Google Trends scraping library.
The RSS path is lightweight (HTTP only); the Explore paths drive Chrome/Selenium.
Run with:
python3 google_trends_mcp.py
"""
from __future__ import annotations
import json
import sys
from typing import Any
# ── Logging ─────────────────────────────────────────────────────────────────────
def log(msg: str) -> None:
print(f"[google_trends_mcp] {msg}", file=sys.stderr, flush=True)
# ── trendspyg import ────────────────────────────────────────────────────────────
# Imported at module load, but a failure must not kill the server: the MCP
# handshake still has to succeed so skald can surface a readable tool error
# instead of a process that dies on startup.
try:
import trendspyg
_IMPORT_ERROR: str | None = None
except Exception as e: # pragma: no cover - depends on the install
trendspyg = None # type: ignore[assignment]
_IMPORT_ERROR = str(e)
log(f"trendspyg is not importable: {e}")
# Google's Explore pages soft-throttle; trendspyg retries past it. Its defaults
# (10 attempts × 8s) allow a ~100s call, far beyond an agent's patience — cap
# the worst case at roughly 25s instead.
MAX_RETRIES = 3
RETRY_WAIT = 6.0
def _describe_error(e: Exception) -> str:
"""Turn a trendspyg exception into a message an agent can act on."""
name = type(e).__name__
if name == "RateLimitError":
return f"Error: Google Trends is throttling this IP ({e}). Retry in a few minutes."
if name == "BrowserError":
return f"Error: Chrome could not be started ({e}). This tool needs Chrome/Selenium installed."
if name == "InvalidParameterError":
return f"Error: Invalid parameter: {e}"
return f"Error: Google Trends request failed ({name}): {e}"
# ── Argument helpers ────────────────────────────────────────────────────────────
def _str_arg(args: dict[str, Any], key: str, default: str = "") -> str:
value = args.get(key, default)
return value.strip() if isinstance(value, str) else default
def _int_arg(args: dict[str, Any], key: str, default: int, lo: int, hi: int) -> int:
try:
value = int(args.get(key, default))
except (TypeError, ValueError):
return default
return max(lo, min(hi, value))
def _bool_arg(args: dict[str, Any], key: str, default: bool) -> bool:
value = args.get(key, default)
return value if isinstance(value, bool) else default
# ── Tool implementations ────────────────────────────────────────────────────────
def _status(args: dict[str, Any]) -> str:
"""Self-check: confirm trendspyg is importable."""
if trendspyg is None:
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
version = getattr(trendspyg, "__version__", "unknown")
return (
f"OK: Google Trends integration is operational (trendspyg v{version}).\n"
" - get_trending: ready (RSS, no browser needed)\n"
" - get_interest_over_time / explore: require Chrome/Selenium"
)
def _get_trending(args: dict[str, Any]) -> str:
"""Current trending search topics for a country (RSS — fast, no browser)."""
if trendspyg is None:
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
geo = _str_arg(args, "geo", "US") or "US"
max_trends = _int_arg(args, "max_trends", 10, 1, 20)
include_articles = _bool_arg(args, "include_articles", False)
try:
raw = trendspyg.download_google_trends_rss(
geo=geo,
output_format="dict",
include_images=False,
include_articles=include_articles,
max_articles_per_trend=3,
)
except Exception as e:
return _describe_error(e)
trends = []
for t in (raw or [])[:max_trends]:
item = {
"title": t.get("trend", ""),
"traffic": t.get("traffic", ""),
"traffic_min": t.get("traffic_min"),
"published": t.get("published", ""),
"explore_link": t.get("explore_link", ""),
}
if include_articles:
item["articles"] = [
{
"headline": a.get("headline", ""),
"url": a.get("url", ""),
"source": a.get("source", ""),
}
for a in (t.get("news_articles") or [])[:3]
]
trends.append(item)
return _json({"geo": geo, "count": len(trends), "trends": trends})
def _get_interest_over_time(args: dict[str, Any]) -> str:
"""A keyword's search interest over time (0-100 scale). Browser-based."""
if trendspyg is None:
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
keyword = _str_arg(args, "keyword")
if not keyword:
return "Error: Missing required parameter 'keyword'."
geo = _str_arg(args, "geo", "US")
timeframe = _str_arg(args, "timeframe", "today 12-m") or "today 12-m"
try:
points = trendspyg.download_google_trends_interest_over_time(
keyword=keyword,
geo=geo,
timeframe=timeframe,
output_format="dict",
max_retries=MAX_RETRIES,
retry_wait=RETRY_WAIT,
)
except Exception as e:
return _describe_error(e)
points = points or []
return _json({
"keyword": keyword,
"geo": geo,
"timeframe": timeframe,
"count": len(points),
"datapoints": points,
})
def _explore(args: dict[str, Any]) -> str:
"""Full keyword analysis: interest over time + related queries + region map."""
if trendspyg is None:
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
keyword = _str_arg(args, "keyword")
if not keyword:
return "Error: Missing required parameter 'keyword'."
try:
envelope = trendspyg.download_google_trends_explore(
keyword=keyword,
geo=_str_arg(args, "geo", "US"),
timeframe=_str_arg(args, "timeframe", "today 12-m") or "today 12-m",
include_related=_bool_arg(args, "include_related", True),
include_geo=_bool_arg(args, "include_geo", True),
max_retries=MAX_RETRIES,
retry_wait=RETRY_WAIT,
)
except Exception as e:
return _describe_error(e)
# trendspyg returns an ExploreEnvelope: already JSON-safe, every field present.
return _json(envelope)
def _json(payload: Any) -> str:
return json.dumps(payload, ensure_ascii=False, default=str)
# ── Tool manifest ────────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "status",
"title": "Status Check",
"description": (
"Self-check that the Google Trends integration is operational: confirms "
"the trendspyg library is installed and reports its version. "
"Call this first if any Google Trends tool fails."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_trending",
"title": "Get Trending Topics",
"description": (
"Get the search topics trending RIGHT NOW in a country, with their traffic "
"volume and (optionally) the news articles behind them. Fast (~1s, no browser). "
"Always current — it cannot look at past dates or filter by keyword; "
"use get_interest_over_time for a specific term."
),
"inputSchema": {
"type": "object",
"properties": {
"geo": {
"type": "string",
"description": "Country or region code (e.g. 'US', 'GB', 'IT', 'US-CA'). Default: 'US'.",
},
"max_trends": {
"type": "integer",
"description": "Maximum number of trending topics to return (1-20). Default: 10.",
},
"include_articles": {
"type": "boolean",
"description": "Include up to 3 news article headlines/URLs per trend. Default: false.",
},
},
"required": [],
},
},
{
"name": "get_interest_over_time",
"title": "Interest Over Time",
"description": (
"Get a keyword's search interest history as Google's 0-100 relative index, "
"oldest point first. Use it to see how a term's popularity moved over a period. "
"Browser-based: takes ~10-25s and needs Chrome. A keyword Google has no data "
"for comes back as a series of zeros."
),
"inputSchema": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Search term to analyze (e.g. 'bitcoin').",
},
"geo": {
"type": "string",
"description": "Country or region code (e.g. 'US', 'IT', 'US-CA'), or '' for worldwide. Default: 'US'.",
},
"timeframe": {
"type": "string",
"description": (
"Google Trends date range: 'today 12-m' (default), 'today 5-y', "
"'today 3-m', 'now 7-d', 'now 1-H', 'all', or a custom "
"'YYYY-MM-DD YYYY-MM-DD'."
),
},
},
"required": ["keyword"],
},
},
{
"name": "explore",
"title": "Explore Keyword",
"description": (
"Full Google Trends Explore picture for one keyword in a single call: interest "
"over time, related queries (top + rising), and interest by region. Prefer this "
"over get_interest_over_time when you also want related terms or the regional "
"breakdown. Browser-based: takes ~10-25s and needs Chrome."
),
"inputSchema": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Search term to analyze (e.g. 'bitcoin').",
},
"geo": {
"type": "string",
"description": "Country or region code (e.g. 'US', 'IT', 'US-CA'), or '' for worldwide. Default: 'US'.",
},
"timeframe": {
"type": "string",
"description": (
"Google Trends date range: 'today 12-m' (default), 'today 5-y', "
"'now 7-d', 'all', or a custom 'YYYY-MM-DD YYYY-MM-DD'."
),
},
"include_related": {
"type": "boolean",
"description": "Include top + rising related queries. Default: true.",
},
"include_geo": {
"type": "boolean",
"description": "Include interest by region. Default: true.",
},
},
"required": ["keyword"],
},
},
]
TOOL_DISPATCH = {
"status": _status,
"get_trending": _get_trending,
"get_interest_over_time": _get_interest_over_time,
"explore": _explore,
}
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def _error(req_id: Any, code: int, message: str) -> str:
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": code, "message": message},
})
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "google-trends",
"version": "1.1.0",
},
})
if method == "notifications/initialized":
return None
if method == "ping":
return _ok(req_id, {})
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments") or {}
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
result = handler(tool_args)
is_err = result.startswith("Error:")
return _text_result(req_id, result, is_error=is_err)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
# A notification we do not handle gets no reply — only requests do.
if req_id is None:
return None
return _error(req_id, -32601, f"Method not found: {method}")
# ── Main loop ────────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting Google Trends MCP server")
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()