From 5e4c41183c4c51a15a54ade3239f1af169b66e38 Mon Sep 17 00:00:00 2001 From: Daniele Date: Mon, 24 Aug 2026 17:29:36 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 25 + connectors/connectors.json | 20 +- connectors/google-trends/connector.json | 13 +- connectors/google-trends/fragment.json | 10 +- connectors/google-trends/google_trends_mcp.py | 601 ++++++++++-------- connectors/google-trends/requirements.txt | 2 +- connectors/google-trends/verify.py | 41 +- 7 files changed, 401 insertions(+), 311 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 762dc10..64be13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added diff --git a/connectors/connectors.json b/connectors/connectors.json index d6929a9..553254e 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -1046,9 +1046,9 @@ "type": "none" }, "folder": "google-trends", - "version": 1, - "version_string": "1.0.0", - "version_release_date": "2026-07-22", + "version": 2, + "version_string": "1.1.0", + "version_release_date": "2026-08-24", "tools": [ { "name": "status", @@ -1070,13 +1070,13 @@ "files": [ { "path": "connector.json", - "sha256": "0f50383fa3d664e6f487983645c56a57a076353a8fea337d121826210ebd3a8a", - "size": 1191 + "sha256": "3b14d898ecec79a75239903db07b0fb78781858583f11056fe70223885a8bab7", + "size": 1363 }, { "path": "google_trends_mcp.py", - "sha256": "5404eb7c81e82180a3b9e3052a4793e80a361ae60156d598d8e62e36b21dea8a", - "size": 13552 + "sha256": "987d3d63f5b4b560e5571f0bbb53f7a9a7d4051945152281d92792a5b64d22d4", + "size": 15718 }, { "path": "icon_lg.png", @@ -1090,13 +1090,13 @@ }, { "path": "requirements.txt", - "sha256": "8b13038b5c79cff2680a9b482491b969ac83ce4efa396f408bfca97385d00621", + "sha256": "7bb5d1d66068a94a518c4bf13e05a074558233b902522b9c1fc983dfd3f20168", "size": 17 }, { "path": "verify.py", - "sha256": "adbae57fc109c06c5d61ff50b67faaf62e1fa493a18cbfdacbc73f24d51ba9b8", - "size": 831 + "sha256": "a9ed69ddaefe285bf6c418bdcf8ec788531bfab02b0a44effe281431648e50b4", + "size": 1585 } ] }, diff --git a/connectors/google-trends/connector.json b/connectors/google-trends/connector.json index ac8d143..972b0a6 100644 --- a/connectors/google-trends/connector.json +++ b/connectors/google-trends/connector.json @@ -1,9 +1,9 @@ { "id": "google-trends", "name": "Google Trends", - "version": 1, - "version_string": "1.0.0", - "version_release_date": "2026-07-22", + "version": 2, + "version_string": "1.1.0", + "version_release_date": "2026-08-24", "type": "mcp_local", "scope": "global", "launch_command": "python3 google_trends_mcp.py", @@ -11,10 +11,11 @@ "requires": ["PYTHON"], "tags": ["trends", "mcp", "local", "google", "search", "analytics"], "dependencies": [ - "trendspyg>=0.7.0" + "trendspyg>=1.6.0" ], "setup_instructions": [ "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" ], "docs": [ @@ -27,6 +28,10 @@ "auth": { "type": "none" }, + "verify": { + "command": "python3 verify.py", + "timeout_secs": 20 + }, "mcp_config": { "command": "python3", "args": ["google_trends_mcp.py"] diff --git a/connectors/google-trends/fragment.json b/connectors/google-trends/fragment.json index 9b8c5a0..30bce81 100644 --- a/connectors/google-trends/fragment.json +++ b/connectors/google-trends/fragment.json @@ -5,7 +5,7 @@ "scope": "global", "icon_small": "google-trends/icon_sm.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": [ "PYTHON" ], @@ -21,9 +21,9 @@ "type": "none" }, "folder": "google-trends", - "version": 1, - "version_string": "1.0.0", - "version_release_date": "2026-07-22", + "version": 2, + "version_string": "1.1.0", + "version_release_date": "2026-08-24", "tools": [ { "name": "status", @@ -42,4 +42,4 @@ "display_name": "Explore Keyword" } ] -} \ No newline at end of file +} diff --git a/connectors/google-trends/google_trends_mcp.py b/connectors/google-trends/google_trends_mcp.py index 410de60..6174579 100644 --- a/connectors/google-trends/google_trends_mcp.py +++ b/connectors/google-trends/google_trends_mcp.py @@ -2,14 +2,14 @@ """Google Trends MCP server (JSON-RPC 2.0 over stdio) using trendspyg. Capabilities (callable as `mcp__google-trends__`): - 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-30s) - explore — full keyword analysis: interest over time + related queries - + interest by region (browser-based, ~10-30s) + 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 v0.7.0 (MIT) — Google Trends scraping library. -RSS path is lightweight (HTTP only); Explore path requires Chrome/Selenium. +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 @@ -21,46 +21,87 @@ import json import sys from typing import Any -import trendspyg - - # ── 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() -> dict[str, Any]: - """Self-check: verify trendspyg is importable and working.""" - v = trendspyg.__version__ - return { - "status": "ok", - "library": "trendspyg", - "version": v, - } +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( - geo: str = "US", - max_trends: int = 10, - include_articles: bool = False, -) -> dict[str, Any]: - """Get current trending search topics for a country (RSS — fast, no browser). +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}" - Args: - geo: Two-letter country code (US, GB, IT, DE, JP, etc.). - max_trends: Max trending topics to return (default 10, max 20). - 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 + 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( @@ -71,314 +112,310 @@ def _get_trending( max_articles_per_trend=3, ) 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 = [] - for t in raw[:max_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 and "articles" in t: + if include_articles: item["articles"] = [ { - "headline": a.get("headline", a.get("title", "")), + "headline": a.get("headline", ""), "url": a.get("url", ""), "source": a.get("source", ""), } - for a in (t.get("articles") or [])[:3] + for a in (t.get("news_articles") or [])[:3] ] trends.append(item) - return {"status": "ok", "geo": geo, "trends": trends} + return _json({"geo": geo, "count": len(trends), "trends": trends}) -def _get_interest_over_time( - keyword: str, - geo: str = "US", - timeframe: str = "today 12-m", - output_format: str = "dict", -) -> dict[str, Any]: - """Get a keyword's search interest over time (0-100 scale). +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}" - Browser-based — takes ~10-30s. Returns data points with date, value, - and is_partial flag. + keyword = _str_arg(args, "keyword") + 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', - '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"} + geo = _str_arg(args, "geo", "US") + timeframe = _str_arg(args, "timeframe", "today 12-m") or "today 12-m" try: - of = "dict" if output_format == "dict" else "json" - data = trendspyg.download_google_trends_interest_over_time( - keyword=keyword.strip(), + points = trendspyg.download_google_trends_interest_over_time( + keyword=keyword, geo=geo, timeframe=timeframe, - output_format=of, + output_format="dict", + max_retries=MAX_RETRIES, + retry_wait=RETRY_WAIT, ) except Exception as e: - return {"status": "error", "message": str(e), "keyword": keyword} + return _describe_error(e) - if of == "json": - data = json.loads(data) if isinstance(data, str) else data - - return { - "status": "ok", + points = points or [] + return _json({ "keyword": keyword, "geo": geo, "timeframe": timeframe, - "datapoints": data, - } + "count": len(points), + "datapoints": points, + }) -def _explore( - keyword: str, - geo: str = "US", - timeframe: str = "today 12-m", - include_related: bool = True, - include_geo: bool = True, -) -> dict[str, Any]: - """Full keyword analysis: interest over time + related queries + region map. +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}" - Browser-based — takes ~10-30s. Returns the complete Explore data in - one call. - - 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"} + keyword = _str_arg(args, "keyword") + if not keyword: + return "Error: Missing required parameter 'keyword'." try: - data = trendspyg.download_google_trends_explore( - keyword=keyword.strip(), - geo=geo, - timeframe=timeframe, - include_related=include_related, - include_geo=include_geo, + 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 {"status": "error", "message": str(e), "keyword": keyword} + return _describe_error(e) - # data is already a dict (ExploreEnvelope) - data["status"] = "ok" - return data + # trendspyg returns an ExploreEnvelope: already JSON-safe, every field present. + return _json(envelope) -# ── 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": ( - _status, - { - "description": "Check if Google Trends MCP is operational", - "inputSchema": {"type": "object", "properties": {}, "required": []}, - }, - ), - "get_trending": ( - _get_trending, - { - "description": "Get current trending search topics for a country (RSS — fast, no browser)", - "inputSchema": { - "type": "object", - "properties": { - "geo": { - "type": "string", - "description": "Two-letter country code (US, GB, IT, DE, etc.)", - "default": "US", - }, - "max_trends": { - "type": "number", - "description": "Max trending topics (1-20)", - "default": 10, - }, - "include_articles": { - "type": "boolean", - "description": "Include news article headlines/URLs", - "default": False, - }, + +# ── 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'.", }, - "required": [], - }, - }, - ), - "get_interest_over_time": ( - _get_interest_over_time, - { - "description": "Get a keyword's search interest history (0-100 scale, browser-based ~10-30s)", - "inputSchema": { - "type": "object", - "properties": { - "keyword": { - "type": "string", - "description": "Search term to analyze", - }, - "geo": { - "type": "string", - "description": "Two-letter country code or '' for worldwide", - "default": "US", - }, - "timeframe": { - "type": "string", - "description": "Google Trends range: 'today 12-m', 'today 5-y', 'now 7-d', 'all'", - "default": "today 12-m", - }, - "output_format": { - "type": "string", - "description": "Output format: 'dict' or 'json'", - "default": "dict", - }, + "max_trends": { + "type": "integer", + "description": "Maximum number of trending topics to return (1-20). Default: 10.", }, - "required": ["keyword"], - }, - }, - ), - "explore": ( - _explore, - { - "description": "Full keyword analysis: interest over time + related queries + region map (browser-based ~10-30s)", - "inputSchema": { - "type": "object", - "properties": { - "keyword": { - "type": "string", - "description": "Search term to analyze", - }, - "geo": { - "type": "string", - "description": "Two-letter country code or '' for worldwide", - "default": "US", - }, - "timeframe": { - "type": "string", - "description": "Google Trends range: 'today 12-m', 'today 5-y', 'now 7-d', 'all'", - "default": "today 12-m", - }, - "include_related": { - "type": "boolean", - "description": "Include top + rising related queries", - "default": True, - }, - "include_geo": { - "type": "boolean", - "description": "Include interest by region", - "default": True, - }, + "include_articles": { + "type": "boolean", + "description": "Include up to 3 news article headlines/URLs per trend. Default: false.", }, - "required": ["keyword"], }, + "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 request handler ─────────────────────────────────────────────────── +# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────── -def handle_request(request: dict[str, Any]) -> dict[str, Any]: - """Dispatch a JSON-RPC 2.0 request and return a response.""" - req_id = request.get("id", None) - method = request.get("method", "") - params = request.get("params", {}) - - if method == "tools/list": - tools_list = [] - for name, (_, schema) in TOOLS.items(): - t = { - "name": name, - "description": schema["description"], - "inputSchema": schema["inputSchema"], - } - # Set title for friendly name in UI - title_map = { - "status": "Status Check", - "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) -> str: + return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}) -def _ok(req_id: Any, result: Any) -> dict[str, Any]: - return {"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) -> dict[str, Any]: - return { +def _error(req_id: Any, code: int, message: str) -> str: + return json.dumps({ "jsonrpc": "2.0", "id": req_id, "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: - """Read JSON-RPC 2.0 requests from stdin and write responses to stdout.""" - log("Google Trends MCP server starting...") - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - request = json.loads(line) - except json.JSONDecodeError as e: - log(f"Invalid JSON: {e}") - resp = _error(None, -32700, f"Parse error: {e}") - else: - resp = handle_request(request) + 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 - sys.stdout.write(json.dumps(resp) + "\n") - sys.stdout.flush() - - log("Google Trends MCP server shutting down.") + resp = handle_request(msg) + if resp is not None: + sys.stdout.write(resp + "\n") + sys.stdout.flush() + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/connectors/google-trends/requirements.txt b/connectors/google-trends/requirements.txt index b2172a5..82e14c6 100644 --- a/connectors/google-trends/requirements.txt +++ b/connectors/google-trends/requirements.txt @@ -1 +1 @@ -trendspyg>=0.7.0 +trendspyg>=1.6.0 diff --git a/connectors/google-trends/verify.py b/connectors/google-trends/verify.py index ff882c4..c92f041 100644 --- a/connectors/google-trends/verify.py +++ b/connectors/google-trends/verify.py @@ -1,25 +1,48 @@ #!/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 sys def check() -> dict: - """Verify trendspyg import and RSS download.""" + """Verify trendspyg is installed and the RSS path actually returns trends.""" try: import trendspyg - except ImportError as e: - return {"ok": False, "message": f"trendspyg not installed: {e}"} + except Exception as 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 { "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": { - "version": v, - "rss_path": "available (no browser needed)", - "explore_path": "available (requires Chrome/Selenium)", + "version": version, + "trends_fetched": len(trends), + "rss_path": "verified (no browser needed)", + "explore_path": "not tested here (requires Chrome/Selenium)", }, }