First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
#!/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/<target>/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."
+147
View File
@@ -0,0 +1,147 @@
#!/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()
+980
View File
@@ -0,0 +1,980 @@
#!/usr/bin/env python3
"""Google Calendar MCP server (JSON-RPC 2.0 over stdio).
Capabilities (callable as `mcp__gcal__<tool>`):
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()
+106
View File
@@ -0,0 +1,106 @@
#!/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()
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
#!/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()
+819
View File
@@ -0,0 +1,819 @@
#!/usr/bin/env python3
"""Google Maps MCP server (JSON-RPC 2.0 over stdio).
Capabilities (callable as `mcp__gmaps__<tool>`):
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()
+464
View File
@@ -0,0 +1,464 @@
#!/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()
+359
View File
@@ -0,0 +1,359 @@
#!/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()
+150
View File
@@ -0,0 +1,150 @@
#!/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()
@@ -0,0 +1 @@
httpx>=0.27.0
+471
View File
@@ -0,0 +1,471 @@
#!/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()
File diff suppressed because it is too large Load Diff
+779
View File
@@ -0,0 +1,779 @@
#!/usr/bin/env python3
"""Weather MCP server (JSON-RPC 2.0 over stdio) using Open-Meteo API.
Capabilities (callable as `mcp__weather__<tool>`):
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
0100+ scale: 020 Good, 2040 Fair, 4060 Moderate, 6080 Poor,
80100 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 0500
scale: 050 Good, 51100 Moderate, 101150 Unhealthy for sensitive
groups, 151200 Unhealthy, 201300 Very Unhealthy, 301500 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 0100+); falls back to the US AQI (0500).
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 (116). 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()
+917
View File
@@ -0,0 +1,917 @@
#!/usr/bin/env node
'use strict';
/**
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio)
*
* Uses whatsapp-web.js + puppeteer to provide read/write access to WhatsApp.
* Session is persisted in ./secrets/whatsapp_session/ (LocalAuth).
* QR code for first-time auth is saved as ASCII art to ./secrets/whatsapp_qr.txt
*
* Run `npm install` inside scripts/whatsapp_mcp/ before first use.
*
* Register with the agent:
* register_mcp(name="whatsapp", transport="stdio",
* command="node", args=["scripts/whatsapp_mcp/index.js"])
*/
const fs = require('fs');
const path = require('path');
const readline = require('readline');
// ── Paths ──────────────────────────────────────────────────────────────────
// __dirname = <project>/scripts/whatsapp_mcp → root = ../..
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
const SECRETS_DIR = path.join(PROJECT_ROOT, 'secrets');
const DATA_DIR = path.join(PROJECT_ROOT, 'data');
const SESSION_DIR = path.join(SECRETS_DIR, 'whatsapp_session');
const QR_FILE = path.join(SECRETS_DIR, 'whatsapp_qr.txt');
const MEDIA_DIR = path.join(DATA_DIR, 'whatsapp_media');
// ── Logging ────────────────────────────────────────────────────────────────
function log(msg) {
process.stderr.write(`[whatsapp_mcp] ${msg}\n`);
}
// ── QR artifact cleanup ──────────────────────────────────────────────────────
// Remove every QR file we may have written (PNG, HTML, ASCII fallback) so a
// stale code is never served after auth succeeds or the session is reset.
function cleanupQrFiles() {
const QR_HTML = path.join(SECRETS_DIR, 'whatsapp_qr.html');
const QR_PNG = path.join(DATA_DIR, 'whatsapp_qr.png');
for (const f of [QR_FILE, QR_HTML, QR_PNG]) {
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) {}
}
}
// ── JSON-RPC notification helper ───────────────────────────────────────────
// Emits a server-initiated notification (no "id") to stdout.
// The Rust McpServer reader loop captures these and writes them to mcp_events.
function notify(method, params) {
const msg = JSON.stringify({ jsonrpc: '2.0', method, params });
process.stdout.write(msg + '\n');
}
// ── State ──────────────────────────────────────────────────────────────────
/**
* Connection states:
* INITIALIZING client is starting up (loading session or launching browser)
* QR_READY need QR scan; QR saved to secrets/whatsapp_qr.html
* AUTHENTICATED QR scanned, session being established
* READY fully connected, tools operational
* DISCONNECTED lost connection (will attempt reconnect)
*/
let state = 'INITIALIZING';
let client = null;
let lastQrStr = null; // dedup: only regenerate files when QR string changes
// ── Stale-browser cleanup (self-healing) ─────────────────────────────────────
// The host (skald) kills this MCP process with SIGKILL on shutdown/restart
// (`kill_on_drop` in the Rust MCP client — see crates/mcp-client/src/server.rs).
// SIGKILL is untrappable, so neither our shutdown handler nor puppeteer's own
// cleanup ever runs: the headless Chrome that whatsapp-web.js spawned is
// reparented to init and keeps an exclusive lock (SingletonLock) on the profile
// directory. The next launch then dies with "The browser is already running for
// <profile>. Use a different userDataDir or stop the running browser first." and
// every WhatsApp tool is dead until the orphan is killed by hand.
//
// skald only ever runs one WhatsApp MCP at a time and SIGKILLs the old node
// before spawning the new one, so ANY Chrome still bound to our profile at
// startup is guaranteed to be such an orphan. On startup we therefore kill every
// process whose command line references our profile dir and clear the stale
// Singleton* lock files — making the server self-healing across hard restarts.
// LocalAuth stores the Chromium profile at <dataPath>/session[-<clientId>].
const SESSION_PROFILE = path.join(SESSION_DIR, 'session');
function cleanupStaleBrowser() {
// 1. Kill any orphaned browser process bound to our profile directory.
try {
const { spawnSync } = require('child_process');
const res = spawnSync('pgrep', ['-f', SESSION_PROFILE], { encoding: 'utf8' });
const pids = (res.stdout || '')
.split('\n')
.map((s) => parseInt(s.trim(), 10))
.filter((pid) => Number.isInteger(pid) && pid !== process.pid);
for (const pid of pids) {
try { process.kill(pid, 'SIGKILL'); } catch (_) {}
}
if (pids.length) {
log(`Cleaned up ${pids.length} orphaned browser process(es) holding ${SESSION_PROFILE}`);
}
} catch (e) {
// pgrep missing/unusable — fall through to lock removal, which alone fixes
// the common case (Chrome treats a lock owned by a dead pid as stale).
log(`Stale-browser scan skipped: ${e.message}`);
}
// 2. Remove leftover single-instance lock files so a fresh launch is unblocked
// even if a just-killed process had not finished releasing them.
for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
try { fs.rmSync(path.join(SESSION_PROFILE, f), { force: true }); } catch (_) {}
}
}
// ── WhatsApp Client ────────────────────────────────────────────────────────
function initClient() {
// Release any profile lock held by an orphaned browser from a prior (hard-
// killed) run before puppeteer tries to open the profile again.
cleanupStaleBrowser();
let Client, LocalAuth;
try {
({ Client, LocalAuth } = require('whatsapp-web.js'));
} catch (e) {
log(`ERROR: whatsapp-web.js not found. Run: cd scripts/whatsapp_mcp && npm install`);
state = 'DISCONNECTED';
return;
}
client = new Client({
authStrategy: new LocalAuth({ dataPath: SESSION_DIR }),
puppeteer: {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
});
client.on('qr', async (qr) => {
state = 'QR_READY';
// Dedup: skip file writes if the QR string hasn't changed.
if (qr === lastQrStr) return;
lastQrStr = qr;
const QR_HTML = path.join(SECRETS_DIR, 'whatsapp_qr.html');
// Generate a scannable HTML page with embedded PNG data-URL.
try {
const qrcode = require('qrcode');
const dataUrl = await qrcode.toDataURL(qr, { width: 300, margin: 2 });
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>WhatsApp QR</title>
<style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;font-family:sans-serif;}
img{border:12px solid white;border-radius:8px;}</style></head>
<body>
<h2>Scan with WhatsApp → Linked Devices → Link a Device</h2>
<img src="${dataUrl}" alt="WhatsApp QR Code" />
<p style="margin-top:1rem;opacity:.6">This QR expires in ~20 s — reload the page if it has changed</p>
</body>
</html>`;
fs.writeFileSync(QR_HTML, html, 'utf8');
log(`QR code saved → open in browser: ${QR_HTML}`);
// Also save a standalone PNG for direct access via HTTP / local file.
const pngPath = path.join(DATA_DIR, 'whatsapp_qr.png');
await qrcode.toFile(pngPath, qr, { width: 300, margin: 2 });
log(`QR PNG saved → ${pngPath}`);
} catch (e) {
// Fallback: ASCII art text file
try {
const qrTerm = require('qrcode-terminal');
let qrText = '';
qrTerm.generate(qr, { small: true }, (str) => { qrText = str; });
fs.writeFileSync(QR_FILE, qrText, 'utf8');
log(`QR code (ASCII) saved to ${QR_FILE} — scan it with WhatsApp`);
} catch (_) {
fs.writeFileSync(QR_FILE, `RAW_QR_STRING:\n${qr}`, 'utf8');
log(`QR raw string saved to ${QR_FILE}`);
}
}
});
client.on('authenticated', () => {
state = 'AUTHENTICATED';
lastQrStr = null;
cleanupQrFiles();
log('Authenticated successfully');
});
client.on('ready', () => {
state = 'READY';
log('WhatsApp client ready');
});
// ── Push notifications: new incoming messages ──────────────────────────
client.on('message', async (msg) => {
// Ignore messages sent by us.
if (msg.fromMe) return;
// Try to resolve the human-readable chat name.
let chatName = msg.from;
try {
const chat = await msg.getChat();
chatName = chat.name || msg.from;
} catch (_) {}
notify('event/whatsapp_message', {
chat_id: msg.from,
chat_name: chatName,
from: msg.author || msg.from,
body: (msg.body || '').slice(0, 1000),
timestamp: msg.timestamp,
is_group: msg.from.endsWith('@g.us'),
});
log(`Notification emitted: new message from ${chatName}`);
});
client.on('auth_failure', (msg) => {
state = 'DISCONNECTED';
log(`Auth failure: ${msg}`);
});
client.on('disconnected', (reason) => {
state = 'DISCONNECTED';
log(`Disconnected: ${reason}`);
});
client.initialize().catch((e) => {
log(`Failed to initialize client: ${e.message}`);
state = 'DISCONNECTED';
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
function requireReady() {
if (state !== 'READY') {
return `WhatsApp not ready (status: ${state}).${
state === 'QR_READY'
? ' Use get_qr to retrieve the QR code and scan it with your phone.'
: state === 'INITIALIZING'
? ' Please wait a moment and try again.'
: ''
}`;
}
return null;
}
function formatTimestamp(unixSec) {
return new Date(unixSec * 1000).toISOString().replace('T', ' ').slice(0, 19);
}
// Resolve the target chat from either an explicit chat_id or a raw phone
// number. Accepting a number removes the list_chats/search_contacts round-trip
// the agent otherwise needs just to message someone. Returns { id } on success
// or { error } with a ready-to-return message. Numbers only address individual
// contacts — groups must be passed as a chat_id.
async function resolveChatId(args) {
if (args.chat_id) return { id: String(args.chat_id) };
if (args.number) {
const digits = String(args.number).replace(/\D/g, '');
if (!digits) return { error: 'Error: `number` has no digits.' };
const wid = await client.getNumberId(digits);
if (!wid) return { error: `Error: ${args.number} is not a WhatsApp number.` };
return { id: wid._serialized };
}
return { error: 'Error: provide either chat_id or number.' };
}
// Map a MIME type to a file extension for saved media (fallback: 'bin').
function extFromMime(mime) {
if (!mime) return 'bin';
const map = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp',
'video/mp4': 'mp4', 'audio/ogg': 'ogg', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
'application/pdf': 'pdf',
};
return map[mime] || (mime.split('/')[1] || 'bin').split(';')[0];
}
// ── Tool implementations ───────────────────────────────────────────────────
// Build a clear, LLM-actionable status report: the state, a plain-language
// description of what it means, and concrete next steps whenever it is not
// fully operational. Our lifecycle `state` is event-driven and can lag behind a
// silent socket drop, so when we believe we are connected we confirm against
// the live socket state (`client.getState()` → WAState) and surface any mismatch.
async function toolStatus() {
let liveState = null; // WAState string, or null
let liveErr = false; // getState() threw (page/browser gone)
if (client && state === 'READY') {
try {
liveState = await client.getState();
} catch (_) {
liveErr = true;
}
}
const report = (icon, label, kind, description, steps) => {
const lines = [`Status: ${label} ${icon} (${kind})`, description];
if (steps && steps.length) {
lines.push('', 'What to do:');
steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
}
return lines.join('\n');
};
// ── Fully operational ──
if (state === 'READY' && (liveState === 'CONNECTED' || (liveState === null && !liveErr))) {
return report('✅', 'READY', 'ok',
'WhatsApp is connected and all tools (read, search, send, media) are operational.');
}
// ── We think we are READY, but the live socket disagrees: silent drop ──
if (state === 'READY') {
if (liveState === 'UNPAIRED' || liveState === 'UNPAIRED_IDLE') {
return report('❌', `READY→${liveState}`, 'action needed',
'This device was unlinked from the phone — the session is no longer valid.',
['Call logout to clear the dead session.',
'Then call get_qr and have the user scan the new QR code.',
'Poll status until it returns READY.']);
}
if (liveState === 'CONFLICT') {
return report('⚠️', 'READY→CONFLICT', 'action needed',
'The session was taken over by WhatsApp Web open elsewhere (another browser/device).',
['Ask the user to close WhatsApp Web in the other browser, OR',
'Call logout and re-scan the QR to reclaim the session here.']);
}
if (liveState === 'TIMEOUT') {
return report('⚠️', 'READY→TIMEOUT', 'transient',
'The connection timed out; the client may reconnect on its own.',
['Wait ~15 s and call status again.',
'If it stays TIMEOUT, call logout and re-scan the QR.']);
}
if (liveState === 'DEPRECATED_VERSION') {
return report('❌', 'READY→DEPRECATED_VERSION', 'needs maintenance',
'WhatsApp rejected the WhatsApp Web version used by whatsapp-web.js.',
['This requires a dependency update (whatsapp-web.js) by the developer — the agent cannot fix it. Inform the user.']);
}
// getState() failed, or returned some other non-CONNECTED value.
return report('⚠️', `READY→${liveErr ? 'UNREACHABLE' : (liveState || 'UNKNOWN')}`, 'uncertain',
'We believe we are connected but the live socket did not confirm it (the headless browser may have hiccuped or crashed).',
['Wait a few seconds and call status again.',
'If it does not recover, call logout and re-scan the QR.']);
}
// ── Other lifecycle states ──
switch (state) {
case 'INITIALIZING':
return report('⏳', 'INITIALIZING', 'wait',
'The client is starting up (launching the browser and/or restoring the saved session).',
['Wait ~15-30 s, then call status again.']);
case 'AUTHENTICATED':
return report('⏳', 'AUTHENTICATED', 'wait',
'The QR was scanned and the session is being established — almost there.',
['Wait a few seconds, then call status again (it should turn READY).']);
case 'QR_READY':
return report('⚠️', 'QR_READY', 'action needed',
'Not logged in yet — WhatsApp needs the user to link this device with a QR code.',
['Call get_qr to obtain the QR code.',
'Ask the user to scan it: WhatsApp → Settings → Linked Devices → Link a Device.',
'Poll status until it returns READY.']);
case 'DISCONNECTED':
default:
return report('❌', state, 'action needed',
'The WhatsApp session is disconnected (lost connection, auth failure, or expired session).',
['Call logout to clear any stale session.',
'Then call get_qr and have the user scan the new QR code.',
'Poll status until it returns READY.']);
}
}
async function toolLogout() {
const prev = client;
// Reset shared state up front so a concurrent call (or the old client's
// late events) can't act on a half-torn-down instance.
client = null;
lastQrStr = null;
state = 'INITIALIZING';
if (prev) {
// Detach listeners so the old client's late 'disconnected'/'qr' events
// don't clobber the freshly re-initialized client's state.
try { prev.removeAllListeners(); } catch (_) {}
// Try a clean logout (tells WhatsApp to unlink this device). It runs JS in
// the browser page, so when the session has already expired the page is
// dead and this throws — exactly the case we must tolerate. Fall back to
// destroy() to at least close the browser and release the profile locks.
try {
await prev.logout();
} catch (e) {
log(`logout(): clean logout failed (session likely expired): ${e.message}`);
try { await prev.destroy(); } catch (e2) {
log(`logout(): destroy after failed logout also failed: ${e2.message}`);
}
}
}
// Force-remove the on-disk session cache — the stale token that previously
// had to be deleted by hand before a new login could succeed.
try {
await fs.promises.rm(SESSION_DIR, {
recursive: true, force: true, maxRetries: 5, retryDelay: 200,
});
} catch (e) {
log(`logout(): failed to clear session cache: ${e.message}`);
return `Error: logged out but could not clear the session cache at ${SESSION_DIR}: ${e.message}. ` +
`Delete that directory manually, then retry.`;
}
// Drop any stale QR artifacts so get_qr won't return an old code.
cleanupQrFiles();
// Re-initialize from scratch — a fresh QR is generated within a few seconds.
initClient();
return 'Logged out: session cache cleared and client re-initialized (no restart needed). ' +
'Wait a few seconds, then call get_qr to scan a new QR code and log in again.';
}
async function toolGetQr() {
if (state === 'READY' || state === 'AUTHENTICATED') {
return 'Already authenticated. No QR code needed.';
}
const QR_PNG = path.join(DATA_DIR, 'whatsapp_qr.png');
if (fs.existsSync(QR_PNG)) {
return `QR code ready.
• Local file: ${QR_PNG}
• URL: /data/whatsapp_qr.png
Open the URL in your browser or scan locally.
Current status: ${state}`;
}
const QR_HTML = path.join(SECRETS_DIR, 'whatsapp_qr.html');
if (fs.existsSync(QR_HTML)) {
return `Open this file in your browser to scan the QR code:\n ${QR_HTML}\n\nThe page shows a scannable QR image. Go to WhatsApp → Settings → Linked Devices → Link a Device.\n\nCurrent status: ${state}`;
}
if (fs.existsSync(QR_FILE)) {
const qr = fs.readFileSync(QR_FILE, 'utf8');
return `Scan this QR code with WhatsApp (Settings → Linked Devices → Link a Device):\n\n${qr}`;
}
return `No QR code available yet. Current status: ${state}. The client may still be initializing — try again in a few seconds.`;
}
async function toolListChats(args) {
const err = requireReady(); if (err) return err;
const max = Math.min(args.max_chats || 20, 50);
const chats = await client.getChats();
const slice = chats.slice(0, max);
const lines = [`Chats (${slice.length} of ${chats.length} total):`];
for (const chat of slice) {
const unread = chat.unreadCount > 0 ? ` [${chat.unreadCount} unread]` : '';
const type = chat.isGroup ? '[group]' : '[contact]';
lines.push(`- ${chat.name} ${type}${unread}`);
lines.push(` ID: ${chat.id._serialized}`);
}
return lines.join('\n');
}
async function toolGetMessages(args) {
const err = requireReady(); if (err) return err;
const resolved = await resolveChatId(args);
if (resolved.error) return resolved.error;
const limit = Math.min(args.limit || 20, 100);
const offset = Math.max(args.offset || 0, 0);
const chat = await client.getChatById(resolved.id);
// fetchMessages always returns the most recent N messages (oldest→newest).
// To support paging, we fetch limit+offset and discard the newest `offset`
// messages, exposing the preceding window.
// offset=0, limit=20 → last 20 messages
// offset=20, limit=20 → messages 2140 from the end (older batch)
const toFetch = Math.min(limit + offset, 200);
const fetched = await chat.fetchMessages({ limit: toFetch });
const windowed = offset > 0 ? fetched.slice(0, fetched.length - offset) : fetched;
const page = windowed.slice(-limit);
if (!page.length) return offset > 0
? `No messages found at offset ${offset} (only ${fetched.length} messages available).`
: 'No messages found.';
const rangeNote = offset > 0 ? `, skipping newest ${offset}` : '';
const lines = [`Messages from "${chat.name}" (${page.length} shown, limit=${limit}${rangeNote}):`];
for (const msg of page) {
const ts = formatTimestamp(msg.timestamp);
const author = msg.fromMe ? 'Me' : (msg.author || msg.from || '?');
// For media, surface the type and the message id so the agent can fetch it
// via download_media; text-only lines stay uncluttered.
const tag = msg.hasMedia ? ` [${msg.type}, download id="${msg.id._serialized}"]` : '';
const body = (msg.body || (msg.hasMedia ? '(no caption)' : '(no text)')).slice(0, 400);
lines.push(`[${ts}] ${author}${tag}: ${body}`);
}
return lines.join('\n');
}
async function toolSendMessage(args) {
const err = requireReady(); if (err) return err;
const { message } = args;
if (!message) return 'Error: Missing required parameter message.';
const resolved = await resolveChatId(args);
if (resolved.error) return resolved.error;
const chat = await client.getChatById(resolved.id);
await chat.sendMessage(message);
return `✅ Message sent to "${chat.name}"`;
}
async function toolSendMedia(args) {
const err = requireReady(); if (err) return err;
const { source } = args;
if (!source) return 'Error: Missing required parameter source (a local file path or an http(s) URL).';
const resolved = await resolveChatId(args);
if (resolved.error) return resolved.error;
const { MessageMedia } = require('whatsapp-web.js');
let media;
try {
if (/^https?:\/\//i.test(source)) {
media = await MessageMedia.fromUrl(source, { unsafeMime: true });
} else {
// Resolve relative paths against the project root so the agent can pass
// e.g. "data/foo.png" without knowing the server's CWD.
const abs = path.isAbsolute(source) ? source : path.join(PROJECT_ROOT, source);
if (!fs.existsSync(abs)) return `Error: file not found: ${abs}`;
media = MessageMedia.fromFilePath(abs);
}
} catch (e) {
return `Error: could not load media from "${source}": ${e.message}`;
}
const chat = await client.getChatById(resolved.id);
await chat.sendMessage(media, {
caption: args.caption || undefined,
sendMediaAsDocument: !!args.as_document,
});
return `✅ Media sent to "${chat.name}"${args.caption ? ` with caption: ${args.caption}` : ''}`;
}
async function toolDownloadMedia(args) {
const err = requireReady(); if (err) return err;
const { message_id } = args;
if (!message_id) return 'Error: Missing required parameter message_id (from the "download id" field in get_messages).';
const msg = await client.getMessageById(message_id);
if (!msg) return `Error: no message found with id "${message_id}".`;
if (!msg.hasMedia) return 'Error: that message has no downloadable media.';
const media = await msg.downloadMedia();
if (!media || !media.data) return 'Error: media could not be downloaded (it may have expired or been deleted).';
if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR, { recursive: true });
const ext = extFromMime(media.mimetype);
const safeBase = (media.filename || `${Date.now()}_${msg.id.id}`).replace(/[^\w.\-]/g, '_');
const fileName = /\.[a-z0-9]+$/i.test(safeBase) ? safeBase : `${safeBase}.${ext}`;
const absPath = path.join(MEDIA_DIR, fileName);
fs.writeFileSync(absPath, Buffer.from(media.data, 'base64'));
return `✅ Media downloaded (${media.mimetype}).\n• Local file: ${absPath}\n• URL: /data/whatsapp_media/${fileName}`;
}
async function toolSearchMessages(args) {
const err = requireReady(); if (err) return err;
const { query } = args;
if (!query) return 'Error: Missing required parameter query.';
const max = Math.min(args.max_results || 20, 50);
const messages = await client.searchMessages(query, { limit: max });
if (!messages.length) return `No messages found for query: "${query}"`;
const lines = [`Search results for "${query}" (${messages.length} found):`];
for (const msg of messages) {
const ts = formatTimestamp(msg.timestamp);
const from = msg.fromMe ? 'Me' : msg.from;
const body = (msg.body || '(media)').slice(0, 200);
lines.push(`[${ts}] ${from} (chat: ${msg.id.remote}): ${body}`);
}
return lines.join('\n');
}
async function toolSearchContacts(args) {
const err = requireReady(); if (err) return err;
const { query } = args;
if (!query) return 'Error: Missing required parameter query. Provide a name or partial name to search for.';
const max = Math.min(args.max_results || 20, 50);
const contacts = await client.getContacts();
const q = query.toLowerCase();
// Deduplicate by serialized ID, then filter by name match, exclude self.
const seen = new Set();
const matches = [];
for (const c of contacts) {
if (!c.name || c.isMe) continue;
const id = c.id._serialized;
if (seen.has(id)) continue;
seen.add(id);
if (c.name.toLowerCase().includes(q)) matches.push(c);
if (matches.length >= max) break;
}
if (!matches.length) return `No contacts found matching "${query}".`;
const lines = [`Contacts matching "${query}" (${matches.length} shown):`];
for (const c of matches) {
const type = c.isGroup ? '[group]' : c.isBusiness ? '[business]' : '[contact]';
lines.push(`- ${c.name} ${type} | ID: ${c.id._serialized}`);
}
return lines.join('\n');
}
// ── MCP Tool definitions ───────────────────────────────────────────────────
const TOOLS = [
{
name: 'status',
description: 'Get the current WhatsApp connection status as a plain-language report: the state, what it means, and — when not operational — step-by-step instructions to fix it. Cross-checks the live socket so a silently dropped session is detected even when bookkeeping still says READY. Call this first whenever another WhatsApp tool fails.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_qr',
description: 'Get the WhatsApp authentication QR code. Only relevant when status is QR_READY. Returns a local file path / URL (PNG or HTML page) to open and scan, with ASCII art as a fallback. The user scans it via WhatsApp → Linked Devices → Link a Device.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'logout',
description: 'Log out of WhatsApp: ends the current session, clears the cached credentials on disk, and re-initializes the client so a fresh QR code is generated immediately — no server restart needed. Use this when the session has expired/become stuck (status DISCONNECTED) or to link a different phone. After calling, wait a few seconds and use get_qr to scan the new code.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_chats',
description: 'List recent WhatsApp chats (individual contacts and groups) with name, ID, and unread count. Use the returned ID in other tools.',
inputSchema: {
type: 'object',
properties: {
max_chats: {
type: 'integer',
description: 'Max chats to return (default 20, max 50).',
},
},
},
},
{
name: 'get_messages',
description: 'Get messages from a WhatsApp chat or group. Identify the chat with EITHER chat_id ' +
'(from list_chats) OR a plain phone number for an individual contact. ' +
'Media messages are tagged with their type and a "download id" — pass that to download_media. ' +
'Supports pagination: use offset to skip the most recent messages and read older history. ' +
'Example: limit=20 offset=0 → last 20; limit=20 offset=20 → previous 20; limit=20 offset=40 → older 20.',
inputSchema: {
type: 'object',
properties: {
chat_id: {
type: 'string',
description: 'The chat ID (e.g. "39xxxxxxxxxx@c.us" for a contact, "xxxxxxxxxx-xxxxxxxxxx@g.us" for a group).',
},
number: {
type: 'string',
description: 'Alternative to chat_id for an individual contact: a phone number with country code (e.g. "393331234567" or "+39 333 123 4567"). Ignored if chat_id is given. Does not work for groups.',
},
limit: {
type: 'integer',
description: 'Number of messages to return (default 20, max 100).',
},
offset: {
type: 'integer',
description: 'Number of recent messages to skip before returning results (default 0). ' +
'Increment by `limit` to page through older history.',
},
},
},
},
{
name: 'send_message',
description: 'Send a WhatsApp text message. Identify the recipient with EITHER chat_id (from ' +
'list_chats) OR a plain phone number for an individual contact — no lookup needed first.',
inputSchema: {
type: 'object',
properties: {
chat_id: {
type: 'string',
description: 'The chat ID to send to (from list_chats). Use for groups.',
},
number: {
type: 'string',
description: 'Alternative to chat_id for an individual contact: a phone number with country code (e.g. "393331234567" or "+39 333 123 4567"). Ignored if chat_id is given.',
},
message: {
type: 'string',
description: 'The text message to send.',
},
},
required: ['message'],
},
},
{
name: 'send_media',
description: 'Send an image, video, audio, or document to a WhatsApp chat or group. Identify the ' +
'recipient with EITHER chat_id OR a phone number (individual contact). The media comes from a local ' +
'file path or an http(s) URL.',
inputSchema: {
type: 'object',
properties: {
chat_id: {
type: 'string',
description: 'The chat ID to send to. Use for groups.',
},
number: {
type: 'string',
description: 'Alternative to chat_id for an individual contact: a phone number with country code. Ignored if chat_id is given.',
},
source: {
type: 'string',
description: 'The media to send: a local file path (absolute, or relative to the project root) or an http(s) URL.',
},
caption: {
type: 'string',
description: 'Optional text caption to attach to the media.',
},
as_document: {
type: 'boolean',
description: 'Send as a file/document instead of an inline photo/video (default false).',
},
},
required: ['source'],
},
},
{
name: 'download_media',
description: 'Download the media (photo, video, audio, document) attached to a WhatsApp message and ' +
'save it locally. Pass the message_id shown as "download id" next to media messages in ' +
'get_messages. Returns the local file path and a /data/ URL.',
inputSchema: {
type: 'object',
properties: {
message_id: {
type: 'string',
description: 'The serialized message id from the "download id" field in get_messages.',
},
},
required: ['message_id'],
},
},
{
name: 'search_messages',
description: 'Search messages across all WhatsApp chats by keyword.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Keyword to search for in message content.',
},
max_results: {
type: 'integer',
description: 'Max results to return (default 20, max 50).',
},
},
required: ['query'],
},
},
{
name: 'search_contacts',
description: 'Search saved WhatsApp contacts by name. Use this to find the ID of a contact ' +
'you want to message but who does not appear in recent chats. ' +
'For conversations already open, use list_chats instead.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Name or partial name to search for (case-insensitive).',
},
max_results: {
type: 'integer',
description: 'Max contacts to return (default 20, max 50).',
},
},
required: ['query'],
},
},
];
// ── JSON-RPC helpers ───────────────────────────────────────────────────────
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 });
}
// ── Request dispatch ───────────────────────────────────────────────────────
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: '1.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 'status': text = await toolStatus(toolArgs); break;
case 'get_qr': text = await toolGetQr(toolArgs); break;
case 'logout': text = await toolLogout(toolArgs); 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 'send_media': text = await toolSendMedia(toolArgs); break;
case 'download_media': text = await toolDownloadMedia(toolArgs); break;
case 'search_messages': text = await toolSearchMessages(toolArgs); break;
case 'search_contacts': text = await toolSearchContacts(toolArgs); break;
default:
return textResult(id, `Unknown tool: ${toolName}`, true);
}
} catch (e) {
log(`Unhandled error in tool '${toolName}': ${e.message}`);
return textResult(id, `Error: Internal error in '${toolName}': ${e.message}`, true);
}
const isErr = 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');
log(`Session dir: ${SESSION_DIR}`);
if (!fs.existsSync(SECRETS_DIR)) {
fs.mkdirSync(SECRETS_DIR, { recursive: true });
}
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
initClient();
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(`Invalid 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');
shutdown(0);
});
// Graceful shutdown for the signals we *can* trap. The host uses SIGKILL
// (untrappable) on hard restarts — that path is covered by cleanupStaleBrowser()
// on the next startup — but a container/OS stop sends SIGTERM first, and here we
// close the browser cleanly so it releases the profile lock on the way out.
process.on('SIGTERM', () => { log('SIGTERM received'); shutdown(0); });
process.on('SIGINT', () => { log('SIGINT received'); shutdown(0); });
}
// Close the browser (releasing the profile lock) before exiting. Guarded against
// re-entry and bounded by a timeout so a wedged page can never hang shutdown.
let shuttingDown = false;
async function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
try {
if (client) {
await Promise.race([
client.destroy(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
}
} catch (_) {}
process.exit(code);
}
main().catch((e) => {
log(`Fatal: ${e.message}`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "whatsapp-mcp-server",
"version": "1.0.0",
"description": "WhatsApp MCP server for skald (JSON-RPC 2.0 over stdio)",
"main": "index.js",
"scripts": {
"start": "node index.js",
"install-deps": "npm install"
},
"dependencies": {
"puppeteer": "^25.1.0",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"whatsapp-web.js": "^1.34.7"
},
"engines": {
"node": ">=18.0.0"
}
}