Release 0.2.0 #4
@@ -16,5 +16,5 @@
|
|||||||
"inject_skills": false,
|
"inject_skills": false,
|
||||||
"inject_memory": ["user-memory/index.md"],
|
"inject_memory": ["user-memory/index.md"],
|
||||||
"icon": "icon.png",
|
"icon": "icon.png",
|
||||||
"strength": "medium"
|
"strength": "average"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"inject_skills": false,
|
"inject_skills": false,
|
||||||
"inject_memory": ["shared-memory/index.md"],
|
"inject_memory": ["shared-memory/index.md"],
|
||||||
"icon": "icon.png",
|
"icon": "icon.png",
|
||||||
"strength": "medium"
|
"strength": "average"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,3 +298,42 @@ fn render_agents_list() -> Result<String> {
|
|||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Every shipped `meta.json` must deserialize. `discover()` warns and skips a
|
||||||
|
/// malformed one so a single bad file cannot blank the roster — which means a
|
||||||
|
/// typo'd field costs an agent its place in the UI and says nothing louder than
|
||||||
|
/// a log line. (It cost the two memory-lint agents theirs: `"strength": "medium"`
|
||||||
|
/// is not an `LlmStrength`.) This test is where that silence gets broken.
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_agent_meta_parses() {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../..")
|
||||||
|
.join(AGENTS_DIR);
|
||||||
|
let dir = std::fs::read_dir(&root)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display()));
|
||||||
|
|
||||||
|
let mut checked = 0;
|
||||||
|
for entry in dir {
|
||||||
|
let path = entry.expect("readable dir entry").path();
|
||||||
|
if !path.is_dir() || path.file_name().and_then(|n| n.to_str()) == Some("common") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let meta_path = path.join("meta.json");
|
||||||
|
if !meta_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&meta_path)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", meta_path.display()));
|
||||||
|
if let Err(e) = serde_json::from_str::<RawMeta>(&raw) {
|
||||||
|
panic!("{} is not a valid agent meta: {e}", meta_path.display());
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
assert!(checked > 0, "no agent meta.json found under {}", root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
# Build a fully static Linux binary (musl) without any host cross-toolchain.
|
|
||||||
#
|
|
||||||
# Since openssl is gone (rustls) and the crypto backend is `ring` (no OpenSSL /
|
|
||||||
# aws-lc cmake build), the only native code left to cross-compile is SQLite
|
|
||||||
# (bundled) and the tree-sitter C grammars — both of which the musl-cross image
|
|
||||||
# handles out of the box. `whisper-local` (whisper.cpp, C++) is dropped via
|
|
||||||
# --no-default-features because it is heavy and irrelevant to a headless server;
|
|
||||||
# set FEATURES="" to include it.
|
|
||||||
#
|
|
||||||
# Requirements: Docker. No Rust/musl toolchain needed on the host.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/build-musl.sh # x86_64 static binary
|
|
||||||
# TARGET=aarch64-unknown-linux-musl \
|
|
||||||
# IMAGE=messense/rust-musl-cross:aarch64-musl \
|
|
||||||
# scripts/build-musl.sh # arm64 static binary
|
|
||||||
#
|
|
||||||
# Output: target/musl/<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."
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Demo MCP server (stdio, JSON-RPC 2.0) exercising MCP elicitation.
|
|
||||||
|
|
||||||
Two tools demonstrate the two card types Skald renders in the Agent Inbox:
|
|
||||||
|
|
||||||
* ``ask_secret`` — elicits a single masked field (``format: password``).
|
|
||||||
Returns only a masked confirmation; the value is held in
|
|
||||||
RAM (this process) and never echoed back to the caller.
|
|
||||||
* ``confirm`` — elicits with an empty schema → a yes/no confirmation.
|
|
||||||
|
|
||||||
Register it from the LLM ("register an MCP server, command python3, args
|
|
||||||
scripts/elicitation_demo_mcp.py") or via the MCP servers UI, then ask the agent
|
|
||||||
to call the tool. The request appears in the Agent Inbox under "Secrets".
|
|
||||||
|
|
||||||
No third-party dependencies: a plain ``readline`` JSON-RPC loop, matching how
|
|
||||||
Skald's stdio client speaks. ``elicitation/create`` is a server→client request;
|
|
||||||
the reply arrives on the same stdin and is matched by its id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import itertools
|
|
||||||
|
|
||||||
_next_id = itertools.count(1)
|
|
||||||
# Demo-only in-RAM secret cache, mirroring the SSH MCP "prompt" method: keep the
|
|
||||||
# value for the process lifetime, never write it to disk, never return it.
|
|
||||||
_secret_cache: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def send(obj: dict) -> None:
|
|
||||||
sys.stdout.write(json.dumps(obj) + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def readline() -> dict | None:
|
|
||||||
"""Blocking read of one non-empty JSON-RPC line; None on EOF."""
|
|
||||||
while True:
|
|
||||||
line = sys.stdin.readline()
|
|
||||||
if not line:
|
|
||||||
return None
|
|
||||||
line = line.strip()
|
|
||||||
if line:
|
|
||||||
return json.loads(line)
|
|
||||||
|
|
||||||
|
|
||||||
def elicit(message: str, requested_schema: dict) -> dict:
|
|
||||||
"""Send ``elicitation/create`` and block until the matching reply arrives.
|
|
||||||
|
|
||||||
Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}).
|
|
||||||
"""
|
|
||||||
eid = f"elicit-{next(_next_id)}"
|
|
||||||
send({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": eid,
|
|
||||||
"method": "elicitation/create",
|
|
||||||
"params": {"message": message, "requestedSchema": requested_schema},
|
|
||||||
})
|
|
||||||
while True:
|
|
||||||
msg = readline()
|
|
||||||
if msg is None:
|
|
||||||
return {"action": "cancel"}
|
|
||||||
if msg.get("id") == eid:
|
|
||||||
return msg.get("result", {"action": "cancel"})
|
|
||||||
# Any other inbound message mid-wait is ignored for this demo.
|
|
||||||
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{
|
|
||||||
"name": "ask_secret",
|
|
||||||
"description": "Ask the user for a secret value (masked) via elicitation.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"label": {"type": "string", "description": "what the secret is for"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "confirm",
|
|
||||||
"description": "Ask the user to confirm an action (yes/no) via elicitation.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"action": {"type": "string", "description": "the action to confirm"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def text_result(mid, text: str, is_error: bool = False) -> None:
|
|
||||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
|
||||||
"content": [{"type": "text", "text": text}], "isError": is_error}})
|
|
||||||
|
|
||||||
|
|
||||||
def handle_call(mid, name: str, args: dict) -> None:
|
|
||||||
if name == "ask_secret":
|
|
||||||
label = args.get("label", "the secret")
|
|
||||||
result = elicit(
|
|
||||||
f"Enter {label}",
|
|
||||||
{"type": "object",
|
|
||||||
"properties": {"secret": {"type": "string", "format": "password",
|
|
||||||
"title": label}},
|
|
||||||
"required": ["secret"]},
|
|
||||||
)
|
|
||||||
action = result.get("action")
|
|
||||||
if action == "accept":
|
|
||||||
value = (result.get("content") or {}).get("secret", "")
|
|
||||||
_secret_cache[label] = value
|
|
||||||
# Never return the secret itself — only proof we received it.
|
|
||||||
text_result(mid, f"OK — received {label} ({len(value)} chars, kept in RAM).")
|
|
||||||
else:
|
|
||||||
text_result(mid, f"Error: {label} required (user {action}).", is_error=True)
|
|
||||||
|
|
||||||
elif name == "confirm":
|
|
||||||
what = args.get("action", "this action")
|
|
||||||
result = elicit(f"Confirm: {what}?", {"type": "object", "properties": {}})
|
|
||||||
action = result.get("action")
|
|
||||||
text_result(mid, f"User {action}ed: {what}." if action == "accept"
|
|
||||||
else f"Not confirmed ({action}): {what}.",
|
|
||||||
is_error=(action != "accept"))
|
|
||||||
else:
|
|
||||||
send({"jsonrpc": "2.0", "id": mid,
|
|
||||||
"error": {"code": -32602, "message": f"unknown tool: {name}"}})
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
while True:
|
|
||||||
msg = readline()
|
|
||||||
if msg is None:
|
|
||||||
break
|
|
||||||
mid = msg.get("id")
|
|
||||||
method = msg.get("method")
|
|
||||||
if method == "initialize":
|
|
||||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
|
||||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
||||||
"serverInfo": {"name": "elicitation-demo", "version": "0.1.0"}}})
|
|
||||||
elif method == "notifications/initialized":
|
|
||||||
pass
|
|
||||||
elif method == "tools/list":
|
|
||||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}})
|
|
||||||
elif method == "tools/call":
|
|
||||||
params = msg.get("params", {})
|
|
||||||
handle_call(mid, params.get("name", ""), params.get("arguments", {}) or {})
|
|
||||||
elif mid is not None:
|
|
||||||
send({"jsonrpc": "2.0", "id": mid,
|
|
||||||
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,980 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate a Google OAuth token for the Calendar API (read + write).
|
|
||||||
|
|
||||||
This script runs a local OAuth flow that:
|
|
||||||
1. Opens your browser automatically to the Google authorization page
|
|
||||||
2. Handles the callback via a local HTTP server
|
|
||||||
3. Saves the resulting token to ./secrets/google_creds.json
|
|
||||||
|
|
||||||
Required OAuth scope: https://www.googleapis.com/auth/calendar
|
|
||||||
(full access — needed for create, update, delete, respond).
|
|
||||||
|
|
||||||
No manual copy-paste required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
SCOPES = [
|
|
||||||
"https://www.googleapis.com/auth/calendar",
|
|
||||||
]
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
SECRET_PATH = os.path.join(_ROOT, "secrets", "google_creds.json")
|
|
||||||
_OAUTH_CLIENT_PATH = os.path.join(_ROOT, "secrets", "google_oauth_client.json")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_oauth_client() -> tuple[str, str]:
|
|
||||||
if not os.path.exists(_OAUTH_CLIENT_PATH):
|
|
||||||
print(f"Missing OAuth client file: {_OAUTH_CLIENT_PATH}")
|
|
||||||
print('Create it with: {"client_id": "...", "client_secret": "..."}')
|
|
||||||
sys.exit(1)
|
|
||||||
with open(_OAUTH_CLIENT_PATH) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data["client_id"], data["client_secret"]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
try:
|
|
||||||
from google.auth.transport.requests import Request
|
|
||||||
from google.oauth2.credentials import Credentials
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"Missing dependencies: {e}")
|
|
||||||
print("Install with: pip install google-auth google-auth-oauthlib google-api-python-client")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
# Try to load existing credentials first.
|
|
||||||
if os.path.exists(SECRET_PATH):
|
|
||||||
print(f"Existing credentials found at {SECRET_PATH}")
|
|
||||||
try:
|
|
||||||
creds = Credentials.from_authorized_user_file(SECRET_PATH, SCOPES)
|
|
||||||
except Exception:
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
if creds and creds.valid:
|
|
||||||
print("Credentials are already valid!")
|
|
||||||
print(f" Scopes: {creds.scopes}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if creds and creds.expired and creds.refresh_token:
|
|
||||||
print("Token expired. Attempting refresh...")
|
|
||||||
try:
|
|
||||||
creds.refresh(Request())
|
|
||||||
print("Token refreshed successfully!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Refresh failed: {e}")
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
if not creds or not creds.valid:
|
|
||||||
client_id, client_secret = _load_oauth_client()
|
|
||||||
flow = InstalledAppFlow.from_client_config(
|
|
||||||
{
|
|
||||||
"installed": {
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
|
||||||
"redirect_uris": ["http://localhost"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SCOPES,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nOpening browser for Google authorization...")
|
|
||||||
creds = flow.run_local_server(
|
|
||||||
port=0,
|
|
||||||
open_browser=True,
|
|
||||||
prompt="consent",
|
|
||||||
access_type="offline",
|
|
||||||
)
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(SECRET_PATH), exist_ok=True)
|
|
||||||
with open(SECRET_PATH, "w") as f:
|
|
||||||
f.write(creds.to_json())
|
|
||||||
|
|
||||||
print(f"\n✅ Google Calendar OAuth token saved to {SECRET_PATH}")
|
|
||||||
print(f" Scopes: {creds.scopes}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate a Google OAuth token for Gmail API.
|
|
||||||
|
|
||||||
This script runs a local OAuth flow that:
|
|
||||||
1. Opens your browser automatically to the Google authorization page
|
|
||||||
2. Handles the callback via a local HTTP server
|
|
||||||
3. Saves the resulting token to ./secrets/gmail_creds.json
|
|
||||||
|
|
||||||
No manual copy-paste required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
SCOPES = [
|
|
||||||
"https://www.googleapis.com/auth/gmail.modify",
|
|
||||||
"https://www.googleapis.com/auth/gmail.labels",
|
|
||||||
]
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
SECRET_PATH = os.path.join(_ROOT, "secrets", "gmail_creds.json")
|
|
||||||
_OAUTH_CLIENT_PATH = os.path.join(_ROOT, "secrets", "google_oauth_client.json")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_oauth_client() -> tuple[str, str]:
|
|
||||||
if not os.path.exists(_OAUTH_CLIENT_PATH):
|
|
||||||
print(f"Missing OAuth client file: {_OAUTH_CLIENT_PATH}")
|
|
||||||
print("Create it with: {\"client_id\": \"...\", \"client_secret\": \"...\"}")
|
|
||||||
sys.exit(1)
|
|
||||||
with open(_OAUTH_CLIENT_PATH) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data["client_id"], data["client_secret"]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
# Lazy-import so we can show helpful errors if not installed.
|
|
||||||
try:
|
|
||||||
from google.auth.transport.requests import Request
|
|
||||||
from google.oauth2.credentials import Credentials
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"Missing dependencies: {e}")
|
|
||||||
print("Install with: pip3 install google-auth google-auth-oauthlib google-api-python-client")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
# Try to load existing credentials first, in case they have refresh token.
|
|
||||||
if os.path.exists(SECRET_PATH):
|
|
||||||
print(f"Existing credentials found at {SECRET_PATH}")
|
|
||||||
try:
|
|
||||||
creds = Credentials.from_authorized_user_file(SECRET_PATH, SCOPES)
|
|
||||||
except Exception:
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
# If creds exist and are valid, we're good.
|
|
||||||
if creds and creds.valid:
|
|
||||||
print("Credentials are already valid!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# If creds exist but expired, try to refresh.
|
|
||||||
if creds and creds.expired and creds.refresh_token:
|
|
||||||
print("Token expired. Attempting refresh...")
|
|
||||||
try:
|
|
||||||
creds.refresh(Request())
|
|
||||||
print("Token refreshed successfully!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Refresh failed: {e}")
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
if not creds or not creds.valid:
|
|
||||||
client_id, client_secret = _load_oauth_client()
|
|
||||||
# Start OAuth flow using local server (opens browser automatically).
|
|
||||||
flow = InstalledAppFlow.from_client_config(
|
|
||||||
{
|
|
||||||
"installed": {
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
|
||||||
"redirect_uris": ["http://localhost"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SCOPES,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nOpening browser for Google authorization...")
|
|
||||||
creds = flow.run_local_server(
|
|
||||||
port=0, # pick a random available port
|
|
||||||
open_browser=True,
|
|
||||||
prompt="consent",
|
|
||||||
access_type="offline",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save credentials.
|
|
||||||
os.makedirs(os.path.dirname(SECRET_PATH), exist_ok=True)
|
|
||||||
with open(SECRET_PATH, "w") as f:
|
|
||||||
f.write(creds.to_json())
|
|
||||||
|
|
||||||
print(f"\n✅ Gmail OAuth token saved to {SECRET_PATH}")
|
|
||||||
print(f" Scopes: {creds.scopes}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
MCP Server for Google Trends data via trendspyg.
|
|
||||||
|
|
||||||
Provides tools to query Google Trends: interest over time, related queries,
|
|
||||||
interest by region, trending now (RSS), and bulk trending CSVs.
|
|
||||||
|
|
||||||
Uses trendspyg v0.7.0 as the data backend.
|
|
||||||
Browser-based tools require Chrome installed on the host.
|
|
||||||
RSS-based tools require no browser and return in ~0.2s.
|
|
||||||
|
|
||||||
Rate limits: Google Trends is a public service. Browser-based queries should
|
|
||||||
be spaced 5-10 seconds apart to avoid HTTP 429. RSS is lighter but still
|
|
||||||
subject to rate limiting on excessive polling.
|
|
||||||
|
|
||||||
Transport: stdio JSON-RPC (mcp.run() default). All diagnostics go to stderr —
|
|
||||||
never stdout — to avoid corrupting the protocol stream.
|
|
||||||
|
|
||||||
Output: tools return plain dicts, so FastMCP emits real ``structuredContent``
|
|
||||||
(a JSON object) plus a pretty-printed text fallback. Errors are raised as
|
|
||||||
``ToolError`` → the client receives an ``isError`` result carrying an
|
|
||||||
LLM-actionable hint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
from typing import Annotated, Any, Literal
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
from mcp.server.fastmcp.exceptions import ToolError
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
# ── trendspyg imports ─────────────────────────────────────────────────────────
|
|
||||||
from trendspyg.explore import (
|
|
||||||
download_google_trends_explore,
|
|
||||||
download_google_trends_interest_over_time,
|
|
||||||
)
|
|
||||||
from trendspyg.downloader import download_google_trends_csv, CATEGORIES, COUNTRIES
|
|
||||||
from trendspyg.rss_downloader import download_google_trends_rss
|
|
||||||
|
|
||||||
# ── Server init ───────────────────────────────────────────────────────────────
|
|
||||||
mcp = FastMCP("google_trends_mcp")
|
|
||||||
|
|
||||||
# ── Typed parameter aliases (drive JSON-schema validation) ────────────────────
|
|
||||||
Hours = Literal[4, 24, 48, 168]
|
|
||||||
SortBy = Literal["relevance", "title", "volume", "recency"]
|
|
||||||
CsvCategory = Literal[
|
|
||||||
"all", "autos", "beauty", "business", "climate", "entertainment", "food",
|
|
||||||
"games", "health", "hobbies", "lifestyle", "media", "pets", "science",
|
|
||||||
"shopping", "sports", "stories", "technology", "travel",
|
|
||||||
]
|
|
||||||
|
|
||||||
Json = dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Utility functions ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _clean(obj: Any) -> Any:
|
|
||||||
"""Recursively convert data into plain JSON-safe Python types.
|
|
||||||
|
|
||||||
Handles datetimes (→ ISO string), numpy scalars (→ native via .item()),
|
|
||||||
and nested dicts/lists/tuples. Guarantees the result is serializable by
|
|
||||||
FastMCP (both for ``structuredContent`` and the text fallback).
|
|
||||||
"""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: _clean(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, (list, tuple)):
|
|
||||||
return [_clean(v) for v in obj]
|
|
||||||
if hasattr(obj, "isoformat"): # datetime / date
|
|
||||||
return obj.isoformat()
|
|
||||||
if hasattr(obj, "item") and not isinstance(obj, (str, bytes)): # numpy scalar
|
|
||||||
try:
|
|
||||||
return obj.item()
|
|
||||||
except Exception:
|
|
||||||
return obj
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _envelope(data: Any) -> Json:
|
|
||||||
"""Normalize trendspyg output to a JSON object for structured tool output.
|
|
||||||
|
|
||||||
trendspyg returns a dict for every mode we call; if a future version hands
|
|
||||||
back a bare list we wrap it so ``structuredContent`` stays a JSON object.
|
|
||||||
"""
|
|
||||||
cleaned = _clean(data)
|
|
||||||
return cleaned if isinstance(cleaned, dict) else {"items": cleaned}
|
|
||||||
|
|
||||||
|
|
||||||
def _json(data: Any) -> str:
|
|
||||||
"""Serialize to a JSON string — used for MCP resources (content, not tools)."""
|
|
||||||
return json.dumps(_clean(data), indent=2, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
async def _to_thread(fn, **kwargs) -> Any:
|
|
||||||
"""Run a blocking trendspyg call off the event loop so the server stays
|
|
||||||
responsive during multi-second browser sessions."""
|
|
||||||
return await anyio.to_thread.run_sync(functools.partial(fn, **kwargs))
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_error(e: Exception, tool: str, subject: str) -> ToolError:
|
|
||||||
"""Build a consistent, LLM-actionable ToolError; log the full traceback to
|
|
||||||
stderr (safe for stdio transport)."""
|
|
||||||
traceback.print_exc(file=sys.stderr)
|
|
||||||
msg = str(e).lower()
|
|
||||||
|
|
||||||
if ("rate" in msg and "limit" in msg) or "429" in msg or "too many" in msg:
|
|
||||||
hint = (
|
|
||||||
f"Rate limited by Google Trends while querying '{subject}'. "
|
|
||||||
f"Wait 30-60 seconds before retrying. "
|
|
||||||
f"Tip: google_trends_rss has a lighter rate-limit footprint."
|
|
||||||
)
|
|
||||||
elif any(k in msg for k in ("chromedriver", "selenium", "webdriver", "session not created")):
|
|
||||||
hint = (
|
|
||||||
f"Browser required for '{tool}' but Chrome/WebDriver is unavailable on this host. "
|
|
||||||
f"Use google_trends_rss instead — it works over plain HTTP, no browser."
|
|
||||||
)
|
|
||||||
elif "chrome" in msg or "binary" in msg:
|
|
||||||
hint = (
|
|
||||||
f"Chrome browser not found — '{tool}' requires Chrome installed. "
|
|
||||||
f"Install Chrome, or use google_trends_rss for browser-free trend data."
|
|
||||||
)
|
|
||||||
elif "not found" in msg or "404" in msg or "no data" in msg:
|
|
||||||
hint = f"No data found for '{subject}'. Try a different keyword or a broader timeframe."
|
|
||||||
elif "invalid" in msg or "unsupported" in msg:
|
|
||||||
hint = f"Invalid parameter for '{tool}': {e}. Use google_trends_countries for valid geo codes."
|
|
||||||
else:
|
|
||||||
hint = f"Error in {tool} for '{subject}': {type(e).__name__}: {e}"
|
|
||||||
|
|
||||||
return ToolError(hint)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tools ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_interest_over_time",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Interest Over Time",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": True,
|
|
||||||
"openWorldHint": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def google_trends_interest_over_time(
|
|
||||||
keyword: Annotated[str, Field(
|
|
||||||
description="Search term (e.g. 'bitcoin', 'running shoes', 'AI').",
|
|
||||||
min_length=1, max_length=200,
|
|
||||||
)],
|
|
||||||
geo: Annotated[str, Field(
|
|
||||||
description="ISO country code (e.g. 'US', 'GB', 'IT') or 'US-CA' for US states. Empty = worldwide.",
|
|
||||||
)] = "",
|
|
||||||
timeframe: Annotated[str, Field(
|
|
||||||
description="Time window. Examples: 'today 12-m', 'today 5-y', 'today 3-m', "
|
|
||||||
"'today 1-m', '2023-01-01 2023-12-31', 'now 7-d', 'now 1-H'.",
|
|
||||||
)] = "today 12-m",
|
|
||||||
category: Annotated[int, Field(
|
|
||||||
description="Google Trends category ID (0 = all). See google_trends_categories.",
|
|
||||||
ge=0,
|
|
||||||
)] = 0,
|
|
||||||
) -> Json:
|
|
||||||
"""Get search interest over time for a keyword.
|
|
||||||
|
|
||||||
Returns a time series of relative popularity (0-100 scale) for a search term.
|
|
||||||
Requires Chrome browser installed on the host (headless mode).
|
|
||||||
|
|
||||||
Each data point has:
|
|
||||||
- date: ISO date string
|
|
||||||
- value: relative search interest (0-100, normalized within the query)
|
|
||||||
- is_partial: true if the current period's data is still incomplete
|
|
||||||
|
|
||||||
Use when: tracking keyword popularity trends, comparing seasonal patterns,
|
|
||||||
validating market timing for a product/idea.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: structured payload with an interest_over_time array.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "Interest in 'electric cars' over the last year in the UK?"
|
|
||||||
→ keyword="electric cars", geo="GB", timeframe="today 12-m"
|
|
||||||
- "Bitcoin search trend in Italy last 90 days"
|
|
||||||
→ keyword="bitcoin", geo="IT", timeframe="today 3-m"
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await _to_thread(
|
|
||||||
download_google_trends_interest_over_time,
|
|
||||||
keyword=keyword,
|
|
||||||
geo=geo,
|
|
||||||
timeframe=timeframe,
|
|
||||||
category=category,
|
|
||||||
headless=True,
|
|
||||||
output_format="dict",
|
|
||||||
)
|
|
||||||
# trendspyg returns a bare list of points here; wrap it with the query
|
|
||||||
# context so the structured payload is self-describing for the LLM.
|
|
||||||
return {
|
|
||||||
"keyword": keyword,
|
|
||||||
"geo": geo or "worldwide",
|
|
||||||
"timeframe": timeframe,
|
|
||||||
"category": category,
|
|
||||||
"interest_over_time": _clean(data),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
raise _tool_error(e, "interest_over_time", keyword)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_explore",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Full Explore",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": True,
|
|
||||||
"openWorldHint": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def google_trends_explore(
|
|
||||||
keyword: Annotated[str, Field(
|
|
||||||
description="Search term (e.g. 'bitcoin', 'running shoes').",
|
|
||||||
min_length=1, max_length=200,
|
|
||||||
)],
|
|
||||||
geo: Annotated[str, Field(
|
|
||||||
description="ISO country code (e.g. 'US', 'GB', 'IT') or 'US-CA' for US states. Empty = worldwide.",
|
|
||||||
)] = "",
|
|
||||||
timeframe: Annotated[str, Field(
|
|
||||||
description="Time window (same format as interest_over_time).",
|
|
||||||
)] = "today 12-m",
|
|
||||||
category: Annotated[int, Field(
|
|
||||||
description="Google Trends category ID (0 = all). See google_trends_categories.",
|
|
||||||
ge=0,
|
|
||||||
)] = 0,
|
|
||||||
include_related: Annotated[bool, Field(
|
|
||||||
description="Include related queries (top + rising). Adds ~2-3s.",
|
|
||||||
)] = True,
|
|
||||||
include_geo: Annotated[bool, Field(
|
|
||||||
description="Include interest-by-region breakdown. Adds ~1-2s.",
|
|
||||||
)] = True,
|
|
||||||
) -> Json:
|
|
||||||
"""Full Google Trends Explore: interest over time + related queries + interest by region.
|
|
||||||
|
|
||||||
The most comprehensive tool — fetches all available data for a keyword in a
|
|
||||||
single browser session. Returns:
|
|
||||||
- interest_over_time: array of {date, value, is_partial}
|
|
||||||
- related_queries: {top: [{query, value, link}], rising: [{query, value, link}]}
|
|
||||||
- interest_by_region: [{geo_code, geo_name, value}]
|
|
||||||
|
|
||||||
Requires Chrome browser installed on the host.
|
|
||||||
|
|
||||||
Use when: you need the complete picture — trend direction, what people also
|
|
||||||
search, and where interest is concentrated geographically.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: structured payload with all three data sections.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "Full Trends picture for 'vegan protein' in the US?"
|
|
||||||
→ keyword="vegan protein", geo="US"
|
|
||||||
- "Quick check on 'climate change' trend"
|
|
||||||
→ keyword="climate change", timeframe="today 5-y", include_related=False
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await _to_thread(
|
|
||||||
download_google_trends_explore,
|
|
||||||
keyword=keyword,
|
|
||||||
geo=geo,
|
|
||||||
timeframe=timeframe,
|
|
||||||
category=category,
|
|
||||||
headless=True,
|
|
||||||
include_related=include_related,
|
|
||||||
include_geo=include_geo,
|
|
||||||
)
|
|
||||||
return _envelope(data)
|
|
||||||
except Exception as e:
|
|
||||||
raise _tool_error(e, "explore", keyword)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_rss",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Trending Now (RSS)",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": False,
|
|
||||||
"openWorldHint": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def google_trends_rss(
|
|
||||||
geo: Annotated[str, Field(
|
|
||||||
description="ISO country code (e.g. 'US', 'GB', 'IT').",
|
|
||||||
)] = "US",
|
|
||||||
include_images: Annotated[bool, Field(
|
|
||||||
description="Include trend images. Adds data volume.",
|
|
||||||
)] = False,
|
|
||||||
include_articles: Annotated[bool, Field(
|
|
||||||
description="Include news articles for each trend. Adds data volume.",
|
|
||||||
)] = False,
|
|
||||||
) -> Json:
|
|
||||||
"""Get currently trending searches via the Google Trends RSS feed.
|
|
||||||
|
|
||||||
⚡ Fast path: pure HTTP, no browser needed, returns in ~0.2s.
|
|
||||||
|
|
||||||
Returns up to ~20 trending topics with optional images and news articles.
|
|
||||||
Each trend includes:
|
|
||||||
- keyword: topic name
|
|
||||||
- volume_text / volume_min: estimated search-volume indicator (e.g. "500+")
|
|
||||||
- explore_url: deep link to the Google Trends Explore page
|
|
||||||
- started_at / ended_at / is_active: trend lifecycle timestamps
|
|
||||||
- image (optional): representative image URL
|
|
||||||
- news (optional): up to 5 related news articles
|
|
||||||
|
|
||||||
Use when: you want to know what's trending *right now* — real-time
|
|
||||||
monitoring, content ideation, newsjacking.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: structured payload with a trends array.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "What's trending in the UK right now?" → geo="GB"
|
|
||||||
- "US trends with news context" → geo="US", include_articles=True
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await _to_thread(
|
|
||||||
download_google_trends_rss,
|
|
||||||
geo=geo,
|
|
||||||
output_format="dict",
|
|
||||||
include_images=include_images,
|
|
||||||
include_articles=include_articles,
|
|
||||||
max_articles_per_trend=5,
|
|
||||||
cache=False,
|
|
||||||
normalize=True,
|
|
||||||
)
|
|
||||||
return _envelope(data)
|
|
||||||
except Exception as e:
|
|
||||||
raise _tool_error(e, "rss", geo)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_csv",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Trending CSV (Bulk)",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": False,
|
|
||||||
"openWorldHint": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def google_trends_csv(
|
|
||||||
geo: Annotated[str, Field(
|
|
||||||
description="ISO country code (e.g. 'US', 'GB', 'IT').",
|
|
||||||
)] = "US",
|
|
||||||
hours: Annotated[Hours, Field(
|
|
||||||
description="Lookback window in hours. One of: 4, 24, 48, 168 (7d).",
|
|
||||||
)] = 24,
|
|
||||||
category: Annotated[CsvCategory, Field(
|
|
||||||
description="Trend category (e.g. 'all', 'technology', 'business', 'sports').",
|
|
||||||
)] = "all",
|
|
||||||
sort_by: Annotated[SortBy, Field(
|
|
||||||
description="Sort order: 'relevance', 'title', 'volume', 'recency'.",
|
|
||||||
)] = "relevance",
|
|
||||||
) -> Json:
|
|
||||||
"""Download bulk trending searches via Google Trends CSV export.
|
|
||||||
|
|
||||||
Returns up to ~480 current trending topics, filterable by time window,
|
|
||||||
category, and sort order. Requires Chrome browser (headless mode).
|
|
||||||
|
|
||||||
Each trend includes: trend name, traffic estimate, explore link, and
|
|
||||||
published timestamp.
|
|
||||||
|
|
||||||
Use when: you need a large dataset of current trends for market research,
|
|
||||||
category analysis, or trend scouting across niches.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: structured payload with the trends collection.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "All trending tech topics in the US in the last 24h"
|
|
||||||
→ geo="US", hours=24, category="technology"
|
|
||||||
- "Trending UK business this past week"
|
|
||||||
→ geo="GB", hours=168, category="business", sort_by="volume"
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await _to_thread(
|
|
||||||
download_google_trends_csv,
|
|
||||||
geo=geo,
|
|
||||||
hours=hours,
|
|
||||||
category=category,
|
|
||||||
sort_by=sort_by,
|
|
||||||
headless=True,
|
|
||||||
normalize=True, # returns a unified envelope dict (ignores output_format)
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
return _envelope(data)
|
|
||||||
except Exception as e:
|
|
||||||
raise _tool_error(e, "csv", f"{geo}/{category}")
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_categories",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Available Categories",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": True,
|
|
||||||
"openWorldHint": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def google_trends_categories() -> Json:
|
|
||||||
"""List all Google Trends categories available for filtering.
|
|
||||||
|
|
||||||
Use this to discover valid category names before calling google_trends_csv
|
|
||||||
with a specific category filter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: category names → labels,
|
|
||||||
e.g. {"all": "All categories", "technology": "Technology", ...}
|
|
||||||
"""
|
|
||||||
return dict(CATEGORIES)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="google_trends_countries",
|
|
||||||
annotations={
|
|
||||||
"title": "Google Trends — Available Countries & Regions",
|
|
||||||
"readOnlyHint": True,
|
|
||||||
"destructiveHint": False,
|
|
||||||
"idempotentHint": True,
|
|
||||||
"openWorldHint": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def google_trends_countries() -> Json:
|
|
||||||
"""List all ISO country codes and US state codes accepted by geo parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 'countries' (ISO codes → names) and 'us_states' (US-XX → names).
|
|
||||||
"""
|
|
||||||
from trendspyg.downloader import US_STATES
|
|
||||||
return {
|
|
||||||
"note": "Use ISO codes (e.g. 'US', 'GB', 'IT') for geo params. Empty string = worldwide.",
|
|
||||||
"countries": dict(COUNTRIES),
|
|
||||||
"us_states": dict(US_STATES),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Resources ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.resource("trends://categories")
|
|
||||||
def trends_categories() -> str:
|
|
||||||
"""Available Google Trends categories as a resource."""
|
|
||||||
return _json(CATEGORIES)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("trends://countries")
|
|
||||||
def trends_countries() -> str:
|
|
||||||
"""Available countries and US states as a resource."""
|
|
||||||
from trendspyg.downloader import US_STATES
|
|
||||||
return _json({"countries": COUNTRIES, "us_states": US_STATES})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
mcp.run()
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Honcho backfill script.
|
|
||||||
|
|
||||||
Deletes the existing Honcho workspace, recreates it with the correct peer
|
|
||||||
config (observe_me=true for the user peer), and re-uploads all interactive
|
|
||||||
non-ephemeral chat history from the SQLite database.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Reads config from the SQLite plugins table automatically.
|
|
||||||
python3 scripts/honcho_backfill.py
|
|
||||||
|
|
||||||
# Or pass overrides:
|
|
||||||
python3 scripts/honcho_backfill.py \
|
|
||||||
--db ./database.db \
|
|
||||||
--base-url http://localhost:8000 \
|
|
||||||
--workspace personal-agent \
|
|
||||||
--dry-run
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
# ── Honcho API helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class HonchoClient:
|
|
||||||
def __init__(self, base_url: str, api_key: str = ""):
|
|
||||||
self.base = base_url.rstrip("/")
|
|
||||||
self.session = requests.Session()
|
|
||||||
if api_key:
|
|
||||||
self.session.headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
self.session.headers["Content-Type"] = "application/json"
|
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
|
||||||
return f"{self.base}{path}"
|
|
||||||
|
|
||||||
def list_session_ids(self, workspace_id: str) -> list[str]:
|
|
||||||
ids = []
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
r = self.session.post(
|
|
||||||
self._url(f"/v3/workspaces/{workspace_id}/sessions/list"),
|
|
||||||
params={"page": page, "size": 100},
|
|
||||||
json={},
|
|
||||||
)
|
|
||||||
if r.status_code == 404:
|
|
||||||
break
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
items = data.get("items", [])
|
|
||||||
ids.extend(item["id"] for item in items)
|
|
||||||
if page >= data.get("pages", 1):
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
return ids
|
|
||||||
|
|
||||||
def delete_all_sessions(self, workspace_id: str):
|
|
||||||
ids = self.list_session_ids(workspace_id)
|
|
||||||
print(f" deleting {len(ids)} existing session(s) …")
|
|
||||||
for sid in ids:
|
|
||||||
r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}/sessions/{sid}"))
|
|
||||||
if r.status_code not in (200, 202, 204, 404):
|
|
||||||
print(f" WARNING: could not delete session {sid}: {r.status_code}")
|
|
||||||
|
|
||||||
def delete_workspace(self, workspace_id: str):
|
|
||||||
self.delete_all_sessions(workspace_id)
|
|
||||||
r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}"))
|
|
||||||
if r.status_code in (200, 202, 204, 404):
|
|
||||||
print(f" workspace '{workspace_id}' deleted (or did not exist)")
|
|
||||||
else:
|
|
||||||
print(f" WARNING: DELETE workspace returned {r.status_code} — continuing anyway")
|
|
||||||
|
|
||||||
def create_workspace(self, workspace_id: str, retries: int = 6, delay: float = 2.0):
|
|
||||||
for attempt in range(1, retries + 1):
|
|
||||||
r = self.session.post(self._url("/v3/workspaces"), json={"id": workspace_id})
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
print(f" workspace '{workspace_id}' created")
|
|
||||||
return
|
|
||||||
# 409 = already exists (fine for --skip-delete path)
|
|
||||||
if r.status_code == 409:
|
|
||||||
print(f" workspace '{workspace_id}' already exists — reusing")
|
|
||||||
return
|
|
||||||
print(f" create workspace attempt {attempt}/{retries}: {r.status_code} — retrying in {delay}s …")
|
|
||||||
time.sleep(delay)
|
|
||||||
raise RuntimeError(f"POST workspace failed after {retries} attempts: {r.status_code} {r.text}")
|
|
||||||
|
|
||||||
def create_peer(self, workspace_id: str, peer_id: str):
|
|
||||||
r = self.session.post(
|
|
||||||
self._url(f"/v3/workspaces/{workspace_id}/peers"),
|
|
||||||
json={"id": peer_id},
|
|
||||||
)
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
print(f" peer '{peer_id}' created")
|
|
||||||
elif r.status_code == 409:
|
|
||||||
print(f" peer '{peer_id}' already exists — reusing")
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"POST peer failed: {r.status_code} {r.text}")
|
|
||||||
|
|
||||||
PEER_CONFIG = {
|
|
||||||
"user": {"observe_me": True},
|
|
||||||
"assistant": {"observe_me": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _add_peers(self, workspace_id: str, session_id: str):
|
|
||||||
"""Add peer config to a session via POST (separate from session creation)."""
|
|
||||||
r = self.session.post(
|
|
||||||
self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers"),
|
|
||||||
json=self.PEER_CONFIG,
|
|
||||||
)
|
|
||||||
if r.status_code not in (200, 201, 409):
|
|
||||||
print(f" WARNING: add peers returned {r.status_code}: {r.text}")
|
|
||||||
|
|
||||||
def create_session(self, workspace_id: str, session_id: str, local_id: int) -> str:
|
|
||||||
body = {
|
|
||||||
"id": session_id,
|
|
||||||
"metadata": {"local_session_id": local_id},
|
|
||||||
}
|
|
||||||
r = self.session.post(
|
|
||||||
self._url(f"/v3/workspaces/{workspace_id}/sessions"),
|
|
||||||
json=body,
|
|
||||||
)
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
self._add_peers(workspace_id, session_id)
|
|
||||||
return session_id
|
|
||||||
if r.status_code == 409:
|
|
||||||
print(f" (session existed — adding peers)")
|
|
||||||
self._add_peers(workspace_id, session_id)
|
|
||||||
return session_id
|
|
||||||
raise RuntimeError(f"POST session failed: {r.status_code} {r.text}")
|
|
||||||
|
|
||||||
def fix_all_session_peers(self, workspace_id: str):
|
|
||||||
"""Add correct peer config to all existing sessions in the workspace."""
|
|
||||||
ids = self.list_session_ids(workspace_id)
|
|
||||||
print(f"Fixing peers on {len(ids)} session(s) …")
|
|
||||||
for sid in ids:
|
|
||||||
self._add_peers(workspace_id, sid)
|
|
||||||
print(f" {sid}", end="\r")
|
|
||||||
print(f"\nDone — {len(ids)} session(s) updated.")
|
|
||||||
|
|
||||||
def add_message(
|
|
||||||
self,
|
|
||||||
workspace_id: str,
|
|
||||||
session_id: str,
|
|
||||||
peer_id: str,
|
|
||||||
content: str,
|
|
||||||
local_message_id: int,
|
|
||||||
created_at: str,
|
|
||||||
):
|
|
||||||
body = {
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"peer_id": peer_id,
|
|
||||||
"content": content,
|
|
||||||
"metadata": {"local_message_id": local_message_id},
|
|
||||||
"created_at": created_at,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
r = self.session.post(
|
|
||||||
self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages"),
|
|
||||||
json=body,
|
|
||||||
)
|
|
||||||
if r.status_code not in (200, 201, 409):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"POST message failed (session={session_id}): {r.status_code} {r.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── DB helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Session:
|
|
||||||
id: int
|
|
||||||
source: str
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Message:
|
|
||||||
id: int
|
|
||||||
role: str
|
|
||||||
content: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_config(db_path: str) -> Optional[dict]:
|
|
||||||
"""Read honcho plugin config from the plugins table."""
|
|
||||||
try:
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
row = con.execute(
|
|
||||||
"SELECT enabled, config FROM plugins WHERE id = 'honcho'"
|
|
||||||
).fetchone()
|
|
||||||
con.close()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
enabled, config_json = row
|
|
||||||
if not enabled:
|
|
||||||
print("WARNING: honcho plugin is disabled in DB; proceeding anyway")
|
|
||||||
return json.loads(config_json)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WARNING: could not read plugin config from DB: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_sessions(db_path: str) -> list[Session]:
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
rows = con.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, source
|
|
||||||
FROM chat_sessions
|
|
||||||
WHERE is_interactive = 1
|
|
||||||
AND is_ephemeral = 0
|
|
||||||
AND source NOT IN ('tic', 'cron')
|
|
||||||
ORDER BY id
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
con.close()
|
|
||||||
return [Session(id=r[0], source=r[1]) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def load_messages(db_path: str, session_id: int) -> list[Message]:
|
|
||||||
"""
|
|
||||||
Load all user/assistant messages for a session, ordered chronologically.
|
|
||||||
Excludes: sub-agent messages (role='agent'), failed, synthetic, empty.
|
|
||||||
"""
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
rows = con.execute(
|
|
||||||
"""
|
|
||||||
SELECT h.id, h.role, h.content, h.created_at
|
|
||||||
FROM chat_history h
|
|
||||||
JOIN chat_sessions_stack s ON s.id = h.session_stack_id
|
|
||||||
WHERE s.session_id = ?
|
|
||||||
AND h.role IN ('user', 'assistant')
|
|
||||||
AND h.status = 'ok'
|
|
||||||
AND h.is_synthetic = 0
|
|
||||||
AND h.content != ''
|
|
||||||
ORDER BY h.id
|
|
||||||
""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchall()
|
|
||||||
con.close()
|
|
||||||
return [Message(id=r[0], role=r[1], content=r[2], created_at=r[3]) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Backfill Honcho from local SQLite DB")
|
|
||||||
parser.add_argument("--db", default="./database.db", help="Path to SQLite DB")
|
|
||||||
parser.add_argument("--base-url", default=None, help="Honcho base URL (overrides DB config)")
|
|
||||||
parser.add_argument("--workspace", default=None, help="Honcho workspace ID (overrides DB config)")
|
|
||||||
parser.add_argument("--api-key", default="", help="Honcho API key")
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Print plan without touching Honcho")
|
|
||||||
parser.add_argument("--delay-ms", type=int, default=50, help="Delay between message uploads (ms)")
|
|
||||||
parser.add_argument("--skip-delete", action="store_true", help="Skip workspace deletion (add to existing)")
|
|
||||||
parser.add_argument("--fix-peers", action="store_true", help="Only fix peer config on existing sessions, then exit")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# ── Resolve config ────────────────────────────────────────────────────────
|
|
||||||
plugin_cfg = load_plugin_config(args.db)
|
|
||||||
base_url = args.base_url or (plugin_cfg or {}).get("base_url", "http://localhost:8000")
|
|
||||||
workspace_id = args.workspace or (plugin_cfg or {}).get("workspace_id", "personal-agent")
|
|
||||||
api_key = args.api_key or (plugin_cfg or {}).get("api_key", "")
|
|
||||||
|
|
||||||
print(f"Honcho base URL : {base_url}")
|
|
||||||
print(f"Workspace ID : {workspace_id}")
|
|
||||||
print(f"DB : {args.db}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
client = HonchoClient(base_url, api_key)
|
|
||||||
|
|
||||||
# ── Fix-peers only mode ───────────────────────────────────────────────────
|
|
||||||
if args.fix_peers:
|
|
||||||
client.fix_all_session_peers(workspace_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Load sessions ─────────────────────────────────────────────────────────
|
|
||||||
sessions = load_sessions(args.db)
|
|
||||||
print(f"Found {len(sessions)} interactive non-ephemeral session(s)")
|
|
||||||
|
|
||||||
total_msgs = 0
|
|
||||||
plan = []
|
|
||||||
for sess in sessions:
|
|
||||||
msgs = load_messages(args.db, sess.id)
|
|
||||||
if not msgs:
|
|
||||||
continue
|
|
||||||
honcho_id = f"{workspace_id}-{sess.id}"
|
|
||||||
plan.append((sess, msgs, honcho_id))
|
|
||||||
total_msgs += len(msgs)
|
|
||||||
print(f" session {sess.id:4d} ({sess.source:10s}) {len(msgs):4d} msgs → {honcho_id}")
|
|
||||||
|
|
||||||
print(f"\nTotal messages to upload: {total_msgs}")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("\n[dry-run] No changes made.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not plan:
|
|
||||||
print("Nothing to upload.")
|
|
||||||
return
|
|
||||||
|
|
||||||
confirm = input("\nProceed? This will DELETE and recreate the Honcho workspace. [y/N] ")
|
|
||||||
if confirm.strip().lower() != "y":
|
|
||||||
print("Aborted.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
delay_s = args.delay_ms / 1000.0
|
|
||||||
|
|
||||||
# ── Reset workspace ───────────────────────────────────────────────────────
|
|
||||||
if not args.skip_delete:
|
|
||||||
print("\n[1/3] Deleting existing workspace …")
|
|
||||||
client.delete_workspace(workspace_id)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("\n[2/3] Creating workspace and peers …")
|
|
||||||
client.create_workspace(workspace_id)
|
|
||||||
client.create_peer(workspace_id, "user")
|
|
||||||
client.create_peer(workspace_id, "assistant")
|
|
||||||
|
|
||||||
# ── Upload messages ───────────────────────────────────────────────────────
|
|
||||||
print(f"\n[3/3] Uploading {total_msgs} messages …")
|
|
||||||
|
|
||||||
for sess, msgs, honcho_id in plan:
|
|
||||||
print(f"\n session {sess.id} → {honcho_id} ({len(msgs)} messages)")
|
|
||||||
client.create_session(workspace_id, honcho_id, sess.id)
|
|
||||||
|
|
||||||
for i, msg in enumerate(msgs, 1):
|
|
||||||
peer_id = "user" if msg.role == "user" else "assistant"
|
|
||||||
try:
|
|
||||||
client.add_message(
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
session_id=honcho_id,
|
|
||||||
peer_id=peer_id,
|
|
||||||
content=msg.content,
|
|
||||||
local_message_id=msg.id,
|
|
||||||
created_at=msg.created_at,
|
|
||||||
)
|
|
||||||
print(f" [{i:4d}/{len(msgs)}] {peer_id:9s} id={msg.id}", end="\r")
|
|
||||||
except RuntimeError as e:
|
|
||||||
print(f"\n ERROR on msg {msg.id}: {e} — skipping")
|
|
||||||
|
|
||||||
if delay_s > 0:
|
|
||||||
time.sleep(delay_s)
|
|
||||||
|
|
||||||
print(f" [{len(msgs):4d}/{len(msgs)}] done ")
|
|
||||||
|
|
||||||
print("\nBackfill complete.")
|
|
||||||
print("Honcho deriver will process messages in the background.")
|
|
||||||
print("Restart personal-agent to reconnect the plugin.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Inspect the last N llm_requests rows for a given model (default: deepseek).
|
|
||||||
Prints a structured summary without dumping raw payloads.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/inspect_llm_requests.py [model_filter] [rows]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python scripts/inspect_llm_requests.py deepseek 5
|
|
||||||
python scripts/inspect_llm_requests.py anthropic 3
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
DB_PATH = Path(__file__).parent.parent / "database.db"
|
|
||||||
MODEL_FILTER = sys.argv[1] if len(sys.argv) > 1 else "deepseek"
|
|
||||||
ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_len(s):
|
|
||||||
if s is None:
|
|
||||||
return "null"
|
|
||||||
return f"{len(s)} chars"
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_message(i, msg):
|
|
||||||
role = msg.get("role", "?")
|
|
||||||
content = msg.get("content")
|
|
||||||
tool_calls = msg.get("tool_calls")
|
|
||||||
tool_call_id = msg.get("tool_call_id")
|
|
||||||
reasoning = msg.get("reasoning_content")
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
if isinstance(content, str):
|
|
||||||
parts.append(f"{len(content)} chars")
|
|
||||||
elif isinstance(content, list):
|
|
||||||
total = sum(len(b.get("text", "")) for b in content if isinstance(b, dict))
|
|
||||||
cache_tags = [b for b in content if isinstance(b, dict) and "cache_control" in b]
|
|
||||||
parts.append(f"{total} chars (content array, {len(content)} blocks)")
|
|
||||||
if cache_tags:
|
|
||||||
parts.append(f"[cache_control on {len(cache_tags)} block(s)]")
|
|
||||||
elif content is None:
|
|
||||||
parts.append("(no content)")
|
|
||||||
|
|
||||||
if reasoning:
|
|
||||||
parts.append(f"[reasoning_content: {len(reasoning)} chars]")
|
|
||||||
|
|
||||||
if tool_calls:
|
|
||||||
names = [tc.get("function", {}).get("name", "?") for tc in tool_calls]
|
|
||||||
parts.append(f"[tool_calls: {', '.join(names)}]")
|
|
||||||
|
|
||||||
if tool_call_id:
|
|
||||||
parts.append(f"(tool_call_id={tool_call_id})")
|
|
||||||
|
|
||||||
detail = " ".join(parts)
|
|
||||||
print(f" {i:>3} {role:<12} {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def est_tokens(obj) -> int:
|
|
||||||
"""Rough token estimate: serialized chars / 4."""
|
|
||||||
return len(json.dumps(obj)) // 4
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_request(row):
|
|
||||||
rid, model_name, req_json, req_headers, resp_json, input_tok, output_tok, duration_ms, created_at = row
|
|
||||||
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f"id={rid} model={model_name} created={created_at}")
|
|
||||||
print(f"tokens: input={input_tok} output={output_tok} duration={duration_ms}ms")
|
|
||||||
|
|
||||||
try:
|
|
||||||
req = json.loads(req_json) if req_json else {}
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [ERROR parsing request_json: {e}]")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Top-level params (excluding messages and tools)
|
|
||||||
skip = {"messages", "tools", "model"}
|
|
||||||
params = {k: v for k, v in req.items() if k not in skip}
|
|
||||||
if params:
|
|
||||||
print(f"\n[params]")
|
|
||||||
for k, v in params.items():
|
|
||||||
print(f" {k} = {json.dumps(v)}")
|
|
||||||
|
|
||||||
# Tools
|
|
||||||
tools = req.get("tools", [])
|
|
||||||
if tools:
|
|
||||||
tool_names = [t.get("function", {}).get("name", "?") for t in tools]
|
|
||||||
tools_tok = est_tokens(tools)
|
|
||||||
print(f"\n[tools] {len(tools)} defined ~{tools_tok} tok est")
|
|
||||||
print(f" {', '.join(tool_names)}")
|
|
||||||
last = tools[-1]
|
|
||||||
if "cache_control" in last:
|
|
||||||
print(f" last tool has cache_control: {last['cache_control']}")
|
|
||||||
|
|
||||||
# Messages
|
|
||||||
messages = req.get("messages", [])
|
|
||||||
sys_msgs = [m for m in messages if m.get("role") == "system"]
|
|
||||||
sys_tok = est_tokens(sys_msgs)
|
|
||||||
conv_msgs = [m for m in messages if m.get("role") != "system"]
|
|
||||||
conv_tok = est_tokens(conv_msgs)
|
|
||||||
print(f"\n[messages] {len(messages)} total (~{est_tokens(messages)} tok est: {len(sys_msgs)} system ~{sys_tok} tok, {len(conv_msgs)} conv ~{conv_tok} tok)")
|
|
||||||
for i, msg in enumerate(messages):
|
|
||||||
summarize_message(i, msg)
|
|
||||||
|
|
||||||
# Response summary
|
|
||||||
if resp_json:
|
|
||||||
try:
|
|
||||||
resp = json.loads(resp_json)
|
|
||||||
usage = resp.get("usage", {})
|
|
||||||
if usage:
|
|
||||||
print(f"\n[usage]")
|
|
||||||
for k, v in usage.items():
|
|
||||||
print(f" {k} = {v}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, model_name, request_json, request_headers,
|
|
||||||
response_json, input_tokens, output_tokens, duration_ms, created_at
|
|
||||||
FROM llm_requests
|
|
||||||
WHERE model_name LIKE ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(f"%{MODEL_FILTER}%", ROWS),
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
print(f"No rows found for model filter '{MODEL_FILTER}'")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Last {len(rows)} request(s) matching '{MODEL_FILTER}' (newest first)")
|
|
||||||
for row in rows:
|
|
||||||
summarize_request(row)
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
httpx>=0.27.0
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""SerpAPI Google Flights MCP server (JSON-RPC 2.0 over stdio).
|
|
||||||
|
|
||||||
Capabilities:
|
|
||||||
serpapi_search_flights — search one-way or round-trip flights via Google Flights
|
|
||||||
through SerpAPI, returning prices, airlines, durations,
|
|
||||||
layovers, and CO2 emissions.
|
|
||||||
|
|
||||||
Auth:
|
|
||||||
API key is read from env var SERPAPI_API_KEY, or from the file at
|
|
||||||
SERPAPI_API_KEY_FILE (default: ./secrets/serpapi_api_key.txt).
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
python3 scripts/mcp/serpapi_flights/server.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Log to stderr so stdout stays clean for JSON-RPC.
|
|
||||||
def log(msg: str) -> None:
|
|
||||||
print(f"[serpapi_flights_mcp] {msg}", file=sys.stderr, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── API key / client init ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SERPAPI_BASE_URL = "https://serpapi.com"
|
|
||||||
_DEFAULT_KEY_FILE = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
|
||||||
"secrets",
|
|
||||||
"serpapi_api_key.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
_init_error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_api_key() -> str | None:
|
|
||||||
# 1. Environment variable
|
|
||||||
key = os.environ.get("SERPAPI_API_KEY", "").strip()
|
|
||||||
if key:
|
|
||||||
return key
|
|
||||||
|
|
||||||
# 2. File
|
|
||||||
key_file = os.environ.get("SERPAPI_API_KEY_FILE", _DEFAULT_KEY_FILE)
|
|
||||||
if os.path.exists(key_file):
|
|
||||||
try:
|
|
||||||
with open(key_file) as f:
|
|
||||||
key = f.read().strip()
|
|
||||||
if key:
|
|
||||||
return key
|
|
||||||
except OSError as e:
|
|
||||||
global _init_error
|
|
||||||
_init_error = f"Failed to read API key file {key_file}: {e}"
|
|
||||||
log(_init_error)
|
|
||||||
return None
|
|
||||||
|
|
||||||
_init_error = (
|
|
||||||
"SerpAPI API key not found. "
|
|
||||||
"Set SERPAPI_API_KEY env var or create secrets/serpapi_api_key.txt "
|
|
||||||
"with just the key on the first line."
|
|
||||||
)
|
|
||||||
log(_init_error)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _serpapi_request(params: dict) -> dict:
|
|
||||||
"""Make a synchronous GET to SerpAPI /search. Raises on HTTP errors."""
|
|
||||||
api_key = _get_api_key()
|
|
||||||
if not api_key:
|
|
||||||
raise _InitError(_init_error or "SerpAPI API key not configured.")
|
|
||||||
|
|
||||||
full_params = {"api_key": api_key, **params}
|
|
||||||
with httpx.Client(timeout=30.0, headers={"User-Agent": "skald-serpapi-mcp/2.0"}) as client:
|
|
||||||
response = client.get(f"{SERPAPI_BASE_URL}/search", params=full_params)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
class _InitError(Exception):
|
|
||||||
"""Raised when the API key is missing or unreadable."""
|
|
||||||
|
|
||||||
|
|
||||||
# ── Error mapping ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _format_api_error(e: Exception) -> str:
|
|
||||||
if isinstance(e, _InitError):
|
|
||||||
return f"Error: {e}"
|
|
||||||
if isinstance(e, httpx.HTTPStatusError):
|
|
||||||
status = e.response.status_code
|
|
||||||
if status == 401:
|
|
||||||
return "Error: Invalid SerpAPI API key. Check secrets/serpapi_api_key.txt or the SERPAPI_API_KEY env var."
|
|
||||||
if status == 429:
|
|
||||||
return "Error: SerpAPI rate limit exceeded. Wait a moment and retry."
|
|
||||||
if status == 400:
|
|
||||||
return f"Error: Bad request — {e.response.text[:200]}. Verify airport codes (3-letter IATA) and dates."
|
|
||||||
return f"Error: SerpAPI request failed (HTTP {status})."
|
|
||||||
if isinstance(e, httpx.TimeoutException):
|
|
||||||
return "Error: Request to SerpAPI timed out (30s). The service may be slow or unreachable; retry."
|
|
||||||
if isinstance(e, httpx.RequestError):
|
|
||||||
return f"Error: Network error contacting SerpAPI: {e}"
|
|
||||||
return f"Error: Unexpected error: {type(e).__name__}: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Output formatting ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _format_flight_results(data: dict, max_results: int) -> str:
|
|
||||||
"""Render SerpAPI Google Flights results as plain text for the LLM."""
|
|
||||||
best_flights = data.get("best_flights", []) or []
|
|
||||||
other_flights = data.get("other_flights", []) or []
|
|
||||||
price_insights = data.get("price_insights") or {}
|
|
||||||
|
|
||||||
if not best_flights and not other_flights:
|
|
||||||
return "No flights found for the given route and dates."
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
if price_insights:
|
|
||||||
pi = price_insights
|
|
||||||
if pi.get("lowest_price"):
|
|
||||||
lines.append(f"Lowest price: {pi['lowest_price']}")
|
|
||||||
if pi.get("typical_price_range"):
|
|
||||||
lo, hi = pi["typical_price_range"][0], pi["typical_price_range"][1]
|
|
||||||
lines.append(f"Typical range: {lo} – {hi}")
|
|
||||||
if lines:
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("Flights:")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
all_flights = (best_flights + other_flights)[:max_results]
|
|
||||||
|
|
||||||
for i, flight in enumerate(all_flights, 1):
|
|
||||||
segments = flight.get("flights", []) or []
|
|
||||||
total_duration = flight.get("total_duration", 0) # minutes
|
|
||||||
price = flight.get("price", 0)
|
|
||||||
layovers = flight.get("layovers", []) or []
|
|
||||||
|
|
||||||
hours, minutes = divmod(total_duration, 60)
|
|
||||||
duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
|
||||||
|
|
||||||
cheapest_tag = " (CHEAPEST)" if i == 1 and best_flights else ""
|
|
||||||
lines.append(f"#{i}: {price}{cheapest_tag}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
for seg in segments:
|
|
||||||
dep = seg.get("departure_airport", {}) or {}
|
|
||||||
arr = seg.get("arrival_airport", {}) or {}
|
|
||||||
airline = seg.get("airline", "?")
|
|
||||||
flight_num = seg.get("flight_number", "?")
|
|
||||||
seg_dur = seg.get("duration", 0)
|
|
||||||
seg_h, seg_m = divmod(seg_dur, 60)
|
|
||||||
seg_dur_str = f"{seg_h}h {seg_m}m" if seg_h > 0 else f"{seg_m}m"
|
|
||||||
|
|
||||||
lines.append(f" {airline} {flight_num}")
|
|
||||||
lines.append(f" {dep.get('id', '?')} {dep.get('time', '?')} -> {arr.get('id', '?')} {arr.get('time', '?')}")
|
|
||||||
lines.append(f" Duration: {seg_dur_str}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if layovers:
|
|
||||||
parts = []
|
|
||||||
for lo in layovers:
|
|
||||||
lo_dur = lo.get("duration", 0)
|
|
||||||
lo_h, lo_m = divmod(lo_dur, 60)
|
|
||||||
parts.append(f"{lo.get('id', '?')} ({lo_h}h {lo_m}m)")
|
|
||||||
lines.append(f" Layovers: {' -> '.join(parts)}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append(f" Total duration: {duration_str}")
|
|
||||||
|
|
||||||
emissions = flight.get("carbon_emissions") or {}
|
|
||||||
if emissions.get("this_flight") is not None:
|
|
||||||
lines.append(f" CO2: {emissions['this_flight']}g")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines).rstrip()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tool implementation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
||||||
_IATA_RE = re.compile(r"^[A-Za-z]{3}$")
|
|
||||||
_VALID_CABINS = {"economy", "premium_economy", "business", "first"}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_int(value: Any, name: str, lo: int, hi: int, default: int) -> int:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
v = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise _ValidationError(f"'{name}' must be an integer between {lo} and {hi}.")
|
|
||||||
if v < lo or v > hi:
|
|
||||||
raise _ValidationError(f"'{name}' must be between {lo} and {hi} (got {v}).")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class _ValidationError(Exception):
|
|
||||||
"""Raised for invalid tool arguments; message is returned to the LLM."""
|
|
||||||
|
|
||||||
|
|
||||||
def _serpapi_search_flights(args: dict) -> str:
|
|
||||||
# ── Required params ────────────────────────────────────────────────────────
|
|
||||||
departure_id = (args.get("departure_id") or "").strip().upper()
|
|
||||||
arrival_id = (args.get("arrival_id") or "").strip().upper()
|
|
||||||
outbound_date = (args.get("outbound_date") or "").strip()
|
|
||||||
|
|
||||||
if not departure_id:
|
|
||||||
raise _ValidationError("Missing required parameter 'departure_id' (3-letter IATA airport or city code, e.g. 'JFK', 'MIL').")
|
|
||||||
if not _IATA_RE.match(departure_id):
|
|
||||||
raise _ValidationError(f"'departure_id' must be exactly 3 ASCII letters (got '{departure_id}'). Use an airport code (e.g. 'JFK') or a city code (e.g. 'NYC', 'MIL', 'LON').")
|
|
||||||
if not arrival_id:
|
|
||||||
raise _ValidationError("Missing required parameter 'arrival_id' (3-letter IATA airport or city code).")
|
|
||||||
if not _IATA_RE.match(arrival_id):
|
|
||||||
raise _ValidationError(f"'arrival_id' must be exactly 3 ASCII letters (got '{arrival_id}'). Use an airport code (e.g. 'FCO') or a city code (e.g. 'ROM').")
|
|
||||||
if not outbound_date:
|
|
||||||
raise _ValidationError("Missing required parameter 'outbound_date' (YYYY-MM-DD).")
|
|
||||||
if not _DATE_RE.match(outbound_date):
|
|
||||||
raise _ValidationError(f"'outbound_date' must be in YYYY-MM-DD format (got '{outbound_date}').")
|
|
||||||
|
|
||||||
# ── Optional params ────────────────────────────────────────────────────────
|
|
||||||
return_date = (args.get("return_date") or "").strip() or None
|
|
||||||
if return_date and not _DATE_RE.match(return_date):
|
|
||||||
raise _ValidationError(f"'return_date' must be in YYYY-MM-DD format (got '{return_date}').")
|
|
||||||
|
|
||||||
adults = _validate_int(args.get("adults"), "adults", 1, 10, 1)
|
|
||||||
children = _validate_int(args.get("children"), "children", 0, 8, 0)
|
|
||||||
infants_in_seat = _validate_int(args.get("infants_in_seat"), "infants_in_seat", 0, 4, 0)
|
|
||||||
infants_on_lap = _validate_int(args.get("infants_on_lap"), "infants_on_lap", 0, 4, 0)
|
|
||||||
|
|
||||||
stops_raw = args.get("stops")
|
|
||||||
stops: int | None = None
|
|
||||||
if stops_raw is not None:
|
|
||||||
try:
|
|
||||||
stops = int(stops_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise _ValidationError("'stops' must be 0 (non-stop only), 1 (max 1 stop), or 2 (max 2 stops).")
|
|
||||||
if stops not in (0, 1, 2):
|
|
||||||
raise _ValidationError(f"'stops' must be 0, 1, or 2 (got {stops}).")
|
|
||||||
|
|
||||||
currency = (args.get("currency") or "EUR").strip().upper()
|
|
||||||
if not re.match(r"^[A-Z]{3}$", currency):
|
|
||||||
raise _ValidationError(f"'currency' must be a 3-letter ISO code (got '{currency}').")
|
|
||||||
|
|
||||||
preferred_cabins = args.get("preferred_cabins")
|
|
||||||
if preferred_cabins is not None:
|
|
||||||
preferred_cabins = str(preferred_cabins).strip().lower()
|
|
||||||
if preferred_cabins not in _VALID_CABINS:
|
|
||||||
raise _ValidationError(f"'preferred_cabins' must be one of: {', '.join(sorted(_VALID_CABINS))}.")
|
|
||||||
|
|
||||||
hl = args.get("hl") or "en"
|
|
||||||
|
|
||||||
max_results = _validate_int(args.get("max_results"), "max_results", 1, 50, 10)
|
|
||||||
|
|
||||||
# ── Build SerpAPI params ───────────────────────────────────────────────────
|
|
||||||
api_params: dict[str, Any] = {
|
|
||||||
"engine": "google_flights",
|
|
||||||
"departure_id": departure_id,
|
|
||||||
"arrival_id": arrival_id,
|
|
||||||
"outbound_date": outbound_date,
|
|
||||||
"adults": adults,
|
|
||||||
"children": children,
|
|
||||||
"infants_in_seat": infants_in_seat,
|
|
||||||
"infants_on_lap": infants_on_lap,
|
|
||||||
"currency": currency,
|
|
||||||
"hl": hl,
|
|
||||||
}
|
|
||||||
if return_date:
|
|
||||||
api_params["return_date"] = return_date
|
|
||||||
api_params["type"] = "1" # round-trip
|
|
||||||
else:
|
|
||||||
api_params["type"] = "2" # one-way
|
|
||||||
if stops is not None:
|
|
||||||
api_params["stops"] = stops
|
|
||||||
if preferred_cabins:
|
|
||||||
api_params["preferred_cabins"] = preferred_cabins
|
|
||||||
|
|
||||||
# ── Call ───────────────────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
data = _serpapi_request(api_params)
|
|
||||||
except Exception as e:
|
|
||||||
return _format_api_error(e)
|
|
||||||
|
|
||||||
if data.get("error"):
|
|
||||||
return f"Error: SerpAPI returned an error: {data['error']}"
|
|
||||||
|
|
||||||
return _format_flight_results(data, max_results)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tool manifest ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{
|
|
||||||
"name": "serpapi_search_flights",
|
|
||||||
"description": (
|
|
||||||
"Search one-way or round-trip flights on Google Flights via SerpAPI. "
|
|
||||||
"Returns a plain-text list of routes with price, airline + flight number, "
|
|
||||||
"departure/arrival times, segment durations, total duration, layovers, "
|
|
||||||
"and CO2 emissions. The first result is typically the cheapest.\n"
|
|
||||||
"Both airport codes (3 letters, e.g. 'JFK', 'FCO') and city codes "
|
|
||||||
"(3 letters covering all airports of a city, e.g. 'NYC', 'ROM', 'MIL', "
|
|
||||||
"'LON') are accepted for departure_id and arrival_id — prefer city codes "
|
|
||||||
"when the user does not name a specific airport."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"departure_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Departure airport or city code — exactly 3 ASCII letters. Examples: 'JFK' (New York JFK), 'MIL' (any Milan airport), 'LON' (any London airport), 'ROM' (any Rome airport).",
|
|
||||||
},
|
|
||||||
"arrival_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Arrival airport or city code — exactly 3 ASCII letters. See departure_id for examples.",
|
|
||||||
},
|
|
||||||
"outbound_date": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Outbound date in YYYY-MM-DD format (e.g. '2026-08-01').",
|
|
||||||
},
|
|
||||||
"return_date": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Return date in YYYY-MM-DD for round-trip searches. Omit for one-way.",
|
|
||||||
},
|
|
||||||
"adults": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of adult passengers (12+). Default 1, max 10.",
|
|
||||||
},
|
|
||||||
"children": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of children (2-11). Default 0, max 8.",
|
|
||||||
},
|
|
||||||
"infants_in_seat": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of infants occupying a seat. Default 0, max 4.",
|
|
||||||
},
|
|
||||||
"infants_on_lap": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of infants on an adult's lap (under 2). Default 0, max 4.",
|
|
||||||
},
|
|
||||||
"stops": {
|
|
||||||
"type": "integer",
|
|
||||||
"enum": [0, 1, 2],
|
|
||||||
"description": "Maximum number of stops: 0 = non-stop only, 1 = max 1 stop, 2 = max 2 stops. Omit to allow any.",
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "ISO 4217 currency code for prices (e.g. 'EUR', 'USD', 'GBP'). Default 'EUR'.",
|
|
||||||
},
|
|
||||||
"preferred_cabins": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["economy", "premium_economy", "business", "first"],
|
|
||||||
"description": "Cabin class filter. Omit to search all cabins.",
|
|
||||||
},
|
|
||||||
"hl": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Language code for results (e.g. 'en', 'it', 'fr'). Default 'en'.",
|
|
||||||
},
|
|
||||||
"max_results": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Maximum number of flight options to return. Default 10, max 50.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["departure_id", "arrival_id", "outbound_date"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
TOOL_DISPATCH = {
|
|
||||||
"serpapi_search_flights": _serpapi_search_flights,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(req_id: Any, result: Any) -> str:
|
|
||||||
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
|
|
||||||
|
|
||||||
|
|
||||||
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
|
|
||||||
payload: dict = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {"content": [{"type": "text", "text": text}]},
|
|
||||||
}
|
|
||||||
if is_error:
|
|
||||||
payload["result"]["isError"] = True
|
|
||||||
return json.dumps(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_request(msg: dict) -> str | None:
|
|
||||||
method = msg.get("method", "")
|
|
||||||
req_id = msg.get("id")
|
|
||||||
|
|
||||||
if method == "initialize":
|
|
||||||
return _ok(req_id, {
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {"tools": {}},
|
|
||||||
"serverInfo": {
|
|
||||||
"name": "serpapi_flights",
|
|
||||||
"version": "2.0.0",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if method == "notifications/initialized":
|
|
||||||
return None
|
|
||||||
|
|
||||||
if method == "tools/list":
|
|
||||||
return _ok(req_id, {"tools": TOOLS})
|
|
||||||
|
|
||||||
if method == "tools/call":
|
|
||||||
params = msg.get("params", {})
|
|
||||||
tool_name = params.get("name", "")
|
|
||||||
tool_args = params.get("arguments", {}) or {}
|
|
||||||
|
|
||||||
handler = TOOL_DISPATCH.get(tool_name)
|
|
||||||
if handler is None:
|
|
||||||
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
text = handler(tool_args)
|
|
||||||
except _ValidationError as e:
|
|
||||||
return _text_result(req_id, f"Error: {e}", is_error=True)
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Unhandled exception in tool '{tool_name}': {e}")
|
|
||||||
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
|
|
||||||
|
|
||||||
is_err = text.startswith("Error:")
|
|
||||||
return _text_result(req_id, text, is_error=is_err)
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main loop ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
log("Starting SerpAPI Google Flights MCP server")
|
|
||||||
# Validate API key eagerly so configuration errors surface at startup.
|
|
||||||
_get_api_key()
|
|
||||||
try:
|
|
||||||
for line in sys.stdin:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
msg = json.loads(line)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
log(f"Invalid JSON input: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
resp = handle_request(msg)
|
|
||||||
if resp is not None:
|
|
||||||
sys.stdout.write(resp + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,779 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Weather MCP server (JSON-RPC 2.0 over stdio) using Open-Meteo API.
|
|
||||||
|
|
||||||
Capabilities (callable as `mcp__weather__<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()
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition.
|
|
||||||
*
|
|
||||||
* Runs INSIDE the user's per-user container (blueprint §6/§7). Unlike the old
|
|
||||||
* whatsapp-web.js server, this one uses `@whiskeysockets/baileys`: a pure-WebSocket
|
|
||||||
* WhatsApp multi-device client with **no browser** — so it fits the slim
|
|
||||||
* `skald-runtime` image (node, no Chromium) and needs no puppeteer self-healing.
|
|
||||||
*
|
|
||||||
* ── Interactive login contract (the generic §15 seam) ───────────────────────────
|
|
||||||
* A per-user connector that needs an interactive login exposes ONE standard tool,
|
|
||||||
* `login_status`, that Skald's login API calls directly (never the agent). It
|
|
||||||
* returns a small JSON object the login panel renders:
|
|
||||||
*
|
|
||||||
* { "state": "connecting" | "need_scan" | "ready" | "logged_out",
|
|
||||||
* "qr": "data:image/png;base64,…" // present only while state == need_scan
|
|
||||||
* "message": "human-readable line" }
|
|
||||||
*
|
|
||||||
* The panel polls it; when `state == "ready"` Skald flips the connector's
|
|
||||||
* `auth_state` to `ready`. WhatsApp's credential is the persisted session on disk
|
|
||||||
* (`./auth/`, under the bind-mounted home → survives a container recreate), not a
|
|
||||||
* token — so there is nothing to paste back, only a QR to scan.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Baileys uses the Web Crypto global (`crypto.subtle`), which Node only exposes as
|
|
||||||
// `globalThis.crypto` from v20+. The container ships Node 18 (Debian bookworm), so
|
|
||||||
// polyfill it from `node:crypto` — without this, the socket dies on connect with
|
|
||||||
// "crypto is not defined" and never reaches the QR.
|
|
||||||
const nodeCrypto = require('crypto');
|
|
||||||
if (!globalThis.crypto) globalThis.crypto = nodeCrypto.webcrypto;
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const readline = require('readline');
|
|
||||||
const qrcode = require('qrcode');
|
|
||||||
|
|
||||||
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, jidNormalizedUser;
|
|
||||||
try {
|
|
||||||
const baileys = require('@whiskeysockets/baileys');
|
|
||||||
makeWASocket = baileys.default || baileys.makeWASocket;
|
|
||||||
useMultiFileAuthState = baileys.useMultiFileAuthState;
|
|
||||||
DisconnectReason = baileys.DisconnectReason;
|
|
||||||
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
|
|
||||||
jidNormalizedUser = baileys.jidNormalizedUser;
|
|
||||||
} catch (e) {
|
|
||||||
process.stderr.write(`[whatsapp_mcp] FATAL: baileys not installed (${e.message}). Run npm install.\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
|
||||||
// Everything hangs off __dirname (the connector dir inside the container home,
|
|
||||||
// `~/.skald/mcp/<name>/`), which is bind-mounted and therefore durable.
|
|
||||||
const AUTH_DIR = path.join(__dirname, 'auth'); // multi-file auth state (the "session")
|
|
||||||
const MEDIA_DIR = path.join(__dirname, 'media');
|
|
||||||
|
|
||||||
function log(msg) { process.stderr.write(`[whatsapp_mcp] ${msg}\n`); }
|
|
||||||
|
|
||||||
// A silent logger: Baileys requires one, and anything it prints must never reach
|
|
||||||
// stdout (that channel is reserved for JSON-RPC framing).
|
|
||||||
const silentLogger = (() => {
|
|
||||||
const noop = () => {};
|
|
||||||
const l = { level: 'silent', trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop };
|
|
||||||
l.child = () => l;
|
|
||||||
return l;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Connection state ─────────────────────────────────────────────────────────
|
|
||||||
// connecting – socket starting or reconnecting
|
|
||||||
// need_scan – a QR is available; the user must scan it
|
|
||||||
// ready – authenticated and connected; tools operational
|
|
||||||
// logged_out – the phone unlinked this device; a fresh QR + scan is required
|
|
||||||
let state = 'connecting';
|
|
||||||
let sock = null;
|
|
||||||
let curQr = null; // latest raw QR string (null once scanned / connected)
|
|
||||||
let meJid = null;
|
|
||||||
let starting = false;
|
|
||||||
|
|
||||||
// ── Lightweight in-memory store ───────────────────────────────────────────────
|
|
||||||
// Baileys keeps no chat/contact store of its own; we build a minimal one from the
|
|
||||||
// history-sync event and live upserts. It lives for the process lifetime — enough
|
|
||||||
// for "what's going on now", not a full archive.
|
|
||||||
const chats = new Map(); // jid -> { id, name, unread, conversationTimestamp }
|
|
||||||
const contacts = new Map(); // jid -> { id, name }
|
|
||||||
const messages = new Map(); // jid -> [ { id, fromMe, ts, text, author } ] (capped)
|
|
||||||
|
|
||||||
const MAX_MSGS_PER_CHAT = 200;
|
|
||||||
|
|
||||||
function pushMessage(jid, m) {
|
|
||||||
if (!jid) return;
|
|
||||||
let arr = messages.get(jid);
|
|
||||||
if (!arr) { arr = []; messages.set(jid, arr); }
|
|
||||||
arr.push(m);
|
|
||||||
if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT);
|
|
||||||
}
|
|
||||||
|
|
||||||
function contactName(jid) {
|
|
||||||
const c = contacts.get(jid);
|
|
||||||
if (c && c.name) return c.name;
|
|
||||||
const ch = chats.get(jid);
|
|
||||||
if (ch && ch.name) return ch.name;
|
|
||||||
return jid ? jid.split('@')[0] : 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOf(msg) {
|
|
||||||
const m = msg.message;
|
|
||||||
if (!m) return '';
|
|
||||||
return (
|
|
||||||
m.conversation ||
|
|
||||||
m.extendedTextMessage?.text ||
|
|
||||||
m.imageMessage?.caption ||
|
|
||||||
m.videoMessage?.caption ||
|
|
||||||
m.documentMessage?.caption ||
|
|
||||||
(m.imageMessage ? '[image]' : '') ||
|
|
||||||
(m.videoMessage ? '[video]' : '') ||
|
|
||||||
(m.audioMessage ? '[audio]' : '') ||
|
|
||||||
(m.documentMessage ? '[document]' : '') ||
|
|
||||||
(m.stickerMessage ? '[sticker]' : '') ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WhatsApp socket lifecycle ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function startSock() {
|
|
||||||
if (starting) return;
|
|
||||||
starting = true;
|
|
||||||
try {
|
|
||||||
if (!makeWASocket) { state = 'connecting'; return; }
|
|
||||||
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
||||||
|
|
||||||
const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
|
||||||
let version;
|
|
||||||
try { ({ version } = await fetchLatestBaileysVersion()); } catch (_) { /* baileys default */ }
|
|
||||||
|
|
||||||
sock = makeWASocket({
|
|
||||||
version,
|
|
||||||
auth: authState,
|
|
||||||
logger: silentLogger,
|
|
||||||
browser: ['Skald', 'Chrome', '1.0.0'],
|
|
||||||
syncFullHistory: false,
|
|
||||||
markOnlineOnConnect: false,
|
|
||||||
generateHighQualityLinkPreview: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
sock.ev.on('creds.update', saveCreds);
|
|
||||||
|
|
||||||
sock.ev.on('connection.update', (u) => {
|
|
||||||
const { connection, lastDisconnect, qr } = u;
|
|
||||||
if (qr) { curQr = qr; state = 'need_scan'; log('QR ready — awaiting scan'); }
|
|
||||||
if (connection === 'open') {
|
|
||||||
curQr = null;
|
|
||||||
state = 'ready';
|
|
||||||
meJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : null;
|
|
||||||
log('connection open — ready');
|
|
||||||
}
|
|
||||||
if (connection === 'close') {
|
|
||||||
const code = lastDisconnect?.error?.output?.statusCode;
|
|
||||||
if (code === DisconnectReason.loggedOut) {
|
|
||||||
state = 'logged_out';
|
|
||||||
curQr = null;
|
|
||||||
log('logged out by phone — clearing session');
|
|
||||||
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
||||||
// Re-init so a fresh QR is produced immediately.
|
|
||||||
starting = false;
|
|
||||||
setTimeout(() => startSock(), 500);
|
|
||||||
} else {
|
|
||||||
state = 'connecting';
|
|
||||||
log(`connection closed (code ${code ?? '?'}) — reconnecting`);
|
|
||||||
starting = false;
|
|
||||||
setTimeout(() => startSock(), 1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial history sync: chats, contacts and a batch of messages.
|
|
||||||
sock.ev.on('messaging-history.set', ({ chats: hc, contacts: hcs, messages: hm }) => {
|
|
||||||
for (const c of hc || []) {
|
|
||||||
chats.set(c.id, {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name || c.subject || null,
|
|
||||||
unread: c.unreadCount || 0,
|
|
||||||
conversationTimestamp: Number(c.conversationTimestamp) || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const c of hcs || []) {
|
|
||||||
contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
|
|
||||||
}
|
|
||||||
for (const m of hm || []) ingestMessage(m, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
sock.ev.on('chats.upsert', (cs) => {
|
|
||||||
for (const c of cs) chats.set(c.id, {
|
|
||||||
id: c.id, name: c.name || c.subject || null,
|
|
||||||
unread: c.unreadCount || 0,
|
|
||||||
conversationTimestamp: Number(c.conversationTimestamp) || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
sock.ev.on('contacts.upsert', (cs) => {
|
|
||||||
for (const c of cs) contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
|
|
||||||
});
|
|
||||||
sock.ev.on('contacts.update', (cs) => {
|
|
||||||
for (const c of cs) {
|
|
||||||
const prev = contacts.get(c.id) || { id: c.id };
|
|
||||||
contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sock.ev.on('messages.upsert', ({ messages: ms, type }) => {
|
|
||||||
for (const m of ms) ingestMessage(m, type === 'notify');
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
log(`startSock error: ${e.message}`);
|
|
||||||
state = 'connecting';
|
|
||||||
} finally {
|
|
||||||
starting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingestMessage(m, live) {
|
|
||||||
try {
|
|
||||||
const jid = m.key?.remoteJid;
|
|
||||||
if (!jid || jid === 'status@broadcast') return;
|
|
||||||
const text = textOf(m);
|
|
||||||
pushMessage(jid, {
|
|
||||||
id: m.key?.id,
|
|
||||||
fromMe: !!m.key?.fromMe,
|
|
||||||
ts: Number(m.messageTimestamp) || 0,
|
|
||||||
text,
|
|
||||||
author: m.key?.participant || (m.key?.fromMe ? meJid : jid),
|
|
||||||
});
|
|
||||||
if (live && !chats.has(jid)) {
|
|
||||||
chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 });
|
|
||||||
} else if (live) {
|
|
||||||
const ch = chats.get(jid);
|
|
||||||
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
|
|
||||||
if (m.pushName && !ch.name) ch.name = m.pushName;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Turn a plain phone number or a chat id into a WhatsApp jid.
|
|
||||||
function toJid(chat_id, number) {
|
|
||||||
if (chat_id && chat_id.includes('@')) return chat_id;
|
|
||||||
const raw = (chat_id || number || '').replace(/[^0-9]/g, '');
|
|
||||||
if (!raw) return null;
|
|
||||||
return `${raw}@s.whatsapp.net`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireReady() {
|
|
||||||
if (state !== 'ready') {
|
|
||||||
throw new Error(`WhatsApp is not connected (state: ${state}). ` +
|
|
||||||
(state === 'need_scan' || state === 'logged_out'
|
|
||||||
? 'Open the connector in Skald and scan the QR code to sign in.'
|
|
||||||
: 'It is still connecting — try again in a few seconds.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tools: interactive login (the §15 generic contract) ─────────────────────────
|
|
||||||
|
|
||||||
async function toolLoginStatus() {
|
|
||||||
let qrDataUrl = null;
|
|
||||||
if (state === 'need_scan' && curQr) {
|
|
||||||
try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch (_) {}
|
|
||||||
}
|
|
||||||
const message = {
|
|
||||||
connecting: 'Connecting to WhatsApp…',
|
|
||||||
need_scan: 'Scan this QR code: WhatsApp → Settings → Linked Devices → Link a Device.',
|
|
||||||
ready: 'WhatsApp is connected.',
|
|
||||||
logged_out: 'This device was unlinked. Scan the new QR code to sign in again.',
|
|
||||||
}[state] || state;
|
|
||||||
// Returned as a JSON string in a text content part; the login API parses it.
|
|
||||||
return JSON.stringify({ state, qr: qrDataUrl, message });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toolStatus() {
|
|
||||||
const s = await toolLoginStatus();
|
|
||||||
const { state: st, message } = JSON.parse(s);
|
|
||||||
const chatCount = chats.size;
|
|
||||||
return `WhatsApp status: ${st.toUpperCase()}\n${message}` +
|
|
||||||
(st === 'ready' ? `\nKnown chats: ${chatCount}` : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toolLogout() {
|
|
||||||
try { if (sock) await sock.logout(); } catch (_) {}
|
|
||||||
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
||||||
chats.clear(); contacts.clear(); messages.clear();
|
|
||||||
curQr = null; state = 'connecting'; starting = false; meJid = null;
|
|
||||||
setTimeout(() => startSock(), 500);
|
|
||||||
return 'Logged out and cleared the session. A new QR code will be generated — open the connector in Skald and scan it.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tools: messaging ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function toolListChats(args) {
|
|
||||||
requireReady();
|
|
||||||
const max = Math.min(Math.max(1, args.max_chats || 20), 50);
|
|
||||||
const list = [...chats.values()]
|
|
||||||
.sort((a, b) => (b.conversationTimestamp || 0) - (a.conversationTimestamp || 0))
|
|
||||||
.slice(0, max);
|
|
||||||
if (!list.length) return 'No chats known yet. History may still be syncing — try again in a few seconds.';
|
|
||||||
const lines = [`Recent WhatsApp chats (${list.length}):`];
|
|
||||||
for (const c of list) {
|
|
||||||
const kind = c.id.endsWith('@g.us') ? '[group]' : '[chat]';
|
|
||||||
const unread = c.unread ? ` (${c.unread} unread)` : '';
|
|
||||||
lines.push(`- ${c.name || contactName(c.id)} ${kind}${unread} | ID: ${c.id}`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toolGetMessages(args) {
|
|
||||||
requireReady();
|
|
||||||
const jid = toJid(args.chat_id, args.number);
|
|
||||||
if (!jid) return 'Error: provide chat_id or number.';
|
|
||||||
const limit = Math.min(Math.max(1, args.limit || 20), 100);
|
|
||||||
const offset = Math.max(0, args.offset || 0);
|
|
||||||
const arr = (messages.get(jid) || []).slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
||||||
if (!arr.length) return `No messages buffered for ${contactName(jid)} (${jid}). Only messages seen since sign-in are available.`;
|
|
||||||
const end = arr.length - offset;
|
|
||||||
const slice = arr.slice(Math.max(0, end - limit), Math.max(0, end));
|
|
||||||
const lines = [`Messages with ${contactName(jid)} (${jid}):`];
|
|
||||||
for (const m of slice) {
|
|
||||||
const who = m.fromMe ? 'me' : (jid.endsWith('@g.us') ? contactName(m.author) : contactName(jid));
|
|
||||||
const when = m.ts ? new Date(m.ts * 1000).toISOString().replace('T', ' ').slice(0, 16) : '';
|
|
||||||
lines.push(`[${when}] ${who}: ${m.text}`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toolSendMessage(args) {
|
|
||||||
requireReady();
|
|
||||||
const jid = toJid(args.chat_id, args.number);
|
|
||||||
if (!jid) return 'Error: provide chat_id or number.';
|
|
||||||
if (!args.message) return 'Error: message is required.';
|
|
||||||
await sock.sendMessage(jid, { text: String(args.message) });
|
|
||||||
return `Message sent to ${contactName(jid)} (${jid}).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toolSearchContacts(args) {
|
|
||||||
requireReady();
|
|
||||||
const q = String(args.query || '').toLowerCase();
|
|
||||||
if (!q) return 'Error: query is required.';
|
|
||||||
const max = Math.min(Math.max(1, args.max_results || 20), 50);
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const c of contacts.values()) {
|
|
||||||
if (out.length >= max) break;
|
|
||||||
const name = c.name || '';
|
|
||||||
if (name.toLowerCase().includes(q) || c.id.includes(q)) {
|
|
||||||
if (seen.has(c.id)) continue;
|
|
||||||
seen.add(c.id);
|
|
||||||
out.push(`- ${name || contactName(c.id)} | ID: ${c.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!out.length) return `No contacts found matching "${args.query}".`;
|
|
||||||
return [`Contacts matching "${args.query}" (${out.length}):`, ...out].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MCP tool definitions ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOOLS = [
|
|
||||||
{
|
|
||||||
name: 'login_status',
|
|
||||||
description: 'Interactive-login status for this connector (used by the Skald login panel). Returns a JSON object {state, qr, message}: state is connecting|need_scan|ready|logged_out; qr is a data-URL PNG present only while a scan is needed. Safe to poll.',
|
|
||||||
inputSchema: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
description: 'WhatsApp connection status as a short human-readable report. Call this first when another WhatsApp tool fails.',
|
|
||||||
inputSchema: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'logout',
|
|
||||||
description: 'Log out of WhatsApp: end the session, clear the stored credentials, and generate a fresh QR code to link a (possibly different) phone. After calling, the user must scan the new QR in the Skald connector page.',
|
|
||||||
inputSchema: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'list_chats',
|
|
||||||
description: 'List recent WhatsApp chats (contacts and groups) with name, ID and unread count. Only chats seen since sign-in / history sync are known.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { max_chats: { type: 'integer', description: 'Max chats to return (default 20, max 50).' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'get_messages',
|
|
||||||
description: 'Get buffered messages from a chat. Identify it with EITHER chat_id (from list_chats) OR a phone number with country code for an individual contact. Only messages seen since sign-in are available (no deep history).',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
chat_id: { type: 'string', description: 'Chat ID, e.g. "39XXXXXXXXXX@s.whatsapp.net" or "…@g.us".' },
|
|
||||||
number: { type: 'string', description: 'Alternative to chat_id: phone number with country code (e.g. "393331234567"). Ignored if chat_id is given.' },
|
|
||||||
limit: { type: 'integer', description: 'Number of messages (default 20, max 100).' },
|
|
||||||
offset: { type: 'integer', description: 'Skip this many of the most recent messages (default 0).' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'send_message',
|
|
||||||
description: 'Send a WhatsApp text message. Identify the recipient with EITHER chat_id (from list_chats, use for groups) OR a phone number with country code for an individual contact.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
chat_id: { type: 'string', description: 'Chat ID to send to (use for groups).' },
|
|
||||||
number: { type: 'string', description: 'Alternative to chat_id: phone number with country code. Ignored if chat_id is given.' },
|
|
||||||
message: { type: 'string', description: 'The text to send.' },
|
|
||||||
},
|
|
||||||
required: ['message'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'search_contacts',
|
|
||||||
description: 'Search known WhatsApp contacts by name or number. Use to find a contact ID to message.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string', description: 'Name or partial name/number (case-insensitive).' },
|
|
||||||
max_results: { type: 'integer', description: 'Max contacts to return (default 20, max 50).' },
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── JSON-RPC framing ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function okResponse(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); }
|
|
||||||
function textResult(id, text, isError = false) {
|
|
||||||
const result = { content: [{ type: 'text', text }] };
|
|
||||||
if (isError) result.isError = true;
|
|
||||||
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequest(msg) {
|
|
||||||
const { method, id, params } = msg;
|
|
||||||
|
|
||||||
if (method === 'initialize') {
|
|
||||||
return okResponse(id, {
|
|
||||||
protocolVersion: '2024-11-05',
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
serverInfo: { name: 'whatsapp', version: '2.0.0' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (method === 'notifications/initialized') return null;
|
|
||||||
if (method === 'tools/list') return okResponse(id, { tools: TOOLS });
|
|
||||||
|
|
||||||
if (method === 'tools/call') {
|
|
||||||
const toolName = params?.name || '';
|
|
||||||
const toolArgs = params?.arguments || {};
|
|
||||||
let text;
|
|
||||||
try {
|
|
||||||
switch (toolName) {
|
|
||||||
case 'login_status': text = await toolLoginStatus(); break;
|
|
||||||
case 'status': text = await toolStatus(); break;
|
|
||||||
case 'logout': text = await toolLogout(); break;
|
|
||||||
case 'list_chats': text = await toolListChats(toolArgs); break;
|
|
||||||
case 'get_messages': text = await toolGetMessages(toolArgs); break;
|
|
||||||
case 'send_message': text = await toolSendMessage(toolArgs); break;
|
|
||||||
case 'search_contacts': text = await toolSearchContacts(toolArgs); break;
|
|
||||||
default:
|
|
||||||
return textResult(id, `Unknown tool: ${toolName}`, true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(`tool '${toolName}' error: ${e.message}`);
|
|
||||||
return textResult(id, `Error: ${e.message}`, true);
|
|
||||||
}
|
|
||||||
const isErr = typeof text === 'string' && text.startsWith('Error:');
|
|
||||||
return textResult(id, text, isErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
log('Starting WhatsApp MCP server (Baileys)');
|
|
||||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
|
||||||
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
|
|
||||||
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
||||||
rl.on('line', async (line) => {
|
|
||||||
line = line.trim();
|
|
||||||
if (!line) return;
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(line); } catch (e) { log(`bad JSON on stdin: ${e.message}`); return; }
|
|
||||||
const resp = await handleRequest(msg);
|
|
||||||
if (resp !== null) process.stdout.write(resp + '\n');
|
|
||||||
});
|
|
||||||
rl.on('close', () => { log('stdin closed, shutting down'); process.exit(0); });
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => { log('SIGTERM'); process.exit(0); });
|
|
||||||
process.on('SIGINT', () => { log('SIGINT'); process.exit(0); });
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); });
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "skald-whatsapp-mcp",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "WhatsApp MCP connector for Skald (Baileys, no browser).",
|
|
||||||
"main": "index.js",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@whiskeysockets/baileys": "^6.7.9",
|
|
||||||
"qrcode": "^1.5.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user