Compare commits

..
2 Commits
Author SHA1 Message Date
dguiducciandClaude Opus 5 8bcf09a67e chore: delete scripts/ and cut requirements.txt down to its real consumers
Nightly Build / build (push) Successful in 7m13s
scripts/ held the pre-marketplace MCP servers (gmail, gcal, gmaps, ssh, weather,
google_trends, whatsapp, serpapi_flights). Nothing referenced them any more:
connectors are admin-curated and installed into connectors/ from the
marketplace, and ci/package.sh never shipped scripts/ in the first place — so on
every installed box requirements.txt was pulling google-auth, googlemaps,
paramiko, trendspyg and friends for files that did not exist there.

requirements.txt now states what it is actually for: the two TTS plugins, which
spawn a bare `python3` on an embedded server script and so have no dependency
reconciler of their own. A connector's deps stay with the connector —
`ensure_installed` puts them in .pydeps/node_modules inside the user's
container, `ensure_installed_host` beside the files for a global one.

The venv itself stays load-bearing for those two plugins and for the host pip
that installs a global connector's deps, so the run/install/update scripts keep
creating it — but their "Python MCP servers will be unavailable" warning was
naming the one thing that no longer depends on it, and now says what really
breaks.

CONNECTOR_MANIFEST_GUIDE.md moves to the repo root: it was the one thing in
scripts/ still referenced (CLAUDE.md), and being under a gitignored directory it
had never been committed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:19:01 +01:00
dguiducciandClaude Opus 5 70f6a927bc fix: memory-lint agents were missing from the agents page
Both metas declared "strength": "medium", which is not an LlmStrength
(very_low | low | average | high | very_high). `discover()` warns and skips a
meta.json it cannot deserialize — deliberately, so one bad file does not blank
the whole roster — so the two agents never reached /api/agents and the page's
"system" section only ever showed event-triage.

The skip is right; its silence is not. `agents::tests::every_shipped_agent_meta_parses`
deserializes every shipped meta.json, so a typo'd field now fails the build
instead of quietly costing an agent its place in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:18:51 +01:00
32 changed files with 435 additions and 7512 deletions
+6 -4
View File
@@ -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.
**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.
@@ -367,11 +367,13 @@ Copy `default.config.yaml` → `config.yml`. Never commit `config.yml` (contains
## 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/`)
+348
View File
@@ -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 23).
---
## 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`.
```
+1 -1
View File
@@ -161,7 +161,7 @@ Automatic build on NiPoGi with Gitea Actions (native runner v2.1.0):
### 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`)
- `aarch64-linux-gnu-strip` for ARM64 binaries
- `actions/checkout@v4` works (native runner has Node.js)
+1 -1
View File
@@ -16,5 +16,5 @@
"inject_skills": false,
"inject_memory": ["user-memory/index.md"],
"icon": "icon.png",
"strength": "medium"
"strength": "average"
}
+1 -1
View File
@@ -16,5 +16,5 @@
"inject_skills": false,
"inject_memory": ["shared-memory/index.md"],
"icon": "icon.png",
"strength": "medium"
"strength": "average"
}
-1
View File
@@ -86,7 +86,6 @@ When working on **Skald itself** (the project you are in), follow these addition
- Agent prompts: `agents/`
- Extracted crates: `crates/`
- Web app (Lit components): `web/`
- Python MCP scripts: `scripts/`
- Config: `config.yml` (copy from `default.config.yaml`)
- Docs: `docs/`
- Database: `database.db` (unless overridden in `config.yml`)
-1
View File
@@ -116,7 +116,6 @@ When working on **Skald itself** (the project you are in), follow these addition
- Agent prompts: `agents/`
- Extracted crates: `crates/`
- Web app (Lit components): `web/`
- Python MCP scripts: `scripts/`
- Config: `config.yml`
- Docs: `docs/`
- Database: `database.db`
+39
View File
@@ -298,3 +298,42 @@ fn render_agents_list() -> Result<String> {
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
/// Every shipped `meta.json` must deserialize. `discover()` warns and skips a
/// malformed one so a single bad file cannot blank the roster — which means a
/// typo'd field costs an agent its place in the UI and says nothing louder than
/// a log line. (It cost the two memory-lint agents theirs: `"strength": "medium"`
/// is not an `LlmStrength`.) This test is where that silence gets broken.
#[test]
fn every_shipped_agent_meta_parses() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(AGENTS_DIR);
let dir = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display()));
let mut checked = 0;
for entry in dir {
let path = entry.expect("readable dir entry").path();
if !path.is_dir() || path.file_name().and_then(|n| n.to_str()) == Some("common") {
continue;
}
let meta_path = path.join("meta.json");
if !meta_path.exists() {
continue;
}
let raw = std::fs::read_to_string(&meta_path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", meta_path.display()));
if let Err(e) = serde_json::from_str::<RawMeta>(&raw) {
panic!("{} is not a valid agent meta: {e}", meta_path.display());
}
checked += 1;
}
assert!(checked > 0, "no agent meta.json found under {}", root.display());
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ impl VerifyReport {
/// runs inside the user's container via `docker exec`.
pub enum VerifyTarget<'a> {
/// Run on the Skald host process. `workdir` is an absolute host path
/// (typically `<data_root>/scripts/<id>/`).
/// (typically `connectors/<folder>/`).
Host { workdir: &'a Path },
/// Run inside the user's sandbox container. `workdir` is an absolute path
/// *inside* the container (e.g. `/root/.skald/mcp/<name>`).
+4 -4
View File
@@ -192,7 +192,7 @@ check_optional_deps() {
if command -v python3 >/dev/null 2>&1; then
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
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 ""
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
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& 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
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& 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
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
else
info "✔ Python venv already exists"
+4 -4
View File
@@ -199,7 +199,7 @@ check_optional_deps() {
if command -v python3 >/dev/null 2>&1; then
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
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 ""
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
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& 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
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& 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
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
else
info "✔ Python venv already exists"
+17 -16
View File
@@ -1,25 +1,26 @@
google-auth
google-auth-oauthlib
google-api-python-client
googlemaps
requests
httpx
# SSH MCP server (scripts/ssh_mcp_server.py)
paramiko>=3.4
# Google Trends MCP server (scripts/google_trends_mcp.py)
mcp
trendspyg>=0.7.0
# Host Python dependencies.
#
# These exist for the two TTS plugins ONLY. Both spawn a bare `python3` (resolved
# from PATH, which run.sh points at .venv/bin) to run an embedded server script,
# so their imports must be satisfied here — unlike MCP connectors, they have no
# dependency reconciler of their own.
#
# MCP connectors do NOT belong in this file: one ships its own requirements.txt /
# 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
# for a global one). Adding a connector's deps here would install them on every
# box for a connector nobody activated.
# Kokoro ONNX TTS (plugin-tts-kokoro)
kokoro-onnx
soundfile
# Orpheus TTS 3B (plugin-tts-orpheus-3b) — heavy deps moved to requirements-optional.txt
# See requirements-optional.txt if you need the Orpheus TTS plugin.
# Orpheus TTS 3B (plugin-tts-orpheus-3b) — the light half; the GPU/ML deps
# (torch, transformers, snac, …) live in requirements-optional.txt
scipy
# Shared by both TTS servers
fastapi
uvicorn
scipy
numpy
pydantic
+2 -2
View File
@@ -20,12 +20,12 @@ if [ -f "$REQUIREMENTS" ] && { [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/
echo "[run-docker.sh] Setting up Python venv with uv …"
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& 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
echo "[run-docker.sh] Setting up Python venv …"
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& 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
+1 -1
View File
@@ -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%"
if !ERRORLEVEL! equ 0 ( echo [run.bat] Python venv ready. ) else ( echo [run.bat] Warning: Python venv setup failed )
) 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.
)
)
)
+7 -5
View File
@@ -46,8 +46,10 @@ fi
# ── Python venv setup (optional) ─────────────────────────────────────────────
# Creates .venv/ and installs requirements.txt if Python is available.
# If Python is not installed, the app starts normally but Python-based MCP
# servers (e.g. Gmail, Google Calendar) will fail to connect.
# If Python is not installed, the app starts normally but the TTS plugins (which
# 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"
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 …"
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& 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
echo "[run.sh] Setting up Python venv …"
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& 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
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
-43
View File
@@ -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."
-147
View File
@@ -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()
-980
View File
@@ -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()
-106
View File
@@ -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
-108
View File
@@ -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()
-819
View File
@@ -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()
-464
View File
@@ -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()
-359
View File
@@ -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()
-150
View File
@@ -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
-471
View File
@@ -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
-779
View File
@@ -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
0100+ scale: 020 Good, 2040 Fair, 4060 Moderate, 6080 Poor,
80100 Very poor, >100 Extremely poor."""
v = _num(value)
if v is None:
return "Unknown" if value is None else f"Unknown ({value})"
if v <= 20:
return "Good"
if v <= 40:
return "Fair"
if v <= 60:
return "Moderate"
if v <= 80:
return "Poor"
if v <= 100:
return "Very Poor"
return "Extremely Poor"
def _aqi_label_us(value: Any) -> str:
"""US AQI band name. Open-Meteo returns a numeric USAQI on the 0500
scale: 050 Good, 51100 Moderate, 101150 Unhealthy for sensitive
groups, 151200 Unhealthy, 201300 Very Unhealthy, 301500 Hazardous."""
v = _num(value)
if v is None:
return "Unknown" if value is None else f"Unknown ({value})"
if v <= 50:
return "Good"
if v <= 100:
return "Moderate"
if v <= 150:
return "Unhealthy for sensitive groups"
if v <= 200:
return "Unhealthy"
if v <= 300:
return "Very Unhealthy"
return "Hazardous"
def _wind_direction(degrees: float | None) -> str:
"""Convert wind degrees to compass direction."""
if degrees is None:
return "?"
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
idx = round(degrees / 22.5) % 16
return directions[idx]
def _num(val: Any) -> float | None:
"""Best-effort numeric coercion. Returns None if missing or non-numeric.
Avoids ValueError/TypeError when the API returns null or an unexpected type
and the caller wants a safe numeric comparison.
"""
if val is None or isinstance(val, bool):
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _format_http_error(e: Exception, api_label: str) -> str:
"""Map an httpx/network exception into an actionable Error: string.
`api_label` names the failing endpoint family (e.g. "Forecast", "Geocoding")
so the user/LLM knows where to look.
"""
if isinstance(e, httpx.TimeoutException):
return f"Error: {api_label} API request timed out. Retry in a moment."
if isinstance(e, httpx.HTTPStatusError):
return f"Error: {api_label} API returned HTTP {e.response.status_code}."
if isinstance(e, httpx.HTTPError):
return f"Error: {api_label} API request failed (network error): {e}."
return f"Error: {api_label} API call failed: {e}"
def _air_quality_advice(eu_aqi: Any, us_aqi: Any) -> str | None:
"""Health-advice string based on the Open-Meteo numeric AQI scales.
Prefers the European AQI (EAQI 0100+); falls back to the US AQI (0500).
Returns None when no AQI value is available.
"""
eu_n = _num(eu_aqi)
us_n = _num(us_aqi)
if eu_n is not None:
if eu_n <= 20:
return "✅ Air quality is good — no health concerns."
if eu_n <= 40:
return "✅ Air quality is fair — no health concerns."
if eu_n <= 60:
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
if eu_n <= 80:
return "⚠️ Poor air quality. Consider reducing outdoor activities, especially if you have respiratory conditions."
return "🚨 Very poor or extremely poor air quality. Avoid outdoor exertion. Wear a mask if you must go out."
if us_n is not None:
if us_n <= 50:
return "✅ Air quality is good — no health concerns."
if us_n <= 100:
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
if us_n <= 150:
return "⚠️ Unhealthy for sensitive groups. Reduce prolonged outdoor exertion."
return "🚨 Unhealthy or hazardous air quality. Avoid outdoor exertion. Wear a mask if you must go out."
return None
def _geocode(city: str) -> tuple[float, float, str, str] | None:
"""Resolve a city name to coordinates. Returns (lat, lon, name, country).
Returns None when the city is not found. Raises httpx.HTTPError on a
network/HTTP failure — the caller is expected to catch and surface it via
`_format_http_error` so the error is actionable rather than "Internal error".
"""
params = {"name": city, "count": 3, "language": "en", "format": "json"}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(GEOCODING_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
if not results:
return None
r = results[0]
return (
float(r["latitude"]),
float(r["longitude"]),
r.get("name", city),
r.get("country", ""),
)
# ── Tool implementations ────────────────────────────────────────────────────────
def _weather_status(args: dict[str, Any]) -> str:
"""Self-check: confirm Open-Meteo is reachable and serving data.
Performs one cheap geocode ("Rome") plus one current-weather probe so we
exercise the Geocoding + Forecast endpoints in a single round-trip.
"""
try:
geo = _geocode("Rome")
if geo is None:
return "Error: Geocoding API returned no result for the probe query."
lat, lon, _, _ = geo
params = {"latitude": lat, "longitude": lon, "current": "temperature_2m"}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
if not data.get("current"):
return "Error: Forecast API responded but returned no current data for the probe."
return (
"OK: Open-Meteo is reachable. Geocoding and Forecast APIs respond.\n"
"All tools (get_current_weather, get_forecast, get_air_quality) are operational."
)
except Exception as e:
return _format_http_error(e, "Forecast")
def _weather_current(args: dict[str, Any]) -> str:
"""Get current weather conditions for a city."""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
units = args.get("units", "metric")
if units not in ("metric", "imperial"):
return "Error: 'units' must be 'metric' or 'imperial'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"current": (
"temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,"
"wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover,"
"precipitation,rain,uv_index,pressure_msl,visibility"
),
"timezone": "auto",
}
if units == "imperial":
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Forecast")
cur = data.get("current", {})
if not cur:
return f"Error: No current weather data available for '{city}'."
temp_unit = "°F" if units == "imperial" else "°C"
wind_unit = "mph" if units == "imperial" else "km/h"
precip_unit = "in" if units == "imperial" else "mm"
# Open-Meteo returns visibility always in meters; no unit selector exists,
# so we convert explicitly per unit system.
vis_m = _num(cur.get("visibility"))
if vis_m is not None:
if units == "imperial":
vis_str, vis_unit = f"{vis_m / 1609.34:.1f}", "mi"
else:
vis_str, vis_unit = f"{vis_m / 1000:.1f}", "km"
else:
vis_str, vis_unit = "?", "km" if units == "metric" else "mi"
temp = cur.get("temperature_2m", "?")
feels_like = cur.get("apparent_temperature", "?")
humidity = cur.get("relative_humidity_2m", "?")
wmo = cur.get("weather_code")
wind_speed = cur.get("wind_speed_10m", "?")
wind_deg = cur.get("wind_direction_10m")
wind_gust = cur.get("wind_gusts_10m")
cloud = cur.get("cloud_cover", "?")
precip = _num(cur.get("precipitation"))
rain = _num(cur.get("rain"))
uv = cur.get("uv_index", "?")
pressure = cur.get("pressure_msl", "?")
loc_label = f"{name}, {country}" if country else name
lines = [
f"📍 {loc_label} (Current weather)",
f"",
f"🌡 Temperature: {temp}{temp_unit} (feels like {feels_like}{temp_unit})",
f"☁️ Conditions: {_wmo_desc(wmo)}",
f"💧 Humidity: {humidity}%",
f"🌬 Wind: {wind_speed} {wind_unit} from {_wind_direction(wind_deg)}",
]
wind_gust_n = _num(wind_gust)
if wind_gust_n and wind_gust_n > 0:
lines.append(f" Gusts up to {wind_gust_n:g} {wind_unit}")
lines.append(f"☁️ Cloud cover: {cloud}%")
lines.append(f"📊 Pressure: {pressure} hPa")
lines.append(f"👁 Visibility: {vis_str} {vis_unit}")
lines.append(f"☀️ UV index: {uv}")
if precip and precip > 0:
lines.append(f"🌧 Precipitation: {precip:g} {precip_unit}")
elif rain and rain > 0:
lines.append(f"🌧 Rain: {rain:g} {precip_unit}")
return "\n".join(lines)
def _weather_forecast(args: dict[str, Any]) -> str:
"""Get multi-day weather forecast for a city."""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
days_raw = args.get("days", 5)
try:
days = int(days_raw)
except (TypeError, ValueError):
return f"Error: 'days' must be an integer between 1 and 16. Got: {days_raw!r}."
if days < 1 or days > 16:
return "Error: 'days' must be between 1 and 16."
units = args.get("units", "metric")
if units not in ("metric", "imperial"):
return "Error: 'units' must be 'metric' or 'imperial'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"daily": (
"weather_code,temperature_2m_max,temperature_2m_min,"
"apparent_temperature_max,apparent_temperature_min,"
"precipitation_sum,rain_sum,precipitation_probability_max,"
"sunrise,sunset,wind_speed_10m_max,wind_direction_10m_dominant"
),
"forecast_days": days,
"timezone": "auto",
}
if units == "imperial":
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Forecast")
daily = data.get("daily", {})
if not daily or "time" not in daily:
return f"Error: No forecast data available for '{city}'."
temp_unit = "°F" if units == "imperial" else "°C"
wind_unit = "mph" if units == "imperial" else "km/h"
precip_unit = "in" if units == "imperial" else "mm"
loc_label = f"{name}, {country}" if country else name
lines = [f"📍 {loc_label}{days}-day forecast"]
lines.append("")
times = daily.get("time", [])
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
feel_max = daily.get("apparent_temperature_max", [])
feel_min = daily.get("apparent_temperature_min", [])
wmos = daily.get("weather_code", [])
precip_sum = daily.get("precipitation_sum", [])
rain_sum = daily.get("rain_sum", [])
precip_prob = daily.get("precipitation_probability_max", [])
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
wind_max = daily.get("wind_speed_10m_max", [])
wind_dir = daily.get("wind_direction_10m_dominant", [])
for i, t in enumerate(times):
lines.append(f"── {t} ──")
lines.append(f" 🌡 {min_temps[i] if i < len(min_temps) else '?'}{max_temps[i] if i < len(max_temps) else '?'}{temp_unit}"
f" (feels {feel_min[i] if i < len(feel_min) else '?'}{feel_max[i] if i < len(feel_max) else '?'}{temp_unit})")
lines.append(f" ☁️ {_wmo_desc(wmos[i] if i < len(wmos) else None)}")
prob = precip_prob[i] if i < len(precip_prob) else 0
ps_n = _num(precip_sum[i] if i < len(precip_sum) else None)
rs_n = _num(rain_sum[i] if i < len(rain_sum) else None)
if prob and prob > 0:
lines.append(f" 🌧 Rain: {prob}% chance"
f"{f', {ps_n:g} {precip_unit} precip' if ps_n and ps_n > 0 else ''}"
f"{f' ({rs_n:g} {precip_unit} rain)' if rs_n and rs_n > 0 else ''}")
wd = wind_dir[i] if i < len(wind_dir) else None
wm_n = _num(wind_max[i] if i < len(wind_max) else None)
if wm_n is not None and wm_n > 0:
lines.append(f" 🌬 Wind: up to {wm_n:g} {wind_unit} from {_wind_direction(wd)}")
sr = sunrises[i] if i < len(sunrises) else ""
ss = sunsets[i] if i < len(sunsets) else ""
if sr and ss:
lines.append(f" 🌅 Sunrise: {sr} | 🌇 Sunset: {ss}")
lines.append("")
return "\n".join(lines)
def _weather_air_quality(args: dict[str, Any]) -> str | dict[str, Any]:
"""Get air quality data for a city.
On success returns a structured result carrying the formatted text summary
alongside the raw numeric AQI/pollutant values (see `outputSchema`). On
failure returns a plain `Error:` string.
"""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"current": (
"european_aqi,us_aqi,pm2_5,pm10,"
"nitrogen_dioxide,ozone,carbon_monoxide,sulphur_dioxide,ammonia"
),
"timezone": "auto",
}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(AIR_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Air Quality")
cur = data.get("current", {})
if not cur:
return f"Error: No air quality data available for '{city}'."
eu_aqi = cur.get("european_aqi")
us_aqi = cur.get("us_aqi")
# Numeric pollutant values (None when missing/non-numeric).
pm25 = _num(cur.get("pm2_5"))
pm10 = _num(cur.get("pm10"))
no2 = _num(cur.get("nitrogen_dioxide"))
o3 = _num(cur.get("ozone"))
co = _num(cur.get("carbon_monoxide"))
so2 = _num(cur.get("sulphur_dioxide"))
nh3 = _num(cur.get("ammonia"))
pollutants = {"pm2_5": pm25, "pm10": pm10, "no2": no2, "o3": o3,
"co": co, "so2": so2, "nh3": nh3}
eu_label = _aqi_label_eu(eu_aqi) if eu_aqi is not None else None
us_label = _aqi_label_us(us_aqi) if us_aqi is not None else None
advice = _air_quality_advice(eu_aqi, us_aqi)
loc_label = f"{name}, {country}" if country else name
# ── Formatted text summary (kept inside the structured payload so the LLM
# still has the human-readable emoji output alongside the raw numbers).
def _fmt(val: float | None) -> str:
return f"{val:g} µg/m³" if val is not None else "?"
lines = [f"📍 {loc_label} (Air Quality)", ""]
if eu_aqi is not None:
lines.append(f"🇪🇺 European AQI: {eu_aqi} ({eu_label})")
if us_aqi is not None:
lines.append(f"🇺🇸 US AQI: {us_aqi} ({us_label})")
lines.append("")
lines.append(" • PM2.5: " + _fmt(pm25))
lines.append(" • PM10: " + _fmt(pm10))
lines.append(" • NO₂: " + _fmt(no2))
lines.append(" • O₃: " + _fmt(o3))
lines.append(" • CO: " + _fmt(co))
lines.append(" • SO₂: " + _fmt(so2))
lines.append(" • NH₃: " + _fmt(nh3))
lines.append("")
if advice:
lines.append(advice)
return {
"location": loc_label,
"summary": "\n".join(lines),
"european_aqi": eu_aqi,
"european_aqi_label": eu_label,
"us_aqi": us_aqi,
"us_aqi_label": us_label,
"pollutants_ug_m3": pollutants,
"health_advice": advice,
}
# ── Tool manifest ────────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "status",
"description": (
"Self-check that the Weather integration is operational: verifies the "
"Open-Meteo Geocoding and Forecast APIs are reachable by performing one "
"cheap probe (geocode 'Rome' + current temperature). Call this first "
"whenever another weather tool fails, or to give the user a quick yes/no "
"on whether weather data is usable right now."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_current_weather",
"description": (
"Get current weather conditions for any city worldwide. "
"Returns temperature, feels-like, humidity, wind (speed/direction/gusts), "
"conditions (clear/rain/snow/etc.), cloud cover, pressure, visibility, "
"UV index, and precipitation. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'London', 'Rome', 'Tokyo').",
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
},
},
"required": ["city"],
},
},
{
"name": "get_forecast",
"description": (
"Get multi-day weather forecast for any city worldwide. "
"Returns daily min/max temperature (and feels-like), conditions, rain probability, "
"precipitation amounts, wind max/direction, sunrise/sunset times. "
"Use when you need to plan upcoming days. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'Paris', 'New York').",
},
"days": {
"type": "integer",
"description": "Number of forecast days (116). Default: 5.",
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
},
},
"required": ["city"],
},
},
{
"name": "get_air_quality",
"description": (
"Get current air quality for any city worldwide. "
"Returns European and US AQI indices, plus detailed pollutant levels: "
"PM2.5, PM10, NO₂, O₃, CO, SO₂, NH₃. Includes health advice based on the AQI level. "
"Returns structured content: a `summary` text plus the raw numeric AQI and "
"pollutant values for machine consumption. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'Beijing', 'London').",
},
},
"required": ["city"],
},
"outputSchema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"summary": {"type": "string"},
"european_aqi": {"type": ["number", "null"]},
"european_aqi_label": {"type": ["string", "null"]},
"us_aqi": {"type": ["number", "null"]},
"us_aqi_label": {"type": ["string", "null"]},
"pollutants_ug_m3": {
"type": "object",
"properties": {
"pm2_5": {"type": ["number", "null"]},
"pm10": {"type": ["number", "null"]},
"no2": {"type": ["number", "null"]},
"o3": {"type": ["number", "null"]},
"co": {"type": ["number", "null"]},
"so2": {"type": ["number", "null"]},
"nh3": {"type": ["number", "null"]},
},
},
"health_advice": {"type": ["string", "null"]},
},
},
},
]
TOOL_DISPATCH = {
"status": _weather_status,
"get_current_weather": _weather_current,
"get_forecast": _weather_forecast,
"get_air_quality": _weather_air_quality,
}
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def _structured_result(req_id: Any, structured: dict) -> str:
"""Build a JSON-RPC result carrying structuredContent (canonical for MCP
structured tool results) plus a text mirror in `content[]` for plain
clients. The structured object is expected to embed a human-readable
`summary` string alongside the raw numeric fields.
Skald prefers structuredContent when present, so the LLM sees the JSON
object (which contains the formatted summary)."""
summary = structured.get("summary")
if not isinstance(summary, str):
summary = json.dumps(structured, ensure_ascii=False)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [{"type": "text", "text": summary}],
"structuredContent": structured,
},
})
def _error(req_id: Any, code: int, message: str) -> str:
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": code, "message": message},
})
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "weather",
"version": "1.2.0",
},
})
if method == "notifications/initialized":
return None
if method == "ping":
return _ok(req_id, {})
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
result = handler(tool_args)
# A dict return is a structured result (structuredContent); a str
# return is plain text (an "Error:" prefix marks it as isError).
if isinstance(result, dict):
return _structured_result(req_id, result)
is_err = result.startswith("Error:")
return _text_result(req_id, result, is_error=is_err)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
return _error(req_id, -32601, f"Method not found: {method}")
# ── Main loop ────────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting weather MCP server (Open-Meteo)")
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
-498
View File
@@ -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); });
-14
View File
@@ -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"
}
}
+3 -3
View File
@@ -269,13 +269,13 @@ main() {
if command -v uv >/dev/null 2>&1; then
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& 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
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& 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
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
# ── Restart ────────────────────────────────────────────────────────────────