Compare commits
2
Commits
046f060fcd
...
8bcf09a67e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bcf09a67e | ||
|
|
70f6a927bc |
@@ -193,7 +193,7 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s
|
|||||||
|
|
||||||
**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (`<connectors-page>`) is the **single** Connectors surface — a row list, one row per connector (there is no separate catalog page): the user view (activate/deactivate + granted globals) always, plus the admin affordances when `role_id === 'admin'` — the **Add connector** dropdown (from the Marketplace, or manually via the `#connectors/new` sub-page), per-row removal from the catalog, and the **Sign-in providers** modal. The Marketplace stays its own page (`marketplace.js`), reached from that dropdown and linking back to `#connectors`. `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts both the OAuth login panel and the QR login panel.
|
**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (`<connectors-page>`) is the **single** Connectors surface — a row list, one row per connector (there is no separate catalog page): the user view (activate/deactivate + granted globals) always, plus the admin affordances when `role_id === 'admin'` — the **Add connector** dropdown (from the Marketplace, or manually via the `#connectors/new` sub-page), per-row removal from the catalog, and the **Sign-in providers** modal. The Marketplace stays its own page (`marketplace.js`), reached from that dropdown and linking back to `#connectors`. `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts both the OAuth login panel and the QR login panel.
|
||||||
|
|
||||||
**Dependency reconciler (`mcp::install::ensure_installed`).** Copying a local-script connector's files into a container never installed its deps. `ensure_installed` closes that: a **content-hash reconciler** keyed on the connector's *source* files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — `npm ci --omit=dev` (node, from `package.json`) and/or `pip install --target .pydeps` (python, from `requirements.txt`, put on the server's `PYTHONPATH` by `user_row_spec`). Runs at activation **and** on every per-user startup path (`UserContext` build, remount) via `mcp::prepare_local_connector`, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore **never vendored** — connectors ship `package.json`/`requirements.txt`, not `node_modules/`. Authoring contract for connectors lives in `scripts/CONNECTOR_MANIFEST_GUIDE.md`.
|
**Dependency reconciler (`mcp::install::ensure_installed`).** Copying a local-script connector's files into a container never installed its deps. `ensure_installed` closes that: a **content-hash reconciler** keyed on the connector's *source* files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — `npm ci --omit=dev` (node, from `package.json`) and/or `pip install --target .pydeps` (python, from `requirements.txt`, put on the server's `PYTHONPATH` by `user_row_spec`). Runs at activation **and** on every per-user startup path (`UserContext` build, remount) via `mcp::prepare_local_connector`, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore **never vendored** — connectors ship `package.json`/`requirements.txt`, not `node_modules/`. Authoring contract for connectors lives in `CONNECTOR_MANIFEST_GUIDE.md` (repo root).
|
||||||
|
|
||||||
**Connector versioning.** `mcp_catalog` carries `version` (INTEGER — the update-comparison key), `version_string` (semver, display) and `version_release_date` (ISO, display), snapshotted from the feed on install. The marketplace list computes `update_available` = feed `version` > installed `version` (strict) and surfaces it as an "Update" button (`marketplace.js`). The integer is the UI signal; the actual re-install trigger is the reconciler's content-hash.
|
**Connector versioning.** `mcp_catalog` carries `version` (INTEGER — the update-comparison key), `version_string` (semver, display) and `version_release_date` (ISO, display), snapshotted from the feed on install. The marketplace list computes `update_available` = feed `version` > installed `version` (strict) and surfaces it as an "Update" button (`marketplace.js`). The integer is the UI signal; the actual re-install trigger is the reconciler's content-hash.
|
||||||
|
|
||||||
@@ -367,11 +367,13 @@ Copy `default.config.yaml` → `config.yml`. Never commit `config.yml` (contains
|
|||||||
|
|
||||||
## Python environment
|
## Python environment
|
||||||
|
|
||||||
All Python scripts (MCP servers, setup scripts) use a local virtualenv at `.venv/` in the project root.
|
Host-side Python runs from a local virtualenv at `.venv/` in the project root. `run.sh` creates it on first launch (using `uv` if available, otherwise `python3 -m venv`), installs `requirements.txt`, and prepends `.venv/bin` to `PATH` before starting the app, so every child process resolves `python3` to the venv. No manual activation needed.
|
||||||
|
|
||||||
`run.sh` creates it automatically on first launch (using `uv` if available, otherwise `python3 -m venv`) and installs `requirements.txt`. It then prepends `.venv/bin` to `PATH` before starting the app, so every child process — MCP server launches, `execute_cmd` shell calls — resolves `python3` to the venv automatically. No manual activation needed. **Python is optional**: if neither `uv` nor `python3` is found, the app starts normally and only Python-based MCP servers will be unavailable.
|
**`requirements.txt` is for the two TTS plugins, and nothing else.** `plugin-tts-kokoro` and `plugin-tts-orpheus-3b` write an embedded server script to disk and spawn a bare `python3` on it — they have no dependency reconciler of their own, so their imports must be satisfied in the venv. The GPU/ML half of Orpheus (torch, transformers, snac, bitsandbytes, huggingface_hub) is split into `requirements-optional.txt`, installed by hand.
|
||||||
|
|
||||||
To add a Python dependency: add it to `requirements.txt`. It will be installed on the next `./run.sh` invocation if `.venv` does not yet exist — or run `uv pip install -r requirements.txt` manually.
|
**A connector's deps never go in `requirements.txt`.** A connector ships its own `requirements.txt`/`package.json` and `mcp::install::ensure_installed` installs it into `.pydeps`/`node_modules` — inside the user's container for a per-user connector, beside the connector's files on the host for a global one (`ensure_installed_host`). Putting them in the root file would install them on every box for a connector nobody activated; this is what the file used to do for the since-deleted `scripts/` MCP servers.
|
||||||
|
|
||||||
|
**Python is optional**: with neither `uv` nor `python3` present the app starts normally; the TTS plugins fail to start and a host-run global connector has no interpreter to install its deps with. Per-user connectors are unaffected — they run in the container, which ships its own Python.
|
||||||
|
|
||||||
## Frontend components (`web/components/`)
|
## Frontend components (`web/components/`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Skald Connector Authoring Guide
|
||||||
|
|
||||||
|
Instructions for generating a **correct connector** for the Skald marketplace
|
||||||
|
(`https://connectors.skaldagent.net`). Give this file to the agent that produces
|
||||||
|
new connectors.
|
||||||
|
|
||||||
|
A connector is a folder served by the marketplace. Skald installs it, verifies
|
||||||
|
every file against a SHA-256 pinned in the index, then either runs it on the host
|
||||||
|
(global connector) or copies it into the user's container and runs it there
|
||||||
|
(per-user connector, blueprint §6/§7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The two documents
|
||||||
|
|
||||||
|
### 1a. The root index — `connectors.json`
|
||||||
|
|
||||||
|
One array of entries, each pointing at a connector folder. **The index is the
|
||||||
|
signable root: it is the only place that lists a connector's files and their
|
||||||
|
SHA-256 digests.** Skald refuses any file whose bytes do not match.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"connectors": [
|
||||||
|
{
|
||||||
|
"id": "whatsapp", // unique slug = folder name
|
||||||
|
"name": "WhatsApp",
|
||||||
|
"version": 1, // INTEGER build number — the update key (§7)
|
||||||
|
"version_string": "2.0.1", // semver, display only
|
||||||
|
"version_release_date": "2026-07-19", // ISO date, display only
|
||||||
|
"type": "mcp_local", // mcp_local | mcp_remote (see §3)
|
||||||
|
"scope": "user", // user | global (see §3)
|
||||||
|
"icon_small": "whatsapp/icon_sm.svg",
|
||||||
|
"icon_large": "whatsapp/icon_lg.svg",
|
||||||
|
"user_description": "Send and read WhatsApp messages from your linked account.",
|
||||||
|
"requires": ["NODE"], // human hint: NODE | PYTHON | OAUTH | API_KEY
|
||||||
|
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
|
||||||
|
"auth": { "type": "qr" }, // may be repeated here and in the manifest
|
||||||
|
"folder": "whatsapp", // defaults to id
|
||||||
|
"files": [
|
||||||
|
{ "path": "index.js", "sha256": "…", "size": 21258 },
|
||||||
|
{ "path": "package.json", "sha256": "…", "size": 302 },
|
||||||
|
{ "path": "connector.json", "sha256": "…", "size": 620 },
|
||||||
|
{ "path": "icon_sm.svg", "sha256": "…", "size": 306 },
|
||||||
|
{ "path": "icon_lg.svg", "sha256": "…", "size": 308 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
|
||||||
|
- `files[].path` is relative to the connector folder. List **every** file the
|
||||||
|
connector ships (server code, `package.json`/`requirements.txt`, icons, and the
|
||||||
|
`connector.json` itself). A missing or mismatched digest fails the install.
|
||||||
|
- Compute `sha256` over the exact bytes served: `sha256sum <file>`.
|
||||||
|
- Do **not** list `node_modules/` or any generated deps — those are installed on
|
||||||
|
the box, not shipped (see §5).
|
||||||
|
- `size` is optional but recommended.
|
||||||
|
|
||||||
|
### 1b. The per-connector manifest — `<folder>/connector.json`
|
||||||
|
|
||||||
|
The richer document. Fetched per connector and mapped into Skald's catalog.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "whatsapp",
|
||||||
|
"name": "WhatsApp",
|
||||||
|
"version": 1, // INTEGER build number — the update key (§7)
|
||||||
|
"version_string": "2.0.1", // semver, display only
|
||||||
|
"version_release_date": "2026-07-19", // ISO date, display only
|
||||||
|
"type": "mcp_local",
|
||||||
|
"scope": "user",
|
||||||
|
"auth": { "type": "qr" }, // none | api_key | oauth2 | qr (see §4)
|
||||||
|
"mcp_config": {
|
||||||
|
"command": "node", // interpreter (local) …
|
||||||
|
"args": ["index.js"], // … args[0] MUST name the entry file
|
||||||
|
"transport": "stdio" // stdio (local) | streamable-http (remote)
|
||||||
|
},
|
||||||
|
"docs": [{
|
||||||
|
"lang": "en",
|
||||||
|
"description": "Human blurb shown in the UI.",
|
||||||
|
"llm_short_description": "One line the model reads to decide whether to use this connector."
|
||||||
|
}],
|
||||||
|
"env": [], // form fields the user fills (see §4b)
|
||||||
|
"tools": [ // OPTIONAL — friendly UI names per tool (§2a)
|
||||||
|
{ "name": "send_message", "display_name": "Send Message" }
|
||||||
|
],
|
||||||
|
"homepage": "https://…",
|
||||||
|
"icon_small": "icon_sm.svg", // relative to the folder here
|
||||||
|
"icon_large": "icon_lg.svg",
|
||||||
|
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`mcp_config.args[0]` is load-bearing for a local connector:** it is how Skald
|
||||||
|
learns which file to run. At activation Skald rewrites it to the file's path
|
||||||
|
inside the user's container (`/root/.skald/mcp/<name>/<entry>`), so keep it a
|
||||||
|
plain relative filename (`index.js`, `server.py`, `pkg/server.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Server contract (MCP over stdio)
|
||||||
|
|
||||||
|
A **local** connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It
|
||||||
|
MUST handle:
|
||||||
|
|
||||||
|
- `initialize` → `{ protocolVersion, capabilities: { tools: {} }, serverInfo }`
|
||||||
|
- `notifications/initialized` → no response
|
||||||
|
- `tools/list` → `{ tools: [ { name, description, inputSchema } ] }`
|
||||||
|
- `tools/call` → `{ content: [ { type: "text", text } ], isError? }`
|
||||||
|
|
||||||
|
**stdout is reserved for JSON-RPC only.** Send all logs/diagnostics to **stderr**.
|
||||||
|
Anything a library prints to stdout (a logger, a banner) corrupts the protocol —
|
||||||
|
silence it (e.g. Baileys/pino → a silent logger; Python → `print(…, file=sys.stderr)`).
|
||||||
|
|
||||||
|
A **remote** connector is an HTTP MCP endpoint (`mcp_config.url` +
|
||||||
|
`transport: "streamable-http"`); no code runs on the box.
|
||||||
|
|
||||||
|
### 2a. Friendly tool names (`tools[]`) — optional
|
||||||
|
|
||||||
|
Raw MCP tool names are ugly in the chat UI (`search_files`, `send_message`). The
|
||||||
|
optional top-level `tools[]` block gives each one a human title shown as the tool
|
||||||
|
card's heading:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tools": [
|
||||||
|
{ "name": "send_message", "display_name": "Send Message" },
|
||||||
|
{ "name": "list_chats", "display_name": "List Chats" },
|
||||||
|
{ "name": "download_media", "display_name": "Download Media" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `name` — the **raw** tool name exactly as your server returns it from `tools/list`.
|
||||||
|
- `display_name` — the friendly card title (English only; not internationalized).
|
||||||
|
|
||||||
|
**Resolution order** for a tool's card title is **`tools[].display_name` → the MCP
|
||||||
|
`title` field → a prettified raw name**. So you have two ways to set a friendly
|
||||||
|
name, and can skip `tools[]` entirely:
|
||||||
|
|
||||||
|
1. **This block** — the authoritative override, curated in the manifest.
|
||||||
|
2. **The MCP `title` field** — if your `tools/list` entries already carry a
|
||||||
|
`title` (MCP 2025-06-18+), Skald uses it automatically; no manifest change
|
||||||
|
needed. `tools[]` wins if both are present.
|
||||||
|
3. If neither is set, Skald title-cases the raw name (`send_message` → "Send
|
||||||
|
Message").
|
||||||
|
|
||||||
|
**Icons are per connector, not per tool.** Every tool of a connector shows that
|
||||||
|
connector's own `icon_small`; there is no per-tool icon field. Only list a tool in
|
||||||
|
`tools[]` when its prettified name isn't good enough — partial lists are fine
|
||||||
|
(unlisted tools fall through to steps 2–3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Placement & risk vocabulary (what the words mean)
|
||||||
|
|
||||||
|
| Manifest | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `scope: "user"` | runs **once per user**, inside their container. Personal creds. |
|
||||||
|
| `scope: "global"` | runs **once for the household**, on the host. Shared, stateless. Admin enables it with a key. |
|
||||||
|
| `type: "mcp_local"` | ships code that will **execute on the box** — installing needs the admin `mcp.register_local_script` capability (RCE-bearing act, §14). |
|
||||||
|
| `type: "mcp_remote"` | just an HTTP URL; no local code. |
|
||||||
|
|
||||||
|
Pick the narrowest: a personal messaging/email/calendar connector is
|
||||||
|
`scope: "user"`; a shared search API is `scope: "global"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication (`auth.type`)
|
||||||
|
|
||||||
|
| `auth.type` | Flow | Ships |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `none` | nothing to sign in | — |
|
||||||
|
| `api_key` | user pastes a key/secret into a form | an `env[]` schema (§4b) |
|
||||||
|
| `oauth2` | browser consent → paste code back | `auth.provider` + `auth.scopes` + `auth.deliver` (§4c) |
|
||||||
|
| `qr` | server shows a QR, user scans with a phone | a `login_status` tool (§4d) |
|
||||||
|
|
||||||
|
### 4b. `api_key` — the `env[]` schema
|
||||||
|
|
||||||
|
Each entry drives one form field **and** is injected as an env var / URL token to
|
||||||
|
the server:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"env": [{
|
||||||
|
"name": "tavilyApiKey",
|
||||||
|
"label": "Tavily API key",
|
||||||
|
"description": "Create one at https://app.tavily.com.",
|
||||||
|
"required": true,
|
||||||
|
"secret": true, // rendered masked, stored encrypted
|
||||||
|
"example": "tvly-xxxxxxxx"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
The server reads each value from `process.env.<name>` (or `os.environ`). For a
|
||||||
|
**remote** connector that wants the key in the URL, use a placeholder:
|
||||||
|
`"url": "https://mcp.example.com/?key={SECRET:tavilyApiKey}"`.
|
||||||
|
|
||||||
|
### 4c. `oauth2` — provider consent
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"auth": {
|
||||||
|
"type": "oauth2",
|
||||||
|
"provider": "google", // slug into the admin's sign-in providers
|
||||||
|
"scopes": ["https://www.googleapis.com/auth/gmail.modify"],
|
||||||
|
"deliver": { "as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest names **only** the provider slug, scopes, and how the obtained token
|
||||||
|
is delivered — never client secrets or endpoint URLs (those are admin-entered,
|
||||||
|
kept off the public feed). Skald handles PKCE + code exchange and injects the
|
||||||
|
credential as the named env var. `format`: `google_authorized_user` (Google) or
|
||||||
|
`refresh_token`. Today only `as: "env"` is wired.
|
||||||
|
|
||||||
|
### 4d. `qr` / interactive device login — the generic contract
|
||||||
|
|
||||||
|
For a connector whose credential is produced by **scanning/pairing** (WhatsApp
|
||||||
|
today), there is no code to paste. The rule:
|
||||||
|
|
||||||
|
> **Expose one extra tool, `login_status`, returning a JSON object** (as the
|
||||||
|
> `text` of a normal text result). Skald calls it directly (never the agent) and a
|
||||||
|
> login panel polls it.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// login_status result text (a JSON string):
|
||||||
|
{
|
||||||
|
"state": "connecting" | "need_scan" | "ready" | "logged_out",
|
||||||
|
"qr": "data:image/png;base64,…", // present ONLY while state == need_scan
|
||||||
|
"message": "human-readable line"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `activate` on a `qr` connector inserts a **pending** row and **starts the
|
||||||
|
server** (so it can produce the QR), then hands off to the login panel.
|
||||||
|
- The panel polls `POST /api/mcp/login/status`; when `state == "ready"` the
|
||||||
|
connector is marked ready and starts automatically on later logins.
|
||||||
|
- Also expose a `logout` tool (clears the session, forces a fresh QR) — the panel
|
||||||
|
calls it via `POST /api/mcp/login/reset` to re-link a different phone.
|
||||||
|
- The **credential is the on-disk session**, not a token. Persist it **inside the
|
||||||
|
connector's own directory** (e.g. `./auth/` next to the entry file). That folder
|
||||||
|
lives under the bind-mounted home, so it survives container recreates and
|
||||||
|
connector updates. Never store it under a shared/global path.
|
||||||
|
|
||||||
|
Skald resolves `auth.type: "qr"` the same way whether it appears in the index
|
||||||
|
entry or the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dependencies (node & python) — how they get installed
|
||||||
|
|
||||||
|
**Do not ship `node_modules/` or vendored wheels.** Declare deps as a standard
|
||||||
|
manifest **file** and Skald installs them inside the container:
|
||||||
|
|
||||||
|
- **node:** ship a `package.json` with a `dependencies` map. Skald runs
|
||||||
|
`npm ci --omit=dev` (falling back to `npm install --omit=dev`) in the connector
|
||||||
|
dir. `node_modules/` resolves automatically beside the entry file.
|
||||||
|
- **python:** ship a `requirements.txt`. Skald installs it with
|
||||||
|
`pip install --target .pydeps` and puts `.pydeps` on the server's `PYTHONPATH`.
|
||||||
|
|
||||||
|
This runs at activation **and** on every startup, guarded by a **content hash** of
|
||||||
|
the connector's source files:
|
||||||
|
|
||||||
|
- first activation / a brand-new container → full install,
|
||||||
|
- a connector **update** (any shipped file changed) → re-copy + re-install,
|
||||||
|
- unchanged → skipped in microseconds.
|
||||||
|
|
||||||
|
So you never write install steps into the manifest — just ship the dep file, list
|
||||||
|
it in the index with its SHA-256, and set `requires: ["NODE"]` / `["PYTHON"]` as a
|
||||||
|
human hint. Pin versions in `package.json` / `requirements.txt` for reproducible
|
||||||
|
installs. Keep the dep tree lean (containers are slim; avoid native-heavy
|
||||||
|
packages where a pure alternative exists — e.g. Baileys instead of a browser).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verify-before-save (optional but recommended)
|
||||||
|
|
||||||
|
Ship a `verify.py` / verify snippet and reference it:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"verify": { "command": "python3 verify.py", "timeout_secs": 15 }
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs with the collected env/secret injected and must print **one JSON object**
|
||||||
|
on stdout: `{"ok": bool, "message": string, "details"?: object}`, exit 0 on
|
||||||
|
success. Used for `api_key`/`none` connectors to test creds before activating.
|
||||||
|
(A `qr` connector needs no verify — its `login_status` is the live check.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Versioning & updates
|
||||||
|
|
||||||
|
Three fields, in **both** the index entry and the `connector.json`, kept identical:
|
||||||
|
|
||||||
|
| field | type | role |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `version` | **integer** | monotonic build number, **per connector** — the machine comparison key |
|
||||||
|
| `version_string` | string (semver) | display only |
|
||||||
|
| `version_release_date` | ISO date `YYYY-MM-DD` | display only |
|
||||||
|
|
||||||
|
- `version` is a **number, not a string** (`1`, not `"1"` or `"2.0.1"`). Start at
|
||||||
|
`1` for the first release under this scheme; **`+1` on every change** to any
|
||||||
|
shipped file **or to any manifest metadata** (description, icons, `version_string`).
|
||||||
|
Never reuse or decrement.
|
||||||
|
- Skald stores the installed `version` and compares it to the feed's: a strictly
|
||||||
|
greater feed `version` shows **"update available"** in the marketplace, and the
|
||||||
|
Install button becomes **Update**. Clicking it re-downloads the files and rewrites
|
||||||
|
the catalog row.
|
||||||
|
- **The integer is the *only* "is there an update?" signal** — it is compared
|
||||||
|
strictly (`feed > installed`). `version_string` (semver), icons and
|
||||||
|
`llm_short_description` are **never** compared, so a change to any of them that
|
||||||
|
does not also bump the integer is **invisible**: no "update available" badge
|
||||||
|
appears. This is the common trap — a "content-only" edit (e.g. a better
|
||||||
|
`llm_short_description`) that forgets the integer.
|
||||||
|
- **Two propagation paths, do not conflate them:**
|
||||||
|
- *Per-user code + deps* (the scripts, `package.json`/`requirements.txt`) reconcile
|
||||||
|
on a **content-hash** of the source files (§5), so new code lands at each user's
|
||||||
|
next login even without a reinstall.
|
||||||
|
- *Catalog metadata* (`llm_short_description` → the model's prompt, icons, friendly
|
||||||
|
name) is **not** in that hash — it lives in the catalog row and is rewritten only
|
||||||
|
by an explicit **reinstall/Update**. On reinstall Skald re-pulls the current feed
|
||||||
|
(never the browse cache) and pushes the new description live: enabled global
|
||||||
|
servers restart with it, and every logged-in user who activated the connector has
|
||||||
|
it restarted with the fresh `llm_short_description` — no re-login needed.
|
||||||
|
- So: to ship a new `llm_short_description`, **bump the integer** (so the admin sees
|
||||||
|
"update available") and the admin clicks **Update**. Nothing auto-propagates a
|
||||||
|
description change.
|
||||||
|
- `version_string` and `version_release_date` are display metadata only — never
|
||||||
|
compared. (Migration note: replace any legacy string `"version": "2.0.1"` with
|
||||||
|
the integer `version` + `version_string`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Checklist for a new connector
|
||||||
|
|
||||||
|
1. Folder `myconn/` with: entry file, `connector.json`, deps file
|
||||||
|
(`package.json`/`requirements.txt`), `icon_sm.svg`, `icon_lg.svg`,
|
||||||
|
optional `verify.*`.
|
||||||
|
2. Server speaks MCP over stdio (§2); **stdout = JSON-RPC only**.
|
||||||
|
3. `mcp_config.args[0]` names the entry file.
|
||||||
|
4. Correct `type` + `scope` (§3) and `auth.type` (§4).
|
||||||
|
5. For `qr`: implement `login_status` (+ `logout`), persist the session under the
|
||||||
|
connector dir (§4d).
|
||||||
|
6. Deps declared as a file, **not** vendored (§5).
|
||||||
|
7. Add the entry to `connectors.json` with a correct `sha256` for **every** file.
|
||||||
|
8. Bump `version`.
|
||||||
|
```
|
||||||
@@ -161,7 +161,7 @@ Automatic build on NiPoGi with Gitea Actions (native runner v2.1.0):
|
|||||||
|
|
||||||
### Technical notes
|
### Technical notes
|
||||||
|
|
||||||
- `scripts/` in `.gitignore` — CI scripts moved to `ci/` (tracked by git)
|
- `scripts/` removed — CI scripts live in `ci/` (tracked by git); the legacy MCP servers it held are superseded by marketplace connectors
|
||||||
- Build without `whisper-local` on Linux (`--no-default-features`)
|
- Build without `whisper-local` on Linux (`--no-default-features`)
|
||||||
- `aarch64-linux-gnu-strip` for ARM64 binaries
|
- `aarch64-linux-gnu-strip` for ARM64 binaries
|
||||||
- `actions/checkout@v4` works (native runner has Node.js)
|
- `actions/checkout@v4` works (native runner has Node.js)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ When working on **Skald itself** (the project you are in), follow these addition
|
|||||||
- Agent prompts: `agents/`
|
- Agent prompts: `agents/`
|
||||||
- Extracted crates: `crates/`
|
- Extracted crates: `crates/`
|
||||||
- Web app (Lit components): `web/`
|
- Web app (Lit components): `web/`
|
||||||
- Python MCP scripts: `scripts/`
|
|
||||||
- Config: `config.yml` (copy from `default.config.yaml`)
|
- Config: `config.yml` (copy from `default.config.yaml`)
|
||||||
- Docs: `docs/`
|
- Docs: `docs/`
|
||||||
- Database: `database.db` (unless overridden in `config.yml`)
|
- Database: `database.db` (unless overridden in `config.yml`)
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ When working on **Skald itself** (the project you are in), follow these addition
|
|||||||
- Agent prompts: `agents/`
|
- Agent prompts: `agents/`
|
||||||
- Extracted crates: `crates/`
|
- Extracted crates: `crates/`
|
||||||
- Web app (Lit components): `web/`
|
- Web app (Lit components): `web/`
|
||||||
- Python MCP scripts: `scripts/`
|
|
||||||
- Config: `config.yml`
|
- Config: `config.yml`
|
||||||
- Docs: `docs/`
|
- Docs: `docs/`
|
||||||
- Database: `database.db`
|
- Database: `database.db`
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ impl VerifyReport {
|
|||||||
/// runs inside the user's container via `docker exec`.
|
/// runs inside the user's container via `docker exec`.
|
||||||
pub enum VerifyTarget<'a> {
|
pub enum VerifyTarget<'a> {
|
||||||
/// Run on the Skald host process. `workdir` is an absolute host path
|
/// Run on the Skald host process. `workdir` is an absolute host path
|
||||||
/// (typically `<data_root>/scripts/<id>/`).
|
/// (typically `connectors/<folder>/`).
|
||||||
Host { workdir: &'a Path },
|
Host { workdir: &'a Path },
|
||||||
/// Run inside the user's sandbox container. `workdir` is an absolute path
|
/// Run inside the user's sandbox container. `workdir` is an absolute path
|
||||||
/// *inside* the container (e.g. `/root/.skald/mcp/<name>`).
|
/// *inside* the container (e.g. `/root/.skald/mcp/<name>`).
|
||||||
|
|||||||
+4
-4
@@ -192,7 +192,7 @@ check_optional_deps() {
|
|||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
|
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
|
||||||
else
|
else
|
||||||
warn "Python 3 not found — Python MCP servers (Gmail, GCal, GMaps, ...) will not work."
|
warn "Python 3 not found — the TTS plugins and host-run connectors will not work."
|
||||||
echo " Install it from https://www.python.org/downloads/"
|
echo " Install it from https://www.python.org/downloads/"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
@@ -327,13 +327,13 @@ if [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/bin/python3" -m pip --versio
|
|||||||
if command -v uv >/dev/null 2>&1; then
|
if command -v uv >/dev/null 2>&1; then
|
||||||
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (uv)" \
|
&& info "✔ Python venv ready (uv)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (pip)" \
|
&& info "✔ Python venv ready (pip)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
else
|
else
|
||||||
warn "python3 not found — Python MCP servers will be unavailable."
|
warn "python3 not found — the TTS plugins and host-run connectors will be unavailable."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
info "✔ Python venv already exists"
|
info "✔ Python venv already exists"
|
||||||
|
|||||||
+4
-4
@@ -199,7 +199,7 @@ check_optional_deps() {
|
|||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
|
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
|
||||||
else
|
else
|
||||||
warn "Python 3 not found — Python MCP servers (Gmail, GCal, GMaps, ...) will not work."
|
warn "Python 3 not found — the TTS plugins and host-run connectors will not work."
|
||||||
echo " Install it from https://www.python.org/downloads/"
|
echo " Install it from https://www.python.org/downloads/"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
@@ -332,13 +332,13 @@ if [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/bin/python3" -m pip --versio
|
|||||||
if command -v uv >/dev/null 2>&1; then
|
if command -v uv >/dev/null 2>&1; then
|
||||||
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (uv)" \
|
&& info "✔ Python venv ready (uv)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (pip)" \
|
&& info "✔ Python venv ready (pip)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
else
|
else
|
||||||
warn "python3 not found — Python MCP servers will be unavailable."
|
warn "python3 not found — the TTS plugins and host-run connectors will be unavailable."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
info "✔ Python venv already exists"
|
info "✔ Python venv already exists"
|
||||||
|
|||||||
+17
-16
@@ -1,25 +1,26 @@
|
|||||||
google-auth
|
# Host Python dependencies.
|
||||||
google-auth-oauthlib
|
#
|
||||||
google-api-python-client
|
# These exist for the two TTS plugins ONLY. Both spawn a bare `python3` (resolved
|
||||||
googlemaps
|
# from PATH, which run.sh points at .venv/bin) to run an embedded server script,
|
||||||
requests
|
# so their imports must be satisfied here — unlike MCP connectors, they have no
|
||||||
httpx
|
# dependency reconciler of their own.
|
||||||
|
#
|
||||||
# SSH MCP server (scripts/ssh_mcp_server.py)
|
# MCP connectors do NOT belong in this file: one ships its own requirements.txt /
|
||||||
paramiko>=3.4
|
# package.json, and `mcp::install::ensure_installed` installs it into `.pydeps` /
|
||||||
|
# `node_modules` inside the user's container (or beside the connector on the host
|
||||||
# Google Trends MCP server (scripts/google_trends_mcp.py)
|
# for a global one). Adding a connector's deps here would install them on every
|
||||||
mcp
|
# box for a connector nobody activated.
|
||||||
trendspyg>=0.7.0
|
|
||||||
|
|
||||||
# Kokoro ONNX TTS (plugin-tts-kokoro)
|
# Kokoro ONNX TTS (plugin-tts-kokoro)
|
||||||
kokoro-onnx
|
kokoro-onnx
|
||||||
soundfile
|
soundfile
|
||||||
|
|
||||||
# Orpheus TTS 3B (plugin-tts-orpheus-3b) — heavy deps moved to requirements-optional.txt
|
# Orpheus TTS 3B (plugin-tts-orpheus-3b) — the light half; the GPU/ML deps
|
||||||
# See requirements-optional.txt if you need the Orpheus TTS plugin.
|
# (torch, transformers, snac, …) live in requirements-optional.txt
|
||||||
|
scipy
|
||||||
|
|
||||||
|
# Shared by both TTS servers
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
scipy
|
|
||||||
numpy
|
numpy
|
||||||
pydantic
|
pydantic
|
||||||
|
|||||||
+2
-2
@@ -20,12 +20,12 @@ if [ -f "$REQUIREMENTS" ] && { [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/
|
|||||||
echo "[run-docker.sh] Setting up Python venv with uv …"
|
echo "[run-docker.sh] Setting up Python venv with uv …"
|
||||||
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
||||||
&& echo "[run-docker.sh] Python venv ready." \
|
&& echo "[run-docker.sh] Python venv ready." \
|
||||||
|| echo "[run-docker.sh] Warning: Python venv setup failed — Python MCP servers will be unavailable."
|
|| echo "[run-docker.sh] Warning: Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
echo "[run-docker.sh] Setting up Python venv …"
|
echo "[run-docker.sh] Setting up Python venv …"
|
||||||
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
||||||
&& echo "[run-docker.sh] Python venv ready." \
|
&& echo "[run-docker.sh] Python venv ready." \
|
||||||
|| echo "[run-docker.sh] Warning: Python venv setup failed — Python MCP servers will be unavailable."
|
|| echo "[run-docker.sh] Warning: Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if not exist "%VENV_DIR%\Scripts\python3.exe" (
|
|||||||
call python3 -m venv "%VENV_DIR%" && call "%VENV_DIR%\Scripts\pip" install -r "%REQUIREMENTS%"
|
call python3 -m venv "%VENV_DIR%" && call "%VENV_DIR%\Scripts\pip" install -r "%REQUIREMENTS%"
|
||||||
if !ERRORLEVEL! equ 0 ( echo [run.bat] Python venv ready. ) else ( echo [run.bat] Warning: Python venv setup failed )
|
if !ERRORLEVEL! equ 0 ( echo [run.bat] Python venv ready. ) else ( echo [run.bat] Warning: Python venv setup failed )
|
||||||
) else (
|
) else (
|
||||||
echo [run.bat] Warning: python3 not found -- Python MCP servers will be unavailable.
|
echo [run.bat] Warning: python3 not found -- the TTS plugins and host-run connectors will be unavailable.
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ fi
|
|||||||
|
|
||||||
# ── Python venv setup (optional) ─────────────────────────────────────────────
|
# ── Python venv setup (optional) ─────────────────────────────────────────────
|
||||||
# Creates .venv/ and installs requirements.txt if Python is available.
|
# Creates .venv/ and installs requirements.txt if Python is available.
|
||||||
# If Python is not installed, the app starts normally but Python-based MCP
|
# If Python is not installed, the app starts normally but the TTS plugins (which
|
||||||
# servers (e.g. Gmail, Google Calendar) will fail to connect.
|
# spawn `python3` directly) will fail to start, and a host-run global connector
|
||||||
|
# will have no interpreter to install its own deps with. Per-user connectors are
|
||||||
|
# unaffected: they run inside the user's container, which ships its own Python.
|
||||||
VENV_DIR=".venv"
|
VENV_DIR=".venv"
|
||||||
REQUIREMENTS="requirements.txt"
|
REQUIREMENTS="requirements.txt"
|
||||||
|
|
||||||
@@ -60,14 +62,14 @@ if [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/bin/python3" -m pip --versio
|
|||||||
echo "[run.sh] Setting up Python venv with uv …"
|
echo "[run.sh] Setting up Python venv with uv …"
|
||||||
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
||||||
&& echo "[run.sh] Python venv ready." \
|
&& echo "[run.sh] Python venv ready." \
|
||||||
|| echo "[run.sh] Warning: Python venv setup failed — Python MCP servers will be unavailable."
|
|| echo "[run.sh] Warning: Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
echo "[run.sh] Setting up Python venv …"
|
echo "[run.sh] Setting up Python venv …"
|
||||||
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
||||||
&& echo "[run.sh] Python venv ready." \
|
&& echo "[run.sh] Python venv ready." \
|
||||||
|| echo "[run.sh] Warning: Python venv setup failed — Python MCP servers will be unavailable."
|
|| echo "[run.sh] Warning: Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
else
|
else
|
||||||
echo "[run.sh] Warning: python3 not found — Python MCP servers will be unavailable."
|
echo "[run.sh] Warning: python3 not found — the TTS plugins and host-run connectors will be unavailable."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -269,13 +269,13 @@ main() {
|
|||||||
if command -v uv >/dev/null 2>&1; then
|
if command -v uv >/dev/null 2>&1; then
|
||||||
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (uv)" \
|
&& info "✔ Python venv ready (uv)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
|
||||||
&& info "✔ Python venv ready (pip)" \
|
&& info "✔ Python venv ready (pip)" \
|
||||||
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
|
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
|
||||||
else
|
else
|
||||||
warn "python3 not found — Python MCP servers will be unavailable."
|
warn "python3 not found — the TTS plugins and host-run connectors will be unavailable."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Restart ────────────────────────────────────────────────────────────────
|
# ── Restart ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user