# Changelog All notable changes to the Skald Connectors Marketplace are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/). ## [Unreleased] ### Fixed - **ssh: `sudo` almost always failed with "sudo password required (user declined or timed out)" (v6 / 1.1.0).** Three independent causes, all fixed: - **`sudo -n` is now always tried first**, even when the alias is configured `sudo: "prompt"`. Previously `prompt` went straight to `sudo -S` and *unconditionally* elicited a password — so a host where the login user has a `NOPASSWD:` rule still opened an Agent Inbox prompt, and any unattended run (scheduled agent, no human watching the Inbox) hit the client's 300 s elicitation deadline and got back `cancel`, i.e. the reported error. A password is now requested only after `sudo -n` has proved the host actually demands one; `sudo` never runs the command when it refuses for a missing password, so the probe is side-effect free. - **A leading `sudo` in `command` is stripped and turned into `sudo=true`.** Agents routinely write `exec(command="sudo systemctl restart x")`. With `sudo=false` that ran a bare `sudo` on a tty-less channel ("a terminal is required"); with `sudo=true` it nested `sudo -S … sudo …`, where the *inner* sudo prompts on a tty it does not have. The prefix (with `-u USER`, `-n`, `-S`, `-E`, `-H`, `-i`, `-k`, `-p PROMPT`, `--` and friends) is now peeled off and expressed as `sudo=true` + `sudo_user`; an unrecognised flag leaves the command untouched rather than mangling it. `sudo_user` alone also implies `sudo=true`. - **Under sudo the command now runs as `sh -c ''`**, so pipes and redirections are privileged too — `sudo tee`/`sudo … > file` behaves as written instead of running the redirection as the login user. - Errors are now actionable: sudo-disabled, `nopasswd`-but-password-required, password-rejected (the cached password is discarded so the next call re-prompts) and no-password-available each say what to change. A command that failed on a *nested* sudo we could not peel off (e.g. `cd /x && sudo …`) comes back with a `hint` telling the agent to use `sudo=true` instead. ### Added - **ssh: `SSH_MCP_SUDO_PASSWORD`** — optional, secret, non-interactive sudo password for unattended runs where nobody can answer the Inbox prompt (mirrors the existing `SSH_MCP_KEY_PASSPHRASE`). Consulted only after `sudo -n` has shown the host requires a password, so on a NOPASSWD host it is never fed to sudo's stdin — where it would have landed in the command's own stdin instead. ### Changed - **ssh: version realigned to 6 / 1.1.0 in both manifests.** `fragment.json` carried `5` / `1.0.4` while `connector.json` carried `2` / `1.0.1`. Skald takes the installed version from the manifest and the feed version from the index, so the index was permanently 3 ahead: the connector advertised an update forever and re-installed to `2` every time. - **ssh: stdout and stderr are drained together, and `timeout_sec` is a real wall-clock deadline.** Both streams share one SSH channel window, so reading stdout to EOF first stalled as soon as a chatty stderr filled that window (a >2 MB stderr deadlocked the call until the timeout). Command stdin is now always closed after the optional sudo password, so a remote command that reads stdin sees EOF instead of hanging. - **ssh: messages arriving while the server waits for an elicitation reply are queued, not dropped.** A concurrent `tools/call` used to be discarded silently, leaving the client to time out on a request the server had thrown away. - **ssh: the client's `elicitation` capability is recorded at `initialize`.** Without it the server no longer blocks on a prompt nobody can answer — it fails immediately and says so. - **ssh: tool descriptions rewritten** to tell the agent explicitly not to put `sudo` in `command`, and to describe `prompt` as "tries `sudo -n` first, prompts only if the host demands it". Integer arguments (`depth`, `max_results`, `context_lines`, `timeout_sec`) now tolerate `null` and numeric strings instead of raising an internal error. - **Docs: `CONNECTOR_MANIFEST_GUIDE.md` is now the single source of truth, rewritten against the client implementation.** `~/projects/skald-circle` dropped its copy and references this file instead, so the guide was re-verified line by line against `src/frontend/api/{marketplace,mcp}.rs` and `crates/skald-core/src/mcp/{mod,install,verify,oauth}.rs`. What was wrong and is now fixed: - **It told the author to hand-write `connectors.json` with `sha256sum`** (§1a, §8) and never mentioned `fragment.json`, `index.json` or `scripts/compile.py` — i.e. it described a workflow this repo abandoned. The build pipeline is now §1 in full, and `connectors.json` is documented as generated. - **`tools[]` was said to go in `fragment.json`.** The client parses no `tools` field on the index entry — a block placed only there is inert. It must be in `connector.json` (this is why `google-trends`' four display names never reached the UI). - **`auth` was said to resolve "the same way whether it appears in the index entry or the manifest".** The index's `auth` is never parsed; only the manifest's is, with `requires` as the sole coarse fallback. - **`auth.type: "password"` was undocumented** while `email` ships it — the client recognizes only `none`/`api_key`/`oauth2`/`qr`/`ssh_key` and silently normalizes everything else to `none`. - **Placeholder substitution was overstated.** It happens in exactly two places (`mcp_config.url` and `verify.command`) with *different* miss behaviour (literal token + api_key fallback vs empty string), and never in `mcp_config.env` — so an `env[].name` must be the real environment variable name. - **`verify` was described as an `api_key`/`none` feature with a 15 s default.** Any auth type may use it; the script must be a shipped file whose basename appears in the command; the runtime applies a fixed 20 s and ignores `timeout_secs`. - **`deliver: {as: "file"}` was labelled "legacy"** — it is rejected at activation, unimplemented. - Added: the "who reads what" authority table (index vs manifest), the flat-folder constraint (`compile.py` does not recurse, so a subdirectory's files are silently unshipped), the version-desync trap (manifest wins, index-lower kills updates forever), exact dependency-install commands and paths, host assets not copied into containers, and the client's hard limits (8 MiB/file, path-safety rejects, no-`files[]` refusal). Also fixed an unbalanced code fence that swallowed the end of the file. - **Docs: `docs/connector.manifest_guide.md` retired** to a pointer at the root guide — the duplicate copy is what allowed the drift. - **Docs: `CLAUDE.md` audited against the repo.** Added § The consuming project (the client is the separate Rust repo at `~/projects/skald-circle`, with a map of the files that implement each part of the format) and corrected the same inaccuracies listed above where they also appeared here: placeholder scope and miss behaviour, verify workdir (`./connectors//` on the host, not `./scripts//`), verify timeout, `tools[]` location, `deliver: file`, `auth.type: "password"`, plus new invariants for version desync and flat folders. - **`scripts/compile.py`: docstring corrected** — it claimed `connector.json` was auto-excluded from `files[]`, the opposite of what `EXCLUDE_FILES` does. - **Docs: `SKALD.md` merged into `CLAUDE.md` and removed.** The two files had drifted apart — `CLAUDE.md` still described a hand-maintained `connectors.json` (pre-`compile.py`), an rsync deploy, 5 connectors, and a `files[]` that excluded `connector.json`. `CLAUDE.md` is now the single authoritative spec, carrying every `SKALD.md` section (full schemas for `connectors.json` / `fragment.json` / `connector.json`, reserved enums, `auth`/`deliver`/`env`/`verify` fields, placeholder syntax, icon conventions, file integrity, local workflow, deploy) corrected against the actual repo state: 18 connectors, the `compile.py` pipeline, and `connector.json` included in the hashed `files[]`. `CLAUDE.md` now also names `CONNECTOR_MANIFEST_GUIDE.md` (repo root) as the authoritative connector-authoring spec. ## 2026-08-24 ### Fixed - **google-trends: the MCP server never completed a handshake (v2 / 1.1.0)** — the connector was unusable since it shipped. `handle_request` implemented exactly two methods, `tools/list` and `tools/call`; everything else fell through to `-32601 Method not found`. Since `initialize` is the *first* message any MCP client sends, the client got an error to its opening request and aborted before a single tool could be listed. Three further protocol defects sat behind it: - **`tools/list` returned a bare array** instead of the `{"tools": [...]}` object the spec requires — so even a client that tolerated the missing handshake would have parsed zero tools. - **No `notifications/initialized`, no `ping`.** The former is the notification a client sends immediately *after* `initialize`; the latter is the standard liveness probe. - **Notifications got answered.** Any message without an `id` (a notification, by definition) still produced a full JSON-RPC response written to stdout — a protocol violation that desynchronises a strict client. `handle_request` now returns `None` for every id-less message. - Fix: the server now mirrors the shape every other local connector in this repo already uses (`wikipedia`, `weather`, `gmaps`): `initialize` → `protocolVersion 2024-11-05` + `serverInfo`, silent `notifications/initialized`, `ping` → `{}`, `tools/list` → `{"tools": TOOLS}`, and a `TOOLS` manifest list + `TOOL_DISPATCH` map replacing the old dict-of-tuples and the `title_map` that was rebuilt inside the request handler on every call. - Verified end-to-end by piping a real handshake into the process: `initialize` → `tools/list` → `tools/call` for each tool, plus a notification and an unknown method ✅ - **google-trends: `include_articles: true` always returned zero articles** — a silent data bug independent of the handshake. The RSS mapper read `t.get("articles")`, but trendspyg emits the key as **`news_articles`**. The lookup never matched, so the field came back as an empty list for every trend and the caller had no way to tell "no articles" from "wrong key". Now reads `news_articles`; `explore_link` (the trend's Google Trends URL, previously discarded) is included too. - **google-trends: a malformed tool argument crashed the call as a protocol error** — tools were invoked as `fn(**arguments)` against typed keyword parameters, so an unexpected key or a string where a number belonged raised `TypeError` and surfaced as `-32603 Internal error`, which reads to the agent as a broken server rather than a bad argument. Handlers now take a single `args: dict` and coerce through `_str_arg` / `_int_arg` / `_bool_arg` (clamping `max_trends` to 1-20 instead of resetting a negative value to 10). ### Changed - **google-trends: tool failures are now marked as failures.** Errors were returned as `{"status": "error", ...}` inside a *successful* result — structurally indistinguishable from data. They now follow the repo convention: an `Error: …` text result carrying `isError: true`. trendspyg's typed exceptions are translated into actionable messages (`RateLimitError` → retry later, `BrowserError` → Chrome missing, `InvalidParameterError` → bad input) instead of a bare `str(e)`. - **google-trends: browser calls now fail fast.** `explore` and `get_interest_over_time` used trendspyg's defaults of 10 retries × 8s, allowing a ~100s call — well past any agent's tool timeout. Capped at 3 × 6.0s (~25s worst case), matching the "~10-25s" the tool descriptions promise. - **google-trends: `trendspyg` pinned to `>=1.6.0`** (was `>=0.7.0`) and imported defensively — an import failure no longer kills the process at startup, so the handshake still succeeds and the missing dependency is reported as a readable tool error. - **google-trends: `verify` is now actually wired.** `verify.py` shipped in `files[]` since day one but `connector.json` declared no `verify` block, so skald never ran it. Added (`python3 verify.py`, 20s), and the script was upgraded from a bare `import trendspyg` check to a real RSS fetch against Google Trends that fails if zero trends come back. - **google-trends: `explore` no longer mutates trendspyg's envelope.** It injected `data["status"] = "ok"` into the returned `ExploreEnvelope`, assuming the return was always a dict. The envelope is now passed through untouched. - **google-trends: dropped the `output_format` parameter** from `get_interest_over_time`. It was exposed in the input schema but was a no-op for the caller — the `"json"` branch immediately re-parsed the string back into the same dict the `"dict"` branch produced. - **google-trends: tool descriptions rewritten** to state what each tool is *for* and when to prefer one over another (`explore` over `get_interest_over_time` when related queries or the regional breakdown are wanted), and that `get_trending` is always-current and cannot look at past dates. ## 2026-08-23 ### Added - **whatsapp: chats and messages now survive a restart (v8 / 2.2.0)** — closes the gap left open by v7. WhatsApp delivers a history sync at *login*, not on reconnect, so with a purely in-memory store every process restart left the connector blind until new messages happened to arrive — and the process had restarted 23 times in two weeks. The store is now mirrored to `store/`, next to `auth/` and bind-mounted the same way, so it outlives both a restart and a container recreate. - **No SQLite, deliberately.** `node:sqlite` needs Node 22 (and is only unflagged from Node 24); the runtime image ships Debian trixie's `nodejs` = 20.19.2. `better-sqlite3` is a native module the slim image has no toolchain to build. So: `store/messages.jsonl`, an append-only log (one `appendFileSync` per message, O(1)), plus `store/meta.json`, a debounced snapshot of chats/contacts (5s, since they churn in bursts during a history sync). Both written via write-tmp-then-`rename`, which is atomic — a crash mid-write cannot truncate the store. - The log is compacted on load and every 500 appends: the capped in-memory Maps are re-serialised, so the file cannot creep upward across restarts. - **Fixes a pre-existing duplication bug in the process.** `pushMessage` appended unconditionally, so every history re-sync re-added messages already held. It now returns false on a known message id (a ≤500-element scan per message) and `ingestMessage` skips both the transcript and the log — without it, persistence would have multiplied the duplicates once per restart instead of merely showing them. - `MAX_MSGS_PER_CHAT` 200 → 500, worth more now that it is not thrown away at every restart. `logout` deletes `store/` along with `auth/`, so re-linking a different phone cannot inherit the previous account's history. - ⚠️ Data at rest: message text is now written to disk in the user's bind-mounted home. Nothing was persisted before this change beyond the session keys. - Verified on `skald-runtime:v4` with a seeded store — 751 log lines containing 50 exact duplicates, a 700-message chat, and a deliberately torn trailing line → loaded as 701 messages (duplicates collapsed, torn line skipped, no crash), compacted to 501 lines, the 700-message chat trimmed to its most recent 500, `meta.json` round-tripped, and a second run reloading 501 → 501 unchanged ✅ ### Fixed - **whatsapp: history sync silently disabled, and Signal sessions corrupted by reconnects (v7 / 2.1.0)** — diagnosed from the live server, where two users (two separate accounts, separate `auth/` dirs) share one log file. Four distinct defects, plus a dependency upgrade. - **The connector processed no history at all on Baileys 6.7.x.** `Socket/index.js` derives the history gate from the sync flag when the caller leaves it unset: `if (config.shouldSyncHistoryMessage === undefined) newConfig.shouldSyncHistoryMessage = () => !!newConfig.syncFullHistory;`. The connector passed neither, and shipped `syncFullHistory: false` — so the gate evaluated to `() => false`, `shouldProcessHistoryMsg` was permanently false, and the history-sync blob was discarded. `list_chats` / `get_messages` only ever saw messages arriving live after startup, which is what "no chats known yet" really meant. The derivation does not exist in 6.17.x or 7.x, where the default is `() => true` — so the *same connector* behaved differently per user depending on which version npm had resolved. - Fix: `shouldSyncHistoryMessage: () => true` is now passed **explicitly**, making behaviour identical on every Baileys version, and `syncFullHistory: true`. The `browser` identity had to change too: `getWebInfo` only requests a desktop-grade sync when `browser[0]` is `'Mac OS'` or `'Windows'` (`PLATFORM_MAP` has exactly those two keys) — with the old `['Skald', 'Chrome', …]` the sub-platform stayed `WEB_BROWSER` and `syncFullHistory` would have been inert. Now `['Mac OS', 'Chrome', '121.0.0']`; the trade-off is that the phone lists the device as "Mac OS Chrome" rather than "Skald". - **Every reconnect leaked its socket, giving `auth/` two concurrent writers.** The `connection === 'close'` branch only set `starting = false` and re-entered `startSock()`: no `end()`, no `removeAllListeners()`. Each pass called `useMultiFileAuthState(AUTH_DIR)` again, building a fresh key cache, while the previous socket stayed alive with its own `creds.update → saveCreds` handler bound to the *old* snapshot. At ~15 reconnects/day (106× 428 `connectionClosed`, 42× 500 `badSession`, 34× 503, 9× 405 over two weeks) that is the standard route to inconsistent Signal state — and the logs showed the symptom: 1422 `Bad MAC` lines and 51× `Closing open session in favor of incoming prekey bundle`. Every failing session address was the account's **own LID at device 0** (its own phone): the own-device sync stream, not a remote contact. - Fix: `teardownSock()` detaches listeners and closes the socket before a new one is built, each socket carries a **generation number** so events from a superseded socket are dropped, and reconnects go through a single-slot timer with exponential backoff (1.5s → 60s, reset on `open`) instead of a fixed 1.5s re-entry. Disconnects are now logged by name (`428 connectionClosed`) rather than bare code. - **Undecryptable messages were stored as blank lines.** A message Signal cannot open arrives with no `message` payload and `messageStubType = CIPHERTEXT (2)`; `textOf()` returned `''` and `ingestMessage` stored it anyway, so `get_messages` rendered `[timestamp] name: ` and the agent could not distinguish a silent gap from an empty message. They are now labelled `[undecryptable message]`, counted, and surfaced in `status`. Text-less protocol frames (reactions, receipts, key distribution) are dropped instead of padding transcripts with blanks. - **`fetchLatestBaileysVersion()` ran on every reconnect** — ~15 outbound calls/day, each able to hand the socket a protocol version the installed library cannot speak (the plausible source of the 9× 405 closes). Now fetched once and cached for 6h, falling back to the last good value, then to the library default. - **Log noise made the shared file unreadable.** `libsignal` bypasses the Baileys logger and calls `console.error` directly — one `Failed to decrypt…` line plus a full stack trace *per candidate session* — which is how ~30 real failures became 1422 lines. `console.error` is now wrapped to collapse the burst into one counted line, and every connector line carries the linked account (`[whatsapp_mcp Daniele]`) so the two users' entries are separable. `console.log`/`info`/`warn` are redirected to stderr as well: stdout is the JSON-RPC channel and a dependency printing there would corrupt the framing. ### Changed - **whatsapp: Baileys `^6.7.9` → pinned `7.0.0-rc14`, connector converted to ESM (v7 / 2.1.0)** — the caret range was resolving to different versions per install: 6.7.24 for one user, **6.17.16** for the other (that version sorts above 6.7.24 by semver but sits outside the maintained 6.7.x line; npm's `legacy` dist-tag points at 6.7.24, `latest` at 7.0.0-rc14). The user on 6.17.16 logged 681 decrypt failures against the other's 30. Both `@whiskeysockets/baileys` and `qrcode` are now pinned exactly, matching the convention already used by playwright/http-fetch/firecrawl. - 7.0.0-rc14 is a release candidate — there is no stable 7.x — chosen deliberately: its defaults already do the right thing (`syncFullHistory: true`, no derived history gate) and the API surface the connector uses is unchanged (`makeWASocket`, `useMultiFileAuthState`, `DisconnectReason`, `fetchLatestBaileysVersion`, `jidNormalizedUser`, plus `WAMessageStubType`). - It ships **ESM-only** (`"type": "module"`, engines `node >= 20`), so `index.js` moved from CommonJS to ESM rather than leaning on Node's `require(esm)` bridge; `__dirname` is derived from `import.meta.url`. `package.json` gains `"type": "module"` and `engines.node >= 20` (the `skald-runtime:v4` image runs 20.19.2). - Verified on the real runtime image, not locally: ESM import resolves under Node 20.19.2, `initialize` + `tools/list` (7 tools) + `status` + `list_chats` answer correctly, protocol version fetch and QR generation work, and the `logout` path exercises teardown → single reconnect → fresh QR with no duplicate socket ✅ - Re-aligned manifest↔fragment versions to `7` / `2.1.0` (they had drifted to 3/2.0.3 vs 6/2.0.6; skald reads `installed_version` from the manifest, so the update badge would not have appeared). - ⚠️ Still outstanding: the chat/contact/message store is **in-memory only**. History sync arrives at login, not on every reconnect, so whatever the full sync delivers is lost at the next process restart. Persisting the store is the remaining piece and was left out of this change. ## 2026-08-20 ### Fixed - **exa: `verify.py` rejected every API key, valid or not (v5 / 1.0.4)** — activating Exa always failed with `Exa API key is invalid or unauthorized (HTTP 403)`. The key was never actually tested; two independent bugs stacked up. - **Cloudflare blocked the probe before Exa saw it.** `mcp.exa.ai` sits behind Cloudflare, which bans urllib's default `Python-urllib/3.x` agent with HTTP 403 / `error code: 1010` (browser-signature ban). The script sent no `User-Agent`, and mapped any 403 straight to "API key is invalid" — so the message named the key for a failure the key had nothing to do with. Reproduced on the server with **no key set at all**: `{"ok": false, "message": "Exa API key is invalid or unauthorized (HTTP 403)"}`. - **The endpoint it probed cannot validate a key anyway.** The probe was a JSON-RPC `initialize` against the MCP endpoint, which answers HTTP 200 regardless of what `?exaApiKey=` carries — verified against a real key, a syntactically valid bogus key, and no key at all. Fixing only the headers would have flipped the bug to the opposite failure: every key accepted, including garbage. - Fix — when a key is configured the probe is now a minimal `POST https://api.exa.ai/search` (`numResults: 1`) with the key in the `x-api-key` header, the only call that exercises the credential: 200 → valid, 401/403 with Exa's own JSON error → invalid, 402 → out of credits, 429 → valid but throttled. With no key configured (Exa's free tier) it probes MCP `initialize` and reports *reachability only*, never validity. Both requests now send a `User-Agent`, and the MCP one also sends `Accept: application/json, text/event-stream` (without it the endpoint returns HTTP 406, `Client must accept both application/json and text/event-stream`). - A non-2xx status is reported as an invalid key **only when Exa itself says so in a JSON error body**; an opaque 401/403 (Cloudflare, a proxy) is now reported as "blocked before reaching the API — the key was not tested". Mapping a bare status code to "bad key" is precisely what made this script reject valid keys. - Re-aligned manifest↔fragment versions to `5` / `1.0.4` (they were 2/1.0.1 vs 4/1.0.3; skald reads `installed_version` from the manifest, so the update badge would never have appeared). `verify.timeout_secs` 15 → 20, for margin over the script's own 12s per-request timeout. - Tested end-to-end: valid key → `{"ok": true, "message": "Exa API key is valid"}`; bogus key → `{"ok": false, "message": "Exa API key is invalid or unauthorized: Invalid API key"}`; no key → `{"ok": true, "message": "Exa MCP endpoint is reachable (free tier, no API key)"}` ✅. Runtime path also confirmed unaffected: `initialize` + `notifications/initialized` + a real `web_search_exa` call against `https://mcp.exa.ai/mcp?exaApiKey=…` returned results ✅ - ⚠️ Same latent pattern elsewhere: `context7`, `tavily`, and `serpapi-flights` also probe over urllib with no `User-Agent` and map bare 401/403 to a credential verdict. They pass today because their hosts do not run Cloudflare's browser-signature check — not because the scripts are correct. ## 2026-08-19 ### Added - New connector: **Playwright** (mcp_local, scope global, v1 / 1.0.0) — full browser automation via the official `@playwright/mcp@0.0.79` (Microsoft), wrapped like http-fetch/firecrawl: `package.json` pins the package and `index.js` rewrites argv (`--headless --isolated --no-sandbox`, plus a `process.argv.slice(2)` passthrough) and imports the package's `cli.js`, which self-executes at import time. `cli.js` is not in the package `exports` map, so the wrapper resolves it from the exported `package.json` path. - `auth: none`, `requires: ["NODE"]`. Runs headless with an **isolated in-memory profile**: no cookies/login state persisted between sessions (chosen over the default persistent profile — a shared household browser must not accumulate per-user sessions, and a persistent profile also allows only one browser instance at a time). - **Browser download**: the upstream `playwright` package does NOT download browsers at `npm install` (verified on 1.63.0-alpha-2026-08-05), and `@playwright/mcp` 0.0.79 no longer ships the old `browser_install` tool (the README section is now empty). So the connector's `package.json` carries a `postinstall` hook — `node node_modules/@playwright/mcp/cli.js install-browser chromium` — which downloads Chromium + headless shell + ffmpeg only (~350 MB; a bare `install-browser` would also fetch Firefox and WebKit). Verified on a cold directory: `npm install --omit=dev` runs the hook and the browser launches. - `verify.js` (30s) resolves `playwright-core` relative to `@playwright/mcp` (it is a transitive dep, not declared by the connector's own package.json) and launches a real headless Chromium on about:blank — catches the two real failure modes (binary missing, system libraries missing on slim hosts) before the activation is saved. - Default tool set = 24 tools (no `--caps` extras); all get `display_name` in `tools[]` because `@playwright/mcp` does not emit MCP `title` fields in `tools/list` (the "Title:" lines in its README are not on the wire). - ⚠️ Security: the default set includes `browser_run_code_unsafe` (RCE-equivalent, arbitrary JS in the server process) and `browser_evaluate` (arbitrary JS in the page); there is no CLI flag to disable individual core tools. Consistent with the `mcp_local` trust model (guide §3) and declared in the manifest descriptions. - Icons: official Playwright SVG (Microsoft catalog), sized 48/96. - Tested E2E reproducing skald's path (`npm install --omit=dev` + `node index.js`): `initialize`, `tools/list` (24 tools), real `browser_navigate` + `browser_snapshot` on https://example.com ✅, verify probe ok ✅ ### Fixed - **playwright: default browser channel `chrome` → forced `--browser chromium` (v2 / 1.0.1)** — `@playwright/mcp` 0.0.79 defaults to the `chrome` channel, i.e. *system Google Chrome*: on slim Linux hosts (no `/opt/google/chrome/chrome`) every tool call failed with `Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome`. It went unnoticed in local testing because macOS borrowed the installed system Chrome, and `verify.js` (plain `chromium.launch`, no channel) probes the OSS build — not the one the server would actually launch. The wrapper now passes `--browser chromium` (the Chrome-for-Testing build downloaded by the postinstall hook, same as the official Docker image). Confirmed via the browser session registry: `channel: chrome-for-testing` after the fix vs `chrome` before. ## 2026-08-10 ### Fixed - **http-fetch + firecrawl: `npx` launch was broken (v5 / 1.1.0)** — both connectors declared `mcp_config: {command: "npx", args: ["-y", ""]}` and shipped no code files (only `connector.json` + icons). - `npx -y ` is not expressible in skald. For a `type: mcp_local`, skald treats `args[0]` as the **name of the file to run**, not as an argument: at install it computes `script_path = "/" + args[0]` and clears `args_json` (`marketplace.rs::install`), then `global_enable` (`api/mcp.rs`) resolves it to an absolute path and launches ` `. The real command became `npx /…/connectors/http-fetch/-y` — a nonexistent path, with `-y` and the package name lost. The process never answered `initialize`, so `start_server` failed. - The failure was silent: `global_enable` still returns HTTP 200 with an `error` field in the body, so the UI showed the connector as enabled while the runtime had no server. And `render_mcp_list` (`loop_adapters/system.rs`) builds the `## MCP servers` table from `mcp.tools()`, i.e. the **live runtime state**, not the DB → the connector appeared activated and granted to the user but **absent from the system context**. ⚠️ This combination (200 + `error` in the body) makes any connector that fails to start invisible in the UI: worth surfacing on the skald side. - Fix — two-file wrapper for both: a `package.json` pinning the upstream package (`mcp-fetch-server@1.1.2`, `firecrawl-mcp@3.23.7`) and an `index.js` that imports it for side effects (the module starts the JSON-RPC loop on stdio at import). `mcp_config` becomes `{command: "node", args: ["index.js"], transport: "stdio"}`, i.e. a real `local_script`: `ensure_installed_host` runs `npm ci --omit=dev || npm install --omit=dev` in the connector folder before launch, exactly like whatsapp. No `node_modules` shipped, no lockfile (like whatsapp). - Removed legacy fields `launch_command`, top-level `transport`, and `dependencies` (`dependencies` is only for the card, as already seen on gmaps; `transport` belongs inside `mcp_config`). - firecrawl: removed `mcp_config.env: {"FIRECRAWL_API_KEY": "{SECRET:FIRECRAWL_API_KEY}"}` — inert, same case as gmaps on 2026-08-10: `apply_key_placeholder` substitutes tokens only in the URL, never in `env` values. It worked because the admin form sends `env` and that payload overwrites `entry.env_json`. - firecrawl: added `firecrawl_developer_search` to `tools[]` (27 live tools vs 26 declared, verified on 3.23.7); `requires` `["NODE"]` → `["NODE", "API_KEY"]`. - Re-aligned manifest↔fragment versions to `5` / `1.1.0` / `2026-08-10` for both: they were 2/1.0.1 (manifest) vs 4/1.0.3 (fragment), and skald prefers the manifest — so `installed_version` stayed at 2 and the "Update available" badge would never have appeared. - Host requirement: these are `scope: global` connectors, they run on the **host**, not in the container. `mcp-fetch-server` wants Node ≥18, `firecrawl-mcp` wants Node ≥**22**. - Tested end-to-end reproducing skald's path (`npm ci || npm install` + `node /index.js`): `initialize`, `tools/list`, and a real `tools/call`, stdout only JSON-RPC, clean stderr ✅ - Index regenerated with compile.py ✅ - ⚠️ **Applying this to an instance that already has the connector installed is not just an Update.** `refresh_connector_after_reinstall` (`skald/accessors.rs`) updates only the `description` of the `mcp_global_servers` row, then restarts from that row: `command` and `args_json` stay the ones snapshotted at the first `global_enable`, i.e. still `npx` + `/…/connectors//-y`. Procedure: deploy → **Update** from the marketplace (rewrites `script_path` in the catalog) → open the connector page and **re-save the config**, the only call that recomputes `command`/`args` and runs `ensure_installed_host`. Same care already noted for gmaps. Whether the refresh should also re-derive command/args is open for evaluation on the skald side. - **gmaps: missing dependency install + verify wired (v6 / 1.1.0)** - Added `requirements.txt` (`googlemaps>=4.10.0`) — it was the only python connector without one. Dependencies were declared in the manifest `dependencies` field, which skald uses **only for the card**: `ensure_installed_host` looks exclusively at `requirements.txt` / `package.json`. Result: empty `.pydeps` and logs full of `No module named 'googlemaps'`, with the server still answering `tools/list` (→ `connected — 6 tool(s)` on a broken connector). - Wired the `verify` (`python3 verify.py`, 20s): `verify.py` was shipped but the manifest had no `verify` block, and skald reads `verify_command` only from there. Now an activation with broken dependencies fails visibly instead of starting silently. - `verify.py` puts `.pydeps` on `sys.path`: skald sets `PYTHONPATH` only for the *server* process (`global_row_spec`), while verify runs as `sh -c "python3 verify.py"` without it. Without this line, verify would fail with "Missing dependency" even on a correctly installed connector, disabling the row. ⚠️ **Same latent risk for every connector with a `verify` that imports non-stdlib dependencies** (gcal in container): to be checked. - Removed `mcp_config.env: {"GOOGLE_MAPS_API_KEY": "{SECRET:…}"}` (introduced 2026-07-23): inert. `apply_key_placeholder` substitutes `{SECRET:}`/`{ENV:}` tokens **only in the URL**, never in `env` values. It worked because the admin form sends `env` and that payload overwrites `entry.env_json`; with an empty form the process would have received the literal string. ⚠️ **The spec in this file and in CLAUDE.md says the opposite** — either fix the spec, or extend the substitution to `env` values on the skald side. - Re-aligned manifest↔fragment versions to `6` / `1.1.0` / `2026-08-10`: they were 2/1.0.1 vs 5/1.0.4, and skald prefers the manifest (`manifest.version.or(entry.version)`) — so `installed_version` stayed at 2 and the "Update available" badge would never have appeared. - `requires`: `ENV` → `API_KEY`; server error messages cleaned of references to `secrets/gmaps_api_key.txt` (deprecated path). - Index regenerated with compile.py ✅ ## 2026-08-07 ### Added - New connector: **LinkedIn** (mcp_local, scope user): server.py + session.py + verify.py + PNG icons - Added to `connectors/index.json`, index regenerated with compile.py (17 connectors total) - `.gitignore` updated to ignore `.claude/` - Deploy to connectors.skaldagent.net via `skaldserver` alias (192.168.1.100, LAN — no Tailscale) ## 2026-07-23 ### Changed - **gmaps: env var injection fix** — added `mcp_config.env` in `connector.json` to inject `GOOGLE_MAPS_API_KEY` into the MCP process. The connector was declared as `delivery: env` but without `mcp_config.env` Skald could not pass the variable to the Python process. Version bump: fragment 4→5, connector 1→2. Index regenerated with compile.py ✅ - **Context7 icon update (PNG)** — replaced Context7 icons from SVG to PNG (new icon provided by the user): `icon_sm.png` — 48×48 (2.7 KB), `icon_lg.png` — 96×96 (4.5 KB). Old SVGs removed, references updated in fragment.json and connector.json. Version bump: fragment 3→4, connector 1→2. Index regenerated with compile.py ✅ - **SerpAPI Flights icon update (PNG)** — replaced SerpAPI Flights icons from SVG to PNG: `icon_sm.png` — 48×48 (2.9 KB), `icon_lg.png` — 96×96 (6.8 KB). Old SVGs removed, references updated in fragment.json and connector.json. Version bump: fragment 4→5, connector 2→3. Index regenerated with compile.py ✅