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.
This commit is contained in:
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **google-trends: the MCP server never completed a handshake (v2 / 1.1.0)** — the connector was unusable since it shipped. `handle_request` implemented exactly two methods, `tools/list` and `tools/call`; everything else fell through to `-32601 Method not found`. Since `initialize` is the *first* message any MCP client sends, the client got an error to its opening request and aborted before a single tool could be listed. Three further protocol defects sat behind it:
|
||||||
|
- **`tools/list` returned a bare array** instead of the `{"tools": [...]}` object the spec requires — so even a client that tolerated the missing handshake would have parsed zero tools.
|
||||||
|
- **No `notifications/initialized`, no `ping`.** The former is the notification a client sends immediately *after* `initialize`; the latter is the standard liveness probe.
|
||||||
|
- **Notifications got answered.** Any message without an `id` (a notification, by definition) still produced a full JSON-RPC response written to stdout — a protocol violation that desynchronises a strict client. `handle_request` now returns `None` for every id-less message.
|
||||||
|
- Fix: the server now mirrors the shape every other local connector in this repo already uses (`wikipedia`, `weather`, `gmaps`): `initialize` → `protocolVersion 2024-11-05` + `serverInfo`, silent `notifications/initialized`, `ping` → `{}`, `tools/list` → `{"tools": TOOLS}`, and a `TOOLS` manifest list + `TOOL_DISPATCH` map replacing the old dict-of-tuples and the `title_map` that was rebuilt inside the request handler on every call.
|
||||||
|
- Verified end-to-end by piping a real handshake into the process: `initialize` → `tools/list` → `tools/call` for each tool, plus a notification and an unknown method ✅
|
||||||
|
|
||||||
|
- **google-trends: `include_articles: true` always returned zero articles** — a silent data bug independent of the handshake. The RSS mapper read `t.get("articles")`, but trendspyg emits the key as **`news_articles`**. The lookup never matched, so the field came back as an empty list for every trend and the caller had no way to tell "no articles" from "wrong key". Now reads `news_articles`; `explore_link` (the trend's Google Trends URL, previously discarded) is included too.
|
||||||
|
|
||||||
|
- **google-trends: a malformed tool argument crashed the call as a protocol error** — tools were invoked as `fn(**arguments)` against typed keyword parameters, so an unexpected key or a string where a number belonged raised `TypeError` and surfaced as `-32603 Internal error`, which reads to the agent as a broken server rather than a bad argument. Handlers now take a single `args: dict` and coerce through `_str_arg` / `_int_arg` / `_bool_arg` (clamping `max_trends` to 1-20 instead of resetting a negative value to 10).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **google-trends: tool failures are now marked as failures.** Errors were returned as `{"status": "error", ...}` inside a *successful* result — structurally indistinguishable from data. They now follow the repo convention: an `Error: …` text result carrying `isError: true`. trendspyg's typed exceptions are translated into actionable messages (`RateLimitError` → retry later, `BrowserError` → Chrome missing, `InvalidParameterError` → bad input) instead of a bare `str(e)`.
|
||||||
|
- **google-trends: browser calls now fail fast.** `explore` and `get_interest_over_time` used trendspyg's defaults of 10 retries × 8s, allowing a ~100s call — well past any agent's tool timeout. Capped at 3 × 6.0s (~25s worst case), matching the "~10-25s" the tool descriptions promise.
|
||||||
|
- **google-trends: `trendspyg` pinned to `>=1.6.0`** (was `>=0.7.0`) and imported defensively — an import failure no longer kills the process at startup, so the handshake still succeeds and the missing dependency is reported as a readable tool error.
|
||||||
|
- **google-trends: `verify` is now actually wired.** `verify.py` shipped in `files[]` since day one but `connector.json` declared no `verify` block, so skald never ran it. Added (`python3 verify.py`, 20s), and the script was upgraded from a bare `import trendspyg` check to a real RSS fetch against Google Trends that fails if zero trends come back.
|
||||||
|
- **google-trends: `explore` no longer mutates trendspyg's envelope.** It injected `data["status"] = "ok"` into the returned `ExploreEnvelope`, assuming the return was always a dict. The envelope is now passed through untouched.
|
||||||
|
- **google-trends: dropped the `output_format` parameter** from `get_interest_over_time`. It was exposed in the input schema but was a no-op for the caller — the `"json"` branch immediately re-parsed the string back into the same dict the `"dict"` branch produced.
|
||||||
|
- **google-trends: tool descriptions rewritten** to state what each tool is *for* and when to prefer one over another (`explore` over `get_interest_over_time` when related queries or the regional breakdown are wanted), and that `get_trending` is always-current and cannot look at past dates.
|
||||||
|
|
||||||
## 2026-08-23
|
## 2026-08-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+10
-10
@@ -1046,9 +1046,9 @@
|
|||||||
"type": "none"
|
"type": "none"
|
||||||
},
|
},
|
||||||
"folder": "google-trends",
|
"folder": "google-trends",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"version_string": "1.0.0",
|
"version_string": "1.1.0",
|
||||||
"version_release_date": "2026-07-22",
|
"version_release_date": "2026-08-24",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"name": "status",
|
"name": "status",
|
||||||
@@ -1070,13 +1070,13 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "connector.json",
|
"path": "connector.json",
|
||||||
"sha256": "0f50383fa3d664e6f487983645c56a57a076353a8fea337d121826210ebd3a8a",
|
"sha256": "3b14d898ecec79a75239903db07b0fb78781858583f11056fe70223885a8bab7",
|
||||||
"size": 1191
|
"size": 1363
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "google_trends_mcp.py",
|
"path": "google_trends_mcp.py",
|
||||||
"sha256": "5404eb7c81e82180a3b9e3052a4793e80a361ae60156d598d8e62e36b21dea8a",
|
"sha256": "987d3d63f5b4b560e5571f0bbb53f7a9a7d4051945152281d92792a5b64d22d4",
|
||||||
"size": 13552
|
"size": 15718
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "icon_lg.png",
|
"path": "icon_lg.png",
|
||||||
@@ -1090,13 +1090,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "requirements.txt",
|
"path": "requirements.txt",
|
||||||
"sha256": "8b13038b5c79cff2680a9b482491b969ac83ce4efa396f408bfca97385d00621",
|
"sha256": "7bb5d1d66068a94a518c4bf13e05a074558233b902522b9c1fc983dfd3f20168",
|
||||||
"size": 17
|
"size": 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "verify.py",
|
"path": "verify.py",
|
||||||
"sha256": "adbae57fc109c06c5d61ff50b67faaf62e1fa493a18cbfdacbc73f24d51ba9b8",
|
"sha256": "a9ed69ddaefe285bf6c418bdcf8ec788531bfab02b0a44effe281431648e50b4",
|
||||||
"size": 831
|
"size": 1585
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "google-trends",
|
"id": "google-trends",
|
||||||
"name": "Google Trends",
|
"name": "Google Trends",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"version_string": "1.0.0",
|
"version_string": "1.1.0",
|
||||||
"version_release_date": "2026-07-22",
|
"version_release_date": "2026-08-24",
|
||||||
"type": "mcp_local",
|
"type": "mcp_local",
|
||||||
"scope": "global",
|
"scope": "global",
|
||||||
"launch_command": "python3 google_trends_mcp.py",
|
"launch_command": "python3 google_trends_mcp.py",
|
||||||
@@ -11,10 +11,11 @@
|
|||||||
"requires": ["PYTHON"],
|
"requires": ["PYTHON"],
|
||||||
"tags": ["trends", "mcp", "local", "google", "search", "analytics"],
|
"tags": ["trends", "mcp", "local", "google", "search", "analytics"],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trendspyg>=0.7.0"
|
"trendspyg>=1.6.0"
|
||||||
],
|
],
|
||||||
"setup_instructions": [
|
"setup_instructions": [
|
||||||
"Install dependencies: pip install -r requirements.txt",
|
"Install dependencies: pip install -r requirements.txt",
|
||||||
|
"The 'explore' and 'get_interest_over_time' tools also need Chrome installed (Selenium)",
|
||||||
"Run: python3 google_trends_mcp.py"
|
"Run: python3 google_trends_mcp.py"
|
||||||
],
|
],
|
||||||
"docs": [
|
"docs": [
|
||||||
@@ -27,6 +28,10 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"type": "none"
|
"type": "none"
|
||||||
},
|
},
|
||||||
|
"verify": {
|
||||||
|
"command": "python3 verify.py",
|
||||||
|
"timeout_secs": 20
|
||||||
|
},
|
||||||
"mcp_config": {
|
"mcp_config": {
|
||||||
"command": "python3",
|
"command": "python3",
|
||||||
"args": ["google_trends_mcp.py"]
|
"args": ["google_trends_mcp.py"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"scope": "global",
|
"scope": "global",
|
||||||
"icon_small": "google-trends/icon_sm.png",
|
"icon_small": "google-trends/icon_sm.png",
|
||||||
"icon_large": "google-trends/icon_lg.png",
|
"icon_large": "google-trends/icon_lg.png",
|
||||||
"user_description": "Current trending topics by country, keyword search interest history, related queries, and regional interest maps. Powered by trendspyg \u2014 no API key required.",
|
"user_description": "Current trending topics by country, keyword search interest history, related queries, and regional interest maps. Powered by trendspyg — no API key required.",
|
||||||
"requires": [
|
"requires": [
|
||||||
"PYTHON"
|
"PYTHON"
|
||||||
],
|
],
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
"type": "none"
|
"type": "none"
|
||||||
},
|
},
|
||||||
"folder": "google-trends",
|
"folder": "google-trends",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"version_string": "1.0.0",
|
"version_string": "1.1.0",
|
||||||
"version_release_date": "2026-07-22",
|
"version_release_date": "2026-08-24",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"name": "status",
|
"name": "status",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
Capabilities (callable as `mcp__google-trends__<tool>`):
|
Capabilities (callable as `mcp__google-trends__<tool>`):
|
||||||
status — self-check: confirms trendspyg is importable
|
status — self-check: confirms trendspyg is importable
|
||||||
get_trending — current trending topics per country (fast, RSS, no browser)
|
get_trending — current trending topics per country (fast, RSS, no browser)
|
||||||
get_interest_over_time — keyword interest history (browser-based, ~10-30s)
|
get_interest_over_time — keyword interest history (browser-based, ~10-25s)
|
||||||
explore — full keyword analysis: interest over time + related queries
|
explore — full keyword analysis: interest over time + related
|
||||||
+ interest by region (browser-based, ~10-30s)
|
queries + interest by region (browser-based, ~10-25s)
|
||||||
|
|
||||||
Data source: trendspyg v0.7.0 (MIT) — Google Trends scraping library.
|
Data source: trendspyg (MIT) — Google Trends scraping library.
|
||||||
RSS path is lightweight (HTTP only); Explore path requires Chrome/Selenium.
|
The RSS path is lightweight (HTTP only); the Explore paths drive Chrome/Selenium.
|
||||||
|
|
||||||
Run with:
|
Run with:
|
||||||
python3 google_trends_mcp.py
|
python3 google_trends_mcp.py
|
||||||
@@ -21,46 +21,87 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import trendspyg
|
|
||||||
|
|
||||||
|
|
||||||
# ── Logging ─────────────────────────────────────────────────────────────────────
|
# ── Logging ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
def log(msg: str) -> None:
|
||||||
print(f"[google_trends_mcp] {msg}", file=sys.stderr, flush=True)
|
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 ────────────────────────────────────────────────────────
|
# ── Tool implementations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _status() -> dict[str, Any]:
|
def _status(args: dict[str, Any]) -> str:
|
||||||
"""Self-check: verify trendspyg is importable and working."""
|
"""Self-check: confirm trendspyg is importable."""
|
||||||
v = trendspyg.__version__
|
if trendspyg is None:
|
||||||
return {
|
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
|
||||||
"status": "ok",
|
version = getattr(trendspyg, "__version__", "unknown")
|
||||||
"library": "trendspyg",
|
return (
|
||||||
"version": v,
|
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(
|
def _get_trending(args: dict[str, Any]) -> str:
|
||||||
geo: str = "US",
|
"""Current trending search topics for a country (RSS — fast, no browser)."""
|
||||||
max_trends: int = 10,
|
if trendspyg is None:
|
||||||
include_articles: bool = False,
|
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get current trending search topics for a country (RSS — fast, no browser).
|
|
||||||
|
|
||||||
Args:
|
geo = _str_arg(args, "geo", "US") or "US"
|
||||||
geo: Two-letter country code (US, GB, IT, DE, JP, etc.).
|
max_trends = _int_arg(args, "max_trends", 10, 1, 20)
|
||||||
max_trends: Max trending topics to return (default 10, max 20).
|
include_articles = _bool_arg(args, "include_articles", False)
|
||||||
include_articles: Include news article headlines/URLs (default False).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A dict with: geo, fetched_at, trending_trends[] (each with title,
|
|
||||||
traffic_volume, articles[] when requested).
|
|
||||||
"""
|
|
||||||
max_trends = min(max_trends, 20)
|
|
||||||
if max_trends < 1:
|
|
||||||
max_trends = 10
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = trendspyg.download_google_trends_rss(
|
raw = trendspyg.download_google_trends_rss(
|
||||||
@@ -71,314 +112,310 @@ def _get_trending(
|
|||||||
max_articles_per_trend=3,
|
max_articles_per_trend=3,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e), "geo": geo}
|
return _describe_error(e)
|
||||||
|
|
||||||
if not raw:
|
|
||||||
return {"status": "ok", "geo": geo, "trends": []}
|
|
||||||
|
|
||||||
# raw is a list of trend dicts
|
|
||||||
trends = []
|
trends = []
|
||||||
for t in raw[:max_trends]:
|
for t in (raw or [])[:max_trends]:
|
||||||
item = {
|
item = {
|
||||||
"title": t.get("trend", ""),
|
"title": t.get("trend", ""),
|
||||||
"traffic": t.get("traffic", ""),
|
"traffic": t.get("traffic", ""),
|
||||||
"traffic_min": t.get("traffic_min"),
|
"traffic_min": t.get("traffic_min"),
|
||||||
"published": t.get("published", ""),
|
"published": t.get("published", ""),
|
||||||
|
"explore_link": t.get("explore_link", ""),
|
||||||
}
|
}
|
||||||
if include_articles and "articles" in t:
|
if include_articles:
|
||||||
item["articles"] = [
|
item["articles"] = [
|
||||||
{
|
{
|
||||||
"headline": a.get("headline", a.get("title", "")),
|
"headline": a.get("headline", ""),
|
||||||
"url": a.get("url", ""),
|
"url": a.get("url", ""),
|
||||||
"source": a.get("source", ""),
|
"source": a.get("source", ""),
|
||||||
}
|
}
|
||||||
for a in (t.get("articles") or [])[:3]
|
for a in (t.get("news_articles") or [])[:3]
|
||||||
]
|
]
|
||||||
trends.append(item)
|
trends.append(item)
|
||||||
|
|
||||||
return {"status": "ok", "geo": geo, "trends": trends}
|
return _json({"geo": geo, "count": len(trends), "trends": trends})
|
||||||
|
|
||||||
|
|
||||||
def _get_interest_over_time(
|
def _get_interest_over_time(args: dict[str, Any]) -> str:
|
||||||
keyword: str,
|
"""A keyword's search interest over time (0-100 scale). Browser-based."""
|
||||||
geo: str = "US",
|
if trendspyg is None:
|
||||||
timeframe: str = "today 12-m",
|
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
|
||||||
output_format: str = "dict",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get a keyword's search interest over time (0-100 scale).
|
|
||||||
|
|
||||||
Browser-based — takes ~10-30s. Returns data points with date, value,
|
keyword = _str_arg(args, "keyword")
|
||||||
and is_partial flag.
|
if not keyword:
|
||||||
|
return "Error: Missing required parameter 'keyword'."
|
||||||
|
|
||||||
Args:
|
geo = _str_arg(args, "geo", "US")
|
||||||
keyword: Search term to analyze.
|
timeframe = _str_arg(args, "timeframe", "today 12-m") or "today 12-m"
|
||||||
geo: Two-letter country code (US, GB, IT, etc.) or '' for worldwide.
|
|
||||||
timeframe: Google Trends range ('today 12-m', 'today 5-y',
|
|
||||||
'now 7-d', 'now 1-H', 'all', or 'YYYY-MM-DD YYYY-MM-DD').
|
|
||||||
output_format: 'dict' (default) or 'json'.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with keyword, geo, timeframe, datapoints[].
|
|
||||||
"""
|
|
||||||
if not keyword or not keyword.strip():
|
|
||||||
return {"status": "error", "message": "keyword is required"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
of = "dict" if output_format == "dict" else "json"
|
points = trendspyg.download_google_trends_interest_over_time(
|
||||||
data = trendspyg.download_google_trends_interest_over_time(
|
keyword=keyword,
|
||||||
keyword=keyword.strip(),
|
|
||||||
geo=geo,
|
geo=geo,
|
||||||
timeframe=timeframe,
|
timeframe=timeframe,
|
||||||
output_format=of,
|
output_format="dict",
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
retry_wait=RETRY_WAIT,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e), "keyword": keyword}
|
return _describe_error(e)
|
||||||
|
|
||||||
if of == "json":
|
points = points or []
|
||||||
data = json.loads(data) if isinstance(data, str) else data
|
return _json({
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
"geo": geo,
|
"geo": geo,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"datapoints": data,
|
"count": len(points),
|
||||||
}
|
"datapoints": points,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _explore(
|
def _explore(args: dict[str, Any]) -> str:
|
||||||
keyword: str,
|
"""Full keyword analysis: interest over time + related queries + region map."""
|
||||||
geo: str = "US",
|
if trendspyg is None:
|
||||||
timeframe: str = "today 12-m",
|
return f"Error: trendspyg is not installed: {_IMPORT_ERROR}"
|
||||||
include_related: bool = True,
|
|
||||||
include_geo: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Full keyword analysis: interest over time + related queries + region map.
|
|
||||||
|
|
||||||
Browser-based — takes ~10-30s. Returns the complete Explore data in
|
keyword = _str_arg(args, "keyword")
|
||||||
one call.
|
if not keyword:
|
||||||
|
return "Error: Missing required parameter 'keyword'."
|
||||||
Args:
|
|
||||||
keyword: Search term to analyze.
|
|
||||||
geo: Two-letter country code (US, GB, IT, etc.) or '' for worldwide.
|
|
||||||
timeframe: Google Trends range ('today 12-m', 'today 5-y', etc.).
|
|
||||||
include_related: Include related queries (top + rising).
|
|
||||||
include_geo: Include interest by region.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete ExploreEnvelope as a JSON-safe dict.
|
|
||||||
"""
|
|
||||||
if not keyword or not keyword.strip():
|
|
||||||
return {"status": "error", "message": "keyword is required"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = trendspyg.download_google_trends_explore(
|
envelope = trendspyg.download_google_trends_explore(
|
||||||
keyword=keyword.strip(),
|
keyword=keyword,
|
||||||
geo=geo,
|
geo=_str_arg(args, "geo", "US"),
|
||||||
timeframe=timeframe,
|
timeframe=_str_arg(args, "timeframe", "today 12-m") or "today 12-m",
|
||||||
include_related=include_related,
|
include_related=_bool_arg(args, "include_related", True),
|
||||||
include_geo=include_geo,
|
include_geo=_bool_arg(args, "include_geo", True),
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
retry_wait=RETRY_WAIT,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e), "keyword": keyword}
|
return _describe_error(e)
|
||||||
|
|
||||||
# data is already a dict (ExploreEnvelope)
|
# trendspyg returns an ExploreEnvelope: already JSON-safe, every field present.
|
||||||
data["status"] = "ok"
|
return _json(envelope)
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# ── JSON-RPC helpers ───────────────────────────────────────────────────────────
|
def _json(payload: Any) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
TOOLS: dict[str, tuple[Any, dict[str, Any]]] = {
|
|
||||||
"status": (
|
# ── Tool manifest ────────────────────────────────────────────────────────────────
|
||||||
_status,
|
|
||||||
|
TOOLS = [
|
||||||
{
|
{
|
||||||
"description": "Check if Google Trends MCP is operational",
|
"name": "status",
|
||||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
"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."
|
||||||
),
|
),
|
||||||
"get_trending": (
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
_get_trending,
|
},
|
||||||
{
|
{
|
||||||
"description": "Get current trending search topics for a country (RSS — fast, no browser)",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"geo": {
|
"geo": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Two-letter country code (US, GB, IT, DE, etc.)",
|
"description": "Country or region code (e.g. 'US', 'GB', 'IT', 'US-CA'). Default: 'US'.",
|
||||||
"default": "US",
|
|
||||||
},
|
},
|
||||||
"max_trends": {
|
"max_trends": {
|
||||||
"type": "number",
|
"type": "integer",
|
||||||
"description": "Max trending topics (1-20)",
|
"description": "Maximum number of trending topics to return (1-20). Default: 10.",
|
||||||
"default": 10,
|
|
||||||
},
|
},
|
||||||
"include_articles": {
|
"include_articles": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Include news article headlines/URLs",
|
"description": "Include up to 3 news article headlines/URLs per trend. Default: false.",
|
||||||
"default": False,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
|
||||||
"get_interest_over_time": (
|
|
||||||
_get_interest_over_time,
|
|
||||||
{
|
{
|
||||||
"description": "Get a keyword's search interest history (0-100 scale, browser-based ~10-30s)",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"keyword": {
|
"keyword": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Search term to analyze",
|
"description": "Search term to analyze (e.g. 'bitcoin').",
|
||||||
},
|
},
|
||||||
"geo": {
|
"geo": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Two-letter country code or '' for worldwide",
|
"description": "Country or region code (e.g. 'US', 'IT', 'US-CA'), or '' for worldwide. Default: 'US'.",
|
||||||
"default": "US",
|
|
||||||
},
|
},
|
||||||
"timeframe": {
|
"timeframe": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Google Trends range: 'today 12-m', 'today 5-y', 'now 7-d', 'all'",
|
"description": (
|
||||||
"default": "today 12-m",
|
"Google Trends date range: 'today 12-m' (default), 'today 5-y', "
|
||||||
},
|
"'today 3-m', 'now 7-d', 'now 1-H', 'all', or a custom "
|
||||||
"output_format": {
|
"'YYYY-MM-DD YYYY-MM-DD'."
|
||||||
"type": "string",
|
),
|
||||||
"description": "Output format: 'dict' or 'json'",
|
|
||||||
"default": "dict",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["keyword"],
|
"required": ["keyword"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
|
||||||
"explore": (
|
|
||||||
_explore,
|
|
||||||
{
|
{
|
||||||
"description": "Full keyword analysis: interest over time + related queries + region map (browser-based ~10-30s)",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"keyword": {
|
"keyword": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Search term to analyze",
|
"description": "Search term to analyze (e.g. 'bitcoin').",
|
||||||
},
|
},
|
||||||
"geo": {
|
"geo": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Two-letter country code or '' for worldwide",
|
"description": "Country or region code (e.g. 'US', 'IT', 'US-CA'), or '' for worldwide. Default: 'US'.",
|
||||||
"default": "US",
|
|
||||||
},
|
},
|
||||||
"timeframe": {
|
"timeframe": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Google Trends range: 'today 12-m', 'today 5-y', 'now 7-d', 'all'",
|
"description": (
|
||||||
"default": "today 12-m",
|
"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": {
|
"include_related": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Include top + rising related queries",
|
"description": "Include top + rising related queries. Default: true.",
|
||||||
"default": True,
|
|
||||||
},
|
},
|
||||||
"include_geo": {
|
"include_geo": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Include interest by region",
|
"description": "Include interest by region. Default: true.",
|
||||||
"default": True,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["keyword"],
|
"required": ["keyword"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
]
|
||||||
|
|
||||||
|
TOOL_DISPATCH = {
|
||||||
|
"status": _status,
|
||||||
|
"get_trending": _get_trending,
|
||||||
|
"get_interest_over_time": _get_interest_over_time,
|
||||||
|
"explore": _explore,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── JSON-RPC request handler ───────────────────────────────────────────────────
|
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def handle_request(request: dict[str, Any]) -> dict[str, Any]:
|
def _ok(req_id: Any, result: Any) -> str:
|
||||||
"""Dispatch a JSON-RPC 2.0 request and return a response."""
|
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
|
||||||
req_id = request.get("id", None)
|
|
||||||
method = request.get("method", "")
|
|
||||||
params = request.get("params", {})
|
|
||||||
|
|
||||||
if method == "tools/list":
|
|
||||||
tools_list = []
|
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
|
||||||
for name, (_, schema) in TOOLS.items():
|
payload: dict = {
|
||||||
t = {
|
"jsonrpc": "2.0",
|
||||||
"name": name,
|
"id": req_id,
|
||||||
"description": schema["description"],
|
"result": {"content": [{"type": "text", "text": text}]},
|
||||||
"inputSchema": schema["inputSchema"],
|
|
||||||
}
|
}
|
||||||
# Set title for friendly name in UI
|
if is_error:
|
||||||
title_map = {
|
payload["result"]["isError"] = True
|
||||||
"status": "Status Check",
|
return json.dumps(payload)
|
||||||
"get_trending": "Get Trending Topics",
|
|
||||||
"get_interest_over_time": "Interest Over Time",
|
|
||||||
"explore": "Explore Keyword",
|
|
||||||
}
|
|
||||||
if name in title_map:
|
|
||||||
t["title"] = title_map[name]
|
|
||||||
tools_list.append(t)
|
|
||||||
return _ok(req_id, tools_list)
|
|
||||||
|
|
||||||
elif method == "tools/call":
|
|
||||||
tool_name = params.get("name", "")
|
|
||||||
arguments = params.get("arguments", {})
|
|
||||||
|
|
||||||
if tool_name not in TOOLS:
|
|
||||||
return _error(req_id, -32601, f"Method not found: {tool_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
fn, _ = TOOLS[tool_name]
|
|
||||||
result = fn(**arguments)
|
|
||||||
# Ensure result is JSON-serializable and wrapped in content
|
|
||||||
if isinstance(result, dict):
|
|
||||||
content = [{"type": "text", "text": json.dumps(result, default=str)}]
|
|
||||||
else:
|
|
||||||
content = [{"type": "text", "text": str(result)}]
|
|
||||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": content}}
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Error calling {tool_name}: {e}")
|
|
||||||
return _error(req_id, -32603, f"Internal error: {e}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
return _error(req_id, -32601, f"Method not found: {method}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(req_id: Any, result: Any) -> dict[str, Any]:
|
def _error(req_id: Any, code: int, message: str) -> str:
|
||||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
return json.dumps({
|
||||||
|
|
||||||
|
|
||||||
def _error(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req_id,
|
"id": req_id,
|
||||||
"error": {"code": code, "message": message},
|
"error": {"code": code, "message": message},
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Main loop ──────────────────────────────────────────────────────────────────
|
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:
|
def main() -> None:
|
||||||
"""Read JSON-RPC 2.0 requests from stdin and write responses to stdout."""
|
log("Starting Google Trends MCP server")
|
||||||
log("Google Trends MCP server starting...")
|
try:
|
||||||
for line in sys.stdin:
|
for line in sys.stdin:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
request = json.loads(line)
|
msg = json.loads(line)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
log(f"Invalid JSON: {e}")
|
log(f"Invalid JSON input: {e}")
|
||||||
resp = _error(None, -32700, f"Parse error: {e}")
|
continue
|
||||||
else:
|
|
||||||
resp = handle_request(request)
|
|
||||||
|
|
||||||
sys.stdout.write(json.dumps(resp) + "\n")
|
resp = handle_request(msg)
|
||||||
|
if resp is not None:
|
||||||
|
sys.stdout.write(resp + "\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
except KeyboardInterrupt:
|
||||||
log("Google Trends MCP server shutting down.")
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
trendspyg>=0.7.0
|
trendspyg>=1.6.0
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Verify Google Trends MCP is functional — just tests import + RSS path (fast, no browser)."""
|
"""Verify the Google Trends connector: import + a real RSS fetch (fast, no browser)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def check() -> dict:
|
def check() -> dict:
|
||||||
"""Verify trendspyg import and RSS download."""
|
"""Verify trendspyg is installed and the RSS path actually returns trends."""
|
||||||
try:
|
try:
|
||||||
import trendspyg
|
import trendspyg
|
||||||
except ImportError as e:
|
except Exception as e:
|
||||||
return {"ok": False, "message": f"trendspyg not installed: {e}"}
|
return {"ok": False, "message": f"trendspyg is not installed: {e}"}
|
||||||
|
|
||||||
|
version = getattr(trendspyg, "__version__", "unknown")
|
||||||
|
|
||||||
|
try:
|
||||||
|
trends = trendspyg.download_google_trends_rss(
|
||||||
|
geo="US",
|
||||||
|
output_format="dict",
|
||||||
|
include_images=False,
|
||||||
|
include_articles=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"trendspyg v{version} is installed but Google Trends is unreachable: {e}",
|
||||||
|
"details": {"version": version},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not trends:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"trendspyg v{version} reached Google Trends but got no trends back.",
|
||||||
|
"details": {"version": version},
|
||||||
|
}
|
||||||
|
|
||||||
v = getattr(trendspyg, "__version__", "unknown")
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"trendspyg v{v} — Google Trends MCP is ready",
|
"message": f"trendspyg v{version} — fetched {len(trends)} trending topics from Google Trends.",
|
||||||
"details": {
|
"details": {
|
||||||
"version": v,
|
"version": version,
|
||||||
"rss_path": "available (no browser needed)",
|
"trends_fetched": len(trends),
|
||||||
"explore_path": "available (requires Chrome/Selenium)",
|
"rss_path": "verified (no browser needed)",
|
||||||
|
"explore_path": "not tested here (requires Chrome/Selenium)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user