From 70f6a927bc52e1a58ab8d489a2d6cb71c97012c8 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Tue, 28 Jul 2026 22:18:51 +0100 Subject: [PATCH] fix: memory-lint agents were missing from the agents page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both metas declared "strength": "medium", which is not an LlmStrength (very_low | low | average | high | very_high). `discover()` warns and skips a meta.json it cannot deserialize — deliberately, so one bad file does not blank the whole roster — so the two agents never reached /api/agents and the page's "system" section only ever showed event-triage. The skip is right; its silence is not. `agents::tests::every_shipped_agent_meta_parses` deserializes every shipped meta.json, so a typo'd field now fails the build instead of quietly costing an agent its place in the UI. Co-Authored-By: Claude Opus 5 --- agents/memory-lint-private/meta.json | 2 +- agents/memory-lint-shared/meta.json | 2 +- crates/skald-core/src/agents.rs | 39 + scripts/build-musl.sh | 43 - scripts/elicitation_demo_mcp.py | 147 -- scripts/gcal_mcp_server.py | 980 ------------- scripts/gcal_oauth_setup.py | 106 -- scripts/gmail_mcp_server.py | 1243 ----------------- scripts/gmail_oauth_setup.py | 108 -- scripts/gmaps_mcp_server.py | 819 ----------- scripts/google_trends_mcp.py | 464 ------- scripts/honcho_backfill.py | 359 ----- scripts/inspect_llm_requests.py | 150 -- scripts/mcp/serpapi_flights/requirements.txt | 1 - scripts/mcp/serpapi_flights/server.py | 471 ------- scripts/ssh_mcp_server.py | 1285 ------------------ scripts/weather_mcp_server.py | 779 ----------- scripts/whatsapp_mcp/index.js | 498 ------- scripts/whatsapp_mcp/package.json | 14 - 19 files changed, 41 insertions(+), 7469 deletions(-) delete mode 100755 scripts/build-musl.sh delete mode 100644 scripts/elicitation_demo_mcp.py delete mode 100755 scripts/gcal_mcp_server.py delete mode 100644 scripts/gcal_oauth_setup.py delete mode 100644 scripts/gmail_mcp_server.py delete mode 100644 scripts/gmail_oauth_setup.py delete mode 100644 scripts/gmaps_mcp_server.py delete mode 100644 scripts/google_trends_mcp.py delete mode 100644 scripts/honcho_backfill.py delete mode 100644 scripts/inspect_llm_requests.py delete mode 100644 scripts/mcp/serpapi_flights/requirements.txt delete mode 100644 scripts/mcp/serpapi_flights/server.py delete mode 100644 scripts/ssh_mcp_server.py delete mode 100644 scripts/weather_mcp_server.py delete mode 100644 scripts/whatsapp_mcp/index.js delete mode 100644 scripts/whatsapp_mcp/package.json diff --git a/agents/memory-lint-private/meta.json b/agents/memory-lint-private/meta.json index 4662937..31e6523 100644 --- a/agents/memory-lint-private/meta.json +++ b/agents/memory-lint-private/meta.json @@ -16,5 +16,5 @@ "inject_skills": false, "inject_memory": ["user-memory/index.md"], "icon": "icon.png", - "strength": "medium" + "strength": "average" } diff --git a/agents/memory-lint-shared/meta.json b/agents/memory-lint-shared/meta.json index 8cc4d5a..b9974ea 100644 --- a/agents/memory-lint-shared/meta.json +++ b/agents/memory-lint-shared/meta.json @@ -16,5 +16,5 @@ "inject_skills": false, "inject_memory": ["shared-memory/index.md"], "icon": "icon.png", - "strength": "medium" + "strength": "average" } diff --git a/crates/skald-core/src/agents.rs b/crates/skald-core/src/agents.rs index 9b75766..fbd8760 100644 --- a/crates/skald-core/src/agents.rs +++ b/crates/skald-core/src/agents.rs @@ -298,3 +298,42 @@ fn render_agents_list() -> Result { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + /// Every shipped `meta.json` must deserialize. `discover()` warns and skips a + /// malformed one so a single bad file cannot blank the roster — which means a + /// typo'd field costs an agent its place in the UI and says nothing louder than + /// a log line. (It cost the two memory-lint agents theirs: `"strength": "medium"` + /// is not an `LlmStrength`.) This test is where that silence gets broken. + #[test] + fn every_shipped_agent_meta_parses() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(AGENTS_DIR); + let dir = std::fs::read_dir(&root) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display())); + + let mut checked = 0; + for entry in dir { + let path = entry.expect("readable dir entry").path(); + if !path.is_dir() || path.file_name().and_then(|n| n.to_str()) == Some("common") { + continue; + } + let meta_path = path.join("meta.json"); + if !meta_path.exists() { + continue; + } + let raw = std::fs::read_to_string(&meta_path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", meta_path.display())); + if let Err(e) = serde_json::from_str::(&raw) { + panic!("{} is not a valid agent meta: {e}", meta_path.display()); + } + checked += 1; + } + assert!(checked > 0, "no agent meta.json found under {}", root.display()); + } +} diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh deleted file mode 100755 index 4ff8e96..0000000 --- a/scripts/build-musl.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env sh -# Build a fully static Linux binary (musl) without any host cross-toolchain. -# -# Since openssl is gone (rustls) and the crypto backend is `ring` (no OpenSSL / -# aws-lc cmake build), the only native code left to cross-compile is SQLite -# (bundled) and the tree-sitter C grammars — both of which the musl-cross image -# handles out of the box. `whisper-local` (whisper.cpp, C++) is dropped via -# --no-default-features because it is heavy and irrelevant to a headless server; -# set FEATURES="" to include it. -# -# Requirements: Docker. No Rust/musl toolchain needed on the host. -# -# Usage: -# scripts/build-musl.sh # x86_64 static binary -# TARGET=aarch64-unknown-linux-musl \ -# IMAGE=messense/rust-musl-cross:aarch64-musl \ -# scripts/build-musl.sh # arm64 static binary -# -# Output: target/musl//release/skald -set -eu - -TARGET="${TARGET:-x86_64-unknown-linux-musl}" -IMAGE="${IMAGE:-messense/rust-musl-cross:x86_64-musl}" -# Word-splitting is intentional so callers can pass multiple flags. -FEATURES="${FEATURES:---no-default-features}" - -PROJ="$(cd "$(dirname "$0")/.." && pwd)" - -echo "[build-musl] target=$TARGET image=$IMAGE features='$FEATURES'" - -# A dedicated CARGO_TARGET_DIR keeps musl artifacts from clashing with the host -# (macOS) build cache; a named volume caches the crates.io registry across runs. -docker run --rm -t \ - -v "$PROJ":/home/rust/src \ - -v skald-musl-registry:/root/.cargo/registry \ - -e CARGO_TARGET_DIR=/home/rust/src/target/musl \ - "$IMAGE" \ - cargo build --release --target "$TARGET" $FEATURES --bin skald - -BIN="$PROJ/target/musl/$TARGET/release/skald" -echo "[build-musl] built: $BIN" -file "$BIN" 2>/dev/null || true -echo "[build-musl] copy this single file to the server and run it — no shared libs required." diff --git a/scripts/elicitation_demo_mcp.py b/scripts/elicitation_demo_mcp.py deleted file mode 100644 index a7c3448..0000000 --- a/scripts/elicitation_demo_mcp.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""Demo MCP server (stdio, JSON-RPC 2.0) exercising MCP elicitation. - -Two tools demonstrate the two card types Skald renders in the Agent Inbox: - - * ``ask_secret`` — elicits a single masked field (``format: password``). - Returns only a masked confirmation; the value is held in - RAM (this process) and never echoed back to the caller. - * ``confirm`` — elicits with an empty schema → a yes/no confirmation. - -Register it from the LLM ("register an MCP server, command python3, args -scripts/elicitation_demo_mcp.py") or via the MCP servers UI, then ask the agent -to call the tool. The request appears in the Agent Inbox under "Secrets". - -No third-party dependencies: a plain ``readline`` JSON-RPC loop, matching how -Skald's stdio client speaks. ``elicitation/create`` is a server→client request; -the reply arrives on the same stdin and is matched by its id. -""" - -import sys -import json -import itertools - -_next_id = itertools.count(1) -# Demo-only in-RAM secret cache, mirroring the SSH MCP "prompt" method: keep the -# value for the process lifetime, never write it to disk, never return it. -_secret_cache: dict[str, str] = {} - - -def send(obj: dict) -> None: - sys.stdout.write(json.dumps(obj) + "\n") - sys.stdout.flush() - - -def readline() -> dict | None: - """Blocking read of one non-empty JSON-RPC line; None on EOF.""" - while True: - line = sys.stdin.readline() - if not line: - return None - line = line.strip() - if line: - return json.loads(line) - - -def elicit(message: str, requested_schema: dict) -> dict: - """Send ``elicitation/create`` and block until the matching reply arrives. - - Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). - """ - eid = f"elicit-{next(_next_id)}" - send({ - "jsonrpc": "2.0", - "id": eid, - "method": "elicitation/create", - "params": {"message": message, "requestedSchema": requested_schema}, - }) - while True: - msg = readline() - if msg is None: - return {"action": "cancel"} - if msg.get("id") == eid: - return msg.get("result", {"action": "cancel"}) - # Any other inbound message mid-wait is ignored for this demo. - - -TOOLS = [ - { - "name": "ask_secret", - "description": "Ask the user for a secret value (masked) via elicitation.", - "inputSchema": { - "type": "object", - "properties": {"label": {"type": "string", "description": "what the secret is for"}}, - }, - }, - { - "name": "confirm", - "description": "Ask the user to confirm an action (yes/no) via elicitation.", - "inputSchema": { - "type": "object", - "properties": {"action": {"type": "string", "description": "the action to confirm"}}, - }, - }, -] - - -def text_result(mid, text: str, is_error: bool = False) -> None: - send({"jsonrpc": "2.0", "id": mid, "result": { - "content": [{"type": "text", "text": text}], "isError": is_error}}) - - -def handle_call(mid, name: str, args: dict) -> None: - if name == "ask_secret": - label = args.get("label", "the secret") - result = elicit( - f"Enter {label}", - {"type": "object", - "properties": {"secret": {"type": "string", "format": "password", - "title": label}}, - "required": ["secret"]}, - ) - action = result.get("action") - if action == "accept": - value = (result.get("content") or {}).get("secret", "") - _secret_cache[label] = value - # Never return the secret itself — only proof we received it. - text_result(mid, f"OK — received {label} ({len(value)} chars, kept in RAM).") - else: - text_result(mid, f"Error: {label} required (user {action}).", is_error=True) - - elif name == "confirm": - what = args.get("action", "this action") - result = elicit(f"Confirm: {what}?", {"type": "object", "properties": {}}) - action = result.get("action") - text_result(mid, f"User {action}ed: {what}." if action == "accept" - else f"Not confirmed ({action}): {what}.", - is_error=(action != "accept")) - else: - send({"jsonrpc": "2.0", "id": mid, - "error": {"code": -32602, "message": f"unknown tool: {name}"}}) - - -def main() -> None: - while True: - msg = readline() - if msg is None: - break - mid = msg.get("id") - method = msg.get("method") - if method == "initialize": - send({"jsonrpc": "2.0", "id": mid, "result": { - "protocolVersion": "2025-06-18", "capabilities": {}, - "serverInfo": {"name": "elicitation-demo", "version": "0.1.0"}}}) - elif method == "notifications/initialized": - pass - elif method == "tools/list": - send({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}}) - elif method == "tools/call": - params = msg.get("params", {}) - handle_call(mid, params.get("name", ""), params.get("arguments", {}) or {}) - elif mid is not None: - send({"jsonrpc": "2.0", "id": mid, - "error": {"code": -32601, "message": f"method not found: {method}"}}) - - -if __name__ == "__main__": - main() diff --git a/scripts/gcal_mcp_server.py b/scripts/gcal_mcp_server.py deleted file mode 100755 index 52f7003..0000000 --- a/scripts/gcal_mcp_server.py +++ /dev/null @@ -1,980 +0,0 @@ -#!/usr/bin/env python3 -"""Google Calendar MCP server (JSON-RPC 2.0 over stdio). - -Capabilities (callable as `mcp__gcal__`): - status — self-check: credentials, token refresh, API reachability - list_calendars — list calendars accessible to the user - list_events — chronological event listing with optional filters - get_event — read a single event by ID - create_event — create an event - update_event — patch fields of an existing event - delete_event — permanently delete an event - respond_to_event — set RSVP / attendance response - -Credentials are read from ./secrets/google_creds.json by default. -Override with GOOGLE_CREDS_PATH env var. - -Required OAuth scopes: - https://www.googleapis.com/auth/calendar - (or https://www.googleapis.com/auth/calendar.events for events-only) - -Run scripts/gcal_oauth_setup.py to (re-)authenticate. -""" - -from __future__ import annotations - -import json -import os -import sys -import threading -import time -from datetime import datetime, timezone -from typing import Any, Callable - -# Log to stderr so stdout stays clean for JSON-RPC. -def log(msg: str) -> None: - print(f"[gcal_mcp] {msg}", file=sys.stderr, flush=True) - -# Protects all stdout writes (main thread + poll thread). -_stdout_lock = threading.Lock() - -# ── Push notifications ───────────────────────────────────────────────────────── - -def _emit_notification(method: str, params: dict) -> None: - """Write a JSON-RPC notification (no id) to stdout.""" - msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) - with _stdout_lock: - sys.stdout.write(msg + "\n") - sys.stdout.flush() - - -# ISO-8601 UTC timestamp of when we last polled. -# We emit events whose `created` field is >= this value. -_last_poll_at: str | None = None -_poll_thread: threading.Thread | None = None -_POLL_INTERVAL_SECS = 300 # 5 minutes - - -def _utc_now_iso() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _start_polling() -> None: - """Build service eagerly, record start time, launch poll thread.""" - global _last_poll_at, _poll_thread - svc = _get_service() - if svc is None: - log("GCal push polling disabled: service not available.") - return - _last_poll_at = _utc_now_iso() - log(f"GCal polling started (tracking events created after {_last_poll_at}, interval={_POLL_INTERVAL_SECS}s).") - _poll_thread = threading.Thread(target=_poll_loop, daemon=True, name="gcal-poll") - _poll_thread.start() - - -def _poll_loop() -> None: - while True: - time.sleep(_POLL_INTERVAL_SECS) - _poll_once() - - -def _poll_once() -> None: - global _last_poll_at - svc = _get_service() - if svc is None or _last_poll_at is None: - return - - since = _last_poll_at - _last_poll_at = _utc_now_iso() # advance cursor before the call (safe: we only advance) - - try: - result = _call(lambda: svc.events().list( - calendarId="primary", - updatedMin=since, - singleEvents=True, - orderBy="updated", - maxResults=50, - ).execute(), "Calendar") - except Exception as e: - log(f"GCal poll error: {_format_google_error(e, 'Calendar')}") - return - - for ev in result.get("items", []): - # Emit only events that were newly *created* in this window (not just modified). - created = ev.get("created", "") - if created < since: - continue - start = ev.get("start") or {} - end = ev.get("end") or {} - _emit_notification("event/new_calendar_event", { - "event_id": ev.get("id"), - "summary": ev.get("summary", "(no title)"), - "start": start.get("dateTime") or start.get("date"), - "end": end.get("dateTime") or end.get("date"), - "location": ev.get("location"), - "description": (ev.get("description") or "")[:500], - "html_link": ev.get("htmlLink"), - "created": created, - }) - log(f"Notification emitted: new calendar event {ev.get('id')!r} — {ev.get('summary')!r}") - - -# ── Credentials / service ────────────────────────────────────────────────────── - -_service = None -_creds = None -_creds_path: str | None = None -_init_error: str | None = None - - -def _persist_creds() -> None: - """Write the current credentials back to disk (used after a token refresh).""" - if _creds is not None and _creds_path: - try: - with open(_creds_path, "w") as f: - f.write(_creds.to_json()) - except Exception as e: - log(f"Could not persist refreshed credentials: {e}") - - -def _build_service() -> Any: - """Build and return a Google Calendar service object, or None on failure.""" - global _init_error, _creds, _creds_path - try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from googleapiclient.discovery import build - except ImportError as e: - _init_error = f"Missing dependencies: {e}. Install google-api-python-client and google-auth." - log(_init_error) - return None - - _creds_path = os.environ.get( - "GOOGLE_CREDS_PATH", - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "secrets", "google_creds.json"), - ) - - if not os.path.exists(_creds_path): - _init_error = ( - f"Credentials file not found at {_creds_path}. " - "Run scripts/gcal_oauth_setup.py to authenticate, or set GOOGLE_CREDS_PATH." - ) - log(_init_error) - return None - - try: - creds = Credentials.from_authorized_user_file(_creds_path) - except Exception as e: - _init_error = f"Failed to load credentials from {_creds_path}: {e}" - log(_init_error) - return None - - # Publish creds globally so _persist_creds / _call can see them. - _creds = creds - - # Refresh expired token automatically at startup. - if creds.expired and creds.refresh_token: - try: - creds.refresh(Request()) - _persist_creds() - log("Token refreshed and saved.") - except Exception as e: - log(f"Token refresh failed: {e}") - - try: - service = build("calendar", "v3", credentials=creds) - except Exception as e: - _init_error = f"Failed to build Calendar service: {e}" - log(_init_error) - return None - - log(f"Calendar service built successfully (creds: {_creds_path})") - return service - - -def _get_service() -> Any: - global _service - if _service is None: - _service = _build_service() - return _service - - -# ── Error mapping & refresh-on-auth-error ────────────────────────────────────── - - -def _is_auth_error(e: Exception) -> bool: - """True for 401 HttpError / RefreshError — candidates for a refresh+retry.""" - try: - from googleapiclient.errors import HttpError - except ImportError: - return False - if isinstance(e, HttpError): - return getattr(e, "status_code", None) == 401 - try: - from google.auth.exceptions import RefreshError - except ImportError: - return False - return isinstance(e, RefreshError) - - -def _call(fn: Callable[[], Any], api_label: str) -> Any: - """Run a googleapiclient call with one refresh-on-auth-error retry. - - If the access token expired mid-session the first call raises a 401 HttpError - or a RefreshError. We refresh once, persist the new token, and retry the call. - Anything else (or a second failure) is re-raised so the caller can format it - via _format_google_error. - """ - try: - return fn() - except Exception as e: - if not _is_auth_error(e) or _creds is None or not getattr(_creds, "refresh_token", None): - raise - try: - from google.auth.transport.requests import Request - _creds.refresh(Request()) - _persist_creds() - log("Access token refreshed mid-session after auth error; retrying the call.") - except Exception as refresh_err: - log(f"Mid-session token refresh failed: {refresh_err}") - raise - return fn() - - -def _http_error_reason(e: Exception) -> str: - """Best-effort short reason string from an HttpError (for 400/4xx detail).""" - return str(e).strip().replace("\n", " ")[:200] - - -def _format_google_error(e: Exception, api_label: str) -> str: - """Map a googleapiclient / google-auth exception into an actionable Error: string.""" - try: - from googleapiclient.errors import HttpError - except ImportError: - HttpError = None # type: ignore - try: - from google.auth.exceptions import RefreshError - except ImportError: - RefreshError = None # type: ignore - - if RefreshError is not None and isinstance(e, RefreshError): - return ( - f"Error: {api_label} API token refresh failed (the refresh token may have been revoked " - "or expired). Re-run scripts/gcal_oauth_setup.py to re-authenticate." - ) - - if HttpError is not None and isinstance(e, HttpError): - status = getattr(e, "status_code", None) - if status == 401: - return ( - f"Error: {api_label} API rejected the access token (401). The OAuth token is invalid " - "or revoked. Re-run scripts/gcal_oauth_setup.py to re-authenticate." - ) - if status == 403: - return ( - f"Error: {api_label} API returned 403 Forbidden. The OAuth scopes granted are " - "insufficient for this operation, or the Calendar API is disabled in the Google Cloud " - "Console. Verify the scopes in scripts/gcal_oauth_setup.py and the API enablement." - ) - if status == 404: - return ( - f"Error: {api_label} API returned 404 Not Found. Check the event/calendar ID and the " - "calendar_id parameter." - ) - if status == 429: - return f"Error: {api_label} API rate limit exceeded (429). Wait a moment and retry." - if status == 400: - return ( - f"Error: {api_label} API rejected the request as invalid (400). Check the parameters. " - f"Detail: {_http_error_reason(e)}" - ) - if status is not None and 500 <= status < 600: - return f"Error: {api_label} API returned a server error (HTTP {status}). Retry in a moment." - return f"Error: {api_label} API call failed (HTTP {status}). Detail: {_http_error_reason(e)}" - - return f"Error: {api_label} API call failed: {e}" - - -def _status_report(icon: str, label: str, kind: str, description: str, steps: list[str] | None = None) -> str: - lines = [f"Status: {label} {icon} ({kind})", description] - if steps: - lines.append("") - lines.append("What to do:") - for i, s in enumerate(steps, 1): - lines.append(f"{i}. {s}") - return "\n".join(lines) - - -# ── Tool implementations ─────────────────────────────────────────────────────── - - -def _gcal_status(args: dict | None = None) -> str: - """Self-check: credentials load, the token refreshes when needed, and the API answers. - - Performs one cheap calendarList().list(maxResults=1) probe so we exercise key - validation, the OAuth token, the network, and the Calendar API in a single call. - """ - # Step 1: deps + creds file + service build. - svc = _get_service() - if svc is None: - return _status_report("❌", "NOT_CONFIGURED", "action needed", - f"The Google Calendar service could not be built: {_init_error or 'unknown error'}.", - ["Run scripts/gcal_oauth_setup.py to authenticate and create secrets/google_creds.json.", - "Or set the GOOGLE_CREDS_PATH env var to point at an existing credentials file."]) - - # Step 2: live probe — refresh-on-auth-error is handled inside _call. - try: - result = _call(lambda: svc.calendarList().list(maxResults=1).execute(), "Calendar") - except Exception as e: - return _status_report("❌", "AUTH_OR_API_ERROR", "action needed", - f"The Calendar API did not respond to the probe call: {_format_google_error(e, 'Calendar')}", - ["Run scripts/gcal_oauth_setup.py to refresh / re-issue credentials.", - "If credentials are valid, verify the Google Calendar API is enabled in the Google Cloud Console."]) - - items = result.get("items", []) if isinstance(result, dict) else [] - primary = next((c for c in items if c.get("primary")), None) - suffix = f"\nAccount: {primary.get('id')}" if primary else "" - - return _status_report("✅", "READY", "ok", - "Google Calendar integration is operational: credentials load, the access token refreshes " - "automatically, and the Calendar API responds. All tools (list/get/create/update/delete/RSVP) " - "are usable." + suffix) - - -def _gcal_list_calendars(args: dict | None = None) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - try: - result = _call(lambda: svc.calendarList().list().execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - items = result.get("items", []) - if not items: - return "No calendars found." - - lines = [] - for cal in items: - cal_id = cal.get("id", "?") - summary = cal.get("summary", "(no name)") - primary = " [PRIMARY]" if cal.get("primary", False) else "" - lines.append(f"- {summary}{primary} (id: {cal_id})") - return "\n".join(lines) - - -def _gcal_list_events(args: dict) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - calendar_id = args.get("calendar_id", "primary") - max_results = args.get("max_results", 100) - full_text = args.get("full_text") - time_zone = args.get("time_zone", "Europe/Rome") - - # Accept both "time_min"/"time_max" (preferred, mirrors GCal API) and the - # legacy "start_time"/"end_time" aliases so old callers keep working. - # Default time_min to now so we never return stale past events by accident. - start_time = args.get("time_min") or args.get("start_time") or _utc_now_iso() - end_time = args.get("time_max") or args.get("end_time") - - params: dict = { - "calendarId": calendar_id, - "maxResults": min(max(int(max_results), 1), 250), - "timeZone": time_zone, - "timeMin": start_time, - "singleEvents": True, # expand recurring events into individual instances - "orderBy": "startTime", # chronological order (requires singleEvents=True) - } - - if end_time: - params["timeMax"] = end_time - - if full_text: - params["q"] = full_text - - try: - result = _call(lambda: svc.events().list(**params).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - items = result.get("items", []) - if not items: - return "No events found." - - lines = [f"Events ({len(items)} total):"] - for ev in items: - summary = ev.get("summary", "(no title)") - start = ev.get("start", {}) - end = ev.get("end", {}) - start_str = start.get("dateTime") or start.get("date") or "?" - end_str = end.get("dateTime") or end.get("date") or "?" - ev_id = ev.get("id", "?") - lines.append(f"- {summary}") - lines.append(f" When: {start_str} → {end_str}") - lines.append(f" ID: {ev_id}") - return "\n".join(lines) - - -def _gcal_get_event(args: dict) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - event_id = args.get("event_id") - if not event_id: - return "Error: Missing required parameter 'event_id'." - - calendar_id = args.get("calendar_id", "primary") - - try: - result = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - summary = result.get("summary", "(no title)") - description = result.get("description", "(no description)") - start = result.get("start", {}) - end = result.get("end", {}) - start_str = start.get("dateTime") or start.get("date") or "?" - end_str = end.get("dateTime") or end.get("date") or "?" - location = result.get("location", "(no location)") - attendees = result.get("attendees", []) - - lines = [ - f"Event: {summary}", - f" ID: {event_id}", - f" When: {start_str} → {end_str}", - f" Location: {location}", - f" Description: {description}", - ] - if attendees: - lines.append(" Attendees:") - for a in attendees: - email = a.get("email", "?") - status = a.get("responseStatus", "?") - lines.append(f" - {email} ({status})") - return "\n".join(lines) - - -def _gcal_create_event(args: dict) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - summary = args.get("summary") - if not summary: - return "Error: Missing required parameter 'summary'." - - start = args.get("start") - end = args.get("end") - if not start or not end: - return "Error: Missing required parameters 'start' and/or 'end'." - - calendar_id = args.get("calendar_id", "primary") - - # Build start/end objects: support dateTime (with timezone) or date (all-day). - def _time_obj(value: str, time_zone: str) -> dict: - if "T" in value: - return {"dateTime": value, "timeZone": time_zone} - return {"date": value} - - time_zone = args.get("time_zone", "Europe/Rome") - - body: dict = { - "summary": summary, - "start": _time_obj(start, time_zone), - "end": _time_obj(end, time_zone), - } - - if args.get("description"): - body["description"] = args["description"] - if args.get("location"): - body["location"] = args["location"] - - attendees_raw = args.get("attendees", []) - if attendees_raw: - body["attendees"] = [{"email": e} for e in attendees_raw] - - if args.get("recurrence"): - body["recurrence"] = args["recurrence"] # e.g. ["RRULE:FREQ=WEEKLY;COUNT=5"] - - reminders_raw = args.get("reminders") - if reminders_raw is not None: - body["reminders"] = _build_reminders(reminders_raw) - - try: - result = _call(lambda: svc.events().insert(calendarId=calendar_id, body=body).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - ev_id = result.get("id", "?") - link = result.get("htmlLink", "") - return f"✅ Event created: {summary}\n ID: {ev_id}\n Link: {link}" - - -def _build_reminders(reminders_raw: list) -> dict: - """Accept both list-of-dicts and list-of-minutes (popup only).""" - overrides = [] - for r in reminders_raw: - if isinstance(r, dict): - overrides.append({"method": r.get("method", "popup"), "minutes": int(r.get("minutes", 10))}) - else: - overrides.append({"method": "popup", "minutes": int(r)}) - return {"useDefault": False, "overrides": overrides} - - -def _gcal_update_event(args: dict) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - event_id = args.get("event_id") - if not event_id: - return "Error: Missing required parameter 'event_id'." - - calendar_id = args.get("calendar_id", "primary") - time_zone = args.get("time_zone", "Europe/Rome") - - # Fetch the existing event so we can patch only what changed. - try: - existing = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - def _time_obj(value: str, tz: str) -> dict: - if "T" in value: - return {"dateTime": value, "timeZone": tz} - return {"date": value} - - if args.get("summary"): - existing["summary"] = args["summary"] - if args.get("description") is not None: - existing["description"] = args["description"] - if args.get("location") is not None: - existing["location"] = args["location"] - if args.get("start"): - existing["start"] = _time_obj(args["start"], time_zone) - if args.get("end"): - existing["end"] = _time_obj(args["end"], time_zone) - if args.get("attendees") is not None: - existing["attendees"] = [{"email": e} for e in args["attendees"]] - if args.get("reminders") is not None: - existing["reminders"] = _build_reminders(args["reminders"]) - - try: - result = _call(lambda: svc.events().update(calendarId=calendar_id, eventId=event_id, body=existing).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - summary = result.get("summary", event_id) - return f"✅ Event updated: {summary}\n ID: {event_id}" - - -def _gcal_delete_event(args: dict) -> str: - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - event_id = args.get("event_id") - if not event_id: - return "Error: Missing required parameter 'event_id'." - - calendar_id = args.get("calendar_id", "primary") - - try: - _call(lambda: svc.events().delete(calendarId=calendar_id, eventId=event_id).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - return f"✅ Event {event_id} deleted." - - -def _gcal_respond_to_event(args: dict) -> str: - """RSVP to an event by updating the self attendee status.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - event_id = args.get("event_id") - if not event_id: - return "Error: Missing required parameter 'event_id'." - - response = args.get("response", "").lower() - valid = {"accepted", "declined", "tentative", "needsAction"} - if response not in valid: - return f"Error: 'response' must be one of: {', '.join(sorted(valid))}." - - calendar_id = args.get("calendar_id", "primary") - - try: - existing = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - attendees = existing.get("attendees", []) - updated = False - for a in attendees: - if a.get("self"): - a["responseStatus"] = response - updated = True - break - - if not updated: - # No self attendee found — add one. - # We need the authenticated user's email; fetch it from settings. - try: - cal_info = _call(lambda: svc.calendars().get(calendarId="primary").execute(), "Calendar") - self_email = cal_info.get("id", "") - except Exception: - self_email = "" - if self_email: - attendees.append({"email": self_email, "self": True, "responseStatus": response}) - existing["attendees"] = attendees - else: - return "Error: Could not determine your email to set RSVP." - - try: - result = _call(lambda: svc.events().patch( - calendarId=calendar_id, - eventId=event_id, - body={"attendees": existing["attendees"]}, - sendUpdates="none", - ).execute(), "Calendar") - except Exception as e: - return _format_google_error(e, "Calendar") - - summary = result.get("summary", event_id) - return f"✅ RSVP set to '{response}' for event: {summary}" - - -# ── Tool manifest ────────────────────────────────────────────────────────────── - -_REMINDER_ITEM_SCHEMA = { - "type": ["integer", "object"], - "description": "A reminder: either an integer (minutes before the event, popup) or an object.", -} -_REMINDER_ITEM_DESCRIPTION = ( - "Optional custom reminders. Pass integers for popup reminders (e.g. [10, 30, 60]) " - "or dicts for full control ([{'method': 'popup', 'minutes': 10}]). Overrides calendar defaults." -) - -TOOLS = [ - # ── Self-check ───────────────────────────────────────────────────────────── - { - "name": "status", - "description": ( - "Self-check that the Google Calendar integration is operational: verifies the OAuth " - "credentials load, the access token refreshes when needed, and the Calendar API responds, " - "by performing one cheap calendarList probe. Call this first whenever another gcal tool " - "fails, or to give the user a quick yes/no on whether Calendar is usable right now." - ), - "inputSchema": {"type": "object", "properties": {}}, - }, - # ── Read-only ────────────────────────────────────────────────────────────── - { - "name": "list_calendars", - "description": "Lists all calendars accessible to the authenticated user. Use it to discover calendar_id values to pass to the other gcal tools.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "list_events", - "description": ( - "Lists calendar events from a given calendar, ordered chronologically. " - "If time_min is omitted, defaults to NOW (current UTC time) — so you never get past events by accident. " - "If time_max is omitted, the API returns events from time_min onward up to max_results. " - "Always pass time_min and time_max explicitly when you need a specific range." - ), - "inputSchema": { - "type": "object", - "properties": { - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - "time_min": { - "type": "string", - "description": "ISO 8601 lower bound (inclusive), e.g. '2025-01-01T00:00:00+01:00'. Also accepted as 'start_time'.", - }, - "time_max": { - "type": "string", - "description": "ISO 8601 upper bound (exclusive). Also accepted as 'end_time'.", - }, - "max_results": { - "type": "integer", - "description": "Max events to return. Default 100.", - }, - "full_text": { - "type": "string", - "description": "Free-text search across title, description, location, attendees.", - }, - "time_zone": { - "type": "string", - "description": "IANA timezone. Default 'Europe/Rome'.", - }, - }, - }, - }, - { - "name": "get_event", - "description": "Returns a single calendar event by ID, including attendees with their RSVP status.", - "inputSchema": { - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "The ID of the event to retrieve.", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - }, - "required": ["event_id"], - }, - }, - # ── Write ────────────────────────────────────────────────────────────────── - { - "name": "create_event", - "description": ( - "Creates a new event in the specified calendar and returns its ID + HTML link. " - "Use ISO 8601 for start/end (e.g. '2025-06-15T10:00:00' for timed events, " - "'2025-06-15' for all-day events)." - ), - "inputSchema": { - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": "Title / subject of the event.", - }, - "start": { - "type": "string", - "description": "Start datetime (ISO 8601) or date (YYYY-MM-DD for all-day).", - }, - "end": { - "type": "string", - "description": "End datetime (ISO 8601) or date (YYYY-MM-DD for all-day).", - }, - "description": { - "type": "string", - "description": "Optional longer description / notes.", - }, - "location": { - "type": "string", - "description": "Optional location string.", - }, - "attendees": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional list of attendee email addresses.", - }, - "recurrence": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional RRULE strings, e.g. ['RRULE:FREQ=WEEKLY;COUNT=4'].", - }, - "time_zone": { - "type": "string", - "description": "IANA timezone for start/end. Default 'Europe/Rome'.", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - "reminders": { - "type": "array", - "items": _REMINDER_ITEM_SCHEMA, - "description": _REMINDER_ITEM_DESCRIPTION, - }, - }, - "required": ["summary", "start", "end"], - }, - }, - { - "name": "update_event", - "description": ( - "Updates an existing event. Only fields provided are changed; omitted fields keep their " - "current values. Returns the updated event ID." - ), - "inputSchema": { - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "ID of the event to update.", - }, - "summary": {"type": "string", "description": "New title."}, - "start": {"type": "string", "description": "New start (ISO 8601 or YYYY-MM-DD)."}, - "end": {"type": "string", "description": "New end (ISO 8601 or YYYY-MM-DD)."}, - "description": {"type": "string", "description": "New description."}, - "location": {"type": "string", "description": "New location."}, - "attendees": { - "type": "array", - "items": {"type": "string"}, - "description": "Replacement attendee list (emails). Replaces all existing attendees.", - }, - "time_zone": { - "type": "string", - "description": "IANA timezone for start/end. Default 'Europe/Rome'.", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - "reminders": { - "type": "array", - "items": _REMINDER_ITEM_SCHEMA, - "description": _REMINDER_ITEM_DESCRIPTION, - }, - }, - "required": ["event_id"], - }, - }, - { - "name": "delete_event", - "description": "Permanently deletes a calendar event. Irreversible.", - "inputSchema": { - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "ID of the event to delete.", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - }, - "required": ["event_id"], - }, - }, - { - "name": "respond_to_event", - "description": "Set your RSVP / attendance response (accepted, declined, tentative, needsAction) for a calendar event.", - "inputSchema": { - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "ID of the event.", - }, - "response": { - "type": "string", - "enum": ["accepted", "declined", "tentative", "needsAction"], - "description": "Your response: accepted, declined, tentative, or needsAction.", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. Defaults to 'primary'.", - }, - }, - "required": ["event_id", "response"], - }, - }, -] - - -# ── JSON-RPC dispatch ────────────────────────────────────────────────────────── - -TOOL_DISPATCH = { - "status": _gcal_status, - "list_calendars": _gcal_list_calendars, - "list_events": _gcal_list_events, - "get_event": _gcal_get_event, - "create_event": _gcal_create_event, - "update_event": _gcal_update_event, - "delete_event": _gcal_delete_event, - "respond_to_event": _gcal_respond_to_event, -} - - -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 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": "gcal", - "version": "0.3.0", - }, - }) - - if method == "notifications/initialized": - return None - - 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", {}) - - 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: - text = handler(tool_args) - is_err = text.startswith("Error:") - return _text_result(req_id, text, 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) - - return json.dumps({ - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": -32601, "message": f"Method not found: {method}"}, - }) - - -# ── Main loop ────────────────────────────────────────────────────────────────── - -def main() -> None: - log("Starting gcal MCP server (read + write)") - # Build the service eagerly and start the background polling thread. - _start_polling() - 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: - with _stdout_lock: - sys.stdout.write(resp + "\n") - sys.stdout.flush() - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/scripts/gcal_oauth_setup.py b/scripts/gcal_oauth_setup.py deleted file mode 100644 index 562d8df..0000000 --- a/scripts/gcal_oauth_setup.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Google OAuth token for the Calendar API (read + write). - -This script runs a local OAuth flow that: -1. Opens your browser automatically to the Google authorization page -2. Handles the callback via a local HTTP server -3. Saves the resulting token to ./secrets/google_creds.json - -Required OAuth scope: https://www.googleapis.com/auth/calendar -(full access — needed for create, update, delete, respond). - -No manual copy-paste required. -""" - -from __future__ import annotations - -import json -import os -import sys - -SCOPES = [ - "https://www.googleapis.com/auth/calendar", -] - -_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SECRET_PATH = os.path.join(_ROOT, "secrets", "google_creds.json") -_OAUTH_CLIENT_PATH = os.path.join(_ROOT, "secrets", "google_oauth_client.json") - - -def _load_oauth_client() -> tuple[str, str]: - if not os.path.exists(_OAUTH_CLIENT_PATH): - print(f"Missing OAuth client file: {_OAUTH_CLIENT_PATH}") - print('Create it with: {"client_id": "...", "client_secret": "..."}') - sys.exit(1) - with open(_OAUTH_CLIENT_PATH) as f: - data = json.load(f) - return data["client_id"], data["client_secret"] - - -def main() -> None: - try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from google_auth_oauthlib.flow import InstalledAppFlow - except ImportError as e: - print(f"Missing dependencies: {e}") - print("Install with: pip install google-auth google-auth-oauthlib google-api-python-client") - sys.exit(1) - - creds = None - - # Try to load existing credentials first. - if os.path.exists(SECRET_PATH): - print(f"Existing credentials found at {SECRET_PATH}") - try: - creds = Credentials.from_authorized_user_file(SECRET_PATH, SCOPES) - except Exception: - creds = None - - if creds and creds.valid: - print("Credentials are already valid!") - print(f" Scopes: {creds.scopes}") - return - - if creds and creds.expired and creds.refresh_token: - print("Token expired. Attempting refresh...") - try: - creds.refresh(Request()) - print("Token refreshed successfully!") - except Exception as e: - print(f"Refresh failed: {e}") - creds = None - - if not creds or not creds.valid: - client_id, client_secret = _load_oauth_client() - flow = InstalledAppFlow.from_client_config( - { - "installed": { - "client_id": client_id, - "client_secret": client_secret, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": ["http://localhost"], - } - }, - SCOPES, - ) - - print("\nOpening browser for Google authorization...") - creds = flow.run_local_server( - port=0, - open_browser=True, - prompt="consent", - access_type="offline", - ) - - os.makedirs(os.path.dirname(SECRET_PATH), exist_ok=True) - with open(SECRET_PATH, "w") as f: - f.write(creds.to_json()) - - print(f"\n✅ Google Calendar OAuth token saved to {SECRET_PATH}") - print(f" Scopes: {creds.scopes}") - - -if __name__ == "__main__": - main() diff --git a/scripts/gmail_mcp_server.py b/scripts/gmail_mcp_server.py deleted file mode 100644 index a8adad5..0000000 --- a/scripts/gmail_mcp_server.py +++ /dev/null @@ -1,1243 +0,0 @@ -#!/usr/bin/env python3 -"""Google Gmail MCP server (JSON-RPC 2.0 over stdio). - -Capabilities (callable as `mcp__gmail__`): - status — self-check: credentials, token refresh, API reachability - list_messages — list messages with optional query / label filter - get_message — read a single message by ID (with optional body) - get_thread — read all messages in a thread - list_labels — list labels/folders with message counts - modify_message — add/remove labels (mark read, archive, star) - send_message — send an email (supports in-thread replies + file attachments) - get_profile — account info (email, totals) - create_label — create a new label - download_attachments — save all attachments from a message to disk - -Provides read, modify, and send access to Gmail via the Gmail API v1. - -Credentials are read from ./secrets/gmail_creds.json by default. -Override with GMAIL_CREDS_PATH env var. - -Run scripts/gmail_oauth_setup.py first to generate the OAuth token. -""" - -from __future__ import annotations - -import base64 -import json -import mimetypes -import os -import re -import sys -import threading -import time -from email.message import EmailMessage -from html.parser import HTMLParser -from typing import Any, Callable - -# Log to stderr so stdout stays clean for JSON-RPC. -def log(msg: str) -> None: - print(f"[gmail_mcp] {msg}", file=sys.stderr, flush=True) - -# Protects all stdout writes (main request-handling thread + poll thread). -_stdout_lock = threading.Lock() - -# ── Push notifications ───────────────────────────────────────────────────────── - -def _emit_notification(method: str, params: dict) -> None: - """Write a JSON-RPC notification (no id) to stdout.""" - msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) - with _stdout_lock: - sys.stdout.write(msg + "\n") - sys.stdout.flush() - - -# State for incremental polling via the Gmail History API. -_last_history_id: str | None = None -_poll_thread: threading.Thread | None = None -_POLL_INTERVAL_SECS = 60 - - -def _start_polling() -> None: - """Build the service eagerly, record the initial historyId, start poll thread.""" - global _last_history_id, _poll_thread - svc = _get_service() - if svc is None: - log("Gmail push polling disabled: service not available.") - return - try: - profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") - _last_history_id = str(profile.get("historyId", "")) - log(f"Gmail polling started (historyId={_last_history_id}, interval={_POLL_INTERVAL_SECS}s).") - except Exception as e: - log(f"Failed to get initial historyId, polling disabled: {_format_google_error(e, 'Gmail')}") - return - _poll_thread = threading.Thread(target=_poll_loop, daemon=True, name="gmail-poll") - _poll_thread.start() - - -def _poll_loop() -> None: - while True: - time.sleep(_POLL_INTERVAL_SECS) - _poll_once() - - -def _poll_once() -> None: - global _last_history_id - svc = _get_service() - if svc is None or not _last_history_id: - return - try: - result = _call(lambda: svc.users().history().list( - userId="me", - startHistoryId=_last_history_id, - labelId="INBOX", - historyTypes=["messageAdded"], - ).execute(), "Gmail") - - # Always advance the cursor, even if no new messages. - new_history_id = result.get("historyId") - if new_history_id: - _last_history_id = str(new_history_id) - - for record in result.get("history", []): - for added in record.get("messagesAdded", []): - msg_stub = added.get("message", {}) - if "INBOX" not in msg_stub.get("labelIds", []): - continue - msg_id = msg_stub.get("id") - if not msg_id: - continue - _fetch_and_emit_email(svc, msg_id) - - except Exception as e: - log(f"Gmail history poll error: {_format_google_error(e, 'Gmail')}") - - -def _fetch_and_emit_email(svc: Any, msg_id: str) -> None: - """Fetch metadata for a message and emit an event/new_email notification.""" - try: - msg = _call(lambda: svc.users().messages().get( - userId="me", - id=msg_id, - format="metadata", - metadataHeaders=["Subject", "From", "Date"], - ).execute(), "Gmail") - headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} - _emit_notification("event/new_email", { - "message_id": msg_id, - "thread_id": msg.get("threadId"), - "subject": headers.get("Subject", "(no subject)"), - "from": headers.get("From", "?"), - "date": headers.get("Date", "?"), - "snippet": msg.get("snippet", "")[:300], - }) - log(f"Notification emitted: new email {msg_id} from {headers.get('From', '?')!r}") - except Exception as e: - log(f"Failed to fetch metadata for message {msg_id}: {_format_google_error(e, 'Gmail')}") - - -# ── Credentials / service ────────────────────────────────────────────────────── - -_service = None -_creds = None -_creds_path: str | None = None -_init_error: str | None = None - - -def _persist_creds() -> None: - """Write the current credentials back to disk (used after a token refresh).""" - if _creds is not None and _creds_path: - try: - with open(_creds_path, "w") as f: - f.write(_creds.to_json()) - except Exception as e: - log(f"Could not persist refreshed credentials: {e}") - - -def _build_service() -> Any: - """Build and return a Gmail service object, or None on failure.""" - global _init_error, _creds, _creds_path - try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from googleapiclient.discovery import build - except ImportError as e: - _init_error = f"Missing dependencies: {e}. Install google-api-python-client and google-auth." - log(_init_error) - return None - - _creds_path = os.environ.get( - "GMAIL_CREDS_PATH", - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "secrets", "gmail_creds.json"), - ) - - if not os.path.exists(_creds_path): - _init_error = ( - f"Credentials file not found at {_creds_path}. " - "Run scripts/gmail_oauth_setup.py first, or set GMAIL_CREDS_PATH." - ) - log(_init_error) - return None - - try: - creds = Credentials.from_authorized_user_file(_creds_path) - except Exception as e: - _init_error = f"Failed to load credentials from {_creds_path}: {e}" - log(_init_error) - return None - - # Publish creds globally so _persist_creds / _call can see them. - _creds = creds - - # Auto-refresh if expired; fail hard if we cannot refresh (need re-auth). - try: - if not creds.valid: - if creds.expired and creds.refresh_token: - creds.refresh(Request()) - _persist_creds() - log("Token refreshed and saved.") - else: - _init_error = "Credentials invalid and cannot be refreshed. Re-run scripts/gmail_oauth_setup.py." - log(_init_error) - return None - except Exception as e: - _init_error = f"Failed to refresh credentials: {e}" - log(_init_error) - return None - - try: - service = build("gmail", "v1", credentials=creds) - except Exception as e: - _init_error = f"Failed to build Gmail service: {e}" - log(_init_error) - return None - - log(f"Gmail service built successfully (creds: {_creds_path})") - return service - - -def _get_service() -> Any: - global _service - if _service is None: - _service = _build_service() - return _service - - -# ── Error mapping & refresh-on-auth-error ────────────────────────────────────── - - -def _is_auth_error(e: Exception) -> bool: - """True for 401 HttpError / RefreshError — candidates for a refresh+retry.""" - try: - from googleapiclient.errors import HttpError - except ImportError: - return False - if isinstance(e, HttpError): - return getattr(e, "status_code", None) == 401 - try: - from google.auth.exceptions import RefreshError - except ImportError: - return False - return isinstance(e, RefreshError) - - -def _call(fn: Callable[[], Any], api_label: str) -> Any: - """Run a googleapiclient call with one refresh-on-auth-error retry. - - If the access token expired mid-session the first call raises a 401 HttpError - or a RefreshError. We refresh once, persist the new token, and retry the call. - Anything else (or a second failure) is re-raised so the caller can format it - via _format_google_error. - """ - try: - return fn() - except Exception as e: - if not _is_auth_error(e) or _creds is None or not getattr(_creds, "refresh_token", None): - raise - try: - from google.auth.transport.requests import Request - _creds.refresh(Request()) - _persist_creds() - log("Access token refreshed mid-session after auth error; retrying the call.") - except Exception as refresh_err: - log(f"Mid-session token refresh failed: {refresh_err}") - raise - return fn() - - -def _http_error_reason(e: Exception) -> str: - """Best-effort short reason string from an HttpError (for 400/4xx detail).""" - return str(e).strip().replace("\n", " ")[:200] - - -def _format_google_error(e: Exception, api_label: str) -> str: - """Map a googleapiclient / google-auth exception into an actionable Error: string.""" - try: - from googleapiclient.errors import HttpError - except ImportError: - HttpError = None # type: ignore - try: - from google.auth.exceptions import RefreshError - except ImportError: - RefreshError = None # type: ignore - - if RefreshError is not None and isinstance(e, RefreshError): - return ( - f"Error: {api_label} API token refresh failed (the refresh token may have been revoked " - "or expired). Re-run scripts/gmail_oauth_setup.py to re-authenticate." - ) - - if HttpError is not None and isinstance(e, HttpError): - status = getattr(e, "status_code", None) - if status == 401: - return ( - f"Error: {api_label} API rejected the access token (401). The OAuth token is invalid " - "or revoked. Re-run scripts/gmail_oauth_setup.py to re-authenticate." - ) - if status == 403: - return ( - f"Error: {api_label} API returned 403 Forbidden. The OAuth scopes granted are " - "insufficient for this operation, or the Gmail API is disabled in the Google Cloud " - "Console. Verify the scopes in scripts/gmail_oauth_setup.py and the API enablement." - ) - if status == 404: - return ( - f"Error: {api_label} API returned 404 Not Found. Check the message/thread/attachment ID." - ) - if status == 429: - return f"Error: {api_label} API rate limit exceeded (429). Wait a moment and retry." - if status == 400: - return ( - f"Error: {api_label} API rejected the request as invalid (400). Check the parameters. " - f"Detail: {_http_error_reason(e)}" - ) - if status is not None and 500 <= status < 600: - return f"Error: {api_label} API returned a server error (HTTP {status}). Retry in a moment." - return f"Error: {api_label} API call failed (HTTP {status}). Detail: {_http_error_reason(e)}" - - return f"Error: {api_label} API call failed: {e}" - - -def _status_report(icon: str, label: str, kind: str, description: str, steps: list[str] | None = None) -> str: - lines = [f"Status: {label} {icon} ({kind})", description] - if steps: - lines.append("") - lines.append("What to do:") - for i, s in enumerate(steps, 1): - lines.append(f"{i}. {s}") - return "\n".join(lines) - - -# ── Helpers ──────────────────────────────────────────────────────────────────── - - -def _decode_body(parts: Any, mime_type: str = "text/plain") -> str: - """Recursively extract the body of the given MIME type from MIME parts.""" - if isinstance(parts, list): - for part in parts: - if part.get("mimeType", "") == mime_type: - data = part.get("body", {}).get("data", "") - if data: - return _safe_b64decode(data) - if "parts" in part: - result = _decode_body(part["parts"], mime_type) - if result: - return result - return "" - - -def _safe_b64decode(data: str) -> str: - """Decode URL-safe base64 to string.""" - try: - # Add padding if needed. - padded = data + "=" * (4 - len(data) % 4) if len(data) % 4 else data - decoded = base64.urlsafe_b64decode(padded) - return decoded.decode("utf-8", errors="replace") - except Exception: - return "(unable to decode)" - - -class _HTMLTextExtractor(HTMLParser): - """Collect readable text from HTML, skipping scripts/styles and adding - newlines around block-level tags. convert_charrefs=True unescapes entities.""" - - _SKIP = {"script", "style", "head"} - _BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"} - - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self._chunks: list[str] = [] - self._skip_depth = 0 - - def handle_starttag(self, tag: str, attrs: Any) -> None: - if tag in self._SKIP: - self._skip_depth += 1 - elif tag == "br": - self._chunks.append("\n") - - def handle_endtag(self, tag: str) -> None: - if tag in self._SKIP and self._skip_depth: - self._skip_depth -= 1 - elif tag in self._BLOCK: - self._chunks.append("\n") - - def handle_data(self, data: str) -> None: - if not self._skip_depth: - self._chunks.append(data) - - def get_text(self) -> str: - return re.sub(r"\n{3,}", "\n\n", "".join(self._chunks)).strip() - - -def _html_to_text(html_str: str) -> str: - """Convert an HTML email body to readable plain text (stdlib only).""" - try: - parser = _HTMLTextExtractor() - parser.feed(html_str) - return parser.get_text() - except Exception: - return html_str - - -def _format_datetime(ts_millis: int | None) -> str: - """Format a unix timestamp in milliseconds to ISO-like string.""" - if ts_millis is None: - return "?" - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts_millis / 1000)) - - -def _format_message_summary(msg: dict) -> str: - """Format a message object (from list with metadata) into a summary line.""" - mid = msg.get("id", "?") - headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} - # When listing with metadata, headers might be elsewhere. - payload = msg.get("payload", {}) - if not headers: - headers = {h["name"]: h["value"] for h in payload.get("headers", [])} - thread_id = msg.get("threadId", "?") - subject = headers.get("Subject", "(no subject)") - sender = headers.get("From", "?") - date = headers.get("Date", "?") - snippet = msg.get("snippet", "")[:80] - return f"- {subject}\n From: {sender} | Date: {date} | ID: {mid} | Thread: {thread_id}\n {snippet}" - - -def _collect_attachments(parts: Any, results: list) -> None: - """Recursively collect attachment filenames and IDs from MIME parts.""" - if not parts: - return - for part in parts: - filename = part.get("filename", "") - attachment_id = part.get("body", {}).get("attachmentId", "") - if filename and attachment_id: - results.append({"filename": filename, "attachmentId": attachment_id}) - if "parts" in part: - _collect_attachments(part["parts"], results) - - -# ── Tool implementations ─────────────────────────────────────────────────────── - - -def _gmail_status(args: dict | None = None) -> str: - """Self-check: credentials load, the token refreshes when needed, and the API answers. - - Performs one cheap users().getProfile(userId='me') probe so we exercise the - OAuth token, the network, and the Gmail API in a single call. - """ - # Step 1: deps + creds file + service build. - svc = _get_service() - if svc is None: - return _status_report("❌", "NOT_CONFIGURED", "action needed", - f"The Gmail service could not be built: {_init_error or 'unknown error'}.", - ["Run scripts/gmail_oauth_setup.py to authenticate and create secrets/gmail_creds.json.", - "Or set the GMAIL_CREDS_PATH env var to point at an existing credentials file."]) - - # Step 2: live probe — refresh-on-auth-error is handled inside _call. - try: - profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") - except Exception as e: - return _status_report("❌", "AUTH_OR_API_ERROR", "action needed", - f"The Gmail API did not respond to the probe call: {_format_google_error(e, 'Gmail')}", - ["Run scripts/gmail_oauth_setup.py to refresh / re-issue credentials.", - "If credentials are valid, verify the Gmail API is enabled in the Google Cloud Console."]) - - email = profile.get("emailAddress", "?") - return _status_report("✅", "READY", "ok", - "Google Gmail integration is operational: credentials load, the access token refreshes " - "automatically, and the Gmail API responds. All tools (list/get/thread/labels/modify/send/" - "download) are usable.\n" - f"Account: {email}") - - -def _gmail_list_messages(args: dict) -> str: - """List messages with optional filters.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - query = args.get("query", "") - max_results = min(args.get("max_results", 20), 50) - label_ids = args.get("label_ids") - page_token = args.get("page_token") - - params: dict = { - "userId": "me", - "maxResults": max_results, - } - if query: - params["q"] = query - if label_ids: - if isinstance(label_ids, str): - label_ids = [label_ids] - params["labelIds"] = label_ids - if page_token: - params["pageToken"] = page_token - - try: - result = _call(lambda: svc.users().messages().list(**params).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - items = result.get("messages", []) - if not items: - return "No messages found." - - # Fetch full metadata for each message. - lines = [f"Messages ({len(items)} total):"] - for entry in items: - try: - msg = _call(lambda e=entry: svc.users().messages().get( - userId="me", id=e["id"], format="metadata", - metadataHeaders=["Subject", "From", "Date"], - ).execute(), "Gmail") - lines.append(_format_message_summary(msg)) - except Exception as e: - lines.append(f"- {entry['id']} (error fetching: {_http_error_reason(e)})") - - # Add paging info. - next_token = result.get("nextPageToken") - if next_token: - lines.append(f"\nMore results available. Use page_token='{next_token}' to get next page.") - - return "\n".join(lines) - - -def _gmail_get_message(args: dict) -> str: - """Get full content of a single message by ID.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - msg_id = args.get("message_id") - if not msg_id: - return "Error: Missing required parameter 'message_id'." - include_body = args.get("include_body", True) - - fmt = "full" if include_body else "metadata" - meta_headers = [] if include_body else ["Subject", "From", "To", "Date"] - - try: - msg = _call(lambda: svc.users().messages().get( - userId="me", id=msg_id, format=fmt, - **({"metadataHeaders": meta_headers} if meta_headers else {}), - ).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - payload = msg.get("payload", {}) - headers = {h["name"]: h["value"] for h in payload.get("headers", [])} - - lines = [ - f"ID: {msg.get('id', '?')}", - f"Thread: {msg.get('threadId', '?')}", - f"From: {headers.get('From', '?')}", - f"To: {headers.get('To', '?')}", - f"Date: {headers.get('Date', '?')}", - f"Subject: {headers.get('Subject', '(no subject)')}", - f"Labels: {', '.join(msg.get('labelIds', []))}", - ] - - # List attachment filenames (needs the full payload). Download them via - # download_attachments, which only needs this message_id. - attachments: list = [] - _collect_attachments(payload.get("parts", []), attachments) - if attachments: - lines.append(f"Attachments: {', '.join(a['filename'] for a in attachments)}") - - if include_body: - parts = payload.get("parts", []) - body_text = _decode_body(parts) # prefer text/plain - body_label = "--- Body ---" - if not body_text: - # Fall back to the HTML part, converted to readable text. - html_body = _decode_body(parts, "text/html") - if html_body: - body_text = _html_to_text(html_body) - body_label = "--- Body (converted from HTML) ---" - if not body_text: - # Single-part message: the body lives inline on the payload. - body_data = payload.get("body", {}).get("data", "") - if body_data: - decoded = _safe_b64decode(body_data) - if payload.get("mimeType", "") == "text/html": - body_text = _html_to_text(decoded) - body_label = "--- Body (converted from HTML) ---" - else: - body_text = decoded - if body_text: - lines.append(f"\n{body_label}") - # Truncate very long bodies. - if len(body_text) > 10000: - lines.append(body_text[:10000] + "\n... [truncated at 10000 chars]") - else: - lines.append(body_text) - else: - lines.append("\n(no text body found)") - - return "\n".join(lines) - - -def _gmail_get_thread(args: dict) -> str: - """Get an entire thread (all messages in it).""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - thread_id = args.get("thread_id") - if not thread_id: - return "Error: Missing required parameter 'thread_id'." - - try: - thread = _call(lambda: svc.users().threads().get( - userId="me", id=thread_id, format="metadata", - metadataHeaders=["Subject", "From", "Date"], - ).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - messages = thread.get("messages", []) - subject = "" - lines = [f"Thread: {thread_id} ({len(messages)} messages)"] - for i, msg in enumerate(messages, 1): - headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} - if not subject: - subject = headers.get("Subject", "(no subject)") - lines.append(f"\n[{i}] From: {headers.get('From', '?')} | Date: {headers.get('Date', '?')}") - lines.append(f" ID: {msg.get('id', '?')}") - snippet = msg.get("snippet", "") - if snippet: - lines.append(f" {snippet[:200]}") - - if subject: - lines.insert(1, f"Subject: {subject}") - - return "\n".join(lines) - - -def _gmail_list_labels(args: dict) -> str: - """List all labels/categories in the Gmail account.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - try: - result = _call(lambda: svc.users().labels().list(userId="me").execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - items = result.get("labels", []) - if not items: - return "No labels found." - - lines = ["Labels:"] - for lbl in items: - lid = lbl.get("id", "?") - name = lbl.get("name", "?") - label_type = lbl.get("type", "?") - msg_count = lbl.get("messagesTotal", "?") - unread = lbl.get("messagesUnread", 0) - lines.append(f"- {name} ({lid}) [{label_type}] — {msg_count} total, {unread} unread") - - return "\n".join(lines) - - -def _gmail_modify_message(args: dict) -> str: - """Modify message labels (add/remove labels, mark read/archive/star).""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - msg_id = args.get("message_id") - if not msg_id: - return "Error: Missing required parameter 'message_id'." - - add_labels = args.get("add_labels", []) - remove_labels = args.get("remove_labels", []) - - if isinstance(add_labels, str): - add_labels = [add_labels] - if isinstance(remove_labels, str): - remove_labels = [remove_labels] - - body: dict = {} - if add_labels: - body["addLabelIds"] = add_labels - if remove_labels: - body["removeLabelIds"] = remove_labels - - try: - _call(lambda: svc.users().messages().modify(userId="me", id=msg_id, body=body).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - changes = [] - if add_labels: - changes.append(f"added labels: {add_labels}") - if remove_labels: - changes.append(f"removed labels: {remove_labels}") - return f"✅ Message {msg_id} modified: {'; '.join(changes)}" - - -# messages.send embeds the whole message as base64 inside the JSON request body; -# Gmail caps that request around 35 MB and base64 inflates the payload ~33%, so we -# reject well before the ceiling with a clear error instead of an opaque 400. -_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 - - -def _gmail_send_message(args: dict) -> str: - """Send an email message, optionally with one or more file attachments.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - to = args.get("to") - subject = args.get("subject", "") - body_text = args.get("body", "") - cc = args.get("cc") - bcc = args.get("bcc") - in_reply_to = args.get("in_reply_to") - thread_id = args.get("thread_id") - attachments = args.get("attachments") or [] - - if not to: - return "Error: Missing required parameter 'to'." - - # Accept a single path or an array (mirrors add_labels / remove_labels). - if isinstance(attachments, str): - attachments = [attachments] - - # Resolve every attachment path (relative paths are anchored at the project - # root, like _build_service / download_attachments) and fail early if any file - # is missing — the email is NOT sent unless every attachment is present. - project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - resolved: list[str] = [] - total_bytes = 0 - for raw_path in attachments: - path = raw_path if os.path.isabs(raw_path) else os.path.join(project_root, raw_path) - if not os.path.isfile(path): - return f"Error: attachment not found: {raw_path}" - total_bytes += os.path.getsize(path) - resolved.append(path) - - if total_bytes > _MAX_ATTACHMENT_BYTES: - return ( - f"Error: attachments total ~{total_bytes // (1024 * 1024)} MB, which exceeds the " - f"{_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB send limit. Send fewer or smaller files." - ) - - # EmailMessage builds a plain text/plain message when there are no attachments - # and switches to multipart/mixed automatically once one is added. - msg = EmailMessage() - msg["To"] = to - if cc: - msg["Cc"] = cc - if bcc: - msg["Bcc"] = bcc - msg["Subject"] = subject - # RFC 2822 threading headers for in-thread reply. - if in_reply_to: - msg["In-Reply-To"] = f"<{in_reply_to}>" - msg["References"] = f"<{in_reply_to}>" - msg.set_content(body_text) - - for path in resolved: - ctype, encoding = mimetypes.guess_type(path) - # Fall back to a generic binary type for unknown or compressed files. - if ctype is None or encoding is not None: - ctype = "application/octet-stream" - maintype, subtype = ctype.split("/", 1) - try: - with open(path, "rb") as f: - data = f.read() - except Exception as e: - return f"Error: could not read attachment {path}: {e}" - msg.add_attachment(data, maintype=maintype, subtype=subtype, - filename=os.path.basename(path)) - - encoded = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8") - - # Build API body — include threadId when replying in-thread. - api_body: dict = {"raw": encoded} - if thread_id: - api_body["threadId"] = thread_id - - try: - sent = _call(lambda: svc.users().messages().send(userId="me", body=api_body).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - suffix = f" ({len(resolved)} attachment{'s' if len(resolved) != 1 else ''})" if resolved else "" - return f"✅ Message sent! ID: {sent.get('id', '?')}{suffix}" - - -def _gmail_get_profile(args: dict) -> str: - """Get Gmail profile info (email address, total/thread count).""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - try: - profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - return ( - f"Email: {profile.get('emailAddress', '?')}\n" - f"Messages total: {profile.get('messagesTotal', '?')}\n" - f"Threads total: {profile.get('threadsTotal', '?')}\n" - f"History ID: {profile.get('historyId', '?')}" - ) - - -def _gmail_create_label(args: dict) -> str: - """Create a new Gmail label.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - name = args.get("name") - if not name: - return "Error: Missing required parameter 'name'." - - label_list_visibility = args.get("label_list_visibility", "labelShow") - message_list_visibility = args.get("message_list_visibility", "show") - - body = { - "name": name, - "labelListVisibility": label_list_visibility, - "messageListVisibility": message_list_visibility, - } - - try: - result = _call(lambda: svc.users().labels().create(userId="me", body=body).execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - return f"✅ Label '{result.get('name', name)}' created (ID: {result.get('id', '?')})" - - -def _gmail_download_attachments(args: dict) -> str: - """Download all attachments from a Gmail message to a local folder.""" - svc = _get_service() - if svc is None: - return f"Error: {_init_error}" - - msg_id = args.get("message_id") - if not msg_id: - return "Error: Missing required parameter 'message_id'." - - # Default to data/gmail_attachments/ (served via /data/... in the frontend, - # consistent with whatsapp_media). Allow override. - default_folder = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", "gmail_attachments", - ) - folder = args.get("folder") or default_folder - - try: - msg = _call(lambda: svc.users().messages().get(userId="me", id=msg_id, format="full").execute(), "Gmail") - except Exception as e: - return _format_google_error(e, "Gmail") - - payload = msg.get("payload", {}) - - attachments: list = [] - _collect_attachments(payload.get("parts", []), attachments) - - if not attachments: - return "No attachments found." - - os.makedirs(folder, exist_ok=True) - - saved = [] - for att in attachments: - filename = att["filename"] - attachment_id = att["attachmentId"] - - try: - result = _call(lambda a=att: svc.users().messages().attachments().get( - userId="me", messageId=msg_id, id=a["attachmentId"], - ).execute(), "Gmail") - except Exception as e: - saved.append(f"- {filename}: ERROR fetching attachment: {_http_error_reason(e)}") - continue - - data = result.get("data", "") - if not data: - saved.append(f"- {filename}: empty attachment data") - continue - - try: - file_data = base64.urlsafe_b64decode(data) - except Exception as e: - saved.append(f"- {filename}: ERROR decoding: {e}") - continue - - safe_name = os.path.basename(filename) - file_path = os.path.join(folder, safe_name) - - try: - with open(file_path, "wb") as f: - f.write(file_data) - except Exception as e: - saved.append(f"- {safe_name}: ERROR writing file: {e}") - continue - - abs_path = os.path.abspath(file_path) - size = len(file_data) - saved.append(f"- {abs_path} ({size} bytes)") - - return "\n".join(["✅ Attachments downloaded:"] + saved) - - -# ── Tool manifest ────────────────────────────────────────────────────────────── - -TOOLS = [ - { - "name": "status", - "description": ( - "Self-check that the Google Gmail integration is operational: verifies the OAuth " - "credentials load, the access token refreshes when needed, and the Gmail API responds, " - "by performing one cheap getProfile probe. Call this first whenever another gmail tool " - "fails, or to give the user a quick yes/no on whether Gmail is usable right now." - ), - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "list_messages", - "description": ( - "List Gmail messages with optional query and label filter. Returns summaries with " - "subject, sender, date, message ID and thread ID. Use Gmail search syntax in 'query' " - "(e.g. 'from:john', 'is:unread', 'after:2024/01/01', 'has:attachment'). Pass the " - "returned IDs to get_message / modify_message." - ), - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Gmail search query (e.g. 'from:john', 'is:unread', 'after:2024/01/01'). Leave empty for all recent messages.", - }, - "max_results": { - "type": "integer", - "description": "Max messages to return (default 20, max 50).", - }, - "label_ids": { - "type": ["string", "array"], - "items": {"type": "string"}, - "description": "Filter by label IDs (e.g. 'INBOX', or ['INBOX','STARRED']). Pass a single string or an array.", - }, - "page_token": { - "type": "string", - "description": "Opaque token from a previous response's 'More results available' line, to fetch the next page.", - }, - }, - }, - }, - { - "name": "get_message", - "description": ( - "Get full content of a Gmail message by ID, including body text (truncated at 10000 " - "chars). HTML-only emails are converted to readable text, and any attachment filenames " - "are listed (download them with download_attachments)." - ), - "inputSchema": { - "type": "object", - "properties": { - "message_id": { - "type": "string", - "description": "The Gmail message ID to retrieve.", - }, - "include_body": { - "type": "boolean", - "description": "Whether to include the full body text (default true).", - }, - }, - "required": ["message_id"], - }, - }, - { - "name": "get_thread", - "description": "Get all messages in a thread by thread ID, newest last.", - "inputSchema": { - "type": "object", - "properties": { - "thread_id": { - "type": "string", - "description": "The Gmail thread ID to retrieve.", - }, - }, - "required": ["thread_id"], - }, - }, - { - "name": "list_labels", - "description": "List all Gmail labels/folders/categories with total and unread message counts. Use to resolve label IDs for modify_message.", - "inputSchema": { - "type": "object", - "properties": {}, - }, - }, - { - "name": "modify_message", - "description": ( - "Modify message labels: mark read, archive, star, etc. Use label IDs like 'UNREAD', " - "'STARRED', 'INBOX'. remove_labels=['UNREAD'] marks as read; remove_labels=['INBOX'] " - "archives. add_labels/remove_labels each accept a single string or an array." - ), - "inputSchema": { - "type": "object", - "properties": { - "message_id": { - "type": "string", - "description": "The Gmail message ID to modify.", - }, - "add_labels": { - "type": ["string", "array"], - "items": {"type": "string"}, - "description": "Label ID(s) to add (e.g. 'STARRED', or ['STARRED','IMPORTANT']).", - }, - "remove_labels": { - "type": ["string", "array"], - "items": {"type": "string"}, - "description": "Label ID(s) to remove (e.g. 'UNREAD' to mark as read, 'INBOX' to archive).", - }, - }, - "required": ["message_id"], - }, - }, - { - "name": "send_message", - "description": ( - "Send an email via Gmail. Supports in-thread replies via the optional in_reply_to " - "(message ID) and thread_id parameters. For a reply, pass both for correct threading " - "across email clients. Attach files by passing local file paths in 'attachments'; the " - "server reads them from disk and, if any path does not exist, the email is NOT sent and " - "an error is returned." - ), - "inputSchema": { - "type": "object", - "properties": { - "to": { - "type": "string", - "description": "Recipient email address.", - }, - "subject": { - "type": "string", - "description": "Email subject line.", - }, - "body": { - "type": "string", - "description": "Plain text body of the email.", - }, - "cc": { - "type": "string", - "description": "CC recipient email (optional).", - }, - "bcc": { - "type": "string", - "description": "BCC recipient email (optional).", - }, - "in_reply_to": { - "type": "string", - "description": "Message ID to reply to (adds In-Reply-To and References headers for proper threading).", - }, - "thread_id": { - "type": "string", - "description": "Thread ID to attach the reply to (ensures the message appears in the correct Gmail thread).", - }, - "attachments": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "File path(s) to attach. Each is absolute, or relative to the project root " - "(e.g. 'data/gmail_attachments/report.pdf'). The server reads each file from " - "disk; if any path does not exist the email is NOT sent and an error is " - "returned. Total size limit ~25 MB." - ), - }, - }, - "required": ["to", "subject", "body"], - }, - }, - { - "name": "get_profile", - "description": "Get Gmail profile info: email address, total message/thread count, current history ID.", - "inputSchema": { - "type": "object", - "properties": {}, - }, - }, - { - "name": "create_label", - "description": "Create a new Gmail label/folder. Returns the new label ID. Fails if a label with the same name already exists.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the new label.", - }, - "label_list_visibility": { - "type": "string", - "description": "Visibility in the label list: 'labelShow' (default), 'labelShowIfUnread', 'labelHide'.", - "default": "labelShow", - }, - "message_list_visibility": { - "type": "string", - "description": "Visibility in the message list: 'show' (default) or 'hide'.", - "default": "show", - }, - }, - "required": ["name"], - }, - }, - { - "name": "download_attachments", - "description": ( - "Download all attachments from a Gmail message to a local folder. " - "Defaults to data/gmail_attachments/ (served via /data/... in the frontend). " - "Returns the absolute path and size of each saved file." - ), - "inputSchema": { - "type": "object", - "properties": { - "message_id": { - "type": "string", - "description": "The Gmail message ID to download attachments from.", - }, - "folder": { - "type": "string", - "description": "Local folder to save attachments into (default: data/gmail_attachments/).", - }, - }, - "required": ["message_id"], - }, - }, -] - - -# ── JSON-RPC dispatch ────────────────────────────────────────────────────────── - -TOOL_DISPATCH = { - "status": _gmail_status, - "list_messages": _gmail_list_messages, - "get_message": _gmail_get_message, - "get_thread": _gmail_get_thread, - "list_labels": _gmail_list_labels, - "modify_message": _gmail_modify_message, - "send_message": _gmail_send_message, - "get_profile": _gmail_get_profile, - "create_label": _gmail_create_label, - "download_attachments": _gmail_download_attachments, -} - - -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 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": "gmail", - "version": "0.2.0", - }, - }) - - if method == "notifications/initialized": - return None - - 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", {}) - - 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: - text = handler(tool_args) - is_err = text.startswith("Error:") - return _text_result(req_id, text, 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) - - return json.dumps({ - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": -32601, "message": f"Method not found: {method}"}, - }) - - -# ── Main loop ────────────────────────────────────────────────────────────────── - -def main() -> None: - log("Starting Gmail MCP server") - # Build the service eagerly and start the background polling thread. - _start_polling() - 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: - with _stdout_lock: - sys.stdout.write(resp + "\n") - sys.stdout.flush() - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/scripts/gmail_oauth_setup.py b/scripts/gmail_oauth_setup.py deleted file mode 100644 index 2a06ae4..0000000 --- a/scripts/gmail_oauth_setup.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Google OAuth token for Gmail API. - -This script runs a local OAuth flow that: -1. Opens your browser automatically to the Google authorization page -2. Handles the callback via a local HTTP server -3. Saves the resulting token to ./secrets/gmail_creds.json - -No manual copy-paste required. -""" - -from __future__ import annotations - -import json -import os -import sys - -SCOPES = [ - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/gmail.labels", -] - -_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SECRET_PATH = os.path.join(_ROOT, "secrets", "gmail_creds.json") -_OAUTH_CLIENT_PATH = os.path.join(_ROOT, "secrets", "google_oauth_client.json") - - -def _load_oauth_client() -> tuple[str, str]: - if not os.path.exists(_OAUTH_CLIENT_PATH): - print(f"Missing OAuth client file: {_OAUTH_CLIENT_PATH}") - print("Create it with: {\"client_id\": \"...\", \"client_secret\": \"...\"}") - sys.exit(1) - with open(_OAUTH_CLIENT_PATH) as f: - data = json.load(f) - return data["client_id"], data["client_secret"] - - -def main() -> None: - # Lazy-import so we can show helpful errors if not installed. - try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from google_auth_oauthlib.flow import InstalledAppFlow - except ImportError as e: - print(f"Missing dependencies: {e}") - print("Install with: pip3 install google-auth google-auth-oauthlib google-api-python-client") - sys.exit(1) - - creds = None - - # Try to load existing credentials first, in case they have refresh token. - if os.path.exists(SECRET_PATH): - print(f"Existing credentials found at {SECRET_PATH}") - try: - creds = Credentials.from_authorized_user_file(SECRET_PATH, SCOPES) - except Exception: - creds = None - - # If creds exist and are valid, we're good. - if creds and creds.valid: - print("Credentials are already valid!") - return - - # If creds exist but expired, try to refresh. - if creds and creds.expired and creds.refresh_token: - print("Token expired. Attempting refresh...") - try: - creds.refresh(Request()) - print("Token refreshed successfully!") - except Exception as e: - print(f"Refresh failed: {e}") - creds = None - - if not creds or not creds.valid: - client_id, client_secret = _load_oauth_client() - # Start OAuth flow using local server (opens browser automatically). - flow = InstalledAppFlow.from_client_config( - { - "installed": { - "client_id": client_id, - "client_secret": client_secret, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": ["http://localhost"], - } - }, - SCOPES, - ) - - print("\nOpening browser for Google authorization...") - creds = flow.run_local_server( - port=0, # pick a random available port - open_browser=True, - prompt="consent", - access_type="offline", - ) - - # Save credentials. - os.makedirs(os.path.dirname(SECRET_PATH), exist_ok=True) - with open(SECRET_PATH, "w") as f: - f.write(creds.to_json()) - - print(f"\n✅ Gmail OAuth token saved to {SECRET_PATH}") - print(f" Scopes: {creds.scopes}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/gmaps_mcp_server.py b/scripts/gmaps_mcp_server.py deleted file mode 100644 index 1f692b2..0000000 --- a/scripts/gmaps_mcp_server.py +++ /dev/null @@ -1,819 +0,0 @@ -#!/usr/bin/env python3 -"""Google Maps MCP server (JSON-RPC 2.0 over stdio). - -Capabilities (callable as `mcp__gmaps__`): - directions — transit/driving/walking directions from A to B - geocode — convert an address or place name to coordinates - reverse_geocode — convert coordinates to an address - search_places — find nearby places (stations, stops, POIs) - distance_matrix — travel time & distance between multiple origins/destinations - -Auth: - API key is read from env var GOOGLE_MAPS_API_KEY, or from the file at - GOOGLE_MAPS_API_KEY_FILE (default: ./secrets/gmaps_api_key.txt). - -Required Google Cloud APIs to enable: - - Directions API - - Geocoding API - - Places API (New) or Places API - - Distance Matrix API - -Run with: - python3 scripts/gmaps_mcp_server.py -""" - -from __future__ import annotations - -import json -import os -import sys -from datetime import datetime, timezone -from typing import Any - -# Log to stderr so stdout stays clean for JSON-RPC. -def log(msg: str) -> None: - print(f"[gmaps_mcp] {msg}", file=sys.stderr, flush=True) - - -# ── API key / client init ────────────────────────────────────────────────────── - -_client = None -_init_error: str | None = None - - -def _get_api_key() -> str | None: - # 1. Environment variable - key = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip() - if key: - return key - - # 2. File - key_file = os.environ.get( - "GOOGLE_MAPS_API_KEY_FILE", - os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "secrets", - "gmaps_api_key.txt", - ), - ) - if os.path.exists(key_file): - with open(key_file) as f: - key = f.read().strip() - if key: - return key - - return None - - -def _get_client(): - global _client, _init_error - if _client is not None: - return _client - - try: - import googlemaps # type: ignore - except ImportError as e: - _init_error = f"Missing dependency: {e}. Run: pip install googlemaps" - log(_init_error) - return None - - api_key = _get_api_key() - if not api_key: - _init_error = ( - "Google Maps API key not found. " - "Set GOOGLE_MAPS_API_KEY env var or create secrets/gmaps_api_key.txt " - "with just the key on the first line." - ) - log(_init_error) - return None - - try: - _client = googlemaps.Client(key=api_key) - log("Google Maps client initialised successfully.") - return _client - except Exception as e: - _init_error = f"Failed to build Maps client: {e}" - log(_init_error) - return None - - -def _format_gmaps_error(e: Exception, api_label: str) -> str: - """Map a googlemaps exception into an actionable Error: string. - - `api_label` is a human name for the failing API (e.g. "Directions", "Geocoding"), - used to point the user at the right Google Cloud Console switch. - """ - try: - from googlemaps import exceptions as gm_exc # type: ignore - except ImportError: - gm_exc = None # type: ignore - - if gm_exc is not None and isinstance(e, gm_exc.ApiError): - status = getattr(e, "status", "") or "" - message = (getattr(e, "message", "") or "").strip() - if status == "OVER_QUERY_LIMIT": - return ( - f"Error: {api_label} API quota exceeded (OVER_QUERY_LIMIT). " - "Check usage and billing in the Google Cloud Console." - ) - if status == "REQUEST_DENIED": - return ( - f"Error: {api_label} API request denied (REQUEST_DENIED). " - "Verify that the API key in secrets/gmaps_api_key.txt is valid and that " - f"the {api_label} API is enabled in the Google Cloud Console." - ) - if status == "INVALID_REQUEST": - return ( - f"Error: {api_label} API rejected the request as invalid (INVALID_REQUEST). " - "Check that the addresses, coordinates, and parameters are well-formed." - ) - if status == "MAX_ELEMENTS_EXCEEDED": - return ( - f"Error: {api_label} API returned MAX_ELEMENTS_EXCEEDED — too many " - "origins×destinations at once. Reduce the input size and retry." - ) - if status == "NOT_FOUND": - return ( - f"Error: {api_label} API could not geocode one of the supplied places. " - "Use more specific place names or coordinates." - ) - return f"Error: {api_label} API error ({status}): {message}" - - if gm_exc is not None and isinstance(e, gm_exc.HTTPError): - status = getattr(e, "status", "") or "" - return f"Error: {api_label} API returned HTTP error {status}." - - if gm_exc is not None and isinstance(e, gm_exc.Timeout): - return f"Error: {api_label} API request timed out. Retry in a moment." - - return f"Error: {api_label} API call failed: {e}" - - -# ── Tool implementations ─────────────────────────────────────────────────────── - -def _maps_status(args: dict) -> str: - """Self-check: confirm the API key is present, valid, and the network works. - - Performs one cheap geocode ("Rome, IT") so we exercise key validation, the - Geocoding API, and the network in a single round-trip. Returns a plain-text - report the LLM can use to decide what to tell the user. - """ - # Step 1: API key present? - api_key = _get_api_key() - if not api_key: - return ( - "Error: Google Maps API key not found. " - "Set GOOGLE_MAPS_API_KEY env var or create secrets/gmaps_api_key.txt " - "with the key on the first line. No Google Maps tool will work until this is fixed." - ) - - # Step 2: dependency present + client built? - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - # Step 3: live call. One geocode is the cheapest "is the key valid?" probe. - try: - result = gmaps.geocode("Rome, IT") - except Exception as e: - return _format_gmaps_error(e, "Geocoding") - - if not result: - return ( - "Error: Geocoding API returned no result for the probe query. " - "The API key may be restricted or the Geocoding API may be disabled." - ) - - return ( - "OK: Google Maps client is ready. API key is present and the Geocoding API responds.\n" - "All tools (directions, geocode, reverse_geocode, search_places, distance_matrix) are operational." - ) - - -def _maps_directions(args: dict) -> str: - """Get directions from origin to destination.""" - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - origin = args.get("origin") - destination = args.get("destination") - if not origin or not destination: - return "Error: Missing required parameters 'origin' and/or 'destination'." - - mode = args.get("mode", "transit").lower() - valid_modes = {"driving", "walking", "bicycling", "transit"} - if mode not in valid_modes: - return f"Error: 'mode' must be one of: {', '.join(sorted(valid_modes))}." - - # Optional departure time: literal "now" or an ISO 8601 datetime string. - # Integers (Unix timestamps) are rejected explicitly — the schema documents - # strings only and silently coercing ints would teach the LLM the wrong call. - departure_raw = args.get("departure_time", "now") - if isinstance(departure_raw, bool): - return "Error: 'departure_time' must be 'now' or an ISO 8601 string (e.g. '2025-06-15T08:30:00+02:00'). Never pass a boolean." - if isinstance(departure_raw, (int, float)): - return "Error: 'departure_time' must be 'now' or an ISO 8601 string (e.g. '2025-06-15T08:30:00+02:00'). Never pass a Unix timestamp integer." - if departure_raw == "now": - departure_time = datetime.now(timezone.utc) - else: - try: - departure_time = datetime.fromisoformat(str(departure_raw).replace("Z", "+00:00")) - except ValueError: - return ( - "Error: 'departure_time' must be the literal 'now' or an ISO 8601 datetime " - f"string with timezone offset (e.g. '2025-06-15T08:30:00+02:00'). Got: {departure_raw!r}." - ) - - # Transit preferences - transit_mode = args.get("transit_mode") # e.g. "bus", "rail", "subway", "train", "tram" - transit_routing_preference = args.get("transit_routing_preference") # "less_walking", "fewer_transfers" - language = args.get("language", "it") - alternatives = args.get("alternatives", False) - - kwargs: dict[str, Any] = { - "origin": origin, - "destination": destination, - "mode": mode, - "language": language, - "alternatives": alternatives, - } - if mode == "transit": - kwargs["departure_time"] = departure_time - if transit_mode: - kwargs["transit_mode"] = transit_mode if isinstance(transit_mode, list) else [transit_mode] - if transit_routing_preference: - kwargs["transit_routing_preference"] = transit_routing_preference - - try: - result = gmaps.directions(**kwargs) - except Exception as e: - return _format_gmaps_error(e, "Directions") - - if not result: - return f"No routes found from '{origin}' to '{destination}'." - - lines = [] - for route_idx, route in enumerate(result): - if alternatives and len(result) > 1: - lines.append(f"\n── Route {route_idx + 1} of {len(result)} ──") - summary = route.get("summary", "") - if summary: - lines.append(f"Via: {summary}") - - legs = route.get("legs", []) - for leg in legs: - duration = leg.get("duration", {}).get("text", "?") - distance = leg.get("distance", {}).get("text", "?") - dep_addr = leg.get("start_address", origin) - arr_addr = leg.get("end_address", destination) - dep_time = leg.get("departure_time", {}).get("text", "") - arr_time = leg.get("arrival_time", {}).get("text", "") - - lines.append(f"From: {dep_addr}") - lines.append(f"To: {arr_addr}") - lines.append(f"Duration: {duration} | Distance: {distance}") - if dep_time: - lines.append(f"Departure: {dep_time} → Arrival: {arr_time}") - - lines.append("\nSteps:") - for step in leg.get("steps", []): - instr = step.get("html_instructions", "") - # Strip basic HTML tags for clean text output - import re - instr = re.sub(r"<[^>]+>", " ", instr).strip() - instr = re.sub(r"\s+", " ", instr) - - step_dur = step.get("duration", {}).get("text", "") - step_dist = step.get("distance", {}).get("text", "") - travel_mode = step.get("travel_mode", "") - - prefix = "" - if travel_mode == "TRANSIT": - td = step.get("transit_details", {}) - line_info = td.get("line", {}) - vehicle = line_info.get("vehicle", {}).get("name", "") - line_name = line_info.get("short_name") or line_info.get("name", "") - dep_stop = td.get("departure_stop", {}).get("name", "") - arr_stop = td.get("arrival_stop", {}).get("name", "") - dep_t = td.get("departure_time", {}).get("text", "") - arr_t = td.get("arrival_time", {}).get("text", "") - num_stops = td.get("num_stops", "") - headsign = td.get("headsign", "") - prefix = ( - f" 🚌 {vehicle} {line_name}" - + (f" → {headsign}" if headsign else "") - + f"\n From: {dep_stop} ({dep_t})" - + f"\n To: {arr_stop} ({arr_t})" - + (f" [{num_stops} stops]" if num_stops else "") - ) - else: - emoji = {"WALKING": "🚶", "DRIVING": "🚗", "BICYCLING": "🚲"}.get(travel_mode, "•") - prefix = f" {emoji} {instr}" - if step_dur or step_dist: - prefix += f" ({step_dur}, {step_dist})" - - lines.append(prefix) - - return "\n".join(lines) - - -def _maps_geocode(args: dict) -> str: - """Convert an address or place name to coordinates.""" - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - address = args.get("address") - if not address: - return "Error: Missing required parameter 'address'." - - language = args.get("language", "it") - region = args.get("region", "it") # country bias - - try: - result = gmaps.geocode(address, language=language, region=region) - except Exception as e: - return _format_gmaps_error(e, "Geocoding") - - if not result: - return f"No results found for '{address}'." - - lines = [] - for i, place in enumerate(result[:5]): - formatted = place.get("formatted_address", "?") - loc = place.get("geometry", {}).get("location", {}) - lat = loc.get("lat", "?") - lng = loc.get("lng", "?") - place_id = place.get("place_id", "") - types = ", ".join(place.get("types", [])) - lines.append(f"{i+1}. {formatted}") - lines.append(f" Coordinates: {lat}, {lng}") - if place_id: - lines.append(f" Place ID: {place_id}") - if types: - lines.append(f" Types: {types}") - - return "\n".join(lines) - - -def _maps_reverse_geocode(args: dict) -> str: - """Convert coordinates to an address.""" - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - lat = args.get("lat") - lng = args.get("lng") - if lat is None or lng is None: - return "Error: Missing required parameters 'lat' and/or 'lng'." - - language = args.get("language", "it") - - try: - result = gmaps.reverse_geocode((float(lat), float(lng)), language=language) - except Exception as e: - return _format_gmaps_error(e, "Geocoding") - - if not result: - return f"No address found for coordinates ({lat}, {lng})." - - place = result[0] - return place.get("formatted_address", "?") - - -def _maps_search_places(args: dict) -> str: - """Search for places near a location.""" - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - query = args.get("query") - location = args.get("location") # "lat,lng" string or address - radius = args.get("radius", 1000) - language = args.get("language", "it") - place_type = args.get("type") # e.g. "train_station", "bus_station", "subway_station" - - if not query and not location: - return "Error: Provide at least 'query' or 'location'." - - # Resolve location string to lat/lng if needed - loc_tuple = None - if location: - if "," in str(location): - parts = str(location).split(",") - try: - loc_tuple = (float(parts[0].strip()), float(parts[1].strip())) - except ValueError: - pass - if loc_tuple is None: - # Geocode the location string - geo = gmaps.geocode(location, language=language) - if geo: - latlng = geo[0].get("geometry", {}).get("location", {}) - loc_tuple = (latlng["lat"], latlng["lng"]) - - kwargs: dict[str, Any] = {"language": language} - if query: - kwargs["query"] = query - if loc_tuple: - kwargs["location"] = loc_tuple - kwargs["radius"] = int(radius) - if place_type: - kwargs["type"] = place_type - - try: - if query: - result = gmaps.places(**kwargs) - else: - result = gmaps.places_nearby(**kwargs) - except Exception as e: - return _format_gmaps_error(e, "Places") - - places = result.get("results", []) - if not places: - return "No places found." - - lines = [f"Found {len(places)} place(s):"] - for p in places[:10]: - name = p.get("name", "?") - addr = p.get("vicinity") or p.get("formatted_address", "") - rating = p.get("rating") - place_id = p.get("place_id", "") - types = ", ".join(p.get("types", [])[:3]) - loc = p.get("geometry", {}).get("location", {}) - lat_p = loc.get("lat", "") - lng_p = loc.get("lng", "") - - line = f"• {name}" - if addr: - line += f"\n Address: {addr}" - if lat_p and lng_p: - line += f"\n Coords: {lat_p}, {lng_p}" - if rating: - line += f"\n Rating: {rating}/5" - if types: - line += f"\n Types: {types}" - if place_id: - line += f"\n Place ID: {place_id}" - lines.append(line) - - return "\n".join(lines) - - -def _maps_distance_matrix(args: dict) -> str: - """Get travel time/distance between origins and destinations.""" - gmaps = _get_client() - if gmaps is None: - return f"Error: {_init_error}" - - origins = args.get("origins") - destinations = args.get("destinations") - if not origins or not destinations: - return "Error: Missing required parameters 'origins' and/or 'destinations'." - - if isinstance(origins, str): - origins = [origins] - if isinstance(destinations, str): - destinations = [destinations] - - mode = args.get("mode", "transit") - language = args.get("language", "it") - - kwargs: dict[str, Any] = { - "origins": origins, - "destinations": destinations, - "mode": mode, - "language": language, - } - if mode == "transit": - kwargs["departure_time"] = datetime.now(timezone.utc) - - try: - result = gmaps.distance_matrix(**kwargs) - except Exception as e: - return _format_gmaps_error(e, "Distance Matrix") - - rows = result.get("rows", []) - dest_addrs = result.get("destination_addresses", destinations) - orig_addrs = result.get("origin_addresses", origins) - - lines = [] - for i, (row, orig) in enumerate(zip(rows, orig_addrs)): - for j, (elem, dest) in enumerate(zip(row.get("elements", []), dest_addrs)): - status = elem.get("status", "") - if status == "OK": - dur = elem.get("duration", {}).get("text", "?") - dist = elem.get("distance", {}).get("text", "?") - lines.append(f"{orig} → {dest}") - lines.append(f" Duration: {dur} | Distance: {dist}") - else: - lines.append(f"{orig} → {dest} [{status}]") - - return "\n".join(lines) if lines else "No results." - - -# ── Tool manifest ────────────────────────────────────────────────────────────── - -TOOLS = [ - { - "name": "status", - "description": ( - "Self-check that the Google Maps integration is operational: verifies the API key is " - "present and valid, the Geocoding API is enabled, and the network works, by performing " - "one cheap geocode probe. Call this first whenever another Maps tool fails, or to give " - "the user a quick yes/no on whether Maps is usable right now." - ), - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "directions", - "description": ( - "Get step-by-step directions from an origin to a destination. " - "Supports transit (bus, train, metro), driving, walking, bicycling. " - "For transit, returns detailed stop-by-stop info with departure/arrival times. " - "Best for 'how do I get from A to B?' or 'which train do I take to go home?'." - ), - "inputSchema": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "description": ( - "Starting address or place name (e.g. 'Milano Centrale') " - "or coordinates as 'latitude,longitude' decimal string " - "with no spaces (e.g. '45.4654,9.1866')." - ), - }, - "destination": { - "type": "string", - "description": ( - "Destination address or place name " - "or coordinates as 'latitude,longitude' decimal string " - "with no spaces (e.g. '45.4654,9.1866')." - ), - }, - "mode": { - "type": "string", - "enum": ["transit", "driving", "walking", "bicycling"], - "description": "Travel mode. Default 'transit'.", - }, - "departure_time": { - "type": "string", - "description": ( - "When to depart. Must be the literal string 'now' (default) " - "or an ISO 8601 datetime string with timezone offset, " - "e.g. '2025-06-15T08:30:00+02:00'. " - "Never pass a Unix timestamp integer — always use a string." - ), - }, - "transit_mode": { - "type": "string", - "enum": ["bus", "rail", "subway", "train", "tram"], - "description": ( - "Restrict results to a specific transit vehicle type. " - "Omit to allow any vehicle. Use 'train' for intercity/regional rail, " - "'subway' for metro, 'tram' for tram lines, 'bus' for buses, " - "'rail' for any rail (train + subway + tram)." - ), - }, - "transit_routing_preference": { - "type": "string", - "enum": ["less_walking", "fewer_transfers"], - "description": "Optimize transit route for fewer transfers or less walking.", - }, - "alternatives": { - "type": "boolean", - "description": "Return multiple route options. Default false.", - }, - "language": { - "type": "string", - "description": "Language for instructions. Default 'it'.", - }, - }, - "required": ["origin", "destination"], - }, - }, - { - "name": "geocode", - "description": "Convert a place name or address into geographic coordinates (latitude, longitude) and a place_id.", - "inputSchema": { - "type": "object", - "properties": { - "address": { - "type": "string", - "description": "Address or place name to geocode.", - }, - "language": { - "type": "string", - "description": "Language for results. Default 'it'.", - }, - "region": { - "type": "string", - "description": "Country code bias (e.g. 'it', 'gb'). Default 'it'.", - }, - }, - "required": ["address"], - }, - }, - { - "name": "reverse_geocode", - "description": "Convert geographic coordinates (lat, lng) into a human-readable address.", - "inputSchema": { - "type": "object", - "properties": { - "lat": {"type": "number", "description": "Latitude."}, - "lng": {"type": "number", "description": "Longitude."}, - "language": { - "type": "string", - "description": "Language for results. Default 'it'.", - }, - }, - "required": ["lat", "lng"], - }, - }, - { - "name": "search_places", - "description": ( - "Search for places near a location. " - "Useful for finding train stations, bus stops, restaurants, etc. " - "near an address or coordinates. " - "At least one of 'query' or 'location' must be provided." - ), - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": ( - "Text search query, e.g. 'stazione ferroviaria', 'bar', 'farmacia'. " - "Required unless 'location' is provided." - ), - }, - "location": { - "type": "string", - "description": ( - "Center of the search area: address, place name, " - "or 'latitude,longitude' decimal string with no spaces " - "(e.g. '45.4654,9.1866'). Required unless 'query' is provided." - ), - }, - "radius": { - "type": "integer", - "description": "Search radius in meters. Default 1000.", - }, - "type": { - "type": "string", - "description": ( - "Filter by place type. Examples: 'train_station', 'bus_station', " - "'subway_station', 'transit_station', 'restaurant'." - ), - }, - "language": { - "type": "string", - "description": "Language for results. Default 'it'.", - }, - }, - }, - }, - { - "name": "distance_matrix", - "description": ( - "Calculate travel times and distances between multiple origins and destinations. " - "Useful for comparing routes or checking ETAs." - ), - "inputSchema": { - "type": "object", - "properties": { - "origins": { - "type": ["string", "array"], - "items": {"type": "string"}, - "description": ( - "One or more origins: address, place name, or 'latitude,longitude' " - "decimal string with no spaces. Pass a single string or a JSON array " - "of strings for multiple origins." - ), - }, - "destinations": { - "type": ["string", "array"], - "items": {"type": "string"}, - "description": ( - "One or more destinations: address, place name, or 'latitude,longitude' " - "decimal string with no spaces. Pass a single string or a JSON array " - "of strings for multiple destinations." - ), - }, - "mode": { - "type": "string", - "enum": ["transit", "driving", "walking", "bicycling"], - "description": "Travel mode. Default 'transit'.", - }, - "language": { - "type": "string", - "description": "Language for results. Default 'it'.", - }, - }, - "required": ["origins", "destinations"], - }, - }, -] - - -# ── JSON-RPC dispatch ────────────────────────────────────────────────────────── - -TOOL_DISPATCH = { - "status": _maps_status, - "directions": _maps_directions, - "geocode": _maps_geocode, - "reverse_geocode": _maps_reverse_geocode, - "search_places": _maps_search_places, - "distance_matrix": _maps_distance_matrix, -} - - -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 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": "gmaps", - "version": "1.1.0", - }, - }) - - if method == "notifications/initialized": - return None - - 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", {}) - - 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: - text = handler(tool_args) - is_err = text.startswith("Error:") - return _text_result(req_id, text, 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) - - return json.dumps({ - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": -32601, "message": f"Method not found: {method}"}, - }) - - -# ── Main loop ────────────────────────────────────────────────────────────────── - -def main() -> None: - log("Starting Google Maps MCP server") - # Eagerly initialise the client so errors surface immediately. - _get_client() - 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() diff --git a/scripts/google_trends_mcp.py b/scripts/google_trends_mcp.py deleted file mode 100644 index 28b0e17..0000000 --- a/scripts/google_trends_mcp.py +++ /dev/null @@ -1,464 +0,0 @@ -#!/usr/bin/env python3 -""" -MCP Server for Google Trends data via trendspyg. - -Provides tools to query Google Trends: interest over time, related queries, -interest by region, trending now (RSS), and bulk trending CSVs. - -Uses trendspyg v0.7.0 as the data backend. -Browser-based tools require Chrome installed on the host. -RSS-based tools require no browser and return in ~0.2s. - -Rate limits: Google Trends is a public service. Browser-based queries should -be spaced 5-10 seconds apart to avoid HTTP 429. RSS is lighter but still -subject to rate limiting on excessive polling. - -Transport: stdio JSON-RPC (mcp.run() default). All diagnostics go to stderr — -never stdout — to avoid corrupting the protocol stream. - -Output: tools return plain dicts, so FastMCP emits real ``structuredContent`` -(a JSON object) plus a pretty-printed text fallback. Errors are raised as -``ToolError`` → the client receives an ``isError`` result carrying an -LLM-actionable hint. -""" - -import functools -import json -import sys -import traceback -from typing import Annotated, Any, Literal - -import anyio -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.exceptions import ToolError -from pydantic import Field - -# ── trendspyg imports ───────────────────────────────────────────────────────── -from trendspyg.explore import ( - download_google_trends_explore, - download_google_trends_interest_over_time, -) -from trendspyg.downloader import download_google_trends_csv, CATEGORIES, COUNTRIES -from trendspyg.rss_downloader import download_google_trends_rss - -# ── Server init ─────────────────────────────────────────────────────────────── -mcp = FastMCP("google_trends_mcp") - -# ── Typed parameter aliases (drive JSON-schema validation) ──────────────────── -Hours = Literal[4, 24, 48, 168] -SortBy = Literal["relevance", "title", "volume", "recency"] -CsvCategory = Literal[ - "all", "autos", "beauty", "business", "climate", "entertainment", "food", - "games", "health", "hobbies", "lifestyle", "media", "pets", "science", - "shopping", "sports", "stories", "technology", "travel", -] - -Json = dict[str, Any] - - -# ── Utility functions ───────────────────────────────────────────────────────── - -def _clean(obj: Any) -> Any: - """Recursively convert data into plain JSON-safe Python types. - - Handles datetimes (→ ISO string), numpy scalars (→ native via .item()), - and nested dicts/lists/tuples. Guarantees the result is serializable by - FastMCP (both for ``structuredContent`` and the text fallback). - """ - if isinstance(obj, dict): - return {k: _clean(v) for k, v in obj.items()} - if isinstance(obj, (list, tuple)): - return [_clean(v) for v in obj] - if hasattr(obj, "isoformat"): # datetime / date - return obj.isoformat() - if hasattr(obj, "item") and not isinstance(obj, (str, bytes)): # numpy scalar - try: - return obj.item() - except Exception: - return obj - return obj - - -def _envelope(data: Any) -> Json: - """Normalize trendspyg output to a JSON object for structured tool output. - - trendspyg returns a dict for every mode we call; if a future version hands - back a bare list we wrap it so ``structuredContent`` stays a JSON object. - """ - cleaned = _clean(data) - return cleaned if isinstance(cleaned, dict) else {"items": cleaned} - - -def _json(data: Any) -> str: - """Serialize to a JSON string — used for MCP resources (content, not tools).""" - return json.dumps(_clean(data), indent=2, ensure_ascii=False, default=str) - - -async def _to_thread(fn, **kwargs) -> Any: - """Run a blocking trendspyg call off the event loop so the server stays - responsive during multi-second browser sessions.""" - return await anyio.to_thread.run_sync(functools.partial(fn, **kwargs)) - - -def _tool_error(e: Exception, tool: str, subject: str) -> ToolError: - """Build a consistent, LLM-actionable ToolError; log the full traceback to - stderr (safe for stdio transport).""" - traceback.print_exc(file=sys.stderr) - msg = str(e).lower() - - if ("rate" in msg and "limit" in msg) or "429" in msg or "too many" in msg: - hint = ( - f"Rate limited by Google Trends while querying '{subject}'. " - f"Wait 30-60 seconds before retrying. " - f"Tip: google_trends_rss has a lighter rate-limit footprint." - ) - elif any(k in msg for k in ("chromedriver", "selenium", "webdriver", "session not created")): - hint = ( - f"Browser required for '{tool}' but Chrome/WebDriver is unavailable on this host. " - f"Use google_trends_rss instead — it works over plain HTTP, no browser." - ) - elif "chrome" in msg or "binary" in msg: - hint = ( - f"Chrome browser not found — '{tool}' requires Chrome installed. " - f"Install Chrome, or use google_trends_rss for browser-free trend data." - ) - elif "not found" in msg or "404" in msg or "no data" in msg: - hint = f"No data found for '{subject}'. Try a different keyword or a broader timeframe." - elif "invalid" in msg or "unsupported" in msg: - hint = f"Invalid parameter for '{tool}': {e}. Use google_trends_countries for valid geo codes." - else: - hint = f"Error in {tool} for '{subject}': {type(e).__name__}: {e}" - - return ToolError(hint) - - -# ── Tools ───────────────────────────────────────────────────────────────────── - -@mcp.tool( - name="google_trends_interest_over_time", - annotations={ - "title": "Google Trends — Interest Over Time", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True, - }, -) -async def google_trends_interest_over_time( - keyword: Annotated[str, Field( - description="Search term (e.g. 'bitcoin', 'running shoes', 'AI').", - min_length=1, max_length=200, - )], - geo: Annotated[str, Field( - description="ISO country code (e.g. 'US', 'GB', 'IT') or 'US-CA' for US states. Empty = worldwide.", - )] = "", - timeframe: Annotated[str, Field( - description="Time window. Examples: 'today 12-m', 'today 5-y', 'today 3-m', " - "'today 1-m', '2023-01-01 2023-12-31', 'now 7-d', 'now 1-H'.", - )] = "today 12-m", - category: Annotated[int, Field( - description="Google Trends category ID (0 = all). See google_trends_categories.", - ge=0, - )] = 0, -) -> Json: - """Get search interest over time for a keyword. - - Returns a time series of relative popularity (0-100 scale) for a search term. - Requires Chrome browser installed on the host (headless mode). - - Each data point has: - - date: ISO date string - - value: relative search interest (0-100, normalized within the query) - - is_partial: true if the current period's data is still incomplete - - Use when: tracking keyword popularity trends, comparing seasonal patterns, - validating market timing for a product/idea. - - Returns: - dict: structured payload with an interest_over_time array. - - Examples: - - "Interest in 'electric cars' over the last year in the UK?" - → keyword="electric cars", geo="GB", timeframe="today 12-m" - - "Bitcoin search trend in Italy last 90 days" - → keyword="bitcoin", geo="IT", timeframe="today 3-m" - """ - try: - data = await _to_thread( - download_google_trends_interest_over_time, - keyword=keyword, - geo=geo, - timeframe=timeframe, - category=category, - headless=True, - output_format="dict", - ) - # trendspyg returns a bare list of points here; wrap it with the query - # context so the structured payload is self-describing for the LLM. - return { - "keyword": keyword, - "geo": geo or "worldwide", - "timeframe": timeframe, - "category": category, - "interest_over_time": _clean(data), - } - except Exception as e: - raise _tool_error(e, "interest_over_time", keyword) - - -@mcp.tool( - name="google_trends_explore", - annotations={ - "title": "Google Trends — Full Explore", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True, - }, -) -async def google_trends_explore( - keyword: Annotated[str, Field( - description="Search term (e.g. 'bitcoin', 'running shoes').", - min_length=1, max_length=200, - )], - geo: Annotated[str, Field( - description="ISO country code (e.g. 'US', 'GB', 'IT') or 'US-CA' for US states. Empty = worldwide.", - )] = "", - timeframe: Annotated[str, Field( - description="Time window (same format as interest_over_time).", - )] = "today 12-m", - category: Annotated[int, Field( - description="Google Trends category ID (0 = all). See google_trends_categories.", - ge=0, - )] = 0, - include_related: Annotated[bool, Field( - description="Include related queries (top + rising). Adds ~2-3s.", - )] = True, - include_geo: Annotated[bool, Field( - description="Include interest-by-region breakdown. Adds ~1-2s.", - )] = True, -) -> Json: - """Full Google Trends Explore: interest over time + related queries + interest by region. - - The most comprehensive tool — fetches all available data for a keyword in a - single browser session. Returns: - - interest_over_time: array of {date, value, is_partial} - - related_queries: {top: [{query, value, link}], rising: [{query, value, link}]} - - interest_by_region: [{geo_code, geo_name, value}] - - Requires Chrome browser installed on the host. - - Use when: you need the complete picture — trend direction, what people also - search, and where interest is concentrated geographically. - - Returns: - dict: structured payload with all three data sections. - - Examples: - - "Full Trends picture for 'vegan protein' in the US?" - → keyword="vegan protein", geo="US" - - "Quick check on 'climate change' trend" - → keyword="climate change", timeframe="today 5-y", include_related=False - """ - try: - data = await _to_thread( - download_google_trends_explore, - keyword=keyword, - geo=geo, - timeframe=timeframe, - category=category, - headless=True, - include_related=include_related, - include_geo=include_geo, - ) - return _envelope(data) - except Exception as e: - raise _tool_error(e, "explore", keyword) - - -@mcp.tool( - name="google_trends_rss", - annotations={ - "title": "Google Trends — Trending Now (RSS)", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": True, - }, -) -async def google_trends_rss( - geo: Annotated[str, Field( - description="ISO country code (e.g. 'US', 'GB', 'IT').", - )] = "US", - include_images: Annotated[bool, Field( - description="Include trend images. Adds data volume.", - )] = False, - include_articles: Annotated[bool, Field( - description="Include news articles for each trend. Adds data volume.", - )] = False, -) -> Json: - """Get currently trending searches via the Google Trends RSS feed. - - ⚡ Fast path: pure HTTP, no browser needed, returns in ~0.2s. - - Returns up to ~20 trending topics with optional images and news articles. - Each trend includes: - - keyword: topic name - - volume_text / volume_min: estimated search-volume indicator (e.g. "500+") - - explore_url: deep link to the Google Trends Explore page - - started_at / ended_at / is_active: trend lifecycle timestamps - - image (optional): representative image URL - - news (optional): up to 5 related news articles - - Use when: you want to know what's trending *right now* — real-time - monitoring, content ideation, newsjacking. - - Returns: - dict: structured payload with a trends array. - - Examples: - - "What's trending in the UK right now?" → geo="GB" - - "US trends with news context" → geo="US", include_articles=True - """ - try: - data = await _to_thread( - download_google_trends_rss, - geo=geo, - output_format="dict", - include_images=include_images, - include_articles=include_articles, - max_articles_per_trend=5, - cache=False, - normalize=True, - ) - return _envelope(data) - except Exception as e: - raise _tool_error(e, "rss", geo) - - -@mcp.tool( - name="google_trends_csv", - annotations={ - "title": "Google Trends — Trending CSV (Bulk)", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": True, - }, -) -async def google_trends_csv( - geo: Annotated[str, Field( - description="ISO country code (e.g. 'US', 'GB', 'IT').", - )] = "US", - hours: Annotated[Hours, Field( - description="Lookback window in hours. One of: 4, 24, 48, 168 (7d).", - )] = 24, - category: Annotated[CsvCategory, Field( - description="Trend category (e.g. 'all', 'technology', 'business', 'sports').", - )] = "all", - sort_by: Annotated[SortBy, Field( - description="Sort order: 'relevance', 'title', 'volume', 'recency'.", - )] = "relevance", -) -> Json: - """Download bulk trending searches via Google Trends CSV export. - - Returns up to ~480 current trending topics, filterable by time window, - category, and sort order. Requires Chrome browser (headless mode). - - Each trend includes: trend name, traffic estimate, explore link, and - published timestamp. - - Use when: you need a large dataset of current trends for market research, - category analysis, or trend scouting across niches. - - Returns: - dict: structured payload with the trends collection. - - Examples: - - "All trending tech topics in the US in the last 24h" - → geo="US", hours=24, category="technology" - - "Trending UK business this past week" - → geo="GB", hours=168, category="business", sort_by="volume" - """ - try: - data = await _to_thread( - download_google_trends_csv, - geo=geo, - hours=hours, - category=category, - sort_by=sort_by, - headless=True, - normalize=True, # returns a unified envelope dict (ignores output_format) - timeout=15, - ) - return _envelope(data) - except Exception as e: - raise _tool_error(e, "csv", f"{geo}/{category}") - - -@mcp.tool( - name="google_trends_categories", - annotations={ - "title": "Google Trends — Available Categories", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - }, -) -def google_trends_categories() -> Json: - """List all Google Trends categories available for filtering. - - Use this to discover valid category names before calling google_trends_csv - with a specific category filter. - - Returns: - dict: category names → labels, - e.g. {"all": "All categories", "technology": "Technology", ...} - """ - return dict(CATEGORIES) - - -@mcp.tool( - name="google_trends_countries", - annotations={ - "title": "Google Trends — Available Countries & Regions", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, - }, -) -def google_trends_countries() -> Json: - """List all ISO country codes and US state codes accepted by geo parameters. - - Returns: - dict: 'countries' (ISO codes → names) and 'us_states' (US-XX → names). - """ - from trendspyg.downloader import US_STATES - return { - "note": "Use ISO codes (e.g. 'US', 'GB', 'IT') for geo params. Empty string = worldwide.", - "countries": dict(COUNTRIES), - "us_states": dict(US_STATES), - } - - -# ── Resources ───────────────────────────────────────────────────────────────── - -@mcp.resource("trends://categories") -def trends_categories() -> str: - """Available Google Trends categories as a resource.""" - return _json(CATEGORIES) - - -@mcp.resource("trends://countries") -def trends_countries() -> str: - """Available countries and US states as a resource.""" - from trendspyg.downloader import US_STATES - return _json({"countries": COUNTRIES, "us_states": US_STATES}) - - -# ── Entry point ─────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - mcp.run() diff --git a/scripts/honcho_backfill.py b/scripts/honcho_backfill.py deleted file mode 100644 index 10ed462..0000000 --- a/scripts/honcho_backfill.py +++ /dev/null @@ -1,359 +0,0 @@ -#!/usr/bin/env python3 -""" -Honcho backfill script. - -Deletes the existing Honcho workspace, recreates it with the correct peer -config (observe_me=true for the user peer), and re-uploads all interactive -non-ephemeral chat history from the SQLite database. - -Usage: - # Reads config from the SQLite plugins table automatically. - python3 scripts/honcho_backfill.py - - # Or pass overrides: - python3 scripts/honcho_backfill.py \ - --db ./database.db \ - --base-url http://localhost:8000 \ - --workspace personal-agent \ - --dry-run -""" - -import argparse -import json -import sqlite3 -import sys -import time -from dataclasses import dataclass -from typing import Optional - -import requests - - -# ── Honcho API helpers ──────────────────────────────────────────────────────── - -class HonchoClient: - def __init__(self, base_url: str, api_key: str = ""): - self.base = base_url.rstrip("/") - self.session = requests.Session() - if api_key: - self.session.headers["Authorization"] = f"Bearer {api_key}" - self.session.headers["Content-Type"] = "application/json" - - def _url(self, path: str) -> str: - return f"{self.base}{path}" - - def list_session_ids(self, workspace_id: str) -> list[str]: - ids = [] - page = 1 - while True: - r = self.session.post( - self._url(f"/v3/workspaces/{workspace_id}/sessions/list"), - params={"page": page, "size": 100}, - json={}, - ) - if r.status_code == 404: - break - r.raise_for_status() - data = r.json() - items = data.get("items", []) - ids.extend(item["id"] for item in items) - if page >= data.get("pages", 1): - break - page += 1 - return ids - - def delete_all_sessions(self, workspace_id: str): - ids = self.list_session_ids(workspace_id) - print(f" deleting {len(ids)} existing session(s) …") - for sid in ids: - r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}/sessions/{sid}")) - if r.status_code not in (200, 202, 204, 404): - print(f" WARNING: could not delete session {sid}: {r.status_code}") - - def delete_workspace(self, workspace_id: str): - self.delete_all_sessions(workspace_id) - r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}")) - if r.status_code in (200, 202, 204, 404): - print(f" workspace '{workspace_id}' deleted (or did not exist)") - else: - print(f" WARNING: DELETE workspace returned {r.status_code} — continuing anyway") - - def create_workspace(self, workspace_id: str, retries: int = 6, delay: float = 2.0): - for attempt in range(1, retries + 1): - r = self.session.post(self._url("/v3/workspaces"), json={"id": workspace_id}) - if r.status_code in (200, 201): - print(f" workspace '{workspace_id}' created") - return - # 409 = already exists (fine for --skip-delete path) - if r.status_code == 409: - print(f" workspace '{workspace_id}' already exists — reusing") - return - print(f" create workspace attempt {attempt}/{retries}: {r.status_code} — retrying in {delay}s …") - time.sleep(delay) - raise RuntimeError(f"POST workspace failed after {retries} attempts: {r.status_code} {r.text}") - - def create_peer(self, workspace_id: str, peer_id: str): - r = self.session.post( - self._url(f"/v3/workspaces/{workspace_id}/peers"), - json={"id": peer_id}, - ) - if r.status_code in (200, 201): - print(f" peer '{peer_id}' created") - elif r.status_code == 409: - print(f" peer '{peer_id}' already exists — reusing") - else: - raise RuntimeError(f"POST peer failed: {r.status_code} {r.text}") - - PEER_CONFIG = { - "user": {"observe_me": True}, - "assistant": {"observe_me": True}, - } - - def _add_peers(self, workspace_id: str, session_id: str): - """Add peer config to a session via POST (separate from session creation).""" - r = self.session.post( - self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers"), - json=self.PEER_CONFIG, - ) - if r.status_code not in (200, 201, 409): - print(f" WARNING: add peers returned {r.status_code}: {r.text}") - - def create_session(self, workspace_id: str, session_id: str, local_id: int) -> str: - body = { - "id": session_id, - "metadata": {"local_session_id": local_id}, - } - r = self.session.post( - self._url(f"/v3/workspaces/{workspace_id}/sessions"), - json=body, - ) - if r.status_code in (200, 201): - self._add_peers(workspace_id, session_id) - return session_id - if r.status_code == 409: - print(f" (session existed — adding peers)") - self._add_peers(workspace_id, session_id) - return session_id - raise RuntimeError(f"POST session failed: {r.status_code} {r.text}") - - def fix_all_session_peers(self, workspace_id: str): - """Add correct peer config to all existing sessions in the workspace.""" - ids = self.list_session_ids(workspace_id) - print(f"Fixing peers on {len(ids)} session(s) …") - for sid in ids: - self._add_peers(workspace_id, sid) - print(f" {sid}", end="\r") - print(f"\nDone — {len(ids)} session(s) updated.") - - def add_message( - self, - workspace_id: str, - session_id: str, - peer_id: str, - content: str, - local_message_id: int, - created_at: str, - ): - body = { - "messages": [ - { - "peer_id": peer_id, - "content": content, - "metadata": {"local_message_id": local_message_id}, - "created_at": created_at, - } - ] - } - r = self.session.post( - self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages"), - json=body, - ) - if r.status_code not in (200, 201, 409): - raise RuntimeError( - f"POST message failed (session={session_id}): {r.status_code} {r.text}" - ) - - -# ── DB helpers ──────────────────────────────────────────────────────────────── - -@dataclass -class Session: - id: int - source: str - -@dataclass -class Message: - id: int - role: str - content: str - created_at: str - - -def load_plugin_config(db_path: str) -> Optional[dict]: - """Read honcho plugin config from the plugins table.""" - try: - con = sqlite3.connect(db_path) - row = con.execute( - "SELECT enabled, config FROM plugins WHERE id = 'honcho'" - ).fetchone() - con.close() - if row is None: - return None - enabled, config_json = row - if not enabled: - print("WARNING: honcho plugin is disabled in DB; proceeding anyway") - return json.loads(config_json) - except Exception as e: - print(f"WARNING: could not read plugin config from DB: {e}") - return None - - -def load_sessions(db_path: str) -> list[Session]: - con = sqlite3.connect(db_path) - rows = con.execute( - """ - SELECT id, source - FROM chat_sessions - WHERE is_interactive = 1 - AND is_ephemeral = 0 - AND source NOT IN ('tic', 'cron') - ORDER BY id - """ - ).fetchall() - con.close() - return [Session(id=r[0], source=r[1]) for r in rows] - - -def load_messages(db_path: str, session_id: int) -> list[Message]: - """ - Load all user/assistant messages for a session, ordered chronologically. - Excludes: sub-agent messages (role='agent'), failed, synthetic, empty. - """ - con = sqlite3.connect(db_path) - rows = con.execute( - """ - SELECT h.id, h.role, h.content, h.created_at - FROM chat_history h - JOIN chat_sessions_stack s ON s.id = h.session_stack_id - WHERE s.session_id = ? - AND h.role IN ('user', 'assistant') - AND h.status = 'ok' - AND h.is_synthetic = 0 - AND h.content != '' - ORDER BY h.id - """, - (session_id,), - ).fetchall() - con.close() - return [Message(id=r[0], role=r[1], content=r[2], created_at=r[3]) for r in rows] - - -# ── Main ────────────────────────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser(description="Backfill Honcho from local SQLite DB") - parser.add_argument("--db", default="./database.db", help="Path to SQLite DB") - parser.add_argument("--base-url", default=None, help="Honcho base URL (overrides DB config)") - parser.add_argument("--workspace", default=None, help="Honcho workspace ID (overrides DB config)") - parser.add_argument("--api-key", default="", help="Honcho API key") - parser.add_argument("--dry-run", action="store_true", help="Print plan without touching Honcho") - parser.add_argument("--delay-ms", type=int, default=50, help="Delay between message uploads (ms)") - parser.add_argument("--skip-delete", action="store_true", help="Skip workspace deletion (add to existing)") - parser.add_argument("--fix-peers", action="store_true", help="Only fix peer config on existing sessions, then exit") - args = parser.parse_args() - - # ── Resolve config ──────────────────────────────────────────────────────── - plugin_cfg = load_plugin_config(args.db) - base_url = args.base_url or (plugin_cfg or {}).get("base_url", "http://localhost:8000") - workspace_id = args.workspace or (plugin_cfg or {}).get("workspace_id", "personal-agent") - api_key = args.api_key or (plugin_cfg or {}).get("api_key", "") - - print(f"Honcho base URL : {base_url}") - print(f"Workspace ID : {workspace_id}") - print(f"DB : {args.db}") - print() - - client = HonchoClient(base_url, api_key) - - # ── Fix-peers only mode ─────────────────────────────────────────────────── - if args.fix_peers: - client.fix_all_session_peers(workspace_id) - return - - # ── Load sessions ───────────────────────────────────────────────────────── - sessions = load_sessions(args.db) - print(f"Found {len(sessions)} interactive non-ephemeral session(s)") - - total_msgs = 0 - plan = [] - for sess in sessions: - msgs = load_messages(args.db, sess.id) - if not msgs: - continue - honcho_id = f"{workspace_id}-{sess.id}" - plan.append((sess, msgs, honcho_id)) - total_msgs += len(msgs) - print(f" session {sess.id:4d} ({sess.source:10s}) {len(msgs):4d} msgs → {honcho_id}") - - print(f"\nTotal messages to upload: {total_msgs}") - - if args.dry_run: - print("\n[dry-run] No changes made.") - return - - if not plan: - print("Nothing to upload.") - return - - confirm = input("\nProceed? This will DELETE and recreate the Honcho workspace. [y/N] ") - if confirm.strip().lower() != "y": - print("Aborted.") - sys.exit(0) - - delay_s = args.delay_ms / 1000.0 - - # ── Reset workspace ─────────────────────────────────────────────────────── - if not args.skip_delete: - print("\n[1/3] Deleting existing workspace …") - client.delete_workspace(workspace_id) - time.sleep(1) - - print("\n[2/3] Creating workspace and peers …") - client.create_workspace(workspace_id) - client.create_peer(workspace_id, "user") - client.create_peer(workspace_id, "assistant") - - # ── Upload messages ─────────────────────────────────────────────────────── - print(f"\n[3/3] Uploading {total_msgs} messages …") - - for sess, msgs, honcho_id in plan: - print(f"\n session {sess.id} → {honcho_id} ({len(msgs)} messages)") - client.create_session(workspace_id, honcho_id, sess.id) - - for i, msg in enumerate(msgs, 1): - peer_id = "user" if msg.role == "user" else "assistant" - try: - client.add_message( - workspace_id=workspace_id, - session_id=honcho_id, - peer_id=peer_id, - content=msg.content, - local_message_id=msg.id, - created_at=msg.created_at, - ) - print(f" [{i:4d}/{len(msgs)}] {peer_id:9s} id={msg.id}", end="\r") - except RuntimeError as e: - print(f"\n ERROR on msg {msg.id}: {e} — skipping") - - if delay_s > 0: - time.sleep(delay_s) - - print(f" [{len(msgs):4d}/{len(msgs)}] done ") - - print("\nBackfill complete.") - print("Honcho deriver will process messages in the background.") - print("Restart personal-agent to reconnect the plugin.") - - -if __name__ == "__main__": - main() diff --git a/scripts/inspect_llm_requests.py b/scripts/inspect_llm_requests.py deleted file mode 100644 index b3fcba4..0000000 --- a/scripts/inspect_llm_requests.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -""" -Inspect the last N llm_requests rows for a given model (default: deepseek). -Prints a structured summary without dumping raw payloads. - -Usage: - python scripts/inspect_llm_requests.py [model_filter] [rows] - -Examples: - python scripts/inspect_llm_requests.py deepseek 5 - python scripts/inspect_llm_requests.py anthropic 3 -""" - -import json -import sqlite3 -import sys -from pathlib import Path - -DB_PATH = Path(__file__).parent.parent / "database.db" -MODEL_FILTER = sys.argv[1] if len(sys.argv) > 1 else "deepseek" -ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 5 - - -def fmt_len(s): - if s is None: - return "null" - return f"{len(s)} chars" - - -def summarize_message(i, msg): - role = msg.get("role", "?") - content = msg.get("content") - tool_calls = msg.get("tool_calls") - tool_call_id = msg.get("tool_call_id") - reasoning = msg.get("reasoning_content") - - parts = [] - - if isinstance(content, str): - parts.append(f"{len(content)} chars") - elif isinstance(content, list): - total = sum(len(b.get("text", "")) for b in content if isinstance(b, dict)) - cache_tags = [b for b in content if isinstance(b, dict) and "cache_control" in b] - parts.append(f"{total} chars (content array, {len(content)} blocks)") - if cache_tags: - parts.append(f"[cache_control on {len(cache_tags)} block(s)]") - elif content is None: - parts.append("(no content)") - - if reasoning: - parts.append(f"[reasoning_content: {len(reasoning)} chars]") - - if tool_calls: - names = [tc.get("function", {}).get("name", "?") for tc in tool_calls] - parts.append(f"[tool_calls: {', '.join(names)}]") - - if tool_call_id: - parts.append(f"(tool_call_id={tool_call_id})") - - detail = " ".join(parts) - print(f" {i:>3} {role:<12} {detail}") - - -def est_tokens(obj) -> int: - """Rough token estimate: serialized chars / 4.""" - return len(json.dumps(obj)) // 4 - - -def summarize_request(row): - rid, model_name, req_json, req_headers, resp_json, input_tok, output_tok, duration_ms, created_at = row - - print(f"\n{'='*70}") - print(f"id={rid} model={model_name} created={created_at}") - print(f"tokens: input={input_tok} output={output_tok} duration={duration_ms}ms") - - try: - req = json.loads(req_json) if req_json else {} - except Exception as e: - print(f" [ERROR parsing request_json: {e}]") - return - - # Top-level params (excluding messages and tools) - skip = {"messages", "tools", "model"} - params = {k: v for k, v in req.items() if k not in skip} - if params: - print(f"\n[params]") - for k, v in params.items(): - print(f" {k} = {json.dumps(v)}") - - # Tools - tools = req.get("tools", []) - if tools: - tool_names = [t.get("function", {}).get("name", "?") for t in tools] - tools_tok = est_tokens(tools) - print(f"\n[tools] {len(tools)} defined ~{tools_tok} tok est") - print(f" {', '.join(tool_names)}") - last = tools[-1] - if "cache_control" in last: - print(f" last tool has cache_control: {last['cache_control']}") - - # Messages - messages = req.get("messages", []) - sys_msgs = [m for m in messages if m.get("role") == "system"] - sys_tok = est_tokens(sys_msgs) - conv_msgs = [m for m in messages if m.get("role") != "system"] - conv_tok = est_tokens(conv_msgs) - print(f"\n[messages] {len(messages)} total (~{est_tokens(messages)} tok est: {len(sys_msgs)} system ~{sys_tok} tok, {len(conv_msgs)} conv ~{conv_tok} tok)") - for i, msg in enumerate(messages): - summarize_message(i, msg) - - # Response summary - if resp_json: - try: - resp = json.loads(resp_json) - usage = resp.get("usage", {}) - if usage: - print(f"\n[usage]") - for k, v in usage.items(): - print(f" {k} = {v}") - except Exception: - pass - - -def main(): - conn = sqlite3.connect(DB_PATH) - rows = conn.execute( - """ - SELECT id, model_name, request_json, request_headers, - response_json, input_tokens, output_tokens, duration_ms, created_at - FROM llm_requests - WHERE model_name LIKE ? - ORDER BY id DESC - LIMIT ? - """, - (f"%{MODEL_FILTER}%", ROWS), - ).fetchall() - conn.close() - - if not rows: - print(f"No rows found for model filter '{MODEL_FILTER}'") - return - - print(f"Last {len(rows)} request(s) matching '{MODEL_FILTER}' (newest first)") - for row in rows: - summarize_request(row) - print(f"\n{'='*70}") - - -if __name__ == "__main__": - main() diff --git a/scripts/mcp/serpapi_flights/requirements.txt b/scripts/mcp/serpapi_flights/requirements.txt deleted file mode 100644 index aa69c38..0000000 --- a/scripts/mcp/serpapi_flights/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -httpx>=0.27.0 diff --git a/scripts/mcp/serpapi_flights/server.py b/scripts/mcp/serpapi_flights/server.py deleted file mode 100644 index cc83f63..0000000 --- a/scripts/mcp/serpapi_flights/server.py +++ /dev/null @@ -1,471 +0,0 @@ -#!/usr/bin/env python3 -"""SerpAPI Google Flights MCP server (JSON-RPC 2.0 over stdio). - -Capabilities: - serpapi_search_flights — search one-way or round-trip flights via Google Flights - through SerpAPI, returning prices, airlines, durations, - layovers, and CO2 emissions. - -Auth: - API key is read from env var SERPAPI_API_KEY, or from the file at - SERPAPI_API_KEY_FILE (default: ./secrets/serpapi_api_key.txt). - -Run with: - python3 scripts/mcp/serpapi_flights/server.py -""" - -from __future__ import annotations - -import json -import os -import re -import sys -from typing import Any - -import httpx - -# Log to stderr so stdout stays clean for JSON-RPC. -def log(msg: str) -> None: - print(f"[serpapi_flights_mcp] {msg}", file=sys.stderr, flush=True) - - -# ── API key / client init ────────────────────────────────────────────────────── - -SERPAPI_BASE_URL = "https://serpapi.com" -_DEFAULT_KEY_FILE = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "secrets", - "serpapi_api_key.txt", -) - -_init_error: str | None = None - - -def _get_api_key() -> str | None: - # 1. Environment variable - key = os.environ.get("SERPAPI_API_KEY", "").strip() - if key: - return key - - # 2. File - key_file = os.environ.get("SERPAPI_API_KEY_FILE", _DEFAULT_KEY_FILE) - if os.path.exists(key_file): - try: - with open(key_file) as f: - key = f.read().strip() - if key: - return key - except OSError as e: - global _init_error - _init_error = f"Failed to read API key file {key_file}: {e}" - log(_init_error) - return None - - _init_error = ( - "SerpAPI API key not found. " - "Set SERPAPI_API_KEY env var or create secrets/serpapi_api_key.txt " - "with just the key on the first line." - ) - log(_init_error) - return None - - -def _serpapi_request(params: dict) -> dict: - """Make a synchronous GET to SerpAPI /search. Raises on HTTP errors.""" - api_key = _get_api_key() - if not api_key: - raise _InitError(_init_error or "SerpAPI API key not configured.") - - full_params = {"api_key": api_key, **params} - with httpx.Client(timeout=30.0, headers={"User-Agent": "skald-serpapi-mcp/2.0"}) as client: - response = client.get(f"{SERPAPI_BASE_URL}/search", params=full_params) - response.raise_for_status() - return response.json() - - -class _InitError(Exception): - """Raised when the API key is missing or unreadable.""" - - -# ── Error mapping ────────────────────────────────────────────────────────────── - -def _format_api_error(e: Exception) -> str: - if isinstance(e, _InitError): - return f"Error: {e}" - if isinstance(e, httpx.HTTPStatusError): - status = e.response.status_code - if status == 401: - return "Error: Invalid SerpAPI API key. Check secrets/serpapi_api_key.txt or the SERPAPI_API_KEY env var." - if status == 429: - return "Error: SerpAPI rate limit exceeded. Wait a moment and retry." - if status == 400: - return f"Error: Bad request — {e.response.text[:200]}. Verify airport codes (3-letter IATA) and dates." - return f"Error: SerpAPI request failed (HTTP {status})." - if isinstance(e, httpx.TimeoutException): - return "Error: Request to SerpAPI timed out (30s). The service may be slow or unreachable; retry." - if isinstance(e, httpx.RequestError): - return f"Error: Network error contacting SerpAPI: {e}" - return f"Error: Unexpected error: {type(e).__name__}: {e}" - - -# ── Output formatting ────────────────────────────────────────────────────────── - -def _format_flight_results(data: dict, max_results: int) -> str: - """Render SerpAPI Google Flights results as plain text for the LLM.""" - best_flights = data.get("best_flights", []) or [] - other_flights = data.get("other_flights", []) or [] - price_insights = data.get("price_insights") or {} - - if not best_flights and not other_flights: - return "No flights found for the given route and dates." - - lines: list[str] = [] - - if price_insights: - pi = price_insights - if pi.get("lowest_price"): - lines.append(f"Lowest price: {pi['lowest_price']}") - if pi.get("typical_price_range"): - lo, hi = pi["typical_price_range"][0], pi["typical_price_range"][1] - lines.append(f"Typical range: {lo} – {hi}") - if lines: - lines.append("") - - lines.append("Flights:") - lines.append("") - - all_flights = (best_flights + other_flights)[:max_results] - - for i, flight in enumerate(all_flights, 1): - segments = flight.get("flights", []) or [] - total_duration = flight.get("total_duration", 0) # minutes - price = flight.get("price", 0) - layovers = flight.get("layovers", []) or [] - - hours, minutes = divmod(total_duration, 60) - duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m" - - cheapest_tag = " (CHEAPEST)" if i == 1 and best_flights else "" - lines.append(f"#{i}: {price}{cheapest_tag}") - lines.append("") - - for seg in segments: - dep = seg.get("departure_airport", {}) or {} - arr = seg.get("arrival_airport", {}) or {} - airline = seg.get("airline", "?") - flight_num = seg.get("flight_number", "?") - seg_dur = seg.get("duration", 0) - seg_h, seg_m = divmod(seg_dur, 60) - seg_dur_str = f"{seg_h}h {seg_m}m" if seg_h > 0 else f"{seg_m}m" - - lines.append(f" {airline} {flight_num}") - lines.append(f" {dep.get('id', '?')} {dep.get('time', '?')} -> {arr.get('id', '?')} {arr.get('time', '?')}") - lines.append(f" Duration: {seg_dur_str}") - lines.append("") - - if layovers: - parts = [] - for lo in layovers: - lo_dur = lo.get("duration", 0) - lo_h, lo_m = divmod(lo_dur, 60) - parts.append(f"{lo.get('id', '?')} ({lo_h}h {lo_m}m)") - lines.append(f" Layovers: {' -> '.join(parts)}") - lines.append("") - - lines.append(f" Total duration: {duration_str}") - - emissions = flight.get("carbon_emissions") or {} - if emissions.get("this_flight") is not None: - lines.append(f" CO2: {emissions['this_flight']}g") - - lines.append("") - - return "\n".join(lines).rstrip() - - -# ── Tool implementation ──────────────────────────────────────────────────────── - -_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -_IATA_RE = re.compile(r"^[A-Za-z]{3}$") -_VALID_CABINS = {"economy", "premium_economy", "business", "first"} - - -def _validate_int(value: Any, name: str, lo: int, hi: int, default: int) -> int: - if value is None: - return default - try: - v = int(value) - except (TypeError, ValueError): - raise _ValidationError(f"'{name}' must be an integer between {lo} and {hi}.") - if v < lo or v > hi: - raise _ValidationError(f"'{name}' must be between {lo} and {hi} (got {v}).") - return v - - -class _ValidationError(Exception): - """Raised for invalid tool arguments; message is returned to the LLM.""" - - -def _serpapi_search_flights(args: dict) -> str: - # ── Required params ──────────────────────────────────────────────────────── - departure_id = (args.get("departure_id") or "").strip().upper() - arrival_id = (args.get("arrival_id") or "").strip().upper() - outbound_date = (args.get("outbound_date") or "").strip() - - if not departure_id: - raise _ValidationError("Missing required parameter 'departure_id' (3-letter IATA airport or city code, e.g. 'JFK', 'MIL').") - if not _IATA_RE.match(departure_id): - raise _ValidationError(f"'departure_id' must be exactly 3 ASCII letters (got '{departure_id}'). Use an airport code (e.g. 'JFK') or a city code (e.g. 'NYC', 'MIL', 'LON').") - if not arrival_id: - raise _ValidationError("Missing required parameter 'arrival_id' (3-letter IATA airport or city code).") - if not _IATA_RE.match(arrival_id): - raise _ValidationError(f"'arrival_id' must be exactly 3 ASCII letters (got '{arrival_id}'). Use an airport code (e.g. 'FCO') or a city code (e.g. 'ROM').") - if not outbound_date: - raise _ValidationError("Missing required parameter 'outbound_date' (YYYY-MM-DD).") - if not _DATE_RE.match(outbound_date): - raise _ValidationError(f"'outbound_date' must be in YYYY-MM-DD format (got '{outbound_date}').") - - # ── Optional params ──────────────────────────────────────────────────────── - return_date = (args.get("return_date") or "").strip() or None - if return_date and not _DATE_RE.match(return_date): - raise _ValidationError(f"'return_date' must be in YYYY-MM-DD format (got '{return_date}').") - - adults = _validate_int(args.get("adults"), "adults", 1, 10, 1) - children = _validate_int(args.get("children"), "children", 0, 8, 0) - infants_in_seat = _validate_int(args.get("infants_in_seat"), "infants_in_seat", 0, 4, 0) - infants_on_lap = _validate_int(args.get("infants_on_lap"), "infants_on_lap", 0, 4, 0) - - stops_raw = args.get("stops") - stops: int | None = None - if stops_raw is not None: - try: - stops = int(stops_raw) - except (TypeError, ValueError): - raise _ValidationError("'stops' must be 0 (non-stop only), 1 (max 1 stop), or 2 (max 2 stops).") - if stops not in (0, 1, 2): - raise _ValidationError(f"'stops' must be 0, 1, or 2 (got {stops}).") - - currency = (args.get("currency") or "EUR").strip().upper() - if not re.match(r"^[A-Z]{3}$", currency): - raise _ValidationError(f"'currency' must be a 3-letter ISO code (got '{currency}').") - - preferred_cabins = args.get("preferred_cabins") - if preferred_cabins is not None: - preferred_cabins = str(preferred_cabins).strip().lower() - if preferred_cabins not in _VALID_CABINS: - raise _ValidationError(f"'preferred_cabins' must be one of: {', '.join(sorted(_VALID_CABINS))}.") - - hl = args.get("hl") or "en" - - max_results = _validate_int(args.get("max_results"), "max_results", 1, 50, 10) - - # ── Build SerpAPI params ─────────────────────────────────────────────────── - api_params: dict[str, Any] = { - "engine": "google_flights", - "departure_id": departure_id, - "arrival_id": arrival_id, - "outbound_date": outbound_date, - "adults": adults, - "children": children, - "infants_in_seat": infants_in_seat, - "infants_on_lap": infants_on_lap, - "currency": currency, - "hl": hl, - } - if return_date: - api_params["return_date"] = return_date - api_params["type"] = "1" # round-trip - else: - api_params["type"] = "2" # one-way - if stops is not None: - api_params["stops"] = stops - if preferred_cabins: - api_params["preferred_cabins"] = preferred_cabins - - # ── Call ─────────────────────────────────────────────────────────────────── - try: - data = _serpapi_request(api_params) - except Exception as e: - return _format_api_error(e) - - if data.get("error"): - return f"Error: SerpAPI returned an error: {data['error']}" - - return _format_flight_results(data, max_results) - - -# ── Tool manifest ────────────────────────────────────────────────────────────── - -TOOLS = [ - { - "name": "serpapi_search_flights", - "description": ( - "Search one-way or round-trip flights on Google Flights via SerpAPI. " - "Returns a plain-text list of routes with price, airline + flight number, " - "departure/arrival times, segment durations, total duration, layovers, " - "and CO2 emissions. The first result is typically the cheapest.\n" - "Both airport codes (3 letters, e.g. 'JFK', 'FCO') and city codes " - "(3 letters covering all airports of a city, e.g. 'NYC', 'ROM', 'MIL', " - "'LON') are accepted for departure_id and arrival_id — prefer city codes " - "when the user does not name a specific airport." - ), - "inputSchema": { - "type": "object", - "properties": { - "departure_id": { - "type": "string", - "description": "Departure airport or city code — exactly 3 ASCII letters. Examples: 'JFK' (New York JFK), 'MIL' (any Milan airport), 'LON' (any London airport), 'ROM' (any Rome airport).", - }, - "arrival_id": { - "type": "string", - "description": "Arrival airport or city code — exactly 3 ASCII letters. See departure_id for examples.", - }, - "outbound_date": { - "type": "string", - "description": "Outbound date in YYYY-MM-DD format (e.g. '2026-08-01').", - }, - "return_date": { - "type": "string", - "description": "Return date in YYYY-MM-DD for round-trip searches. Omit for one-way.", - }, - "adults": { - "type": "integer", - "description": "Number of adult passengers (12+). Default 1, max 10.", - }, - "children": { - "type": "integer", - "description": "Number of children (2-11). Default 0, max 8.", - }, - "infants_in_seat": { - "type": "integer", - "description": "Number of infants occupying a seat. Default 0, max 4.", - }, - "infants_on_lap": { - "type": "integer", - "description": "Number of infants on an adult's lap (under 2). Default 0, max 4.", - }, - "stops": { - "type": "integer", - "enum": [0, 1, 2], - "description": "Maximum number of stops: 0 = non-stop only, 1 = max 1 stop, 2 = max 2 stops. Omit to allow any.", - }, - "currency": { - "type": "string", - "description": "ISO 4217 currency code for prices (e.g. 'EUR', 'USD', 'GBP'). Default 'EUR'.", - }, - "preferred_cabins": { - "type": "string", - "enum": ["economy", "premium_economy", "business", "first"], - "description": "Cabin class filter. Omit to search all cabins.", - }, - "hl": { - "type": "string", - "description": "Language code for results (e.g. 'en', 'it', 'fr'). Default 'en'.", - }, - "max_results": { - "type": "integer", - "description": "Maximum number of flight options to return. Default 10, max 50.", - }, - }, - "required": ["departure_id", "arrival_id", "outbound_date"], - }, - }, -] - - -# ── JSON-RPC dispatch ────────────────────────────────────────────────────────── - -TOOL_DISPATCH = { - "serpapi_search_flights": _serpapi_search_flights, -} - - -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 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": "serpapi_flights", - "version": "2.0.0", - }, - }) - - if method == "notifications/initialized": - return None - - 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: - text = handler(tool_args) - except _ValidationError as e: - return _text_result(req_id, f"Error: {e}", is_error=True) - 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) - - is_err = text.startswith("Error:") - return _text_result(req_id, text, is_error=is_err) - - return json.dumps({ - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": -32601, "message": f"Method not found: {method}"}, - }) - - -# ── Main loop ────────────────────────────────────────────────────────────────── - -def main() -> None: - log("Starting SerpAPI Google Flights MCP server") - # Validate API key eagerly so configuration errors surface at startup. - _get_api_key() - 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() diff --git a/scripts/ssh_mcp_server.py b/scripts/ssh_mcp_server.py deleted file mode 100644 index 857024f..0000000 --- a/scripts/ssh_mcp_server.py +++ /dev/null @@ -1,1285 +0,0 @@ -#!/usr/bin/env python3 -"""SSH MCP server (JSON-RPC 2.0 over stdio). - -Exposes SSH tools that operate on remote hosts with **the same output format** -as Skald's native filesystem tools (`read_file`, `list_files`, `grep_files`, -`edit_file`, `replace_lines`, `exec`). The only thing the LLM sees differently -is the first `alias` argument selecting the host. Tool names here are bare -(`read_file`, `exec`, …); Skald prepends the `mcp__ssh__` prefix automatically. - -Hosts are addressed by alias — hostname and credentials never appear in tool -calls. Aliases live in ``secrets/ssh_aliases.json`` (auto-managed, never edited -by hand). No secret is ever stored in that file. - -Login auth (``auth`` per alias, set on ``add_alias``): - * ``key`` — SSH key / ssh-agent only (default). If the chosen private key - is encrypted, its passphrase is requested on demand via **MCP elicitation** - (lazy: only when paramiko reports the key needs one). ``SSH_MCP_KEY_PASSPHRASE`` - still works as a non-interactive override. - * ``password`` — login password requested on demand via **MCP elicitation** - (Skald shows a masked field in the Agent Inbox); agent/key auth is skipped. - -Elicited login secrets are kept only in this process's RAM with a short TTL -(``SSH_MCP_LOGIN_PW_TTL``), never sent to the LLM and never written to disk; -they are dropped on an authentication failure so the next attempt re-prompts. - -sudo (two methods per alias, set on ``add_alias``): - * ``nopasswd`` — ``sudo -n``: non-interactive, fails fast if NOPASSWD is not - configured on the host (no hung channel). No secret stored anywhere. - * ``prompt`` — ``sudo -S``: the password is requested on demand via **MCP - elicitation** (Skald shows a masked field in the Agent Inbox), fed to - sudo's stdin, kept only in this process's RAM with a short TTL, never sent - to the LLM and never written to disk. - -Connections are pooled per alias with lazy TTL eviction. Host keys are verified -against ``~/.ssh/known_hosts`` (unknown hosts are rejected unless the alias was -added with ``accept_new_host_key=true``). - -Run with: - python3 scripts/ssh_mcp_server.py - -Dependency: paramiko>=3.4 (in requirements.txt; installed into .venv by run.sh). -""" - -from __future__ import annotations - -import itertools -import json -import os -import posixpath -import re -import shlex -import socket -import stat -import sys -import time -from typing import Any - - -# ── Config ─────────────────────────────────────────────────────────────────── - -_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -ALIASES_FILE = os.path.join(_ROOT, "secrets", "ssh_aliases.json") - -POOL_TTL = int(os.environ.get("SSH_MCP_POOL_TTL", "300")) # idle connection eviction -SUDO_PW_TTL = int(os.environ.get("SSH_MCP_SUDO_PW_TTL", "300")) # in-RAM sudo password cache -LOGIN_PW_TTL = int(os.environ.get("SSH_MCP_LOGIN_PW_TTL", "300")) # in-RAM login/passphrase cache -CONNECT_TIMEOUT = int(os.environ.get("SSH_MCP_CONNECT_TIMEOUT", "15")) -DEFAULT_CMD_TIMEOUT = int(os.environ.get("SSH_MCP_COMMAND_TIMEOUT", "120")) - -# Mirror the native list_files skip set so remote listings match local ones. -SKIP_DIRS = {"target", ".git", "node_modules", ".venv", "__pycache__", "secrets"} - -# Match the native read_file cap. -MAX_READ_LINES = 2000 - - -def log(msg: str) -> None: - """Log to stderr; stdout is reserved for JSON-RPC.""" - print(f"[ssh_mcp] {msg}", file=sys.stderr, flush=True) - - -class ToolError(Exception): - """Expected, user-facing failure. Surfaced as ``Error: ``.""" - - -# ── stdio JSON-RPC I/O (single readline path so elicit() can re-enter) ───────── - -def send(obj: dict) -> None: - sys.stdout.write(json.dumps(obj) + "\n") - sys.stdout.flush() - - -def readline() -> dict | None: - """Blocking read of one non-empty JSON-RPC message; None on EOF.""" - while True: - line = sys.stdin.readline() - if not line: - return None - line = line.strip() - if not line: - continue - try: - return json.loads(line) - except json.JSONDecodeError as e: - log(f"invalid JSON input: {e}") - continue - - -_eid = itertools.count(1) - - -def elicit(message: str, requested_schema: dict) -> dict: - """Send an ``elicitation/create`` request and block until the reply arrives. - - Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). While - waiting, any other inbound message is ignored (v1: serial processing). - """ - eid = f"ssh-elicit-{next(_eid)}" - send({ - "jsonrpc": "2.0", - "id": eid, - "method": "elicitation/create", - "params": {"message": message, "requestedSchema": requested_schema}, - }) - while True: - msg = readline() - if msg is None: - return {"action": "cancel"} - if msg.get("id") == eid: - return msg.get("result", {"action": "cancel"}) - log(f"ignoring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}") - - -def _ok(req_id: Any, result: Any) -> dict: - return {"jsonrpc": "2.0", "id": req_id, "result": result} - - -def _text_result(req_id: Any, text: str, is_error: bool = False) -> dict: - res: dict = {"content": [{"type": "text", "text": text}]} - if is_error: - res["isError"] = True - return {"jsonrpc": "2.0", "id": req_id, "result": res} - - -# ── Alias store (auto-managed, 0600) ─────────────────────────────────────────── - -def _load_aliases() -> dict: - try: - with open(ALIASES_FILE) as f: - return json.load(f) - except FileNotFoundError: - return {"aliases": []} - except Exception as e: - log(f"failed to read aliases: {e}") - return {"aliases": []} - - -def _save_aliases(data: dict) -> None: - os.makedirs(os.path.dirname(ALIASES_FILE), exist_ok=True) - tmp = f"{ALIASES_FILE}.tmp.{os.getpid()}" - with open(tmp, "w") as f: - json.dump(data, f, indent=2) - os.replace(tmp, ALIASES_FILE) - try: - os.chmod(ALIASES_FILE, 0o600) - except OSError: - pass - - -def _find_alias(name: str) -> dict | None: - for a in _load_aliases().get("aliases", []): - if a.get("alias") == name: - return a - return None - - -# ── Connection pool (paramiko) ───────────────────────────────────────────────── - -_pool: dict[str, dict] = {} # alias -> {client, sftp, last_used} -_sudo_pw_cache: dict[str, tuple] = {} # alias -> (password, ts) -_login_pw_cache: dict[str, tuple] = {} # "alias:login" | "alias:passphrase" -> (secret, ts) - - -def _login_password(alias: str, kind: str = "login") -> str | None: - """Return the SSH login password (``kind="login"``) or private-key passphrase - (``kind="passphrase"``) for ``alias`` from the RAM cache, or elicit it. - - Never persisted. Returns None if the user declines/cancels/times out. - """ - now = time.time() - key = f"{alias}:{kind}" - cached = _login_pw_cache.get(key) - if cached and (now - cached[1] <= LOGIN_PW_TTL): - return cached[0] - - if kind == "passphrase": - message = f"Enter the passphrase for the private key of SSH alias '{alias}'." - title = f"key passphrase — {alias}" - else: - message = f"Enter the SSH login password for alias '{alias}'." - title = f"SSH password — {alias}" - - result = elicit( - message, - { - "type": "object", - "properties": { - "password": {"type": "string", "format": "password", "title": title} - }, - "required": ["password"], - }, - ) - if result.get("action") == "accept": - pw = (result.get("content") or {}).get("password", "") - _login_pw_cache[key] = (pw, now) - return pw - return None - - -def _clear_login_pw(alias: str) -> None: - """Drop any cached login password / passphrase for ``alias``.""" - for k in [k for k in _login_pw_cache if k.startswith(f"{alias}:")]: - _login_pw_cache.pop(k, None) - - -def _is_auth_failure(paramiko, e: Exception) -> bool: - """True if ``e`` is an SSH auth rejection a login password could resolve. - - ``AuthenticationException`` (wrong/refused key) always qualifies. A plain - ``SSHException`` qualifies only when its message says paramiko had no method - to try — e.g. a password-only host with no key/agent: *"No authentication - methods available"*. Other SSH errors (banner, host key, protocol) do not. - """ - if isinstance(e, paramiko.AuthenticationException): - return True - msg = str(e).lower() - return "authentication method" in msg or "no authentication" in msg - - -def _require_paramiko(): - try: - import paramiko # type: ignore - return paramiko - except ImportError: - raise ToolError( - "paramiko not installed — add 'paramiko>=3.4' to requirements.txt " - "and reinstall the .venv (uv pip install -r requirements.txt)." - ) - - -def _connect(cfg: dict, paramiko): - alias = cfg.get("alias", "") - auth = (cfg.get("auth") or "key").lower() - - identity = cfg.get("identity_file") - identity = os.path.expanduser(identity) if identity else None - - password = None - if auth == "password": - password = _login_password(alias, "login") - if password is None: - raise ToolError( - f"login password required for alias '{alias}' (user declined or timed out)" - ) - - def attempt(passphrase): - # With a password in hand, skip agent/key probing so paramiko goes - # straight to password auth instead of failing on keys first. - use_pw = password is not None - client = paramiko.SSHClient() - client.load_system_host_keys() - known = os.path.expanduser("~/.ssh/known_hosts") - if os.path.exists(known): - try: - client.load_host_keys(known) - except Exception as e: - log(f"could not load known_hosts: {e}") - if cfg.get("accept_new_host_key"): - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - else: - client.set_missing_host_key_policy(paramiko.RejectPolicy()) - client.connect( - hostname=cfg["hostname"], - port=int(cfg.get("port", 22)), - username=cfg.get("username"), - password=password, - key_filename=identity, - passphrase=passphrase, - allow_agent=not use_pw, - look_for_keys=not use_pw, - timeout=CONNECT_TIMEOUT, - ) - return client - - passphrase = os.environ.get("SSH_MCP_KEY_PASSPHRASE") or None - try: - return attempt(passphrase) - except paramiko.PasswordRequiredException: - # Encrypted private key with no passphrase supplied — ask for it (lazy). - if passphrase is not None: - raise # we already had one and it was rejected; don't loop - passphrase = _login_password(alias, "passphrase") - if passphrase is None: - raise ToolError( - f"key passphrase required for alias '{alias}' (user declined or timed out)" - ) - return attempt(passphrase) - except (paramiko.AuthenticationException, paramiko.SSHException) as e: - # Key/agent auth was rejected, or the host offers no method paramiko - # could try (e.g. a password-only host: "No authentication methods - # available"). If we haven't tried a password yet, elicit one and retry. - # Declining re-raises the original error. Covers aliases left as the - # default auth=key that actually need a login password. - if password is not None or not _is_auth_failure(paramiko, e): - raise - password = _login_password(alias, "login") - if password is None: - raise - return attempt(passphrase) - - -def _close(alias: str) -> None: - entry = _pool.pop(alias, None) - if not entry: - return - try: - if entry.get("sftp"): - entry["sftp"].close() - except Exception: - pass - try: - entry["client"].close() - except Exception: - pass - - -def _get_client(alias: str): - cfg = _find_alias(alias) - if not cfg: - raise ToolError(f"unknown alias '{alias}'") - paramiko = _require_paramiko() - now = time.time() - - entry = _pool.get(alias) - if entry: - t = entry["client"].get_transport() - if (now - entry["last_used"] <= POOL_TTL) and t is not None and t.is_active(): - entry["last_used"] = now - return entry["client"] - _close(alias) - - try: - client = _connect(cfg, paramiko) - except paramiko.AuthenticationException: - _clear_login_pw(alias) # wrong password/passphrase → re-prompt next time - raise ToolError(f"authentication failed for alias '{alias}' (check key/agent/password)") - except paramiko.BadHostKeyException: - raise ToolError( - f"host key mismatch for alias '{alias}' (possible MITM) — fix ~/.ssh/known_hosts" - ) - except paramiko.SSHException as e: - if "not found in known_hosts" in str(e): - raise ToolError( - f"unknown host key for alias '{alias}' — re-add it with " - f"accept_new_host_key=true to trust it on first connect" - ) - raise ToolError(f"SSH error for alias '{alias}': {e}") - except (OSError, socket.error) as e: - raise ToolError(f"connection to alias '{alias}' failed: {e}") - - _pool[alias] = {"client": client, "sftp": None, "last_used": now} - return client - - -def _get_sftp(alias: str): - client = _get_client(alias) - entry = _pool[alias] - if entry.get("sftp") is None: - entry["sftp"] = client.open_sftp() - return entry["sftp"] - - -def _run_with_stdin(client, command: str, timeout: int, stdin_data: str | None = None): - """Run a remote command; return (stdout, stderr, exit_code). Raises on timeout.""" - try: - chan_in, chan_out, chan_err = client.exec_command(command, timeout=timeout) - if stdin_data is not None: - try: - chan_in.write(stdin_data) - chan_in.flush() - except Exception: - pass - out = chan_out.read().decode("utf-8", "replace") - err = chan_err.read().decode("utf-8", "replace") - code = chan_out.channel.recv_exit_status() - return out, err, code - except socket.timeout: - raise ToolError(f"command timed out after {timeout}s") - - -# ── sudo ─────────────────────────────────────────────────────────────────────── - -def _sudo_password(alias: str) -> str | None: - """Return the sudo password for ``alias`` from RAM cache, or elicit it. - - Never persisted. Returns None if the user declines/cancels/times out. - """ - now = time.time() - cached = _sudo_pw_cache.get(alias) - if cached and (now - cached[1] <= SUDO_PW_TTL): - return cached[0] - - result = elicit( - f"Enter the sudo password for SSH alias '{alias}'.", - { - "type": "object", - "properties": { - "password": { - "type": "string", - "format": "password", - "title": f"sudo password — {alias}", - } - }, - "required": ["password"], - }, - ) - if result.get("action") == "accept": - pw = (result.get("content") or {}).get("password", "") - _sudo_pw_cache[alias] = (pw, now) - return pw - return None - - -def _sudo_prefix(alias: str, cfg: dict, sudo_user: str | None): - """Build the sudo prefix for ``cfg``. Returns (prefix, stdin_password). - - Raises ToolError when sudo is disabled or the password is unavailable. - """ - method = (cfg.get("sudo") or {}).get("method", "prompt") - u = f"-u {shlex.quote(sudo_user)} " if sudo_user else "" - if method == "none": - raise ToolError(f"sudo is disabled for alias '{alias}'") - if method == "nopasswd": - return f"sudo -n {u}", None - pw = _sudo_password(alias) - if pw is None: - raise ToolError("sudo password required (user declined or timed out)") - return f"sudo -S -p '' {u}", pw - - -# ── SFTP helpers ─────────────────────────────────────────────────────────────── - -def _sftp_read_text(sftp, path: str) -> str: - with sftp.open(path, "r") as f: - data = f.read() - return data.decode("utf-8", "replace") if isinstance(data, (bytes, bytearray)) else data - - -def _sftp_write_atomic(sftp, path: str, content: str) -> None: - """Write atomically: temp file in the same dir + posix_rename. Preserve mode.""" - d = posixpath.dirname(path) or "." - base = posixpath.basename(path) - tmp = posixpath.join(d, f".{base}.tmp.{os.getpid()}") - - mode = None - try: - mode = stat.S_IMODE(sftp.stat(path).st_mode) - except IOError: - pass - - with sftp.open(tmp, "w") as f: - f.write(content) - if mode is not None: - try: - sftp.chmod(tmp, mode) - except IOError: - pass - try: - sftp.posix_rename(tmp, path) - except (IOError, AttributeError): - try: - sftp.remove(path) - except IOError: - pass - sftp.rename(tmp, path) - - -def _sftp_mkdirs(sftp, d: str) -> None: - if not d or d in ("/", "."): - return - try: - sftp.stat(d) - return - except IOError: - pass - parent = posixpath.dirname(d) - if parent and parent != d: - _sftp_mkdirs(sftp, parent) - try: - sftp.mkdir(d) - except IOError: - pass - - -def _relpath(root: str, full: str) -> str: - r = root.rstrip("/") or "/" - return posixpath.relpath(full, r) - - -# ── Tools: aliases ───────────────────────────────────────────────────────────── - -def _tool_list_aliases(args: dict) -> str: - out = [] - for a in _load_aliases().get("aliases", []): - out.append({ - "alias": a.get("alias"), - "hostname": a.get("hostname"), - "port": a.get("port", 22), - "username": a.get("username"), - "auth": a.get("auth", "key"), - "sudo_method": (a.get("sudo") or {}).get("method", "prompt"), - "description": a.get("description", ""), - }) - return json.dumps(out, indent=2) - - -def _tool_add_alias(args: dict) -> str: - name = args.get("alias") - if not name: - return "Error: missing required argument: alias" - if not args.get("hostname"): - return "Error: missing required argument: hostname" - - sudo = args.get("sudo") - method = sudo.get("method") if isinstance(sudo, dict) else (sudo or "prompt") - if method not in ("nopasswd", "prompt", "none"): - return f"Error: invalid sudo method '{method}' (use nopasswd|prompt|none)" - - auth = (args.get("auth") or "key").lower() - if auth not in ("key", "password"): - return f"Error: invalid auth method '{auth}' (use key|password)" - - entry = { - "alias": name, - "hostname": args["hostname"], - "port": int(args.get("port", 22)), - "username": args.get("username"), - "identity_file": args.get("identity_file"), - "description": args.get("description", ""), - "auth": auth, - "sudo": {"method": method}, - "accept_new_host_key": bool(args.get("accept_new_host_key", False)), - } - - data = _load_aliases() - aliases = data.setdefault("aliases", []) - prev = None - for i, a in enumerate(aliases): - if a.get("alias") == name: - prev = a - aliases[i] = entry - break - else: - aliases.append(entry) - _save_aliases(data) - _close(name) # config may have changed — drop any pooled connection - _sudo_pw_cache.pop(name, None) - _clear_login_pw(name) - - target = f"{entry.get('username')}@{entry['hostname']}:{entry['port']}" - if prev: - return f"Updated alias '{name}' → {target} (auth: {auth}, sudo: {method})." - return f"Added alias '{name}' → {target} (auth: {auth}, sudo: {method})." - - -def _tool_remove_alias(args: dict) -> str: - name = args.get("alias") - if not name: - return "Error: missing required argument: alias" - data = _load_aliases() - aliases = data.get("aliases", []) - kept = [a for a in aliases if a.get("alias") != name] - if len(kept) == len(aliases): - return f"Error: alias '{name}' not found" - data["aliases"] = kept - _save_aliases(data) - _close(name) - _sudo_pw_cache.pop(name, None) - _clear_login_pw(name) - return f"Removed alias '{name}'." - - -# ── Tools: filesystem (native output format) ─────────────────────────────────── - -def _tool_read_file(args: dict) -> str: - alias, path = args.get("alias"), args.get("path") - if not alias or not path: - return "Error: 'alias' and 'path' are required" - sftp = _get_sftp(alias) - try: - content = _sftp_read_text(sftp, path) - except IOError as e: - raise ToolError(f"cannot read {path}: {e}") - - lines = content.splitlines() - total = len(lines) - - limit = args.get("limit") - limit = min(int(limit), MAX_READ_LINES) if limit is not None else None - start = max(int(args["start_line"]) - 1, 0) if args.get("start_line") is not None else 0 - if args.get("end_line") is not None: - end = min(int(args["end_line"]), total) - elif limit is not None: - end = min(start + limit, total) - else: - end = total - - if start >= total and total > 0: - return f"(file has only {total} lines; start_line {start + 1} is out of range)" - end = max(end, start) - - width = max(len(str(total)), 3) - return "\n".join( - f"{start + i + 1:>{width}} | {line}" for i, line in enumerate(lines[start:end]) - ) - - -def _tool_list_files(args: dict) -> str: - alias, path = args.get("alias"), args.get("path") - if not alias or not path: - return "Error: 'alias' and 'path' are required" - max_depth = int(args.get("depth", 3)) - dirs_only = bool(args.get("dirs_only", False)) - sftp = _get_sftp(alias) - - out: list[str] = [] - - def walk(d: str, depth: int) -> None: - try: - entries = sftp.listdir_attr(d) - except IOError: - return - for a in entries: - full = posixpath.join(d, a.filename) - if stat.S_ISDIR(a.st_mode): - if a.filename in SKIP_DIRS: - continue - if dirs_only: - out.append(_relpath(path, full)) - if depth + 1 < max_depth: - walk(full, depth + 1) - elif stat.S_ISREG(a.st_mode) and not dirs_only: - out.append(_relpath(path, full)) - - try: - sftp.listdir_attr(path) - except IOError as e: - raise ToolError(f"cannot list {path}: {e}") - walk(path, 0) - out.sort() - return json.dumps(out) - - -def _grep_flags(args: dict) -> str: - flags = "" - if not bool(args.get("case_sensitive", False)): - flags += "-i " - inc = args.get("include_glob") - if inc: - flags += f"--include={shlex.quote(inc)} " - return flags - - -def _tool_grep_files(args: dict) -> str: - alias, path, pattern = args.get("alias"), args.get("path"), args.get("pattern") - if not alias or not path or pattern is None: - return "Error: 'alias', 'path' and 'pattern' are required" - mode = args.get("output_mode", "content") - ctx = min(int(args.get("context_lines", 0) or 0), 10) - maxr = int(args.get("max_results", 100)) - client = _get_client(alias) - - flags = _grep_flags(args) - qpat, qpath = shlex.quote(pattern), shlex.quote(path) - root_prefix = path.rstrip("/") + "/" - - def rel(p: str) -> str: - return p[len(root_prefix):] if p.startswith(root_prefix) else p - - if mode == "files_only": - cmd = f"grep -rlIZ {flags}-E -e {qpat} -- {qpath}" - out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) - if code >= 2 and not out: - raise ToolError(err.strip() or "grep failed") - files = [rel(f) for f in out.split("\0") if f][:maxr] - if not files: - return f'No files match "{pattern}" in {path}.' - return f"{len(files)} file(s):\n" + "\n".join(files) - - if mode == "count": - cmd = f"grep -rcI {flags}-E -e {qpat} -- {qpath}" - out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) - if code >= 2 and not out: - raise ToolError(err.strip() or "grep failed") - items = [] - for line in out.splitlines(): - f, _, c = line.rpartition(":") # rpartition: count is numeric at end - if f and c.isdigit() and int(c) > 0: - items.append((rel(f), int(c))) - items = items[:maxr] - if not items: - return f'No matches for "{pattern}" in {path}.' - return f"{len(items)} file(s):\n" + "\n".join(f"{f}: {c}" for f, c in items) - - # content mode - cflag = f"-C {ctx} " if ctx else "" - cmd = f"grep -rnIZ {cflag}{flags}-E -e {qpat} -- {qpath}" - out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) - if code >= 2 and not out: - raise ToolError(err.strip() or "grep failed") - - entries: list[str] = [] - if ctx == 0: - for line in out.split("\n"): - if not line: - continue - if "\0" in line: - f, _, rest = line.partition("\0") - else: - f, _, rest = line.partition(":") - lineno, _, body = rest.partition(":") - entries.append(f"{rel(f)}:{lineno}: {body}") - if len(entries) >= maxr: - break - else: - prev_file = None - for line in out.split("\n"): - if not line: - continue - if line == "--": - if prev_file is not None and len(entries) < maxr: - entries.append(f"{rel(prev_file)}:---") - continue - if "\0" in line: - f, _, rest = line.partition("\0") - else: - f, _, rest = line.partition(":") - m = re.match(r"(\d+)([:-])(.*)$", rest, re.S) - if not m: - continue - lineno, sep, body = m.group(1), m.group(2), m.group(3) - marker = ">" if sep == ":" else " " - entries.append(f"{marker}{rel(f)}: {lineno}: {body}") - prev_file = f - if len(entries) >= maxr: - break - - if not entries: - return f'No matches for "{pattern}" in {path}.' - return f"{len(entries)} match(es):\n" + "\n".join(entries) - - -def _tool_edit_file(args: dict) -> str: - alias, path = args.get("alias"), args.get("path") - old, new = args.get("old"), args.get("new") - if not alias or not path: - return "Error: 'alias' and 'path' are required" - if old is None or new is None: - return "Error: 'old' and 'new' are required" - replace_all = bool(args.get("replace_all", False)) - sftp = _get_sftp(alias) - try: - content = _sftp_read_text(sftp, path) - except IOError as e: - raise ToolError(f"cannot read {path}: {e}") - - not_found = ( - f"Error: Text not found in {path}. " - f"Call read_file first and copy the text exactly as shown after the '| ' prefix." - ) - if replace_all: - if old not in content: - return not_found - updated = content.replace(old, new) - else: - cnt = content.count(old) - if cnt > 1: - return ( - f"Error: Text found {cnt} times in {path}. " - f"Include more surrounding context in `old` to make it unique, " - f"or set replace_all=true." - ) - if cnt == 0: - return not_found - updated = content.replace(old, new, 1) - - _sftp_write_atomic(sftp, path, updated) - return f"Edited {path}." - - -def _tool_replace_lines(args: dict) -> str: - alias, path = args.get("alias"), args.get("path") - if not alias or not path: - return "Error: 'alias' and 'path' are required" - if args.get("from_line") is None or args.get("to_line") is None or args.get("new") is None: - return "Error: 'from_line', 'to_line' and 'new' are required" - from_line = int(args["from_line"]) - to_line = int(args["to_line"]) - new = args["new"] - if from_line < 1: - return "Error: from_line must be >= 1" - if to_line < from_line: - return "Error: to_line must be >= from_line" - - sftp = _get_sftp(alias) - try: - content = _sftp_read_text(sftp, path) - except IOError as e: - raise ToolError(f"cannot read {path}: {e}") - - lines = content.splitlines() - total = len(lines) - if from_line > total: - return f"Error: from_line {from_line} exceeds file length ({total} lines)" - to_clamped = min(to_line, total) - new_lines = new.splitlines() - lines[from_line - 1:to_clamped] = new_lines - - updated = "\n".join(lines) - if content.endswith("\n"): - updated += "\n" - _sftp_write_atomic(sftp, path, updated) - return f"Replaced lines {from_line}–{to_clamped} in {path} with {len(new_lines)} new lines." - - -# ── Tools: exec / sudo / systemd ──────────────────────────────────────────────── - -def _tool_exec(args: dict) -> str: - alias, command = args.get("alias"), args.get("command") - if not alias or command is None: - return "Error: 'alias' and 'command' are required" - sudo = bool(args.get("sudo", False)) - sudo_user = args.get("sudo_user") - timeout = int(args.get("timeout_sec", DEFAULT_CMD_TIMEOUT)) - cfg = _find_alias(alias) - if not cfg: - return f"Error: unknown alias '{alias}'" - - pw = None - wrapped = command - if sudo: - prefix, pw = _sudo_prefix(alias, cfg, sudo_user) - wrapped = prefix + command - - client = _get_client(alias) - try: - chan_in, chan_out, chan_err = client.exec_command(wrapped, timeout=timeout) - if pw is not None: - try: - chan_in.write(pw + "\n") - chan_in.flush() - except Exception: - pass - out = chan_out.read().decode("utf-8", "replace") - err = chan_err.read().decode("utf-8", "replace") - code = chan_out.channel.recv_exit_status() - except socket.timeout: - return f"Error: command timed out after {timeout}s" - return json.dumps({"stdout": out, "stderr": err, "exit_code": code}) - - -def _tool_systemd(args: dict) -> str: - alias, service, action = args.get("alias"), args.get("service"), args.get("action") - if not alias or not service or not action: - return "Error: 'alias', 'service' and 'action' are required" - allowed = {"status", "start", "stop", "restart", "reload", "enable", "disable"} - if action not in allowed: - return f"Error: invalid action '{action}' (allowed: {', '.join(sorted(allowed))})" - cfg = _find_alias(alias) - if not cfg: - return f"Error: unknown alias '{alias}'" - - qsvc = shlex.quote(service) - client = _get_client(alias) - - parts: list[str] = [] - if action != "status": - prefix, pw = _sudo_prefix(alias, cfg, None) - out, err, code = _run_with_stdin( - client, f"{prefix}systemctl {action} {qsvc}", DEFAULT_CMD_TIMEOUT, - (pw + "\n") if pw else None, - ) - parts.append(f"$ systemctl {action} {service} (exit {code})") - if out.strip(): - parts.append(out.strip()) - if err.strip(): - parts.append(err.strip()) - - status, _, _ = _run_with_stdin( - client, f"systemctl status {qsvc} --no-pager 2>&1 | head -n 20", DEFAULT_CMD_TIMEOUT) - parts.append("── status ──") - parts.append(status.strip()) - - journal, _, _ = _run_with_stdin( - client, f"journalctl -u {qsvc} -n 10 --no-pager 2>&1", DEFAULT_CMD_TIMEOUT) - parts.append("── journal (last 10) ──") - parts.append(journal.strip()) - return "\n".join(parts) - - -# ── Tools: transfer / diagnostics ─────────────────────────────────────────────── - -def _tool_upload(args: dict) -> str: - alias = args.get("alias") - local_path, remote_path = args.get("local_path"), args.get("remote_path") - if not alias or not local_path or not remote_path: - return "Error: 'alias', 'local_path' and 'remote_path' are required" - if not os.path.exists(local_path): - return f"Error: local path not found: {local_path}" - sftp = _get_sftp(alias) - - count = total = 0 - dest_shown = remote_path - if os.path.isdir(local_path): - for root, _dirs, files in os.walk(local_path): - relroot = os.path.relpath(root, local_path) - rdir = remote_path if relroot == "." else posixpath.join( - remote_path, relroot.replace(os.sep, "/")) - _sftp_mkdirs(sftp, rdir) - for fn in files: - lf = os.path.join(root, fn) - sftp.put(lf, posixpath.join(rdir, fn)) - count += 1 - total += os.path.getsize(lf) - else: - # scp/rsync semantics: a trailing-slash or existing-directory remote_path - # means "upload the file INTO that directory". paramiko's sftp.put needs a - # full destination FILE path — handed a directory path it fails with a - # generic "Failure" — so append the local basename in that case. - into_dir = remote_path.endswith("/") - if not into_dir: - try: - into_dir = stat.S_ISDIR(sftp.stat(remote_path).st_mode) - except IOError: - into_dir = False - if into_dir: - target_dir = remote_path.rstrip("/") or "/" - _sftp_mkdirs(sftp, target_dir) - dest = posixpath.join(target_dir, os.path.basename(local_path)) - else: - dest = remote_path - parent = posixpath.dirname(dest) - if parent: - _sftp_mkdirs(sftp, parent) - sftp.put(local_path, dest) - count, total = 1, os.path.getsize(local_path) - dest_shown = dest - return f"Uploaded {count} file(s), {total} bytes → {dest_shown}" - - -def _tool_download(args: dict) -> str: - alias = args.get("alias") - remote_path, local_path = args.get("remote_path"), args.get("local_path") - if not alias or not remote_path or not local_path: - return "Error: 'alias', 'remote_path' and 'local_path' are required" - sftp = _get_sftp(alias) - try: - st = sftp.stat(remote_path) - except IOError as e: - raise ToolError(f"remote path not found: {remote_path} ({e})") - - count = total = 0 - if stat.S_ISDIR(st.st_mode): - def rec(rdir: str, ldir: str) -> None: - nonlocal count, total - os.makedirs(ldir, exist_ok=True) - for a in sftp.listdir_attr(rdir): - rf = posixpath.join(rdir, a.filename) - lf = os.path.join(ldir, a.filename) - if stat.S_ISDIR(a.st_mode): - rec(rf, lf) - elif stat.S_ISREG(a.st_mode): - sftp.get(rf, lf) - count += 1 - total += a.st_size or os.path.getsize(lf) - rec(remote_path, local_path) - else: - parent = os.path.dirname(local_path) - if parent: - os.makedirs(parent, exist_ok=True) - sftp.get(remote_path, local_path) - count, total = 1, os.path.getsize(local_path) - return f"Downloaded {count} file(s), {total} bytes → {local_path}" - - -def _tool_sysinfo(args: dict) -> str: - alias = args.get("alias") - if not alias: - return "Error: 'alias' is required" - client = _get_client(alias) - cmd = ( - "echo OS=$(uname -s 2>/dev/null); " - "echo KERNEL=$(uname -r 2>/dev/null); " - "echo CPU=$(nproc 2>/dev/null); " - "echo MEMTOTAL=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null); " - "echo MEMAVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo 2>/dev/null); " - "echo DISKTOTAL=$(df -kP / 2>/dev/null | tail -1 | awk '{print $2}'); " - "echo DISKAVAIL=$(df -kP / 2>/dev/null | tail -1 | awk '{print $4}'); " - "echo UPTIME=$(uptime -p 2>/dev/null || uptime 2>/dev/null)" - ) - out, _, _ = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) - kv: dict[str, str] = {} - for line in out.splitlines(): - if "=" in line: - k, _, v = line.partition("=") - kv[k.strip()] = v.strip() - - def gb(key: str): - try: - return round(int(kv.get(key, "")) / 1024 / 1024, 2) - except (ValueError, TypeError): - return None - - info = { - "os": kv.get("OS", ""), - "kernel": kv.get("KERNEL", ""), - "cpu_count": int(kv["CPU"]) if kv.get("CPU", "").isdigit() else None, - "ram_total_gb": gb("MEMTOTAL"), - "ram_free_gb": gb("MEMAVAIL"), - "disk_total_gb": gb("DISKTOTAL"), - "disk_free_gb": gb("DISKAVAIL"), - "uptime": kv.get("UPTIME", ""), - } - return json.dumps(info, indent=2) - - -# ── Tool registry ──────────────────────────────────────────────────────────────── - -_ALIAS = {"type": "string", "description": "Host alias registered via add_alias."} -_SFTP_NOTE = ( - " Runs as the login user (no sudo): for paths needing root, use exec with " - "sudo=true (e.g. tee/install)." -) - -TOOLS = [ - { - "name": "list_aliases", - "description": "List configured SSH host aliases (never reveals keys or sudo passwords).", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "add_alias", - "description": "Register or update an SSH host alias. Login via SSH key/agent (default) or login password asked on demand via elicitation.", - "inputSchema": { - "type": "object", - "properties": { - "alias": {"type": "string", "description": "Short name used to address the host."}, - "hostname": {"type": "string", "description": "Host or IP."}, - "port": {"type": "integer", "description": "SSH port (default 22)."}, - "username": {"type": "string", "description": "Login user."}, - "identity_file": {"type": "string", "description": "Path to private key (optional; ssh-agent is also tried). An encrypted key's passphrase is asked via elicitation."}, - "description": {"type": "string", "description": "Free-text note."}, - "auth": {"type": "string", "enum": ["key", "password"], - "description": "Login auth. key: SSH key/agent (default). password: login password asked on demand via elicitation, kept only in RAM."}, - "sudo": {"type": "string", "enum": ["nopasswd", "prompt", "none"], - "description": "How sudo authenticates. Use 'prompt' unless you KNOW otherwise — it is the safe default: runs 'sudo -S' and asks the user for the sudo password on demand via elicitation, so it works on any host where the login user is a normal sudoer. Only pick 'nopasswd' when the remote /etc/sudoers actually grants THIS user passwordless sudo (a NOPASSWD: rule): it runs 'sudo -n' and NEVER prompts, so on a normal host every sudo call fails immediately with 'a password is required'. 'none' disables sudo. Default prompt."}, - "accept_new_host_key": {"type": "boolean", "description": "Trust the host key on first connect (TOFU). Default false."}, - }, - "required": ["alias", "hostname", "username"], - }, - }, - { - "name": "remove_alias", - "description": "Remove a host alias and close its pooled connection.", - "inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]}, - }, - { - "name": "read_file", - "description": "Read a remote file with 1-based line numbers (same format as the local read_file)." + _SFTP_NOTE, - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "path": {"type": "string", "description": "Absolute remote path."}, - "start_line": {"type": "integer", "description": "First line (1-based, inclusive)."}, - "end_line": {"type": "integer", "description": "Last line (1-based, inclusive)."}, - "limit": {"type": "integer", "description": "Max lines to read (cap 2000)."}, - }, - "required": ["alias", "path"], - }, - }, - { - "name": "list_files", - "description": "List files/dirs under a remote path; returns a JSON array of relative paths (same as local list_files).", - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "path": {"type": "string", "description": "Absolute remote directory."}, - "depth": {"type": "integer", "description": "Max recursion depth (default 3; 1 = immediate contents)."}, - "dirs_only": {"type": "boolean", "description": "Only directories (default false)."}, - }, - "required": ["alias", "path"], - }, - }, - { - "name": "grep_files", - "description": "Search a remote path with a regex; output matches the local grep_files (uses remote grep -E).", - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "path": {"type": "string", "description": "Remote file or directory."}, - "pattern": {"type": "string", "description": "Regex (case-insensitive by default)."}, - "case_sensitive": {"type": "boolean", "description": "Default false."}, - "include_glob": {"type": "string", "description": "Restrict to files matching this glob, e.g. '*.rs'."}, - "output_mode": {"type": "string", "enum": ["content", "files_only", "count"], "description": "Default 'content'."}, - "context_lines": {"type": "integer", "description": "Lines of context per match (default 0, max 10)."}, - "max_results": {"type": "integer", "description": "Stop after N results (default 100)."}, - }, - "required": ["alias", "path", "pattern"], - }, - }, - { - "name": "edit_file", - "description": "Find & replace in a remote file (atomic). `old` must match exactly once unless replace_all." + _SFTP_NOTE, - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "path": {"type": "string", "description": "Absolute remote path."}, - "old": {"type": "string", "description": "Exact text to replace."}, - "new": {"type": "string", "description": "Replacement text."}, - "replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)."}, - }, - "required": ["alias", "path", "old", "new"], - }, - }, - { - "name": "replace_lines", - "description": "Replace a 1-based inclusive line range in a remote file (atomic)." + _SFTP_NOTE, - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "path": {"type": "string", "description": "Absolute remote path."}, - "from_line": {"type": "integer", "description": "First line (1-based, inclusive)."}, - "to_line": {"type": "integer", "description": "Last line (1-based, inclusive)."}, - "new": {"type": "string", "description": "Replacement text."}, - }, - "required": ["alias", "path", "from_line", "to_line", "new"], - }, - }, - { - "name": "exec", - "description": "Run a command on the remote host. Set sudo=true to run via sudo (method per alias).", - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "command": {"type": "string", "description": "Shell command."}, - "sudo": {"type": "boolean", "description": "Run via sudo (default false)."}, - "sudo_user": {"type": "string", "description": "Target user for sudo -u (optional)."}, - "timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."}, - }, - "required": ["alias", "command"], - }, - }, - { - "name": "upload", - "description": "Upload a local file or directory (recursive) to the remote host via SFTP." + _SFTP_NOTE, - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "local_path": {"type": "string", "description": "Local file or directory."}, - "remote_path": {"type": "string", "description": "Remote destination. For a single file: a trailing '/' (or an existing remote directory) uploads the file INTO that directory keeping its name; otherwise it is the exact destination file path (parent dirs are created)."}, - }, - "required": ["alias", "local_path", "remote_path"], - }, - }, - { - "name": "download", - "description": "Download a remote file or directory (recursive) to the local host via SFTP.", - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "remote_path": {"type": "string", "description": "Remote file or directory."}, - "local_path": {"type": "string", "description": "Local destination path."}, - }, - "required": ["alias", "remote_path", "local_path"], - }, - }, - { - "name": "sysinfo", - "description": "Report OS, kernel, CPU count, RAM and root-disk usage, and uptime.", - "inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]}, - }, - { - "name": "systemd", - "description": "Manage a systemd service (status/start/stop/restart/reload/enable/disable) + last 10 journal lines. Mutating actions use sudo.", - "inputSchema": { - "type": "object", - "properties": { - "alias": _ALIAS, - "service": {"type": "string", "description": "Service/unit name."}, - "action": {"type": "string", "enum": ["status", "start", "stop", "restart", "reload", "enable", "disable"]}, - }, - "required": ["alias", "service", "action"], - }, - }, -] - -TOOL_DISPATCH = { - "list_aliases": _tool_list_aliases, - "add_alias": _tool_add_alias, - "remove_alias": _tool_remove_alias, - "read_file": _tool_read_file, - "list_files": _tool_list_files, - "grep_files": _tool_grep_files, - "edit_file": _tool_edit_file, - "replace_lines": _tool_replace_lines, - "exec": _tool_exec, - "upload": _tool_upload, - "download": _tool_download, - "sysinfo": _tool_sysinfo, - "systemd": _tool_systemd, -} - - -# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────── - -def handle_message(msg: dict) -> dict | None: - method = msg.get("method", "") - req_id = msg.get("id") - - if method == "initialize": - return _ok(req_id, { - "protocolVersion": "2025-06-18", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "ssh", "version": "1.0.0"}, - }) - if method == "notifications/initialized": - return None - if method == "tools/list": - return _ok(req_id, {"tools": TOOLS}) - if method == "tools/call": - params = msg.get("params", {}) - name = params.get("name", "") - targs = params.get("arguments", {}) or {} - handler = TOOL_DISPATCH.get(name) - if handler is None: - return _text_result(req_id, f"Error: Unknown tool: {name}", True) - try: - text = handler(targs) - except ToolError as e: - text = f"Error: {e}" - except Exception as e: - log(f"unhandled exception in tool '{name}': {e}") - text = f"Error: internal error in '{name}': {e}" - return _text_result(req_id, text, text.startswith("Error:")) - - if req_id is not None: - return {"jsonrpc": "2.0", "id": req_id, - "error": {"code": -32601, "message": f"Method not found: {method}"}} - return None - - -def main() -> None: - log("starting SSH MCP server") - try: - while True: - msg = readline() - if msg is None: - break - resp = handle_message(msg) - if resp is not None: - send(resp) - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/scripts/weather_mcp_server.py b/scripts/weather_mcp_server.py deleted file mode 100644 index 9b5c64a..0000000 --- a/scripts/weather_mcp_server.py +++ /dev/null @@ -1,779 +0,0 @@ -#!/usr/bin/env python3 -"""Weather MCP server (JSON-RPC 2.0 over stdio) using Open-Meteo API. - -Capabilities (callable as `mcp__weather__`): - status — self-check: confirms connectivity to Open-Meteo - get_current_weather — current conditions for any city worldwide - get_forecast — multi-day forecast with daily min/max, rain %, sunrise/sunset - get_air_quality — air quality index, pollutants, health advice - -Data sources (free, no API key required): - - Open-Meteo Forecast API (weather, forecast) - - Open-Meteo Air Quality API (air quality) - - Open-Meteo Geocoding API (city name → coordinates) - -Run with: - python3 scripts/weather_mcp_server.py -""" - -from __future__ import annotations - -import json -import sys -from typing import Any - -import httpx - -# ── Logging ───────────────────────────────────────────────────────────────────── - -def log(msg: str) -> None: - print(f"[weather_mcp] {msg}", file=sys.stderr, flush=True) - - -# ── Constants ─────────────────────────────────────────────────────────────────── - -GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" -FORECAST_URL = "https://api.open-meteo.com/v1/forecast" -AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality" - -COMMON_HEADERS = { - "User-Agent": "SkaldWeatherMCP/1.0", - "Accept": "application/json", -} - -HTTP_TIMEOUT = 10.0 - -# WMO weather codes → human-readable description -WMO_CODES: dict[int, str] = { - 0: "Clear sky", - 1: "Mainly clear", - 2: "Partly cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Depositing rime fog", - 51: "Light drizzle", - 53: "Moderate drizzle", - 55: "Dense drizzle", - 56: "Light freezing drizzle", - 57: "Dense freezing drizzle", - 61: "Slight rain", - 63: "Moderate rain", - 65: "Heavy rain", - 66: "Light freezing rain", - 67: "Heavy freezing rain", - 71: "Slight snowfall", - 73: "Moderate snowfall", - 75: "Heavy snowfall", - 77: "Snow grains", - 80: "Slight rain showers", - 81: "Moderate rain showers", - 82: "Violent rain showers", - 85: "Slight snow showers", - 86: "Heavy snow showers", - 95: "Thunderstorm", - 96: "Thunderstorm with slight hail", - 99: "Thunderstorm with heavy hail", -} - -# ── Helpers ───────────────────────────────────────────────────────────────────── - -def _wmo_desc(code: int | None) -> str: - """Convert WMO weather code to human-readable text.""" - if code is None: - return "Unknown" - return WMO_CODES.get(code, f"Unknown ({code})") - - -def _aqi_label_eu(value: Any) -> str: - """European AQI band name. Open-Meteo returns a numeric EAQI on the - 0–100+ scale: 0–20 Good, 20–40 Fair, 40–60 Moderate, 60–80 Poor, - 80–100 Very poor, >100 Extremely poor.""" - v = _num(value) - if v is None: - return "Unknown" if value is None else f"Unknown ({value})" - if v <= 20: - return "Good" - if v <= 40: - return "Fair" - if v <= 60: - return "Moderate" - if v <= 80: - return "Poor" - if v <= 100: - return "Very Poor" - return "Extremely Poor" - - -def _aqi_label_us(value: Any) -> str: - """US AQI band name. Open-Meteo returns a numeric USAQI on the 0–500 - scale: 0–50 Good, 51–100 Moderate, 101–150 Unhealthy for sensitive - groups, 151–200 Unhealthy, 201–300 Very Unhealthy, 301–500 Hazardous.""" - v = _num(value) - if v is None: - return "Unknown" if value is None else f"Unknown ({value})" - if v <= 50: - return "Good" - if v <= 100: - return "Moderate" - if v <= 150: - return "Unhealthy for sensitive groups" - if v <= 200: - return "Unhealthy" - if v <= 300: - return "Very Unhealthy" - return "Hazardous" - - -def _wind_direction(degrees: float | None) -> str: - """Convert wind degrees to compass direction.""" - if degrees is None: - return "?" - directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", - "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] - idx = round(degrees / 22.5) % 16 - return directions[idx] - - -def _num(val: Any) -> float | None: - """Best-effort numeric coercion. Returns None if missing or non-numeric. - - Avoids ValueError/TypeError when the API returns null or an unexpected type - and the caller wants a safe numeric comparison. - """ - if val is None or isinstance(val, bool): - return None - try: - return float(val) - except (TypeError, ValueError): - return None - - -def _format_http_error(e: Exception, api_label: str) -> str: - """Map an httpx/network exception into an actionable Error: string. - - `api_label` names the failing endpoint family (e.g. "Forecast", "Geocoding") - so the user/LLM knows where to look. - """ - if isinstance(e, httpx.TimeoutException): - return f"Error: {api_label} API request timed out. Retry in a moment." - if isinstance(e, httpx.HTTPStatusError): - return f"Error: {api_label} API returned HTTP {e.response.status_code}." - if isinstance(e, httpx.HTTPError): - return f"Error: {api_label} API request failed (network error): {e}." - return f"Error: {api_label} API call failed: {e}" - - -def _air_quality_advice(eu_aqi: Any, us_aqi: Any) -> str | None: - """Health-advice string based on the Open-Meteo numeric AQI scales. - - Prefers the European AQI (EAQI 0–100+); falls back to the US AQI (0–500). - Returns None when no AQI value is available. - """ - eu_n = _num(eu_aqi) - us_n = _num(us_aqi) - if eu_n is not None: - if eu_n <= 20: - return "✅ Air quality is good — no health concerns." - if eu_n <= 40: - return "✅ Air quality is fair — no health concerns." - if eu_n <= 60: - return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity." - if eu_n <= 80: - return "⚠️ Poor air quality. Consider reducing outdoor activities, especially if you have respiratory conditions." - return "🚨 Very poor or extremely poor air quality. Avoid outdoor exertion. Wear a mask if you must go out." - if us_n is not None: - if us_n <= 50: - return "✅ Air quality is good — no health concerns." - if us_n <= 100: - return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity." - if us_n <= 150: - return "⚠️ Unhealthy for sensitive groups. Reduce prolonged outdoor exertion." - return "🚨 Unhealthy or hazardous air quality. Avoid outdoor exertion. Wear a mask if you must go out." - return None - - -def _geocode(city: str) -> tuple[float, float, str, str] | None: - """Resolve a city name to coordinates. Returns (lat, lon, name, country). - - Returns None when the city is not found. Raises httpx.HTTPError on a - network/HTTP failure — the caller is expected to catch and surface it via - `_format_http_error` so the error is actionable rather than "Internal error". - """ - params = {"name": city, "count": 3, "language": "en", "format": "json"} - with httpx.Client(timeout=HTTP_TIMEOUT) as client: - resp = client.get(GEOCODING_URL, params=params, headers=COMMON_HEADERS) - resp.raise_for_status() - data = resp.json() - - results = data.get("results", []) - if not results: - return None - - r = results[0] - return ( - float(r["latitude"]), - float(r["longitude"]), - r.get("name", city), - r.get("country", ""), - ) - - -# ── Tool implementations ──────────────────────────────────────────────────────── - -def _weather_status(args: dict[str, Any]) -> str: - """Self-check: confirm Open-Meteo is reachable and serving data. - - Performs one cheap geocode ("Rome") plus one current-weather probe so we - exercise the Geocoding + Forecast endpoints in a single round-trip. - """ - try: - geo = _geocode("Rome") - if geo is None: - return "Error: Geocoding API returned no result for the probe query." - lat, lon, _, _ = geo - - params = {"latitude": lat, "longitude": lon, "current": "temperature_2m"} - with httpx.Client(timeout=HTTP_TIMEOUT) as client: - resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS) - resp.raise_for_status() - data = resp.json() - - if not data.get("current"): - return "Error: Forecast API responded but returned no current data for the probe." - - return ( - "OK: Open-Meteo is reachable. Geocoding and Forecast APIs respond.\n" - "All tools (get_current_weather, get_forecast, get_air_quality) are operational." - ) - except Exception as e: - return _format_http_error(e, "Forecast") - - -def _weather_current(args: dict[str, Any]) -> str: - """Get current weather conditions for a city.""" - city = args.get("city", "").strip() - if not city: - return "Error: Missing required parameter 'city'." - - units = args.get("units", "metric") - if units not in ("metric", "imperial"): - return "Error: 'units' must be 'metric' or 'imperial'." - - try: - geo = _geocode(city) - if geo is None: - return f"Error: Could not find location '{city}'. Check spelling and use English names." - lat, lon, name, country = geo - - params = { - "latitude": lat, - "longitude": lon, - "current": ( - "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code," - "wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover," - "precipitation,rain,uv_index,pressure_msl,visibility" - ), - "timezone": "auto", - } - if units == "imperial": - params["temperature_unit"] = "fahrenheit" - params["wind_speed_unit"] = "mph" - params["precipitation_unit"] = "inch" - - with httpx.Client(timeout=HTTP_TIMEOUT) as client: - resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS) - resp.raise_for_status() - data = resp.json() - except Exception as e: - return _format_http_error(e, "Forecast") - - cur = data.get("current", {}) - if not cur: - return f"Error: No current weather data available for '{city}'." - - temp_unit = "°F" if units == "imperial" else "°C" - wind_unit = "mph" if units == "imperial" else "km/h" - precip_unit = "in" if units == "imperial" else "mm" - # Open-Meteo returns visibility always in meters; no unit selector exists, - # so we convert explicitly per unit system. - vis_m = _num(cur.get("visibility")) - if vis_m is not None: - if units == "imperial": - vis_str, vis_unit = f"{vis_m / 1609.34:.1f}", "mi" - else: - vis_str, vis_unit = f"{vis_m / 1000:.1f}", "km" - else: - vis_str, vis_unit = "?", "km" if units == "metric" else "mi" - - temp = cur.get("temperature_2m", "?") - feels_like = cur.get("apparent_temperature", "?") - humidity = cur.get("relative_humidity_2m", "?") - wmo = cur.get("weather_code") - wind_speed = cur.get("wind_speed_10m", "?") - wind_deg = cur.get("wind_direction_10m") - wind_gust = cur.get("wind_gusts_10m") - cloud = cur.get("cloud_cover", "?") - precip = _num(cur.get("precipitation")) - rain = _num(cur.get("rain")) - uv = cur.get("uv_index", "?") - pressure = cur.get("pressure_msl", "?") - - loc_label = f"{name}, {country}" if country else name - lines = [ - f"📍 {loc_label} (Current weather)", - f"", - f"🌡 Temperature: {temp}{temp_unit} (feels like {feels_like}{temp_unit})", - f"☁️ Conditions: {_wmo_desc(wmo)}", - f"💧 Humidity: {humidity}%", - f"🌬 Wind: {wind_speed} {wind_unit} from {_wind_direction(wind_deg)}", - ] - - wind_gust_n = _num(wind_gust) - if wind_gust_n and wind_gust_n > 0: - lines.append(f" Gusts up to {wind_gust_n:g} {wind_unit}") - - lines.append(f"☁️ Cloud cover: {cloud}%") - lines.append(f"📊 Pressure: {pressure} hPa") - lines.append(f"👁 Visibility: {vis_str} {vis_unit}") - lines.append(f"☀️ UV index: {uv}") - - if precip and precip > 0: - lines.append(f"🌧 Precipitation: {precip:g} {precip_unit}") - elif rain and rain > 0: - lines.append(f"🌧 Rain: {rain:g} {precip_unit}") - - return "\n".join(lines) - - -def _weather_forecast(args: dict[str, Any]) -> str: - """Get multi-day weather forecast for a city.""" - city = args.get("city", "").strip() - if not city: - return "Error: Missing required parameter 'city'." - - days_raw = args.get("days", 5) - try: - days = int(days_raw) - except (TypeError, ValueError): - return f"Error: 'days' must be an integer between 1 and 16. Got: {days_raw!r}." - if days < 1 or days > 16: - return "Error: 'days' must be between 1 and 16." - - units = args.get("units", "metric") - if units not in ("metric", "imperial"): - return "Error: 'units' must be 'metric' or 'imperial'." - - try: - geo = _geocode(city) - if geo is None: - return f"Error: Could not find location '{city}'. Check spelling and use English names." - lat, lon, name, country = geo - - params = { - "latitude": lat, - "longitude": lon, - "daily": ( - "weather_code,temperature_2m_max,temperature_2m_min," - "apparent_temperature_max,apparent_temperature_min," - "precipitation_sum,rain_sum,precipitation_probability_max," - "sunrise,sunset,wind_speed_10m_max,wind_direction_10m_dominant" - ), - "forecast_days": days, - "timezone": "auto", - } - if units == "imperial": - params["temperature_unit"] = "fahrenheit" - params["wind_speed_unit"] = "mph" - params["precipitation_unit"] = "inch" - - with httpx.Client(timeout=HTTP_TIMEOUT) as client: - resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS) - resp.raise_for_status() - data = resp.json() - except Exception as e: - return _format_http_error(e, "Forecast") - - daily = data.get("daily", {}) - if not daily or "time" not in daily: - return f"Error: No forecast data available for '{city}'." - - temp_unit = "°F" if units == "imperial" else "°C" - wind_unit = "mph" if units == "imperial" else "km/h" - precip_unit = "in" if units == "imperial" else "mm" - - loc_label = f"{name}, {country}" if country else name - lines = [f"📍 {loc_label} — {days}-day forecast"] - lines.append("") - - times = daily.get("time", []) - max_temps = daily.get("temperature_2m_max", []) - min_temps = daily.get("temperature_2m_min", []) - feel_max = daily.get("apparent_temperature_max", []) - feel_min = daily.get("apparent_temperature_min", []) - wmos = daily.get("weather_code", []) - precip_sum = daily.get("precipitation_sum", []) - rain_sum = daily.get("rain_sum", []) - precip_prob = daily.get("precipitation_probability_max", []) - sunrises = daily.get("sunrise", []) - sunsets = daily.get("sunset", []) - wind_max = daily.get("wind_speed_10m_max", []) - wind_dir = daily.get("wind_direction_10m_dominant", []) - - for i, t in enumerate(times): - lines.append(f"── {t} ──") - lines.append(f" 🌡 {min_temps[i] if i < len(min_temps) else '?'}–{max_temps[i] if i < len(max_temps) else '?'}{temp_unit}" - f" (feels {feel_min[i] if i < len(feel_min) else '?'}–{feel_max[i] if i < len(feel_max) else '?'}{temp_unit})") - lines.append(f" ☁️ {_wmo_desc(wmos[i] if i < len(wmos) else None)}") - - prob = precip_prob[i] if i < len(precip_prob) else 0 - ps_n = _num(precip_sum[i] if i < len(precip_sum) else None) - rs_n = _num(rain_sum[i] if i < len(rain_sum) else None) - if prob and prob > 0: - lines.append(f" 🌧 Rain: {prob}% chance" - f"{f', {ps_n:g} {precip_unit} precip' if ps_n and ps_n > 0 else ''}" - f"{f' ({rs_n:g} {precip_unit} rain)' if rs_n and rs_n > 0 else ''}") - - wd = wind_dir[i] if i < len(wind_dir) else None - wm_n = _num(wind_max[i] if i < len(wind_max) else None) - if wm_n is not None and wm_n > 0: - lines.append(f" 🌬 Wind: up to {wm_n:g} {wind_unit} from {_wind_direction(wd)}") - - sr = sunrises[i] if i < len(sunrises) else "" - ss = sunsets[i] if i < len(sunsets) else "" - if sr and ss: - lines.append(f" 🌅 Sunrise: {sr} | 🌇 Sunset: {ss}") - - lines.append("") - - return "\n".join(lines) - - -def _weather_air_quality(args: dict[str, Any]) -> str | dict[str, Any]: - """Get air quality data for a city. - - On success returns a structured result carrying the formatted text summary - alongside the raw numeric AQI/pollutant values (see `outputSchema`). On - failure returns a plain `Error:` string. - """ - city = args.get("city", "").strip() - if not city: - return "Error: Missing required parameter 'city'." - - try: - geo = _geocode(city) - if geo is None: - return f"Error: Could not find location '{city}'. Check spelling and use English names." - lat, lon, name, country = geo - - params = { - "latitude": lat, - "longitude": lon, - "current": ( - "european_aqi,us_aqi,pm2_5,pm10," - "nitrogen_dioxide,ozone,carbon_monoxide,sulphur_dioxide,ammonia" - ), - "timezone": "auto", - } - with httpx.Client(timeout=HTTP_TIMEOUT) as client: - resp = client.get(AIR_URL, params=params, headers=COMMON_HEADERS) - resp.raise_for_status() - data = resp.json() - except Exception as e: - return _format_http_error(e, "Air Quality") - - cur = data.get("current", {}) - if not cur: - return f"Error: No air quality data available for '{city}'." - - eu_aqi = cur.get("european_aqi") - us_aqi = cur.get("us_aqi") - - # Numeric pollutant values (None when missing/non-numeric). - pm25 = _num(cur.get("pm2_5")) - pm10 = _num(cur.get("pm10")) - no2 = _num(cur.get("nitrogen_dioxide")) - o3 = _num(cur.get("ozone")) - co = _num(cur.get("carbon_monoxide")) - so2 = _num(cur.get("sulphur_dioxide")) - nh3 = _num(cur.get("ammonia")) - pollutants = {"pm2_5": pm25, "pm10": pm10, "no2": no2, "o3": o3, - "co": co, "so2": so2, "nh3": nh3} - - eu_label = _aqi_label_eu(eu_aqi) if eu_aqi is not None else None - us_label = _aqi_label_us(us_aqi) if us_aqi is not None else None - advice = _air_quality_advice(eu_aqi, us_aqi) - - loc_label = f"{name}, {country}" if country else name - - # ── Formatted text summary (kept inside the structured payload so the LLM - # still has the human-readable emoji output alongside the raw numbers). - def _fmt(val: float | None) -> str: - return f"{val:g} µg/m³" if val is not None else "?" - - lines = [f"📍 {loc_label} (Air Quality)", ""] - if eu_aqi is not None: - lines.append(f"🇪🇺 European AQI: {eu_aqi} ({eu_label})") - if us_aqi is not None: - lines.append(f"🇺🇸 US AQI: {us_aqi} ({us_label})") - lines.append("") - lines.append(" • PM2.5: " + _fmt(pm25)) - lines.append(" • PM10: " + _fmt(pm10)) - lines.append(" • NO₂: " + _fmt(no2)) - lines.append(" • O₃: " + _fmt(o3)) - lines.append(" • CO: " + _fmt(co)) - lines.append(" • SO₂: " + _fmt(so2)) - lines.append(" • NH₃: " + _fmt(nh3)) - lines.append("") - if advice: - lines.append(advice) - - return { - "location": loc_label, - "summary": "\n".join(lines), - "european_aqi": eu_aqi, - "european_aqi_label": eu_label, - "us_aqi": us_aqi, - "us_aqi_label": us_label, - "pollutants_ug_m3": pollutants, - "health_advice": advice, - } - - -# ── Tool manifest ──────────────────────────────────────────────────────────────── - -TOOLS = [ - { - "name": "status", - "description": ( - "Self-check that the Weather integration is operational: verifies the " - "Open-Meteo Geocoding and Forecast APIs are reachable by performing one " - "cheap probe (geocode 'Rome' + current temperature). Call this first " - "whenever another weather tool fails, or to give the user a quick yes/no " - "on whether weather data is usable right now." - ), - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "get_current_weather", - "description": ( - "Get current weather conditions for any city worldwide. " - "Returns temperature, feels-like, humidity, wind (speed/direction/gusts), " - "conditions (clear/rain/snow/etc.), cloud cover, pressure, visibility, " - "UV index, and precipitation. Free, no API key required." - ), - "inputSchema": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "City name in English (e.g. 'London', 'Rome', 'Tokyo').", - }, - "units": { - "type": "string", - "enum": ["metric", "imperial"], - "description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.", - }, - }, - "required": ["city"], - }, - }, - { - "name": "get_forecast", - "description": ( - "Get multi-day weather forecast for any city worldwide. " - "Returns daily min/max temperature (and feels-like), conditions, rain probability, " - "precipitation amounts, wind max/direction, sunrise/sunset times. " - "Use when you need to plan upcoming days. Free, no API key required." - ), - "inputSchema": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "City name in English (e.g. 'Paris', 'New York').", - }, - "days": { - "type": "integer", - "description": "Number of forecast days (1–16). Default: 5.", - }, - "units": { - "type": "string", - "enum": ["metric", "imperial"], - "description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.", - }, - }, - "required": ["city"], - }, - }, - { - "name": "get_air_quality", - "description": ( - "Get current air quality for any city worldwide. " - "Returns European and US AQI indices, plus detailed pollutant levels: " - "PM2.5, PM10, NO₂, O₃, CO, SO₂, NH₃. Includes health advice based on the AQI level. " - "Returns structured content: a `summary` text plus the raw numeric AQI and " - "pollutant values for machine consumption. Free, no API key required." - ), - "inputSchema": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "City name in English (e.g. 'Beijing', 'London').", - }, - }, - "required": ["city"], - }, - "outputSchema": { - "type": "object", - "properties": { - "location": {"type": "string"}, - "summary": {"type": "string"}, - "european_aqi": {"type": ["number", "null"]}, - "european_aqi_label": {"type": ["string", "null"]}, - "us_aqi": {"type": ["number", "null"]}, - "us_aqi_label": {"type": ["string", "null"]}, - "pollutants_ug_m3": { - "type": "object", - "properties": { - "pm2_5": {"type": ["number", "null"]}, - "pm10": {"type": ["number", "null"]}, - "no2": {"type": ["number", "null"]}, - "o3": {"type": ["number", "null"]}, - "co": {"type": ["number", "null"]}, - "so2": {"type": ["number", "null"]}, - "nh3": {"type": ["number", "null"]}, - }, - }, - "health_advice": {"type": ["string", "null"]}, - }, - }, - }, -] - -TOOL_DISPATCH = { - "status": _weather_status, - "get_current_weather": _weather_current, - "get_forecast": _weather_forecast, - "get_air_quality": _weather_air_quality, -} - - -# ── 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 _structured_result(req_id: Any, structured: dict) -> str: - """Build a JSON-RPC result carrying structuredContent (canonical for MCP - structured tool results) plus a text mirror in `content[]` for plain - clients. The structured object is expected to embed a human-readable - `summary` string alongside the raw numeric fields. - - Skald prefers structuredContent when present, so the LLM sees the JSON - object (which contains the formatted summary).""" - summary = structured.get("summary") - if not isinstance(summary, str): - summary = json.dumps(structured, ensure_ascii=False) - return json.dumps({ - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": summary}], - "structuredContent": structured, - }, - }) - - -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": "weather", - "version": "1.2.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", {}) - - 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) - # A dict return is a structured result (structuredContent); a str - # return is plain text (an "Error:" prefix marks it as isError). - if isinstance(result, dict): - return _structured_result(req_id, result) - 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) - - return _error(req_id, -32601, f"Method not found: {method}") - - -# ── Main loop ──────────────────────────────────────────────────────────────────── - -def main() -> None: - log("Starting weather MCP server (Open-Meteo)") - 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() diff --git a/scripts/whatsapp_mcp/index.js b/scripts/whatsapp_mcp/index.js deleted file mode 100644 index 638a993..0000000 --- a/scripts/whatsapp_mcp/index.js +++ /dev/null @@ -1,498 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition. - * - * Runs INSIDE the user's per-user container (blueprint §6/§7). Unlike the old - * whatsapp-web.js server, this one uses `@whiskeysockets/baileys`: a pure-WebSocket - * WhatsApp multi-device client with **no browser** — so it fits the slim - * `skald-runtime` image (node, no Chromium) and needs no puppeteer self-healing. - * - * ── Interactive login contract (the generic §15 seam) ─────────────────────────── - * A per-user connector that needs an interactive login exposes ONE standard tool, - * `login_status`, that Skald's login API calls directly (never the agent). It - * returns a small JSON object the login panel renders: - * - * { "state": "connecting" | "need_scan" | "ready" | "logged_out", - * "qr": "data:image/png;base64,…" // present only while state == need_scan - * "message": "human-readable line" } - * - * The panel polls it; when `state == "ready"` Skald flips the connector's - * `auth_state` to `ready`. WhatsApp's credential is the persisted session on disk - * (`./auth/`, under the bind-mounted home → survives a container recreate), not a - * token — so there is nothing to paste back, only a QR to scan. - */ - -// Baileys uses the Web Crypto global (`crypto.subtle`), which Node only exposes as -// `globalThis.crypto` from v20+. The container ships Node 18 (Debian bookworm), so -// polyfill it from `node:crypto` — without this, the socket dies on connect with -// "crypto is not defined" and never reaches the QR. -const nodeCrypto = require('crypto'); -if (!globalThis.crypto) globalThis.crypto = nodeCrypto.webcrypto; - -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); -const qrcode = require('qrcode'); - -let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, jidNormalizedUser; -try { - const baileys = require('@whiskeysockets/baileys'); - makeWASocket = baileys.default || baileys.makeWASocket; - useMultiFileAuthState = baileys.useMultiFileAuthState; - DisconnectReason = baileys.DisconnectReason; - fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion; - jidNormalizedUser = baileys.jidNormalizedUser; -} catch (e) { - process.stderr.write(`[whatsapp_mcp] FATAL: baileys not installed (${e.message}). Run npm install.\n`); -} - -// ── Paths ────────────────────────────────────────────────────────────────── -// Everything hangs off __dirname (the connector dir inside the container home, -// `~/.skald/mcp//`), which is bind-mounted and therefore durable. -const AUTH_DIR = path.join(__dirname, 'auth'); // multi-file auth state (the "session") -const MEDIA_DIR = path.join(__dirname, 'media'); - -function log(msg) { process.stderr.write(`[whatsapp_mcp] ${msg}\n`); } - -// A silent logger: Baileys requires one, and anything it prints must never reach -// stdout (that channel is reserved for JSON-RPC framing). -const silentLogger = (() => { - const noop = () => {}; - const l = { level: 'silent', trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }; - l.child = () => l; - return l; -})(); - -// ── Connection state ───────────────────────────────────────────────────────── -// connecting – socket starting or reconnecting -// need_scan – a QR is available; the user must scan it -// ready – authenticated and connected; tools operational -// logged_out – the phone unlinked this device; a fresh QR + scan is required -let state = 'connecting'; -let sock = null; -let curQr = null; // latest raw QR string (null once scanned / connected) -let meJid = null; -let starting = false; - -// ── Lightweight in-memory store ─────────────────────────────────────────────── -// Baileys keeps no chat/contact store of its own; we build a minimal one from the -// history-sync event and live upserts. It lives for the process lifetime — enough -// for "what's going on now", not a full archive. -const chats = new Map(); // jid -> { id, name, unread, conversationTimestamp } -const contacts = new Map(); // jid -> { id, name } -const messages = new Map(); // jid -> [ { id, fromMe, ts, text, author } ] (capped) - -const MAX_MSGS_PER_CHAT = 200; - -function pushMessage(jid, m) { - if (!jid) return; - let arr = messages.get(jid); - if (!arr) { arr = []; messages.set(jid, arr); } - arr.push(m); - if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT); -} - -function contactName(jid) { - const c = contacts.get(jid); - if (c && c.name) return c.name; - const ch = chats.get(jid); - if (ch && ch.name) return ch.name; - return jid ? jid.split('@')[0] : 'unknown'; -} - -function textOf(msg) { - const m = msg.message; - if (!m) return ''; - return ( - m.conversation || - m.extendedTextMessage?.text || - m.imageMessage?.caption || - m.videoMessage?.caption || - m.documentMessage?.caption || - (m.imageMessage ? '[image]' : '') || - (m.videoMessage ? '[video]' : '') || - (m.audioMessage ? '[audio]' : '') || - (m.documentMessage ? '[document]' : '') || - (m.stickerMessage ? '[sticker]' : '') || - '' - ); -} - -// ── WhatsApp socket lifecycle ────────────────────────────────────────────────── - -async function startSock() { - if (starting) return; - starting = true; - try { - if (!makeWASocket) { state = 'connecting'; return; } - fs.mkdirSync(AUTH_DIR, { recursive: true }); - - const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR); - let version; - try { ({ version } = await fetchLatestBaileysVersion()); } catch (_) { /* baileys default */ } - - sock = makeWASocket({ - version, - auth: authState, - logger: silentLogger, - browser: ['Skald', 'Chrome', '1.0.0'], - syncFullHistory: false, - markOnlineOnConnect: false, - generateHighQualityLinkPreview: false, - }); - - sock.ev.on('creds.update', saveCreds); - - sock.ev.on('connection.update', (u) => { - const { connection, lastDisconnect, qr } = u; - if (qr) { curQr = qr; state = 'need_scan'; log('QR ready — awaiting scan'); } - if (connection === 'open') { - curQr = null; - state = 'ready'; - meJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : null; - log('connection open — ready'); - } - if (connection === 'close') { - const code = lastDisconnect?.error?.output?.statusCode; - if (code === DisconnectReason.loggedOut) { - state = 'logged_out'; - curQr = null; - log('logged out by phone — clearing session'); - try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {} - // Re-init so a fresh QR is produced immediately. - starting = false; - setTimeout(() => startSock(), 500); - } else { - state = 'connecting'; - log(`connection closed (code ${code ?? '?'}) — reconnecting`); - starting = false; - setTimeout(() => startSock(), 1500); - } - } - }); - - // Initial history sync: chats, contacts and a batch of messages. - sock.ev.on('messaging-history.set', ({ chats: hc, contacts: hcs, messages: hm }) => { - for (const c of hc || []) { - chats.set(c.id, { - id: c.id, - name: c.name || c.subject || null, - unread: c.unreadCount || 0, - conversationTimestamp: Number(c.conversationTimestamp) || 0, - }); - } - for (const c of hcs || []) { - contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null }); - } - for (const m of hm || []) ingestMessage(m, false); - }); - - sock.ev.on('chats.upsert', (cs) => { - for (const c of cs) chats.set(c.id, { - id: c.id, name: c.name || c.subject || null, - unread: c.unreadCount || 0, - conversationTimestamp: Number(c.conversationTimestamp) || 0, - }); - }); - sock.ev.on('contacts.upsert', (cs) => { - for (const c of cs) contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null }); - }); - sock.ev.on('contacts.update', (cs) => { - for (const c of cs) { - const prev = contacts.get(c.id) || { id: c.id }; - contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null }); - } - }); - - sock.ev.on('messages.upsert', ({ messages: ms, type }) => { - for (const m of ms) ingestMessage(m, type === 'notify'); - }); - } catch (e) { - log(`startSock error: ${e.message}`); - state = 'connecting'; - } finally { - starting = false; - } -} - -function ingestMessage(m, live) { - try { - const jid = m.key?.remoteJid; - if (!jid || jid === 'status@broadcast') return; - const text = textOf(m); - pushMessage(jid, { - id: m.key?.id, - fromMe: !!m.key?.fromMe, - ts: Number(m.messageTimestamp) || 0, - text, - author: m.key?.participant || (m.key?.fromMe ? meJid : jid), - }); - if (live && !chats.has(jid)) { - chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 }); - } else if (live) { - const ch = chats.get(jid); - ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp; - if (m.pushName && !ch.name) ch.name = m.pushName; - } - } catch (_) {} -} - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -// Turn a plain phone number or a chat id into a WhatsApp jid. -function toJid(chat_id, number) { - if (chat_id && chat_id.includes('@')) return chat_id; - const raw = (chat_id || number || '').replace(/[^0-9]/g, ''); - if (!raw) return null; - return `${raw}@s.whatsapp.net`; -} - -function requireReady() { - if (state !== 'ready') { - throw new Error(`WhatsApp is not connected (state: ${state}). ` + - (state === 'need_scan' || state === 'logged_out' - ? 'Open the connector in Skald and scan the QR code to sign in.' - : 'It is still connecting — try again in a few seconds.')); - } -} - -// ── Tools: interactive login (the §15 generic contract) ───────────────────────── - -async function toolLoginStatus() { - let qrDataUrl = null; - if (state === 'need_scan' && curQr) { - try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch (_) {} - } - const message = { - connecting: 'Connecting to WhatsApp…', - need_scan: 'Scan this QR code: WhatsApp → Settings → Linked Devices → Link a Device.', - ready: 'WhatsApp is connected.', - logged_out: 'This device was unlinked. Scan the new QR code to sign in again.', - }[state] || state; - // Returned as a JSON string in a text content part; the login API parses it. - return JSON.stringify({ state, qr: qrDataUrl, message }); -} - -async function toolStatus() { - const s = await toolLoginStatus(); - const { state: st, message } = JSON.parse(s); - const chatCount = chats.size; - return `WhatsApp status: ${st.toUpperCase()}\n${message}` + - (st === 'ready' ? `\nKnown chats: ${chatCount}` : ''); -} - -async function toolLogout() { - try { if (sock) await sock.logout(); } catch (_) {} - try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {} - chats.clear(); contacts.clear(); messages.clear(); - curQr = null; state = 'connecting'; starting = false; meJid = null; - setTimeout(() => startSock(), 500); - return 'Logged out and cleared the session. A new QR code will be generated — open the connector in Skald and scan it.'; -} - -// ── Tools: messaging ──────────────────────────────────────────────────────────── - -async function toolListChats(args) { - requireReady(); - const max = Math.min(Math.max(1, args.max_chats || 20), 50); - const list = [...chats.values()] - .sort((a, b) => (b.conversationTimestamp || 0) - (a.conversationTimestamp || 0)) - .slice(0, max); - if (!list.length) return 'No chats known yet. History may still be syncing — try again in a few seconds.'; - const lines = [`Recent WhatsApp chats (${list.length}):`]; - for (const c of list) { - const kind = c.id.endsWith('@g.us') ? '[group]' : '[chat]'; - const unread = c.unread ? ` (${c.unread} unread)` : ''; - lines.push(`- ${c.name || contactName(c.id)} ${kind}${unread} | ID: ${c.id}`); - } - return lines.join('\n'); -} - -async function toolGetMessages(args) { - requireReady(); - const jid = toJid(args.chat_id, args.number); - if (!jid) return 'Error: provide chat_id or number.'; - const limit = Math.min(Math.max(1, args.limit || 20), 100); - const offset = Math.max(0, args.offset || 0); - const arr = (messages.get(jid) || []).slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)); - if (!arr.length) return `No messages buffered for ${contactName(jid)} (${jid}). Only messages seen since sign-in are available.`; - const end = arr.length - offset; - const slice = arr.slice(Math.max(0, end - limit), Math.max(0, end)); - const lines = [`Messages with ${contactName(jid)} (${jid}):`]; - for (const m of slice) { - const who = m.fromMe ? 'me' : (jid.endsWith('@g.us') ? contactName(m.author) : contactName(jid)); - const when = m.ts ? new Date(m.ts * 1000).toISOString().replace('T', ' ').slice(0, 16) : ''; - lines.push(`[${when}] ${who}: ${m.text}`); - } - return lines.join('\n'); -} - -async function toolSendMessage(args) { - requireReady(); - const jid = toJid(args.chat_id, args.number); - if (!jid) return 'Error: provide chat_id or number.'; - if (!args.message) return 'Error: message is required.'; - await sock.sendMessage(jid, { text: String(args.message) }); - return `Message sent to ${contactName(jid)} (${jid}).`; -} - -async function toolSearchContacts(args) { - requireReady(); - const q = String(args.query || '').toLowerCase(); - if (!q) return 'Error: query is required.'; - const max = Math.min(Math.max(1, args.max_results || 20), 50); - const seen = new Set(); - const out = []; - for (const c of contacts.values()) { - if (out.length >= max) break; - const name = c.name || ''; - if (name.toLowerCase().includes(q) || c.id.includes(q)) { - if (seen.has(c.id)) continue; - seen.add(c.id); - out.push(`- ${name || contactName(c.id)} | ID: ${c.id}`); - } - } - if (!out.length) return `No contacts found matching "${args.query}".`; - return [`Contacts matching "${args.query}" (${out.length}):`, ...out].join('\n'); -} - -// ── MCP tool definitions ──────────────────────────────────────────────────────── - -const TOOLS = [ - { - name: 'login_status', - description: 'Interactive-login status for this connector (used by the Skald login panel). Returns a JSON object {state, qr, message}: state is connecting|need_scan|ready|logged_out; qr is a data-URL PNG present only while a scan is needed. Safe to poll.', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'status', - description: 'WhatsApp connection status as a short human-readable report. Call this first when another WhatsApp tool fails.', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'logout', - description: 'Log out of WhatsApp: end the session, clear the stored credentials, and generate a fresh QR code to link a (possibly different) phone. After calling, the user must scan the new QR in the Skald connector page.', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'list_chats', - description: 'List recent WhatsApp chats (contacts and groups) with name, ID and unread count. Only chats seen since sign-in / history sync are known.', - inputSchema: { - type: 'object', - properties: { max_chats: { type: 'integer', description: 'Max chats to return (default 20, max 50).' } }, - }, - }, - { - name: 'get_messages', - description: 'Get buffered messages from a chat. Identify it with EITHER chat_id (from list_chats) OR a phone number with country code for an individual contact. Only messages seen since sign-in are available (no deep history).', - inputSchema: { - type: 'object', - properties: { - chat_id: { type: 'string', description: 'Chat ID, e.g. "39XXXXXXXXXX@s.whatsapp.net" or "…@g.us".' }, - number: { type: 'string', description: 'Alternative to chat_id: phone number with country code (e.g. "393331234567"). Ignored if chat_id is given.' }, - limit: { type: 'integer', description: 'Number of messages (default 20, max 100).' }, - offset: { type: 'integer', description: 'Skip this many of the most recent messages (default 0).' }, - }, - }, - }, - { - name: 'send_message', - description: 'Send a WhatsApp text message. Identify the recipient with EITHER chat_id (from list_chats, use for groups) OR a phone number with country code for an individual contact.', - inputSchema: { - type: 'object', - properties: { - chat_id: { type: 'string', description: 'Chat ID to send to (use for groups).' }, - number: { type: 'string', description: 'Alternative to chat_id: phone number with country code. Ignored if chat_id is given.' }, - message: { type: 'string', description: 'The text to send.' }, - }, - required: ['message'], - }, - }, - { - name: 'search_contacts', - description: 'Search known WhatsApp contacts by name or number. Use to find a contact ID to message.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Name or partial name/number (case-insensitive).' }, - max_results: { type: 'integer', description: 'Max contacts to return (default 20, max 50).' }, - }, - required: ['query'], - }, - }, -]; - -// ── JSON-RPC framing ───────────────────────────────────────────────────────── - -function okResponse(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); } -function textResult(id, text, isError = false) { - const result = { content: [{ type: 'text', text }] }; - if (isError) result.isError = true; - return JSON.stringify({ jsonrpc: '2.0', id, result }); -} - -async function handleRequest(msg) { - const { method, id, params } = msg; - - if (method === 'initialize') { - return okResponse(id, { - protocolVersion: '2024-11-05', - capabilities: { tools: {} }, - serverInfo: { name: 'whatsapp', version: '2.0.0' }, - }); - } - if (method === 'notifications/initialized') return null; - if (method === 'tools/list') return okResponse(id, { tools: TOOLS }); - - if (method === 'tools/call') { - const toolName = params?.name || ''; - const toolArgs = params?.arguments || {}; - let text; - try { - switch (toolName) { - case 'login_status': text = await toolLoginStatus(); break; - case 'status': text = await toolStatus(); break; - case 'logout': text = await toolLogout(); break; - case 'list_chats': text = await toolListChats(toolArgs); break; - case 'get_messages': text = await toolGetMessages(toolArgs); break; - case 'send_message': text = await toolSendMessage(toolArgs); break; - case 'search_contacts': text = await toolSearchContacts(toolArgs); break; - default: - return textResult(id, `Unknown tool: ${toolName}`, true); - } - } catch (e) { - log(`tool '${toolName}' error: ${e.message}`); - return textResult(id, `Error: ${e.message}`, true); - } - const isErr = typeof text === 'string' && text.startsWith('Error:'); - return textResult(id, text, isErr); - } - - return JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - log('Starting WhatsApp MCP server (Baileys)'); - fs.mkdirSync(MEDIA_DIR, { recursive: true }); - startSock().catch((e) => log(`initial startSock failed: ${e.message}`)); - - const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - rl.on('line', async (line) => { - line = line.trim(); - if (!line) return; - let msg; - try { msg = JSON.parse(line); } catch (e) { log(`bad JSON on stdin: ${e.message}`); return; } - const resp = await handleRequest(msg); - if (resp !== null) process.stdout.write(resp + '\n'); - }); - rl.on('close', () => { log('stdin closed, shutting down'); process.exit(0); }); - - process.on('SIGTERM', () => { log('SIGTERM'); process.exit(0); }); - process.on('SIGINT', () => { log('SIGINT'); process.exit(0); }); -} - -main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); }); diff --git a/scripts/whatsapp_mcp/package.json b/scripts/whatsapp_mcp/package.json deleted file mode 100644 index 51fe3c3..0000000 --- a/scripts/whatsapp_mcp/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "skald-whatsapp-mcp", - "version": "2.0.0", - "private": true, - "description": "WhatsApp MCP connector for Skald (Baileys, no browser).", - "main": "index.js", - "engines": { - "node": ">=18" - }, - "dependencies": { - "@whiskeysockets/baileys": "^6.7.9", - "qrcode": "^1.5.4" - } -}