First Version
This commit is contained in:
Executable
+43
@@ -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."
|
||||
@@ -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()
|
||||
Executable
+980
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
0–100+ scale: 0–20 Good, 20–40 Fair, 40–60 Moderate, 60–80 Poor,
|
||||
80–100 Very poor, >100 Extremely poor."""
|
||||
v = _num(value)
|
||||
if v is None:
|
||||
return "Unknown" if value is None else f"Unknown ({value})"
|
||||
if v <= 20:
|
||||
return "Good"
|
||||
if v <= 40:
|
||||
return "Fair"
|
||||
if v <= 60:
|
||||
return "Moderate"
|
||||
if v <= 80:
|
||||
return "Poor"
|
||||
if v <= 100:
|
||||
return "Very Poor"
|
||||
return "Extremely Poor"
|
||||
|
||||
|
||||
def _aqi_label_us(value: Any) -> str:
|
||||
"""US AQI band name. Open-Meteo returns a numeric USAQI on the 0–500
|
||||
scale: 0–50 Good, 51–100 Moderate, 101–150 Unhealthy for sensitive
|
||||
groups, 151–200 Unhealthy, 201–300 Very Unhealthy, 301–500 Hazardous."""
|
||||
v = _num(value)
|
||||
if v is None:
|
||||
return "Unknown" if value is None else f"Unknown ({value})"
|
||||
if v <= 50:
|
||||
return "Good"
|
||||
if v <= 100:
|
||||
return "Moderate"
|
||||
if v <= 150:
|
||||
return "Unhealthy for sensitive groups"
|
||||
if v <= 200:
|
||||
return "Unhealthy"
|
||||
if v <= 300:
|
||||
return "Very Unhealthy"
|
||||
return "Hazardous"
|
||||
|
||||
|
||||
def _wind_direction(degrees: float | None) -> str:
|
||||
"""Convert wind degrees to compass direction."""
|
||||
if degrees is None:
|
||||
return "?"
|
||||
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||
idx = round(degrees / 22.5) % 16
|
||||
return directions[idx]
|
||||
|
||||
|
||||
def _num(val: Any) -> float | None:
|
||||
"""Best-effort numeric coercion. Returns None if missing or non-numeric.
|
||||
|
||||
Avoids ValueError/TypeError when the API returns null or an unexpected type
|
||||
and the caller wants a safe numeric comparison.
|
||||
"""
|
||||
if val is None or isinstance(val, bool):
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_http_error(e: Exception, api_label: str) -> str:
|
||||
"""Map an httpx/network exception into an actionable Error: string.
|
||||
|
||||
`api_label` names the failing endpoint family (e.g. "Forecast", "Geocoding")
|
||||
so the user/LLM knows where to look.
|
||||
"""
|
||||
if isinstance(e, httpx.TimeoutException):
|
||||
return f"Error: {api_label} API request timed out. Retry in a moment."
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return f"Error: {api_label} API returned HTTP {e.response.status_code}."
|
||||
if isinstance(e, httpx.HTTPError):
|
||||
return f"Error: {api_label} API request failed (network error): {e}."
|
||||
return f"Error: {api_label} API call failed: {e}"
|
||||
|
||||
|
||||
def _air_quality_advice(eu_aqi: Any, us_aqi: Any) -> str | None:
|
||||
"""Health-advice string based on the Open-Meteo numeric AQI scales.
|
||||
|
||||
Prefers the European AQI (EAQI 0–100+); falls back to the US AQI (0–500).
|
||||
Returns None when no AQI value is available.
|
||||
"""
|
||||
eu_n = _num(eu_aqi)
|
||||
us_n = _num(us_aqi)
|
||||
if eu_n is not None:
|
||||
if eu_n <= 20:
|
||||
return "✅ Air quality is good — no health concerns."
|
||||
if eu_n <= 40:
|
||||
return "✅ Air quality is fair — no health concerns."
|
||||
if eu_n <= 60:
|
||||
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
|
||||
if eu_n <= 80:
|
||||
return "⚠️ Poor air quality. Consider reducing outdoor activities, especially if you have respiratory conditions."
|
||||
return "🚨 Very poor or extremely poor air quality. Avoid outdoor exertion. Wear a mask if you must go out."
|
||||
if us_n is not None:
|
||||
if us_n <= 50:
|
||||
return "✅ Air quality is good — no health concerns."
|
||||
if us_n <= 100:
|
||||
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
|
||||
if us_n <= 150:
|
||||
return "⚠️ Unhealthy for sensitive groups. Reduce prolonged outdoor exertion."
|
||||
return "🚨 Unhealthy or hazardous air quality. Avoid outdoor exertion. Wear a mask if you must go out."
|
||||
return None
|
||||
|
||||
|
||||
def _geocode(city: str) -> tuple[float, float, str, str] | None:
|
||||
"""Resolve a city name to coordinates. Returns (lat, lon, name, country).
|
||||
|
||||
Returns None when the city is not found. Raises httpx.HTTPError on a
|
||||
network/HTTP failure — the caller is expected to catch and surface it via
|
||||
`_format_http_error` so the error is actionable rather than "Internal error".
|
||||
"""
|
||||
params = {"name": city, "count": 3, "language": "en", "format": "json"}
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(GEOCODING_URL, params=params, headers=COMMON_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
r = results[0]
|
||||
return (
|
||||
float(r["latitude"]),
|
||||
float(r["longitude"]),
|
||||
r.get("name", city),
|
||||
r.get("country", ""),
|
||||
)
|
||||
|
||||
|
||||
# ── Tool implementations ────────────────────────────────────────────────────────
|
||||
|
||||
def _weather_status(args: dict[str, Any]) -> str:
|
||||
"""Self-check: confirm Open-Meteo is reachable and serving data.
|
||||
|
||||
Performs one cheap geocode ("Rome") plus one current-weather probe so we
|
||||
exercise the Geocoding + Forecast endpoints in a single round-trip.
|
||||
"""
|
||||
try:
|
||||
geo = _geocode("Rome")
|
||||
if geo is None:
|
||||
return "Error: Geocoding API returned no result for the probe query."
|
||||
lat, lon, _, _ = geo
|
||||
|
||||
params = {"latitude": lat, "longitude": lon, "current": "temperature_2m"}
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not data.get("current"):
|
||||
return "Error: Forecast API responded but returned no current data for the probe."
|
||||
|
||||
return (
|
||||
"OK: Open-Meteo is reachable. Geocoding and Forecast APIs respond.\n"
|
||||
"All tools (get_current_weather, get_forecast, get_air_quality) are operational."
|
||||
)
|
||||
except Exception as e:
|
||||
return _format_http_error(e, "Forecast")
|
||||
|
||||
|
||||
def _weather_current(args: dict[str, Any]) -> str:
|
||||
"""Get current weather conditions for a city."""
|
||||
city = args.get("city", "").strip()
|
||||
if not city:
|
||||
return "Error: Missing required parameter 'city'."
|
||||
|
||||
units = args.get("units", "metric")
|
||||
if units not in ("metric", "imperial"):
|
||||
return "Error: 'units' must be 'metric' or 'imperial'."
|
||||
|
||||
try:
|
||||
geo = _geocode(city)
|
||||
if geo is None:
|
||||
return f"Error: Could not find location '{city}'. Check spelling and use English names."
|
||||
lat, lon, name, country = geo
|
||||
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current": (
|
||||
"temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,"
|
||||
"wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover,"
|
||||
"precipitation,rain,uv_index,pressure_msl,visibility"
|
||||
),
|
||||
"timezone": "auto",
|
||||
}
|
||||
if units == "imperial":
|
||||
params["temperature_unit"] = "fahrenheit"
|
||||
params["wind_speed_unit"] = "mph"
|
||||
params["precipitation_unit"] = "inch"
|
||||
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return _format_http_error(e, "Forecast")
|
||||
|
||||
cur = data.get("current", {})
|
||||
if not cur:
|
||||
return f"Error: No current weather data available for '{city}'."
|
||||
|
||||
temp_unit = "°F" if units == "imperial" else "°C"
|
||||
wind_unit = "mph" if units == "imperial" else "km/h"
|
||||
precip_unit = "in" if units == "imperial" else "mm"
|
||||
# Open-Meteo returns visibility always in meters; no unit selector exists,
|
||||
# so we convert explicitly per unit system.
|
||||
vis_m = _num(cur.get("visibility"))
|
||||
if vis_m is not None:
|
||||
if units == "imperial":
|
||||
vis_str, vis_unit = f"{vis_m / 1609.34:.1f}", "mi"
|
||||
else:
|
||||
vis_str, vis_unit = f"{vis_m / 1000:.1f}", "km"
|
||||
else:
|
||||
vis_str, vis_unit = "?", "km" if units == "metric" else "mi"
|
||||
|
||||
temp = cur.get("temperature_2m", "?")
|
||||
feels_like = cur.get("apparent_temperature", "?")
|
||||
humidity = cur.get("relative_humidity_2m", "?")
|
||||
wmo = cur.get("weather_code")
|
||||
wind_speed = cur.get("wind_speed_10m", "?")
|
||||
wind_deg = cur.get("wind_direction_10m")
|
||||
wind_gust = cur.get("wind_gusts_10m")
|
||||
cloud = cur.get("cloud_cover", "?")
|
||||
precip = _num(cur.get("precipitation"))
|
||||
rain = _num(cur.get("rain"))
|
||||
uv = cur.get("uv_index", "?")
|
||||
pressure = cur.get("pressure_msl", "?")
|
||||
|
||||
loc_label = f"{name}, {country}" if country else name
|
||||
lines = [
|
||||
f"📍 {loc_label} (Current weather)",
|
||||
f"",
|
||||
f"🌡 Temperature: {temp}{temp_unit} (feels like {feels_like}{temp_unit})",
|
||||
f"☁️ Conditions: {_wmo_desc(wmo)}",
|
||||
f"💧 Humidity: {humidity}%",
|
||||
f"🌬 Wind: {wind_speed} {wind_unit} from {_wind_direction(wind_deg)}",
|
||||
]
|
||||
|
||||
wind_gust_n = _num(wind_gust)
|
||||
if wind_gust_n and wind_gust_n > 0:
|
||||
lines.append(f" Gusts up to {wind_gust_n:g} {wind_unit}")
|
||||
|
||||
lines.append(f"☁️ Cloud cover: {cloud}%")
|
||||
lines.append(f"📊 Pressure: {pressure} hPa")
|
||||
lines.append(f"👁 Visibility: {vis_str} {vis_unit}")
|
||||
lines.append(f"☀️ UV index: {uv}")
|
||||
|
||||
if precip and precip > 0:
|
||||
lines.append(f"🌧 Precipitation: {precip:g} {precip_unit}")
|
||||
elif rain and rain > 0:
|
||||
lines.append(f"🌧 Rain: {rain:g} {precip_unit}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _weather_forecast(args: dict[str, Any]) -> str:
|
||||
"""Get multi-day weather forecast for a city."""
|
||||
city = args.get("city", "").strip()
|
||||
if not city:
|
||||
return "Error: Missing required parameter 'city'."
|
||||
|
||||
days_raw = args.get("days", 5)
|
||||
try:
|
||||
days = int(days_raw)
|
||||
except (TypeError, ValueError):
|
||||
return f"Error: 'days' must be an integer between 1 and 16. Got: {days_raw!r}."
|
||||
if days < 1 or days > 16:
|
||||
return "Error: 'days' must be between 1 and 16."
|
||||
|
||||
units = args.get("units", "metric")
|
||||
if units not in ("metric", "imperial"):
|
||||
return "Error: 'units' must be 'metric' or 'imperial'."
|
||||
|
||||
try:
|
||||
geo = _geocode(city)
|
||||
if geo is None:
|
||||
return f"Error: Could not find location '{city}'. Check spelling and use English names."
|
||||
lat, lon, name, country = geo
|
||||
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"daily": (
|
||||
"weather_code,temperature_2m_max,temperature_2m_min,"
|
||||
"apparent_temperature_max,apparent_temperature_min,"
|
||||
"precipitation_sum,rain_sum,precipitation_probability_max,"
|
||||
"sunrise,sunset,wind_speed_10m_max,wind_direction_10m_dominant"
|
||||
),
|
||||
"forecast_days": days,
|
||||
"timezone": "auto",
|
||||
}
|
||||
if units == "imperial":
|
||||
params["temperature_unit"] = "fahrenheit"
|
||||
params["wind_speed_unit"] = "mph"
|
||||
params["precipitation_unit"] = "inch"
|
||||
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return _format_http_error(e, "Forecast")
|
||||
|
||||
daily = data.get("daily", {})
|
||||
if not daily or "time" not in daily:
|
||||
return f"Error: No forecast data available for '{city}'."
|
||||
|
||||
temp_unit = "°F" if units == "imperial" else "°C"
|
||||
wind_unit = "mph" if units == "imperial" else "km/h"
|
||||
precip_unit = "in" if units == "imperial" else "mm"
|
||||
|
||||
loc_label = f"{name}, {country}" if country else name
|
||||
lines = [f"📍 {loc_label} — {days}-day forecast"]
|
||||
lines.append("")
|
||||
|
||||
times = daily.get("time", [])
|
||||
max_temps = daily.get("temperature_2m_max", [])
|
||||
min_temps = daily.get("temperature_2m_min", [])
|
||||
feel_max = daily.get("apparent_temperature_max", [])
|
||||
feel_min = daily.get("apparent_temperature_min", [])
|
||||
wmos = daily.get("weather_code", [])
|
||||
precip_sum = daily.get("precipitation_sum", [])
|
||||
rain_sum = daily.get("rain_sum", [])
|
||||
precip_prob = daily.get("precipitation_probability_max", [])
|
||||
sunrises = daily.get("sunrise", [])
|
||||
sunsets = daily.get("sunset", [])
|
||||
wind_max = daily.get("wind_speed_10m_max", [])
|
||||
wind_dir = daily.get("wind_direction_10m_dominant", [])
|
||||
|
||||
for i, t in enumerate(times):
|
||||
lines.append(f"── {t} ──")
|
||||
lines.append(f" 🌡 {min_temps[i] if i < len(min_temps) else '?'}–{max_temps[i] if i < len(max_temps) else '?'}{temp_unit}"
|
||||
f" (feels {feel_min[i] if i < len(feel_min) else '?'}–{feel_max[i] if i < len(feel_max) else '?'}{temp_unit})")
|
||||
lines.append(f" ☁️ {_wmo_desc(wmos[i] if i < len(wmos) else None)}")
|
||||
|
||||
prob = precip_prob[i] if i < len(precip_prob) else 0
|
||||
ps_n = _num(precip_sum[i] if i < len(precip_sum) else None)
|
||||
rs_n = _num(rain_sum[i] if i < len(rain_sum) else None)
|
||||
if prob and prob > 0:
|
||||
lines.append(f" 🌧 Rain: {prob}% chance"
|
||||
f"{f', {ps_n:g} {precip_unit} precip' if ps_n and ps_n > 0 else ''}"
|
||||
f"{f' ({rs_n:g} {precip_unit} rain)' if rs_n and rs_n > 0 else ''}")
|
||||
|
||||
wd = wind_dir[i] if i < len(wind_dir) else None
|
||||
wm_n = _num(wind_max[i] if i < len(wind_max) else None)
|
||||
if wm_n is not None and wm_n > 0:
|
||||
lines.append(f" 🌬 Wind: up to {wm_n:g} {wind_unit} from {_wind_direction(wd)}")
|
||||
|
||||
sr = sunrises[i] if i < len(sunrises) else ""
|
||||
ss = sunsets[i] if i < len(sunsets) else ""
|
||||
if sr and ss:
|
||||
lines.append(f" 🌅 Sunrise: {sr} | 🌇 Sunset: {ss}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _weather_air_quality(args: dict[str, Any]) -> str | dict[str, Any]:
|
||||
"""Get air quality data for a city.
|
||||
|
||||
On success returns a structured result carrying the formatted text summary
|
||||
alongside the raw numeric AQI/pollutant values (see `outputSchema`). On
|
||||
failure returns a plain `Error:` string.
|
||||
"""
|
||||
city = args.get("city", "").strip()
|
||||
if not city:
|
||||
return "Error: Missing required parameter 'city'."
|
||||
|
||||
try:
|
||||
geo = _geocode(city)
|
||||
if geo is None:
|
||||
return f"Error: Could not find location '{city}'. Check spelling and use English names."
|
||||
lat, lon, name, country = geo
|
||||
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current": (
|
||||
"european_aqi,us_aqi,pm2_5,pm10,"
|
||||
"nitrogen_dioxide,ozone,carbon_monoxide,sulphur_dioxide,ammonia"
|
||||
),
|
||||
"timezone": "auto",
|
||||
}
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(AIR_URL, params=params, headers=COMMON_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return _format_http_error(e, "Air Quality")
|
||||
|
||||
cur = data.get("current", {})
|
||||
if not cur:
|
||||
return f"Error: No air quality data available for '{city}'."
|
||||
|
||||
eu_aqi = cur.get("european_aqi")
|
||||
us_aqi = cur.get("us_aqi")
|
||||
|
||||
# Numeric pollutant values (None when missing/non-numeric).
|
||||
pm25 = _num(cur.get("pm2_5"))
|
||||
pm10 = _num(cur.get("pm10"))
|
||||
no2 = _num(cur.get("nitrogen_dioxide"))
|
||||
o3 = _num(cur.get("ozone"))
|
||||
co = _num(cur.get("carbon_monoxide"))
|
||||
so2 = _num(cur.get("sulphur_dioxide"))
|
||||
nh3 = _num(cur.get("ammonia"))
|
||||
pollutants = {"pm2_5": pm25, "pm10": pm10, "no2": no2, "o3": o3,
|
||||
"co": co, "so2": so2, "nh3": nh3}
|
||||
|
||||
eu_label = _aqi_label_eu(eu_aqi) if eu_aqi is not None else None
|
||||
us_label = _aqi_label_us(us_aqi) if us_aqi is not None else None
|
||||
advice = _air_quality_advice(eu_aqi, us_aqi)
|
||||
|
||||
loc_label = f"{name}, {country}" if country else name
|
||||
|
||||
# ── Formatted text summary (kept inside the structured payload so the LLM
|
||||
# still has the human-readable emoji output alongside the raw numbers).
|
||||
def _fmt(val: float | None) -> str:
|
||||
return f"{val:g} µg/m³" if val is not None else "?"
|
||||
|
||||
lines = [f"📍 {loc_label} (Air Quality)", ""]
|
||||
if eu_aqi is not None:
|
||||
lines.append(f"🇪🇺 European AQI: {eu_aqi} ({eu_label})")
|
||||
if us_aqi is not None:
|
||||
lines.append(f"🇺🇸 US AQI: {us_aqi} ({us_label})")
|
||||
lines.append("")
|
||||
lines.append(" • PM2.5: " + _fmt(pm25))
|
||||
lines.append(" • PM10: " + _fmt(pm10))
|
||||
lines.append(" • NO₂: " + _fmt(no2))
|
||||
lines.append(" • O₃: " + _fmt(o3))
|
||||
lines.append(" • CO: " + _fmt(co))
|
||||
lines.append(" • SO₂: " + _fmt(so2))
|
||||
lines.append(" • NH₃: " + _fmt(nh3))
|
||||
lines.append("")
|
||||
if advice:
|
||||
lines.append(advice)
|
||||
|
||||
return {
|
||||
"location": loc_label,
|
||||
"summary": "\n".join(lines),
|
||||
"european_aqi": eu_aqi,
|
||||
"european_aqi_label": eu_label,
|
||||
"us_aqi": us_aqi,
|
||||
"us_aqi_label": us_label,
|
||||
"pollutants_ug_m3": pollutants,
|
||||
"health_advice": advice,
|
||||
}
|
||||
|
||||
|
||||
# ── Tool manifest ────────────────────────────────────────────────────────────────
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "status",
|
||||
"description": (
|
||||
"Self-check that the Weather integration is operational: verifies the "
|
||||
"Open-Meteo Geocoding and Forecast APIs are reachable by performing one "
|
||||
"cheap probe (geocode 'Rome' + current temperature). Call this first "
|
||||
"whenever another weather tool fails, or to give the user a quick yes/no "
|
||||
"on whether weather data is usable right now."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_current_weather",
|
||||
"description": (
|
||||
"Get current weather conditions for any city worldwide. "
|
||||
"Returns temperature, feels-like, humidity, wind (speed/direction/gusts), "
|
||||
"conditions (clear/rain/snow/etc.), cloud cover, pressure, visibility, "
|
||||
"UV index, and precipitation. Free, no API key required."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name in English (e.g. 'London', 'Rome', 'Tokyo').",
|
||||
},
|
||||
"units": {
|
||||
"type": "string",
|
||||
"enum": ["metric", "imperial"],
|
||||
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
|
||||
},
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_forecast",
|
||||
"description": (
|
||||
"Get multi-day weather forecast for any city worldwide. "
|
||||
"Returns daily min/max temperature (and feels-like), conditions, rain probability, "
|
||||
"precipitation amounts, wind max/direction, sunrise/sunset times. "
|
||||
"Use when you need to plan upcoming days. Free, no API key required."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name in English (e.g. 'Paris', 'New York').",
|
||||
},
|
||||
"days": {
|
||||
"type": "integer",
|
||||
"description": "Number of forecast days (1–16). Default: 5.",
|
||||
},
|
||||
"units": {
|
||||
"type": "string",
|
||||
"enum": ["metric", "imperial"],
|
||||
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
|
||||
},
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_air_quality",
|
||||
"description": (
|
||||
"Get current air quality for any city worldwide. "
|
||||
"Returns European and US AQI indices, plus detailed pollutant levels: "
|
||||
"PM2.5, PM10, NO₂, O₃, CO, SO₂, NH₃. Includes health advice based on the AQI level. "
|
||||
"Returns structured content: a `summary` text plus the raw numeric AQI and "
|
||||
"pollutant values for machine consumption. Free, no API key required."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name in English (e.g. 'Beijing', 'London').",
|
||||
},
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"european_aqi": {"type": ["number", "null"]},
|
||||
"european_aqi_label": {"type": ["string", "null"]},
|
||||
"us_aqi": {"type": ["number", "null"]},
|
||||
"us_aqi_label": {"type": ["string", "null"]},
|
||||
"pollutants_ug_m3": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pm2_5": {"type": ["number", "null"]},
|
||||
"pm10": {"type": ["number", "null"]},
|
||||
"no2": {"type": ["number", "null"]},
|
||||
"o3": {"type": ["number", "null"]},
|
||||
"co": {"type": ["number", "null"]},
|
||||
"so2": {"type": ["number", "null"]},
|
||||
"nh3": {"type": ["number", "null"]},
|
||||
},
|
||||
},
|
||||
"health_advice": {"type": ["string", "null"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
TOOL_DISPATCH = {
|
||||
"status": _weather_status,
|
||||
"get_current_weather": _weather_current,
|
||||
"get_forecast": _weather_forecast,
|
||||
"get_air_quality": _weather_air_quality,
|
||||
}
|
||||
|
||||
|
||||
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
|
||||
|
||||
def _ok(req_id: Any, result: Any) -> str:
|
||||
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
|
||||
|
||||
|
||||
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
|
||||
payload: dict = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]},
|
||||
}
|
||||
if is_error:
|
||||
payload["result"]["isError"] = True
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def _structured_result(req_id: Any, structured: dict) -> str:
|
||||
"""Build a JSON-RPC result carrying structuredContent (canonical for MCP
|
||||
structured tool results) plus a text mirror in `content[]` for plain
|
||||
clients. The structured object is expected to embed a human-readable
|
||||
`summary` string alongside the raw numeric fields.
|
||||
|
||||
Skald prefers structuredContent when present, so the LLM sees the JSON
|
||||
object (which contains the formatted summary)."""
|
||||
summary = structured.get("summary")
|
||||
if not isinstance(summary, str):
|
||||
summary = json.dumps(structured, ensure_ascii=False)
|
||||
return json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": summary}],
|
||||
"structuredContent": structured,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def _error(req_id: Any, code: int, message: str) -> str:
|
||||
return json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": code, "message": message},
|
||||
})
|
||||
|
||||
|
||||
def handle_request(msg: dict) -> str | None:
|
||||
method = msg.get("method", "")
|
||||
req_id = msg.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return _ok(req_id, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "weather",
|
||||
"version": "1.2.0",
|
||||
},
|
||||
})
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "ping":
|
||||
return _ok(req_id, {})
|
||||
|
||||
if method == "tools/list":
|
||||
return _ok(req_id, {"tools": TOOLS})
|
||||
|
||||
if method == "tools/call":
|
||||
params = msg.get("params", {})
|
||||
tool_name = params.get("name", "")
|
||||
tool_args = params.get("arguments", {})
|
||||
|
||||
handler = TOOL_DISPATCH.get(tool_name)
|
||||
if handler is None:
|
||||
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
|
||||
|
||||
try:
|
||||
result = handler(tool_args)
|
||||
# A dict return is a structured result (structuredContent); a str
|
||||
# return is plain text (an "Error:" prefix marks it as isError).
|
||||
if isinstance(result, dict):
|
||||
return _structured_result(req_id, result)
|
||||
is_err = result.startswith("Error:")
|
||||
return _text_result(req_id, result, is_error=is_err)
|
||||
except Exception as e:
|
||||
log(f"Unhandled exception in tool '{tool_name}': {e}")
|
||||
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
|
||||
|
||||
return _error(req_id, -32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
# ── Main loop ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
log("Starting weather MCP server (Open-Meteo)")
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"Invalid JSON input: {e}")
|
||||
continue
|
||||
|
||||
resp = handle_request(msg)
|
||||
if resp is not None:
|
||||
sys.stdout.write(resp + "\n")
|
||||
sys.stdout.flush()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 21–40 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);
|
||||
});
|
||||
Generated
+2702
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user