The connector asked for a sudo password on every privileged call and failed whenever nobody answered it, which is every unattended run. - Always probe `sudo -n` first, even for aliases set to sudo="prompt". `_sudo_prefix` used to elicit unconditionally, so a host granting this user NOPASSWD still opened an Agent Inbox prompt; with no human there it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and surfaced as "sudo password required (user declined or timed out)". sudo refuses before running anything when it wants a password, so the probe is side-effect free. - Strip a leading `sudo` from `command` and turn it into sudo=true. Agents write `exec(command="sudo systemctl restart x")`: with sudo=false that ran a tty-less sudo, with sudo=true it nested `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone. sudo_user now implies sudo=true. - Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and redirections are elevated too instead of only the first word. - Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It is consulted only after `sudo -n` proved a password is needed, so on a NOPASSWD host it never lands in the command's own stdin. - Actionable errors for every sudo failure mode, and a `hint` on a nested sudo we could not peel off. General review of the same server: - Drain stdout and stderr together and make timeout_sec a real wall-clock deadline. Both streams share one SSH channel window, so reading stdout to EOF first stalled once a chatty stderr filled it. Command stdin is now closed after the optional password. - Queue messages that arrive while awaiting an elicitation reply instead of discarding them, so a concurrent tools/call is not lost. - Record the client's `elicitation` capability at initialize and fail fast when it is absent rather than blocking on a prompt nobody can answer. - Tolerate null/string integer arguments (depth, max_results, context_lines, timeout_sec). - Realign the version across both manifests: fragment.json said 5/1.0.4 while connector.json said 2/1.0.1, so the feed was permanently ahead of the installed version and offered an update forever. Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the single source of truth, docs/connector.manifest_guide.md retired to a pointer, CLAUDE.md audited against the repo, compile.py docstring fixed, and an opencode.json config.
36 KiB
Changelog
All notable changes to the Skald Connectors Marketplace are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
- ssh:
sudoalmost always failed with "sudo password required (user declined or timed out)" (v6 / 1.1.0). Three independent causes, all fixed:sudo -nis now always tried first, even when the alias is configuredsudo: "prompt". Previouslypromptwent straight tosudo -Sand unconditionally elicited a password — so a host where the login user has aNOPASSWD: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 backcancel, i.e. the reported error. A password is now requested only aftersudo -nhas proved the host actually demands one;sudonever runs the command when it refuses for a missing password, so the probe is side-effect free.- A leading
sudoincommandis stripped and turned intosudo=true. Agents routinely writeexec(command="sudo systemctl restart x"). Withsudo=falsethat ran a baresudoon a tty-less channel ("a terminal is required"); withsudo=trueit nestedsudo -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 assudo=true+sudo_user; an unrecognised flag leaves the command untouched rather than mangling it.sudo_useralone also impliessudo=true. - Under sudo the command now runs as
sh -c '<command>', so pipes and redirections are privileged too —sudo tee/sudo … > filebehaves 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 ahinttelling the agent to usesudo=trueinstead.
Added
- ssh:
SSH_MCP_SUDO_PASSWORD— optional, secret, non-interactive sudo password for unattended runs where nobody can answer the Inbox prompt (mirrors the existingSSH_MCP_KEY_PASSPHRASE). Consulted only aftersudo -nhas 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.jsoncarried5/1.0.4whileconnector.jsoncarried2/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 to2every time. -
ssh: stdout and stderr are drained together, and
timeout_secis 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/callused to be discarded silently, leaving the client to time out on a request the server had thrown away. -
ssh: the client's
elicitationcapability is recorded atinitialize. 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
sudoincommand, and to describepromptas "triessudo -nfirst, prompts only if the host demands it". Integer arguments (depth,max_results,context_lines,timeout_sec) now toleratenulland numeric strings instead of raising an internal error. -
Docs:
CONNECTOR_MANIFEST_GUIDE.mdis now the single source of truth, rewritten against the client implementation.~/projects/skald-circledropped its copy and references this file instead, so the guide was re-verified line by line againstsrc/frontend/api/{marketplace,mcp}.rsandcrates/skald-core/src/mcp/{mod,install,verify,oauth}.rs. What was wrong and is now fixed:- It told the author to hand-write
connectors.jsonwithsha256sum(§1a, §8) and never mentionedfragment.json,index.jsonorscripts/compile.py— i.e. it described a workflow this repo abandoned. The build pipeline is now §1 in full, andconnectors.jsonis documented as generated. tools[]was said to go infragment.json. The client parses notoolsfield on the index entry — a block placed only there is inert. It must be inconnector.json(this is whygoogle-trends' four display names never reached the UI).authwas said to resolve "the same way whether it appears in the index entry or the manifest". The index'sauthis never parsed; only the manifest's is, withrequiresas the sole coarse fallback.auth.type: "password"was undocumented whileemailships it — the client recognizes onlynone/api_key/oauth2/qr/ssh_keyand silently normalizes everything else tonone.- Placeholder substitution was overstated. It happens in exactly two places (
mcp_config.urlandverify.command) with different miss behaviour (literal token + api_key fallback vs empty string), and never inmcp_config.env— so anenv[].namemust be the real environment variable name. verifywas described as anapi_key/nonefeature 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 ignorestimeout_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.pydoes 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.
- It told the author to hand-write
-
Docs:
docs/connector.manifest_guide.mdretired to a pointer at the root guide — the duplicate copy is what allowed the drift. -
Docs:
CLAUDE.mdaudited 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/<id>/on the host, not./scripts/<id>/), verify timeout,tools[]location,deliver: file,auth.type: "password", plus new invariants for version desync and flat folders. -
scripts/compile.py: docstring corrected — it claimedconnector.jsonwas auto-excluded fromfiles[], the opposite of whatEXCLUDE_FILESdoes. -
Docs:
SKALD.mdmerged intoCLAUDE.mdand removed. The two files had drifted apart —CLAUDE.mdstill described a hand-maintainedconnectors.json(pre-compile.py), an rsync deploy, 5 connectors, and afiles[]that excludedconnector.json.CLAUDE.mdis now the single authoritative spec, carrying everySKALD.mdsection (full schemas forconnectors.json/fragment.json/connector.json, reserved enums,auth/deliver/env/verifyfields, placeholder syntax, icon conventions, file integrity, local workflow, deploy) corrected against the actual repo state: 18 connectors, thecompile.pypipeline, andconnector.jsonincluded in the hashedfiles[].CLAUDE.mdnow also namesCONNECTOR_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_requestimplemented exactly two methods,tools/listandtools/call; everything else fell through to-32601 Method not found. Sinceinitializeis 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/listreturned 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, noping. The former is the notification a client sends immediately afterinitialize; 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_requestnow returnsNonefor 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, silentnotifications/initialized,ping→{},tools/list→{"tools": TOOLS}, and aTOOLSmanifest list +TOOL_DISPATCHmap replacing the old dict-of-tuples and thetitle_mapthat 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/callfor each tool, plus a notification and an unknown method ✅
-
google-trends:
include_articles: truealways returned zero articles — a silent data bug independent of the handshake. The RSS mapper readt.get("articles"), but trendspyg emits the key asnews_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 readsnews_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 raisedTypeErrorand surfaced as-32603 Internal error, which reads to the agent as a broken server rather than a bad argument. Handlers now take a singleargs: dictand coerce through_str_arg/_int_arg/_bool_arg(clampingmax_trendsto 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: anError: …text result carryingisError: true. trendspyg's typed exceptions are translated into actionable messages (RateLimitError→ retry later,BrowserError→ Chrome missing,InvalidParameterError→ bad input) instead of a barestr(e). - google-trends: browser calls now fail fast.
exploreandget_interest_over_timeused 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:
trendspygpinned 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:
verifyis now actually wired.verify.pyshipped infiles[]since day one butconnector.jsondeclared noverifyblock, so skald never ran it. Added (python3 verify.py, 20s), and the script was upgraded from a bareimport trendspygcheck to a real RSS fetch against Google Trends that fails if zero trends come back. - google-trends:
exploreno longer mutates trendspyg's envelope. It injecteddata["status"] = "ok"into the returnedExploreEnvelope, assuming the return was always a dict. The envelope is now passed through untouched. - google-trends: dropped the
output_formatparameter fromget_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 (
exploreoverget_interest_over_timewhen related queries or the regional breakdown are wanted), and thatget_trendingis 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 toauth/and bind-mounted the same way, so it outlives both a restart and a container recreate.- No SQLite, deliberately.
node:sqliteneeds Node 22 (and is only unflagged from Node 24); the runtime image ships Debian trixie'snodejs= 20.19.2.better-sqlite3is a native module the slim image has no toolchain to build. So:store/messages.jsonl, an append-only log (oneappendFileSyncper message, O(1)), plusstore/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.
pushMessageappended 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) andingestMessageskips 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_CHAT200 → 500, worth more now that it is not thrown away at every restart.logoutdeletesstore/along withauth/, 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:v4with 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.jsonround-tripped, and a second run reloading 501 → 501 unchanged ✅
- No SQLite, deliberately.
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.jsderives 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 shippedsyncFullHistory: false— so the gate evaluated to() => false,shouldProcessHistoryMsgwas permanently false, and the history-sync blob was discarded.list_chats/get_messagesonly 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: () => trueis now passed explicitly, making behaviour identical on every Baileys version, andsyncFullHistory: true. Thebrowseridentity had to change too:getWebInfoonly requests a desktop-grade sync whenbrowser[0]is'Mac OS'or'Windows'(PLATFORM_MAPhas exactly those two keys) — with the old['Skald', 'Chrome', …]the sub-platform stayedWEB_BROWSERandsyncFullHistorywould 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. Theconnection === 'close'branch only setstarting = falseand re-enteredstartSock(): noend(), noremoveAllListeners(). Each pass calleduseMultiFileAuthState(AUTH_DIR)again, building a fresh key cache, while the previous socket stayed alive with its owncreds.update → saveCredshandler bound to the old snapshot. At ~15 reconnects/day (106× 428connectionClosed, 42× 500badSession, 34× 503, 9× 405 over two weeks) that is the standard route to inconsistent Signal state — and the logs showed the symptom: 1422Bad MAClines 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 onopen) 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
messagepayload andmessageStubType = CIPHERTEXT (2);textOf()returned''andingestMessagestored it anyway, soget_messagesrendered[timestamp] name:and the agent could not distinguish a silent gap from an empty message. They are now labelled[undecryptable message], counted, and surfaced instatus. 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.
libsignalbypasses the Baileys logger and callsconsole.errordirectly — oneFailed to decrypt…line plus a full stack trace per candidate session — which is how ~30 real failures became 1422 lines.console.erroris 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/warnare redirected to stderr as well: stdout is the JSON-RPC channel and a dependency printing there would corrupt the framing.
- The connector processed no history at all on Baileys 6.7.x.
Changed
- whatsapp: Baileys
^6.7.9→ pinned7.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'slegacydist-tag points at 6.7.24,latestat 7.0.0-rc14). The user on 6.17.16 logged 681 decrypt failures against the other's 30. Both@whiskeysockets/baileysandqrcodeare 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, plusWAMessageStubType). - It ships ESM-only (
"type": "module", enginesnode >= 20), soindex.jsmoved from CommonJS to ESM rather than leaning on Node'srequire(esm)bridge;__dirnameis derived fromimport.meta.url.package.jsongains"type": "module"andengines.node >= 20(theskald-runtime:v4image 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_chatsanswer correctly, protocol version fetch and QR generation work, and thelogoutpath 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 readsinstalled_versionfrom 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.
- 7.0.0-rc14 is a release candidate — there is no stable 7.x — chosen deliberately: its defaults already do the right thing (
2026-08-20
Fixed
- exa:
verify.pyrejected every API key, valid or not (v5 / 1.0.4) — activating Exa always failed withExa 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.aisits behind Cloudflare, which bans urllib's defaultPython-urllib/3.xagent with HTTP 403 /error code: 1010(browser-signature ban). The script sent noUser-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
initializeagainst 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 thex-api-keyheader, 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 MCPinitializeand reports reachability only, never validity. Both requests now send aUser-Agent, and the MCP one also sendsAccept: 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 readsinstalled_versionfrom the manifest, so the update badge would never have appeared).verify.timeout_secs15 → 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 realweb_search_exacall againsthttps://mcp.exa.ai/mcp?exaApiKey=…returned results ✅ - ⚠️ Same latent pattern elsewhere:
context7,tavily, andserpapi-flightsalso probe over urllib with noUser-Agentand 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.
- Cloudflare blocked the probe before Exa saw it.
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.jsonpins the package andindex.jsrewrites argv (--headless --isolated --no-sandbox, plus aprocess.argv.slice(2)passthrough) and imports the package'scli.js, which self-executes at import time.cli.jsis not in the packageexportsmap, so the wrapper resolves it from the exportedpackage.jsonpath.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
playwrightpackage does NOT download browsers atnpm install(verified on 1.63.0-alpha-2026-08-05), and@playwright/mcp0.0.79 no longer ships the oldbrowser_installtool (the README section is now empty). So the connector'spackage.jsoncarries apostinstallhook —node node_modules/@playwright/mcp/cli.js install-browser chromium— which downloads Chromium + headless shell + ffmpeg only (~350 MB; a bareinstall-browserwould also fetch Firefox and WebKit). Verified on a cold directory:npm install --omit=devruns the hook and the browser launches. verify.js(30s) resolvesplaywright-corerelative 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
--capsextras); all getdisplay_nameintools[]because@playwright/mcpdoes not emit MCPtitlefields intools/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) andbrowser_evaluate(arbitrary JS in the page); there is no CLI flag to disable individual core tools. Consistent with themcp_localtrust 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), realbrowser_navigate+browser_snapshoton https://example.com ✅, verify probe ok ✅
Fixed
- playwright: default browser channel
chrome→ forced--browser chromium(v2 / 1.0.1) —@playwright/mcp0.0.79 defaults to thechromechannel, i.e. system Google Chrome: on slim Linux hosts (no/opt/google/chrome/chrome) every tool call failed withChromium distribution 'chrome' is not found at /opt/google/chrome/chrome. It went unnoticed in local testing because macOS borrowed the installed system Chrome, andverify.js(plainchromium.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-testingafter the fix vschromebefore.
2026-08-10
Fixed
-
http-fetch + firecrawl:
npxlaunch was broken (v5 / 1.1.0) — both connectors declaredmcp_config: {command: "npx", args: ["-y", "<package>"]}and shipped no code files (onlyconnector.json+ icons).npx -y <package>is not expressible in skald. For atype: mcp_local, skald treatsargs[0]as the name of the file to run, not as an argument: at install it computesscript_path = "<id>/" + args[0]and clearsargs_json(marketplace.rs::install), thenglobal_enable(api/mcp.rs) resolves it to an absolute path and launches<command> <abs>. The real command becamenpx /…/connectors/http-fetch/-y— a nonexistent path, with-yand the package name lost. The process never answeredinitialize, sostart_serverfailed.- The failure was silent:
global_enablestill returns HTTP 200 with anerrorfield in the body, so the UI showed the connector as enabled while the runtime had no server. Andrender_mcp_list(loop_adapters/system.rs) builds the## MCP serverstable frommcp.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 +errorin 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.jsonpinning the upstream package (mcp-fetch-server@1.1.2,firecrawl-mcp@3.23.7) and anindex.jsthat imports it for side effects (the module starts the JSON-RPC loop on stdio at import).mcp_configbecomes{command: "node", args: ["index.js"], transport: "stdio"}, i.e. a reallocal_script:ensure_installed_hostrunsnpm ci --omit=dev || npm install --omit=devin the connector folder before launch, exactly like whatsapp. Nonode_modulesshipped, no lockfile (like whatsapp). - Removed legacy fields
launch_command, top-leveltransport, anddependencies(dependenciesis only for the card, as already seen on gmaps;transportbelongs insidemcp_config). - firecrawl: removed
mcp_config.env: {"FIRECRAWL_API_KEY": "{SECRET:FIRECRAWL_API_KEY}"}— inert, same case as gmaps on 2026-08-10:apply_key_placeholdersubstitutes tokens only in the URL, never inenvvalues. It worked because the admin form sendsenvand that payload overwritesentry.env_json. - firecrawl: added
firecrawl_developer_searchtotools[](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-10for both: they were 2/1.0.1 (manifest) vs 4/1.0.3 (fragment), and skald prefers the manifest — soinstalled_versionstayed at 2 and the "Update available" badge would never have appeared. - Host requirement: these are
scope: globalconnectors, they run on the host, not in the container.mcp-fetch-serverwants Node ≥18,firecrawl-mcpwants Node ≥22. - Tested end-to-end reproducing skald's path (
npm ci || npm install+node <abs>/index.js):initialize,tools/list, and a realtools/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 thedescriptionof themcp_global_serversrow, then restarts from that row:commandandargs_jsonstay the ones snapshotted at the firstglobal_enable, i.e. stillnpx+/…/connectors/<id>/-y. Procedure: deploy → Update from the marketplace (rewritesscript_pathin the catalog) → open the connector page and re-save the config, the only call that recomputescommand/argsand runsensure_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 manifestdependenciesfield, which skald uses only for the card:ensure_installed_hostlooks exclusively atrequirements.txt/package.json. Result: empty.pydepsand logs full ofNo module named 'googlemaps', with the server still answeringtools/list(→connected — 6 tool(s)on a broken connector). - Wired the
verify(python3 verify.py, 20s):verify.pywas shipped but the manifest had noverifyblock, and skald readsverify_commandonly from there. Now an activation with broken dependencies fails visibly instead of starting silently. verify.pyputs.pydepsonsys.path: skald setsPYTHONPATHonly for the server process (global_row_spec), while verify runs assh -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 averifythat 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_placeholdersubstitutes{SECRET:}/{ENV:}tokens only in the URL, never inenvvalues. It worked because the admin form sendsenvand that payload overwritesentry.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 toenvvalues 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)) — soinstalled_versionstayed at 2 and the "Update available" badge would never have appeared. requires:ENV→API_KEY; server error messages cleaned of references tosecrets/gmaps_api_key.txt(deprecated path).- Index regenerated with compile.py ✅
- Added
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) .gitignoreupdated to ignore.claude/- Deploy to connectors.skaldagent.net via
skaldserveralias (192.168.1.100, LAN — no Tailscale)
2026-07-23
Changed
- gmaps: env var injection fix — added
mcp_config.envinconnector.jsonto injectGOOGLE_MAPS_API_KEYinto the MCP process. The connector was declared asdelivery: envbut withoutmcp_config.envSkald 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 ✅