diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c29d9b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# 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] + +## 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 ✅ + +## 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 ✅ diff --git a/CLAUDE.md b/CLAUDE.md index 22f42c0..c878148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The **Skald Connectors Marketplace** — a catalog of tested connectors (adapters that let Skald agents talk to external services). It is **not an application**: there is no build system, package manager, or test suite. The repo is a set of JSON manifests, static HTML, icons, and standalone Python MCP scripts that are served as-is over HTTP. -`SKALD.md` is the authoritative spec (written in Italian) — read it before changing any schema or manifest. +`SKALD.md` is the authoritative spec — read it before changing any schema or manifest. ## Architecture @@ -25,15 +25,15 @@ Connector taxonomy (see SKALD.md for full enums): - `{ENV:NAME}` — a non-sensitive value (host, port, username…), declared in the top-level `env[]` array. - `{SECRET:NAME}` — a sensitive value (password, API key, token), also declared in `env[]` with `secret: true`. -Legacy tokens `{key}`, `{env:NAME}`, `{secrets}/…` are **deprecated** and skald no longer substitutes them. The api-key of an `auth.type = "api_key"` connector is exposed as `{SECRET:}` (e.g. Tavily: `?tavilyApiKey={SECRET:tavilyApiKey}`). See SKALD.md § Sintassi placeholder. +Legacy tokens `{key}`, `{env:NAME}`, `{secrets}/…` are **deprecated** and skald no longer substitutes them. The api-key of an `auth.type = "api_key"` connector is exposed as `{SECRET:}` (e.g. Tavily: `?tavilyApiKey={SECRET:tavilyApiKey}`). See SKALD.md § Placeholder syntax. -**Config via `env[]` (`env` manifest field):** a connector whose `requires` includes `ENV` declares its required environment variables in a top-level `env` array in `connector.json` — each entry has `name`, `label`, `description`, `required`, `secret`, and optional `default`/`example`. skald renders a real form from this schema (masking `secret: true` fields, marking `required`, using `example` as placeholder) and injects the collected values into the process environment at launch; the server reads `os.environ`. Nothing is written to disk. The `email` connector is the reference example (see SKALD.md § Campo env). **`tavily` now also declares `env[]`** (its API key as a `secret` field). +**Config via `env[]` (`env` manifest field):** a connector whose `requires` includes `ENV` declares its required environment variables in a top-level `env` array in `connector.json` — each entry has `name`, `label`, `description`, `required`, `secret`, and optional `default`/`example`. skald renders a real form from this schema (masking `secret: true` fields, marking `required`, using `example` as placeholder) and injects the collected values into the process environment at launch; the server reads `os.environ`. Nothing is written to disk. The `email` connector is the reference example (see SKALD.md § The env field). **`tavily` now also declares `env[]`** (its API key as a `secret` field). **Verify-before-save (`verify` manifest field).** A connector may declare a `verify` step — a shell command skald runs *after* the user fills the form and *before* persisting the activation, to confirm the credentials actually work: ```json "verify": { "command": "python3 verify.py", "timeout_secs": 20 } ``` -The command runs in the same sandbox as the server (container `skald-{userid}` for `mcp_local` user, host for `mcp_remote` global), with the collected env/secret injected. It must print one JSON object on stdout — `{"ok": bool, "message": string, "details"?: object}` — and exit 0 on success. If `verify` is absent, no test runs and the activation is direct. See SKALD.md § Campo verify. `email` and `tavily` ship a `verify.py`; `gmail` will get one in the OAuth phase. +The command runs in the same sandbox as the server (container `skald-{userid}` for `mcp_local` user, host for `mcp_remote` global), with the collected env/secret injected. It must print one JSON object on stdout — `{"ok": bool, "message": string, "details"?: object}` — and exit 0 on success. If `verify` is absent, no test runs and the activation is direct. See SKALD.md § The verify field. `email` and `tavily` ship a `verify.py`; `gmail` will get one in the OAuth phase. **MCP servers** (`connectors/gmail/gmail_mcp_server.py`, `connectors/email/email_mcp_server.py`) are hand-rolled JSON-RPC 2.0 servers over stdio — no MCP SDK / FastMCP. Shared conventions: **stdout is reserved for JSON-RPC**, all logging goes to stderr, a lock guards stdout writes, a background thread emits `event/new_email` push notifications, and each server exposes the same tool shape (`TOOLS` manifest + `TOOL_DISPATCH` + `handle_request`). Mirror this structure for new local connectors. - **Gmail** uses the Gmail API with OAuth; today it still reads its token from `./secrets/gmail_creds.json` (override `GMAIL_CREDS_PATH`) generated by `gmail_oauth_setup.py`. The `secrets/` path is **deprecated** — the OAuth phase (skald Fase 2) will migrate Gmail to `{ENV:}`/`{SECRET:}` and a loopback-listener flow. Push = History API polling. `verify` is not yet wired for Gmail. @@ -48,6 +48,15 @@ The command runs in the same sandbox as the server (container `skald-{userid}` f - **A new `verify.py` must be added to `files[]`** (with `sha256`+`size`) exactly like any other shipped file — skald refuses to run an unverified script. - The `secrets/` directory is **deprecated** in the model (credentials flow through `{ENV:}`/`{SECRET:}`). Gmail still uses it locally pending the OAuth migration; that path is gitignored — never commit credentials or OAuth tokens. +## Changelog + +Every user-facing change to the marketplace (new connector, connector fix, version bump, icon update, deploy notes, etc.) must be recorded in **`CHANGELOG.md`** at the repo root. Rules: + +- Follow the classic [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format (version headings, `Added`/`Changed`/`Fixed`/`Removed` categories, `[Unreleased]` section at the top). Use **date headings** (`## 2026-08-10`) instead of semver since each connector has its own version. +- Write in English. +- Add the entry in the same commit/change that modifies the connector files — do not defer it. +- Never put changelog entries in `SKALD.md`; it is spec-only. + ## Commands Recompute a file hash (do this before deploy for every changed file; SKALD.md mentions a `scripts/update_hashes.py` helper that does not exist in the repo yet): diff --git a/CONNECTOR_MANIFEST_GUIDE.md b/CONNECTOR_MANIFEST_GUIDE.md new file mode 100644 index 0000000..0dbcdc8 --- /dev/null +++ b/CONNECTOR_MANIFEST_GUIDE.md @@ -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 `. +- 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 — `/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//`), so keep it a +plain relative filename (`index.js`, `server.py`, `pkg/server.py`). + +--- + +## 2. Server contract (MCP over stdio) + +A **local** connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It +MUST handle: + +- `initialize` → `{ protocolVersion, capabilities: { tools: {} }, serverInfo }` +- `notifications/initialized` → no response +- `tools/list` → `{ tools: [ { name, description, inputSchema } ] }` +- `tools/call` → `{ content: [ { type: "text", text } ], isError? }` + +**stdout is reserved for JSON-RPC only.** Send all logs/diagnostics to **stderr**. +Anything a library prints to stdout (a logger, a banner) corrupts the protocol — +silence it (e.g. Baileys/pino → a silent logger; Python → `print(…, file=sys.stderr)`). + +A **remote** connector is an HTTP MCP endpoint (`mcp_config.url` + +`transport: "streamable-http"`); no code runs on the box. + +### 2a. Friendly tool names (`tools[]`) — optional + +Raw MCP tool names are ugly in the chat UI (`search_files`, `send_message`). The +optional top-level `tools[]` block gives each one a human title shown as the tool +card's heading: + +```jsonc +"tools": [ + { "name": "send_message", "display_name": "Send Message" }, + { "name": "list_chats", "display_name": "List Chats" }, + { "name": "download_media", "display_name": "Download Media" } +] +``` + +- `name` — the **raw** tool name exactly as your server returns it from `tools/list`. +- `display_name` — the friendly card title (English only; not internationalized). + +**Resolution order** for a tool's card title is **`tools[].display_name` → the MCP +`title` field → a prettified raw name**. So you have two ways to set a friendly +name, and can skip `tools[]` entirely: + +1. **This block** — the authoritative override, curated in the manifest. +2. **The MCP `title` field** — if your `tools/list` entries already carry a + `title` (MCP 2025-06-18+), Skald uses it automatically; no manifest change + needed. `tools[]` wins if both are present. +3. If neither is set, Skald title-cases the raw name (`send_message` → "Send + Message"). + +**Icons are per connector, not per tool.** Every tool of a connector shows that +connector's own `icon_small`; there is no per-tool icon field. Only list a tool in +`tools[]` when its prettified name isn't good enough — partial lists are fine +(unlisted tools fall through to steps 2–3). + +--- + +## 3. Placement & risk vocabulary (what the words mean) + +| Manifest | Meaning | +| --- | --- | +| `scope: "user"` | runs **once per user**, inside their container. Personal creds. | +| `scope: "global"` | runs **once for the household**, on the host. Shared, stateless. Admin enables it with a key. | +| `type: "mcp_local"` | ships code that will **execute on the box** — installing needs the admin `mcp.register_local_script` capability (RCE-bearing act, §14). | +| `type: "mcp_remote"` | just an HTTP URL; no local code. | + +Pick the narrowest: a personal messaging/email/calendar connector is +`scope: "user"`; a shared search API is `scope: "global"`. + +--- + +## 4. Authentication (`auth.type`) + +| `auth.type` | Flow | Ships | +| --- | --- | --- | +| `none` | nothing to sign in | — | +| `api_key` | user pastes a key/secret into a form | an `env[]` schema (§4b) | +| `oauth2` | browser consent → paste code back | `auth.provider` + `auth.scopes` + `auth.deliver` (§4c) | +| `qr` | server shows a QR, user scans with a phone | a `login_status` tool (§4d) | + +### 4b. `api_key` — the `env[]` schema + +Each entry drives one form field **and** is injected as an env var / URL token to +the server: + +```jsonc +"env": [{ + "name": "tavilyApiKey", + "label": "Tavily API key", + "description": "Create one at https://app.tavily.com.", + "required": true, + "secret": true, // rendered masked, stored encrypted + "example": "tvly-xxxxxxxx" +}] +``` + +The server reads each value from `process.env.` (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`. +``` diff --git a/SKALD.md b/SKALD.md index 320a304..43b0eed 100644 --- a/SKALD.md +++ b/SKALD.md @@ -1,64 +1,17 @@ # Skald Connectors Marketplace +## What it is -### 2026-08-10 — http-fetch + firecrawl: fix `npx` (v5 / 1.1.0) +The **marketplace** is the catalog of tested connectors for Skald. Each connector is an adapter that lets Skald interface with an external service (API, email, calendar, search, messaging, etc.). -Entrambi i connector erano **non funzionanti**: dichiaravano `mcp_config: {command: "npx", args: ["-y", ""]}` e non spedivano nessun file di codice (solo `connector.json` + icone). +Two types of connectors: -- **`npx -y ` non è esprimibile in skald.** Per un `type: mcp_local` skald tratta `args[0]` come il **nome del file da eseguire**, non come un argomento: all'install calcola `script_path = "/" + args[0]` e azzera `args_json` (`marketplace.rs::install`), poi `global_enable` (`api/mcp.rs`) lo risolve in path assoluto e lancia ` `. Il comando reale diventava `npx /…/connectors/http-fetch/-y` — un path inesistente, con `-y` e il nome del package spariti. Il processo non rispondeva mai a `initialize`, `start_server` falliva. -- **Il fallimento era silenzioso**: `global_enable` restituisce comunque HTTP 200 con un campo `error` nel body, quindi la UI mostrava il connector come abilitato mentre il runtime non aveva nessun server. E `render_mcp_list` (`loop_adapters/system.rs`) costruisce la tabella `## MCP servers` da `mcp.tools()`, cioè lo **stato vivo del runtime**, non dal DB → il connector risultava attivato e concesso all'utente ma **assente dal system context**. ⚠️ Questa combinazione (200 + `error` nel body) rende invisibile in UI qualsiasi connector che non parte: vale la pena farla emergere lato skald. -- **Fix — wrapper di due file** per entrambi: `package.json` che pinna il package upstream (`mcp-fetch-server@1.1.2`, `firecrawl-mcp@3.23.7`) e `index.js` che lo importa per side-effect (il modulo avvia il loop JSON-RPC su stdio all'import). `mcp_config` diventa `{command: "node", args: ["index.js"], transport: "stdio"}`, cioè un vero `local_script`: `ensure_installed_host` fa `npm ci --omit=dev || npm install --omit=dev` nella cartella del connector prima del lancio, esattamente come per whatsapp. Nessun `node_modules` spedito, nessun lockfile (come whatsapp). -- **Rimossi i campi legacy** `launch_command`, `transport` top-level e `dependencies` (`dependencies` è solo per la card, come già visto su gmaps; `transport` va dentro `mcp_config`). -- **firecrawl: rimosso `mcp_config.env: {"FIRECRAWL_API_KEY": "{SECRET:FIRECRAWL_API_KEY}"}`** — inerte, stesso caso di gmaps del 2026-08-10: `apply_key_placeholder` sostituisce i token solo nella URL, mai nei valori di `env`. Funziona perché la form admin manda `env` e quel payload sovrascrive `entry.env_json`. -- **firecrawl**: aggiunto `firecrawl_developer_search` a `tools[]` (27 tool live contro i 26 dichiarati, verificato su 3.23.7); `requires` `["NODE"]` → `["NODE", "API_KEY"]`. -- **Versioni riallineate** manifest↔fragment a `5` / `1.1.0` / `2026-08-10` per entrambi: erano 2/1.0.1 (manifest) vs 4/1.0.3 (fragment), e skald preferisce il manifest — quindi `installed_version` restava 2 e il badge "Update available" non sarebbe mai comparso. -- **Requisito host**: sono connector `scope: global`, girano sull'**host** e non nel container. `mcp-fetch-server` vuole Node ≥18, `firecrawl-mcp` vuole Node ≥**22**. -- Testati end-to-end riproducendo il path di skald (`npm ci || npm install` + `node /index.js`): `initialize`, `tools/list` e una `tools/call` reale, stdout solo JSON-RPC, stderr pulito ✅ -- Indice rigenerato con compile.py ✅ +- **`mcp_remote`** — an already-hosted MCP server, reachable via URL (e.g. Tavily). +- **`mcp_local`** — a Python/Node script to run client-side (e.g. Gmail, Google Calendar). -⚠️ **Per applicarlo su un'istanza che ha già il connector installato non basta l'Update.** `refresh_connector_after_reinstall` (`skald/accessors.rs`) aggiorna della riga `mcp_global_servers` **solo la `description`**, poi riparte da quella riga: `command` e `args_json` restano quelli snapshottati al primo `global_enable`, cioè ancora `npx` + `/…/connectors//-y`. Procedura: deploy → **Update** dal marketplace (riscrive `script_path` nel catalogo) → aprire la pagina del connector e **ri-salvare la config**, che è l'unica chiamata che ricalcola `command`/`args` e lancia `ensure_installed_host`. Stessa cura già annotata per gmaps. Da valutare lato skald se il refresh debba ri-derivare anche command/args. +## Local references -### 2026-08-10 — gmaps: fix dipendenze + verify (v6 / 1.1.0) -- **Aggiunto `requirements.txt` (`googlemaps>=4.10.0`)** — era l'unico connector python senza. Le dipendenze erano dichiarate nel campo `dependencies` del manifest, che skald usa **solo per la card**: `ensure_installed_host` guarda esclusivamente `requirements.txt` / `package.json`. Risultato: `.pydeps` vuoto e log pieno di `No module named 'googlemaps'`, con il server che rispondeva comunque a `tools/list` (→ `connected — 6 tool(s)` su un connector non funzionante). -- **Cablato il `verify`** (`python3 verify.py`, 20s): `verify.py` era shippato ma il manifest non aveva il blocco `verify`, e skald legge `verify_command` solo da lì. Ora un'abilitazione con dipendenze rotte fallisce visibilmente invece di partire in silenzio. -- **`verify.py` si mette `.pydeps` su `sys.path`**: skald imposta `PYTHONPATH` solo per il processo *server* (`global_row_spec`), mentre il verify gira come `sh -c "python3 verify.py"` senza. Senza questa riga il verify fallirebbe con "Missing dependency" anche su un connector installato correttamente, disabilitando la riga. ⚠️ **Stesso rischio latente per ogni connector con `verify` che importa dipendenze non-stdlib** (gcal in container): da controllare. -- **Rimosso `mcp_config.env: {"GOOGLE_MAPS_API_KEY": "{SECRET:…}"}`** (introdotto il 2026-07-23): inerte. `apply_key_placeholder` sostituisce i token `{SECRET:}`/`{ENV:}` **solo nella URL**, mai nei valori di `env`. Funzionava perché la form admin manda `env` e quel payload sovrascrive `entry.env_json`; a form vuota il processo avrebbe ricevuto la stringa letterale. ⚠️ **La spec in questo file e in CLAUDE.md dice il contrario** — o si corregge la spec, o si estende la sostituzione ai valori di `env` lato skald. -- **Versioni riallineate** manifest↔fragment a `6` / `1.1.0` / `2026-08-10`: erano 2/1.0.1 vs 5/1.0.4, e skald preferisce il manifest (`manifest.version.or(entry.version)`) — quindi `installed_version` restava 2 e il badge "Update available" non sarebbe mai comparso. -- `requires`: `ENV` → `API_KEY`; messaggi d'errore del server ripuliti dai riferimenti a `secrets/gmaps_api_key.txt` (path deprecato). -- Indice rigenerato con compile.py ✅ - -### 2026-08-07 — Nuovo connector: LinkedIn -- Aggiunto connector `linkedin` (mcp_local, scope user): server.py + session.py + verify.py + icone PNG -- Aggiunto a `connectors/index.json`, indice rigenerato con compile.py (17 connector totali) -- `.gitignore` aggiornato per ignorare `.claude/` -- Deploy su connectors.skaldagent.net via alias `skaldserver` (192.168.1.100, LAN — niente Tailscale) - -### 2026-07-23 — gmaps: fix env var injection -- Aggiunto `mcp_config.env` in `connector.json` per iniettare `GOOGLE_MAPS_API_KEY` nel processo MCP -- Il connector era dichiarato come `delivery: env` ma senza `mcp_config.env` Skald non sapeva passare la variabile al processo Python -- Version bump: fragment 4→5, connector 1→2 -- Indice rigenerato con compile.py ✅ - -### 2026-07-23 — Context7 icon update (PNG) -- Sostituite icone Context7 da SVG a PNG (icona nuova fornita dall'utente): - - `icon_sm.png` — 48×48 (2.7 KB) - - `icon_lg.png` — 96×96 (4.5 KB) -- Vecchi SVG rimossi, riferimenti aggiornati in fragment.json e connector.json -- Version bump: fragment 3→4, connector 1→2 -- Indice rigenerato con compile.py ✅ -## Reference locale - -### 2026-07-23 — SerpAPI Flights icon update (PNG) -- Sostituite icone SerpAPI Flights da SVG a PNG: - - `icon_sm.png` — 48×48 (2.9 KB) - - `icon_lg.png` — 96×96 (6.8 KB) -- Vecchi SVG rimossi, riferimenti aggiornati in fragment.json e connector.json -- Version bump: fragment 4→5, connector 2→3 -- Indice rigenerato con compile.py ✅ - -- **[docs/connector.manifest_guide.md](docs/connector.manifest_guide.md)** — Guida ufficiale per generare connector corretti (copiata da `skald-circle/blueprint/`) - -_Updated: 2026-07-23_ +- **[docs/connector.manifest_guide.md](docs/connector.manifest_guide.md)** — Official guide for producing correct connectors (copied from `skald-circle/blueprint/`) **Remote**: `https://git.skaldagent.net/dguiducci/skald-connectors.git` **Live**: `https://connectors.skaldagent.net/` @@ -66,75 +19,65 @@ _Updated: 2026-07-23_ ## Deploy -Il branch **`main`** è il branch di **release**. Solo codice pronto per produzione finisce qui. -Sviluppo e versioni alfa-staranno su branch separati in futuro. +The **`main`** branch is the **release** branch. Only production-ready code ends up here. +Development and alpha versions will live on separate branches in the future. -Deploy rapido con MCP SSH: +Quick deploy with MCP SSH: ```bash mcp__ssh__exec alias=skald-home-server command="/home/dguiducci/marketplace_deploy.sh" ``` -Oppure via SSH classico: +Or via classic SSH: ```bash ssh dguiducci@skald-home-server /home/dguiducci/marketplace_deploy.sh ``` -Lo script sul server fa: +The script on the server does: 1. `git pull` in `/home/dguiducci/repos/skald-connectors/` -2. `cp -r connectors/*` in `/var/www/connectors.skaldagent.net/` +2. `cp -r connectors/*` into `/var/www/connectors.skaldagent.net/` -La directory `/var/www/connectors.skaldagent.net/` è di proprietà di `dguiducci`, -quindi non serve sudo per la copia. +The `/var/www/connectors.skaldagent.net/` directory is owned by `dguiducci`, +so no sudo is needed for the copy. -**Prima del deploy**, ricordarsi di rigenerare l'indice: +**Before deploying**, remember to regenerate the index: ```bash python3 scripts/compile.py ``` -## Cos'è - -Il **marketplace** è il catalogo dei connector testati per Skald. Ogni connector è un adattatore che permette a Skald di interfacciarsi con un servizio esterno (API, email, calendario, ricerca, messaggistica, ecc.). - -Due tipi di connector: - -- **`mcp_remote`** — un MCP server già hosted, accessibile via URL (es. Tavily). -- **`mcp_local`** — script Python/Node da eseguire lato client (es. Gmail, Google Calendar). - ## Friendly tool names (2026-07-21) -Ogni tool MCP deve esporre un nome friendly per la UI di Skald. Due modi, in ordine di preferenza: +Every MCP tool must expose a friendly name for the Skald UI. Two ways, in order of preference: -1. **Via script MCP (preferito)** — aggiungere `"title": "Friendly Name"` nella definizione di ogni tool dentro `tools/list`. Funziona per tutti gli script locali (Python/Node) che controlliamo. -2. **Via manifest (fallback)** — aggiungere `"tools": [{"name": "...", "display_name": "..."}]` in `connector.json` **e** in `connectors.json`. Usato solo per connector remoti o package esterni (es. `npx -y firecrawl-mcp`). +1. **Via the MCP script (preferred)** — add `"title": "Friendly Name"` in the definition of each tool inside `tools/list`. Works for all local scripts (Python/Node) that we control. +2. **Via the manifest (fallback)** — add `"tools": [{"name": "...", "display_name": "..."}]` in `connector.json` **and** in `connectors.json`. Used only for remote connectors or external packages (e.g. `npx -y firecrawl-mcp`). -**Ordine di risoluzione** (Skald li prova in quest'ordine): -1. `tools[].display_name` dal manifest -2. `title` dal `tools/list` dell'MCP server -3. Prettify automatico del nome raw (`send_message` → "Send Message") +**Resolution order** (Skald tries them in this order): +1. `tools[].display_name` from the manifest +2. `title` from the MCP server's `tools/list` +3. Automatic prettify of the raw name (`send_message` → "Send Message") -Stato attuale (2026-07-21): tutti i 13 connector del marketplace hanno `title` nello script o `tools[]` nel manifest. +Current status (2026-07-21): all 13 marketplace connectors have `title` in the script or `tools[]` in the manifest. - -## Struttura directory +## Directory structure ``` connectors/ -├── index.json ← lista ordinata degli id dei connector (input per compile.py) -├── connectors.json ← INDICE COMPILATO (generato da compile.py, non editare) -├── compile.py ← genera connectors.json -├── index.html ← Catalogo UI (legge connectors.json via fetch) +├── index.json ← ordered list of connector ids (input for compile.py) +├── connectors.json ← COMPILED INDEX (generated by compile.py, do not edit) +├── compile.py ← generates connectors.json (launched by scripts/compile.py) +├── index.html ← Catalog UI (reads connectors.json via fetch) ├── oauth/ -│ └── show.html ← OAuth callback receiver (client-side, no backend) -├── gmail/ ← Un connector per cartella -│ ├── fragment.json ← Frammento dell'indice (id, name, type, ..., SENZA files[]) -│ ├── connector.json ← Configurazione tecnica -│ ├── gmail_mcp_server.py ← Script MCP -│ ├── gmail_oauth_setup.py ← Script setup OAuth -│ ├── requirements.txt ← Dipendenze Python -│ ├── icon_sm.svg ← Icona piccola (48×48) -│ └── icon_lg.svg ← Icona grande (es. 96×96) +│ └── show.html ← OAuth callback receiver +├── gmail/ ← one connector per folder +│ ├── fragment.json ← index fragment (id, name, type, ..., WITHOUT files[]) +│ ├── connector.json ← technical configuration (mcp_config, auth.deliver, ...) +│ ├── gmail_mcp_server.py ← MCP script +│ ├── gmail_oauth_setup.py ← OAuth setup script +│ ├── requirements.txt ← Python dependencies +│ ├── icon_sm.svg ← small icon (48×48) +│ └── icon_lg.svg ← large icon (e.g. 96×96) ├── email/ │ ├── fragment.json │ ├── connector.json @@ -143,27 +86,13 @@ connectors/ │ ├── requirements.txt │ ├── icon_sm.svg │ └── icon_lg.svg -├── ssh/ -│ ├── fragment.json -│ ├── connector.json -│ ├── ssh_mcp_server.py -│ ├── requirements.txt -│ ├── icon_sm.svg -│ └── icon_lg.svg -└── tavily/ - ├── fragment.json - ├── connector.json - ├── verify.py - ├── icon_sm.png - └── icon_lg.png -``` - └── icon_lg.png +└── ... ``` ## Schema — connectors.json (root) -Questo è l'**unico punto di fiducia**. Contiene `type`, `scope` e gli sha256 dei file di ogni connector. -Non ha hash di sé stesso — in futuro potrà essere firmato digitalmente. +This is the **single root of trust**. It contains `type`, `scope`, and the sha256 of each connector's files. +It has no hash of itself — in the future it may be digitally signed. ```json { @@ -192,8 +121,6 @@ Non ha hash di sé stesso — in futuro potrà essere firmato digitalmente. "version_string": "1.0.0", "version_release_date": "2026-07-19", "files": [ -| `auth` | per OAuth | Oggetto con `type`, `provider`, `scopes` per badge UI (NO `deliver` qui, è nel manifest) | - {"path": "gmail_mcp_server.py", "sha256": "a50d4da9621f7a4b092f...", "size": 46772}, {"path": "gmail_oauth_setup.py", "sha256": "e488acb289c43a3e6d54...", "size": 3627}, {"path": "icon_lg.svg", "sha256": "93c8d9c8dae96f0206e5...", "size": 254}, @@ -205,28 +132,28 @@ Non ha hash di sé stesso — in futuro potrà essere firmato digitalmente. } ``` -### Campi dell'indice +### Index fields -| Campo | Obbligatorio | Descrizione | -|-------|-------------|-------------| -| `id` | ✅ | Identificatore unico (kebab-case) | -| `name` | ✅ | Nome visualizzato | -| `type` | ✅ | `mcp_remote` o `mcp_local` | -| `scope` | ✅ | `global` o `user` | -| `icon_small` | ✅ | Path relativo dalla root del marketplace | -| `icon_large` | ✅ | Path relativo dalla root del marketplace | -| `user_description` | ✅ | Descrizione breve per la UI | -| `requires` | ✅ | Array di enum requisiti | -| `tags` | ✅ | Array di tag per filtraggio | -| `folder` | ✅ | Nome della cartella del connector | -| `version` | ✅ | Intero per-connector, +1 a ogni modifica dei file | -| `version_string` | ✅ | Semver (solo display) | -| `version_release_date` | ✅ | Data ISO 8601 YYYY-MM-DD (solo display) | -| `files` | ✅ | Array di file con sha256 (NO self-hash) | +| Field | Required | Description | +|-------|----------|-------------| +| `id` | ✅ | Unique identifier (kebab-case) | +| `name` | ✅ | Displayed name | +| `type` | ✅ | `mcp_remote` or `mcp_local` | +| `scope` | ✅ | `global` or `user` | +| `icon_small` | ✅ | Path relative to the marketplace root | +| `icon_large` | ✅ | Path relative to the marketplace root | +| `user_description` | ✅ | Short description for the UI | +| `requires` | ✅ | Array of requirement enums | +| `tags` | ✅ | Array of tags for filtering | +| `folder` | ✅ | Name of the connector folder | +| `version` | ✅ | Per-connector integer, +1 on every file change | +| `version_string` | ✅ | Semver (display only) | +| `version_release_date` | ✅ | ISO 8601 date YYYY-MM-DD (display only) | +| `files` | ✅ | Array of files with sha256 (NO self-hash) | -## Schema — connector.json (per cartella) +## Schema — connector.json (per folder) -Configurazione tecnica per l'attivazione del connector. +Technical configuration for connector activation. ```json { @@ -280,63 +207,63 @@ Configurazione tecnica per l'attivazione del connector. } ``` -### Campi del connector.json +### connector.json fields -| Campo | Obbligatorio | Descrizione | -|-------|-------------|-------------| -| `id` | ✅ | Identificatore unico (match con folder name) | -| `name` | ✅ | Nome visualizzato | -| `version` | ✅ | Intero per-connector, +1 a ogni modifica dei file | -| `version_string` | ✅ | Semver (solo display) | -| `version_release_date` | ✅ | Data ISO 8601 YYYY-MM-DD (solo display) | -| `type` | ✅ | `mcp_remote` o `mcp_local` | -| `scope` | ✅ | `global` o `user` | -| `requires` | ✅ | Array di enum requisiti | -| `tags` | ✅ | Array di tag | -| `auth` | ✅ | Oggetto configurazione autenticazione | -| `docs` | ✅ | Array di documentazione multilingua. **`llm_short_description`** è il campo che finisce nel system prompt dell'LLM — deve descrivere COSA FA il connector, non elencare i tool (l'LLM li vede dopo `activate_tools`). Esempio: *"Weather — current conditions, 16-day forecast, and AQI data for any location."* -| `icon_small` | ✅ | Nome file icona nella cartella locale | -| `icon_large` | ✅ | Nome file icona nella cartella locale | -| `launch_command` | solo `mcp_local` | Comando per avviare il server MCP | -| `transport` | solo `mcp_local` | `stdio` (default) | -| `dependencies` | consigliato | Dipendenze Python/Node (array vuoto se solo stdlib) | -| `env` | se `requires` include `ENV` | Variabili d'ambiente che l'utente deve fornire (schema per la UI) — vedi § Campo env | -| `setup_instructions` | consigliato | Passi per configurare il connector | -| `mcp_config` | solo `mcp_local` | Configurazione per l'MCP client | -| `homepage` | opzionale | URL del servizio | +| Field | Required | Description | +|-------|----------|-------------| +| `id` | ✅ | Unique identifier (matches the folder name) | +| `name` | ✅ | Displayed name | +| `version` | ✅ | Per-connector integer, +1 on every file change | +| `version_string` | ✅ | Semver (display only) | +| `version_release_date` | ✅ | ISO 8601 date YYYY-MM-DD (display only) | +| `type` | ✅ | `mcp_remote` or `mcp_local` | +| `scope` | ✅ | `global` or `user` | +| `requires` | ✅ | Array of requirement enums | +| `tags` | ✅ | Array of tags | +| `auth` | ✅ | Authentication configuration object | +| `docs` | ✅ | Array of multilingual documentation. **`llm_short_description`** is the field that ends up in the LLM's system prompt — it must describe WHAT the connector DOES, not list its tools (the LLM sees them after `activate_tools`). Example: *"Weather — current conditions, 16-day forecast, and AQI data for any location."* | +| `icon_small` | ✅ | Icon filename in the local folder | +| `icon_large` | ✅ | Icon filename in the local folder | +| `launch_command` | `mcp_local` only | Command to start the MCP server | +| `transport` | `mcp_local` only | `stdio` (default) | +| `dependencies` | recommended | Python/Node dependencies (empty array if stdlib only) | +| `env` | if `requires` includes `ENV` | Environment variables the user must provide (schema for the UI) — see § The env field | +| `setup_instructions` | recommended | Steps to configure the connector | +| `mcp_config` | `mcp_local` only | Configuration for the MCP client | +| `homepage` | optional | Service URL | -## Enum riservati +## Reserved enums -### type (tipo di connector) +### type (connector type) -| Valore | Descrizione | Esempi | -|--------|-------------|--------| -| `mcp_remote` | Server MCP hosted, accessibile via URL | Tavily, Weather | -| `mcp_local` | Script da eseguire localmente | Gmail, Google Calendar, WhatsApp | -| `script` | Script standalone (non MCP) | *(futuro)* | +| Value | Description | Examples | +|-------|-------------|----------| +| `mcp_remote` | Hosted MCP server, reachable via URL | Tavily, Weather | +| `mcp_local` | Script to run locally | Gmail, Google Calendar, WhatsApp | +| `script` | Standalone script (non-MCP) | *(future)* | -### scope (ambito di configurazione) +### scope (configuration scope) -| Valore | Descrizione | Esempi | -|--------|-------------|--------| -| `global` | Una singola istanza/config per tutto il sistema | Tavily, Weather, Google Trends | -| `user` | Ogni utente ha la propria istanza/autenticazione | Gmail, WhatsApp, Google Calendar | +| Value | Description | Examples | +|-------|-------------|----------| +| `global` | A single instance/config for the whole system | Tavily, Weather, Google Trends | +| `user` | Each user has their own instance/authentication | Gmail, WhatsApp, Google Calendar | -### requires (prerequisiti) +### requires (prerequisites) -| Valore | Descrizione | -|--------|-------------| -| `API_KEY` | Richiede una chiave API da configurare | -| `OAUTH` | Richiede autenticazione OAuth (Google, ecc.) | -| `DOCKER` | Richiede Docker Engine | -| `NODE` | Richiede Node.js runtime | -| `PYTHON` | Richiede Python 3 | -| `SECRETS_DIR` | ❌ **Deprecato** — la cartella `secrets/` è rimossa dal modello; i connector devono usare `ENV`/`SECRET` (vedi § Sintassi placeholder) | -| `ENV` | Richiede variabili d'ambiente (dichiarate nel campo `env` del manifest) | +| Value | Description | +|-------|-------------| +| `API_KEY` | Requires an API key to configure | +| `OAUTH` | Requires OAuth authentication (Google, etc.) | +| `DOCKER` | Requires Docker Engine | +| `NODE` | Requires Node.js runtime | +| `PYTHON` | Requires Python 3 | +| `SECRETS_DIR` | ❌ **Deprecated** — the `secrets/` folder is removed from the model; connectors must use `ENV`/`SECRET` (see § Placeholder syntax) | +| `ENV` | Requires environment variables (declared in the manifest `env` field) | -## Campo auth +## The auth field -Struttura che descrive come il connector gestisce l'autenticazione: +Structure describing how the connector handles authentication: ```json // API key in query string @@ -345,112 +272,113 @@ Struttura che descrive come il connector gestisce l'autenticazione: // API key in header {"type": "api_key", "delivery": "header", "param": "X-API-Key"} -// OAuth2 — provider SOLO slug (Skald risolve endpoint + client secrets) +// OAuth2 — provider is ONLY a slug (Skald resolves endpoints + client secrets) {"type": "oauth2", "provider": "google", "scopes": ["...", "..."]} -// OAuth2 con deliver (Skald inietta il JSON authorized_user via env var) +// OAuth2 with deliver (Skald injects the authorized_user JSON via env var) {"type": "oauth2", "provider": "google", "scopes": ["..."], "deliver": {"as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON"}} -// OAuth2 con deliver su file (legacy) +// OAuth2 with file-based deliver (legacy) {"type": "oauth2", "provider": "google", "scopes": ["..."], "deliver": {"as": "file", "format": "google_authorized_user", "path": "{secrets}/gmail_creds.json"}} -// Password / app-password fornita via variabili d'ambiente +// Password / app-password provided via environment variables {"type": "password", "delivery": "env"} -// Nessuna autenticazione +// No authentication {"type": "none"} ``` -### Campo deliver (solo OAuth2) +### The deliver field (OAuth2 only) -Dichiara **come** Skald consegna la credenziale OAuth ottenuta al processo del server MCP. +Declares **how** Skald delivers the obtained OAuth credential to the MCP server process. -| Campo | Obbligatorio | Descrizione | -|-------|-------------|-------------| -| `as` | ✅ | `"file"` (su disco) o `"env"` (variabile d'ambiente) | -| `format` | ✅ | Nome della serializzazione — es. `"google_authorized_user"` (JSON Google che `from_authorized_user_file` legge), `"refresh_token"`, `"access_token"` | -| `path` | solo `as=file` | Path con placeholder `{secrets}` (Skald lo espande a dir per-utente a runtime). DEVE matchare il path in `mcp_config.env`. | -| `env` | solo `as=env` | Nome della variabile d'ambiente in cui Skald inietta l'intero JSON authorized_user. **Non va dichiarata in `mcp_config.env`** — Skald la inietta a runtime. | +| Field | Required | Description | +|-------|----------|-------------| +| `as` | ✅ | `"file"` (on disk) or `"env"` (environment variable) | +| `format` | ✅ | Name of the serialization — e.g. `"google_authorized_user"` (Google JSON that `from_authorized_user_file` reads), `"refresh_token"`, `"access_token"` | +| `path` | `as=file` only | Path with the `{secrets}` placeholder (Skald expands it to a per-user dir at runtime). MUST match the path in `mcp_config.env`. | +| `env` | `as=env` only | Name of the environment variable into which Skald injects the entire authorized_user JSON. **Must not be declared in `mcp_config.env`** — Skald injects it at runtime. | -Il feed NON contiene MAI: `client_id`, `client_secret`, `endpoint` URL, `redirect_uri`. Questi sono risolti lato Skald a partire dal nome del `provider`. +The feed NEVER contains: `client_id`, `client_secret`, `endpoint` URL, `redirect_uri`. These are resolved on the Skald side from the `provider` name. -## Campo env (variabili d'ambiente) +## The env field (environment variables) -Usato quando `requires` include `ENV`. È un **array** che dichiara le variabili -d'ambiente che l'utente deve fornire per far funzionare il connector; **nessuna -credenziale finisce su disco né in `secrets/`** — l'host raccoglie i valori, -obbliga la compilazione dei campi obbligatori, e li inietta come environment -nel processo del server MCP al lancio. Il server le legge da `os.environ`. +Used when `requires` includes `ENV`. It is an **array** declaring the environment +variables the user must provide to make the connector work; **no credential +lands on disk nor in `secrets/`** — the host collects the values, enforces +filling in the required fields, and injects them as environment into the MCP +server process at launch. The server reads them from `os.environ`. ```json "env": [ { - "name": "EMAIL_IMAP_HOST", // nome della variabile d'ambiente - "label": "IMAP host", // etichetta per la UI - "description": "IMAP server hostname (es. imap.gmail.com)", - "required": true, // se true, l'host deve obbligare la compilazione - "secret": false, // se true, la UI la maschera e la tratta come segreto - "example": "imap.gmail.com" // placeholder/esempio (opzionale) + "name": "EMAIL_IMAP_HOST", // name of the environment variable + "label": "IMAP host", // label for the UI + "description": "IMAP server hostname (e.g. imap.gmail.com)", + "required": true, // if true, the host must enforce this field + "secret": false, // if true, the UI masks it and treats it as a secret + "example": "imap.gmail.com" // placeholder/example (optional) }, { "name": "EMAIL_PASSWORD", "label": "Password / app password", - "description": "Password o app-password del provider", + "description": "Password or app-password of the provider", "required": true, "secret": true, - "default": "" // valore di default se non obbligatorio (opzionale) + "default": "" // default value if not required (optional) } ] ``` -| Campo | Obbligatorio | Descrizione | -|-------|-------------|-------------| -| `name` | ✅ | Nome della variabile d'ambiente (UPPER_SNAKE_CASE) | -| `label` | ✅ | Etichetta breve per la UI | -| `description` | ✅ | Testo di aiuto | -| `required` | ✅ | Se `true`, l'host obbliga l'utente a fornire un valore | -| `secret` | consigliato | Se `true`, valore sensibile (mascherato, non loggato) | -| `default` | opzionale | Valore usato se non fornito (solo per non obbligatorie) | -| `example` | opzionale | Placeholder di esempio per la UI | +| Field | Required | Description | +|-------|----------|-------------| +| `name` | ✅ | Name of the environment variable (UPPER_SNAKE_CASE) | +| `label` | ✅ | Short label for the UI | +| `description` | ✅ | Help text | +| `required` | ✅ | If `true`, the host forces the user to provide a value | +| `secret` | recommended | If `true`, sensitive value (masked, not logged) | +| `default` | optional | Value used when not provided (non-required fields only) | +| `example` | optional | Example placeholder for the UI | -## Sintassi placeholder (unificata) +## Placeholder syntax (unified) -Ogni valore che skald deve riempire a runtime con un dato fornito dall'utente -usa **uno di due token**, ovunque compaia (URL, `mcp_config.env`, `verify.command`): +Every value skald must fill at runtime with user-provided data uses **one of +two tokens**, wherever it appears (URL, `mcp_config.env`, `verify.command`): -| Token | Significato | Esempio | -|-------|-------------|---------| -| `{ENV:NAME}` | Variabile non sensibile (hostname, porta, username…) | `{ENV:EMAIL_IMAP_HOST}` | -| `{SECRET:NAME}` | Variabile sensibile (password, API key, token) | `{SECRET:EMAIL_PASSWORD}` | +| Token | Meaning | Example | +|-------|---------|---------| +| `{ENV:NAME}` | Non-sensitive variable (hostname, port, username…) | `{ENV:EMAIL_IMAP_HOST}` | +| `{SECRET:NAME}` | Sensitive variable (password, API key, token) | `{SECRET:EMAIL_PASSWORD}` | -`NAME` è il nome dichiarato nell'array `env[]` (campo `name`). skald raccoglie -i valori tramite un form (maschera i `{SECRET:}`), li inietta come environment -nel processo del server MCP / verify, e sostituisce i token nel manifest. +`NAME` is the name declared in the `env[]` array (the `name` field). skald +collects the values via a form (masking `{SECRET:}` fields), injects them as +environment into the MCP server / verify process, and substitutes the tokens +in the manifest. -Regole: -- I token non riconosciuti (es. `{secrets}/…`, legacy `{key}`, `{env:NAME}`) - sono **deprecati**: skald non li sostituisce e il manifest va aggiornato. -- `{SECRET:}` è riservato alla chiave primaria quando - `auth.type = "api_key"` (es. Tavily: `{SECRET:tavilyApiKey}`). skald tratta - quel valore anche come API key per il routing bearer/header. -- Un token `{ENV:X}` o `{SECRET:X}` la cui `X` non è nell'`env[]` del manifest - viene sostituito con stringa vuota (l'host non può indovinarlo). +Rules: +- Unrecognized tokens (e.g. `{secrets}/…`, legacy `{key}`, `{env:NAME}`) are + **deprecated**: skald does not substitute them and the manifest must be updated. +- `{SECRET:}` is reserved for the primary key when + `auth.type = "api_key"` (e.g. Tavily: `{SECRET:tavilyApiKey}`). skald also + treats that value as the API key for bearer/header routing. +- A `{ENV:X}` or `{SECRET:X}` token whose `X` is not in the manifest's `env[]` + is substituted with an empty string (the host cannot guess it). ### Deprecations -| Token | Stato | Sostituzione | -|-------|-------|--------------| -| `{key}` | ❌ deprecato | `{SECRET:}` | -| `{env:NAME}` | ❌ deprecato | `{ENV:NAME}` | -| `{secrets}/…` | ❌ deprecato | Il connector deve dichiarare il path come `{ENV:…}` (la cartella `secrets/` è rimossa dal modello) | +| Token | Status | Replacement | +|-------|--------|-------------| +| `{key}` | ❌ deprecated | `{SECRET:}` | +| `{env:NAME}` | ❌ deprecated | `{ENV:NAME}` | +| `{secrets}/…` | ❌ deprecated | The connector must declare the path as `{ENV:…}` (the `secrets/` folder is removed from the model) | -## Campo verify (test prima del salvataggio) +## The verify field (test before save) -Dichiara un comando shell che skald esegue **dopo** che l'utente ha compilato -il form e **prima** di persistere l'attivazione. Serve a verificare che le -credenziali appena inserite funzionino davvero. +Declares a shell command that skald runs **after** the user has filled in the +form and **before** persisting the activation. It checks that the credentials +just entered actually work. ```json "verify": { @@ -459,174 +387,145 @@ credenziali appena inserite funzionino davvero. } ``` -| Campo | Obbligatorio | Descrizione | -|-------|-------------|-------------| -| `command` | ✅ | Comando shell. Gira nello stesso sandbox del server: container `skald-{userid}` per `mcp_local` user, host per `mcp_remote` global. Le env/secret dichiarati sono iniettate | -| `timeout_secs` | opzionale | Default 15. skald killa il processo allo scadere | +| Field | Required | Description | +|-------|----------|-------------| +| `command` | ✅ | Shell command. Runs in the same sandbox as the server: container `skald-{userid}` for `mcp_local` user, host for `mcp_remote` global. The declared env/secrets are injected | +| `timeout_secs` | optional | Default 15. skald kills the process at expiry | -### Convenzione output +### Output convention -Il comando deve stampare **un singolo oggetto JSON su stdout** e nient'altro: +The command must print **a single JSON object on stdout** and nothing else: ```json {"ok": true, "message": "IMAP and SMTP authentication successful", "details": {"imap": "...", "smtp": "..."}} {"ok": false, "message": "IMAP login failed: INVALID_CREDENTIALS"} ``` -| Campo | Tipo | Descrizione | +| Field | Type | Description | |-------|------|-------------| -| `ok` | bool | `true` = test passato | -| `message` | string | Messaggio mostrato all'utente (mai loggare secret qui dentro) | -| `details` | object | Opzionale, dettagli strutturati mostrati in `
` |
+| `ok` | bool | `true` = test passed |
+| `message` | string | Message shown to the user (never log secrets inside) |
+| `details` | object | Optional, structured details shown in `
` |
 
-Exit code: 0 su successo, ≠ 0 su fallimento (skald usa l'exit code come
-fallback se il parse JSON fallisce). **Mai stampare credenziali** nel
+Exit code: 0 on success, ≠ 0 on failure (skald uses the exit code as a
+fallback if the JSON parse fails). **Never print credentials** in the
 `message`/`details`.
 
-### Dove mettere lo script
+### Where to put the script
 
-Se `command` referenzia un file (es. `verify.py`), il file va:
-1. Aggiunto all'array `files[]` in `connectors.json` (con `sha256` e `size`)
-2. Salvato nella cartella del connector (`/verify.py`)
+If `command` references a file (e.g. `verify.py`), the file must:
+1. Be added to the `files[]` array in `connectors.json` (with `sha256` and `size`)
+2. Be saved in the connector folder (`/verify.py`)
 
-skald lo scarica, ne verifica lo SHA-256 contro l'indice, e lo rende disponibile
-nello stesso path del server principale (container per i `mcp_local`, dir
-`./scripts//` sull'host per i `mcp_remote`).
+skald downloads it, verifies its SHA-256 against the index, and makes it
+available at the same path as the main server (container for `mcp_local` ones,
+`./scripts//` dir on the host for `mcp_remote` ones).
 
-### Senza verify
+### Without verify
 
-Se `verify` manca, skald **non esegue nessun test** — l'attivazione è diretta
-come oggi. Il connector va in `auth_state='ready'` senza verifica. Per i
-`mcp_remote` non c'è fallback handshake: l'autore del manifest decide se vuole
-il test scrivendo `verify`.
+If `verify` is absent, skald **runs no test** — activation is direct, as
+today. The connector goes to `auth_state='ready'` without verification. For
+`mcp_remote` there is no handshake fallback: the manifest author decides
+whether the test is needed by writing `verify`.
 
-## Convenzioni icone
+## Icon conventions
 
-- **Formato**: SVG per icone vettoriali (meglio per retina/zoom), PNG per raster
-- **Nome**: `icon_sm.{svg|png}` (small, ~48×48px), `icon_lg.{svg|png}` (large, ~96×96px)
-- **Path**: relativo alla cartella del connector
-- **Nell'indice** la path è `{folder}/{filename}` (es. `gmail/icon_sm.svg`)
+- **Format**: SVG for vector icons (better for retina/zoom), PNG for raster
+- **Name**: `icon_sm.{svg|png}` (small, ~48×48px), `icon_lg.{svg|png}` (large, ~96×96px)
+- **Path**: relative to the connector folder
+- **In the index** the path is `{folder}/{filename}` (e.g. `gmail/icon_sm.svg`)
 
-## Integrità file (sha256)
+## File integrity (sha256)
 
-- Gli SHA-256 sono generati **automaticamente** da `scripts/compile.py` a partire
-  dai file fisici presenti in ogni cartella — mai manuali, mai stale.
-- L'unico file firmato (in futuro) sarà `connectors.json` (l'indice).
-- `fragment.json` e `index.json` non hanno hash — sono solo input di compilazione.
+- SHA-256 hashes are generated **automatically** by `scripts/compile.py` from
+  the physical files present in each folder — never manual, never stale.
+- The only file signed (in the future) will be `connectors.json` (the index).
+- `fragment.json` and `index.json` have no hash — they are just compilation inputs.
 
-## Struttura directory
+## Local workflow
 
-```
-connectors/
-├── index.json              ← lista ordinata degli id dei connector (input per compile.py)
-├── connectors.json         ← INDICE COMPILATO (generato da compile.py, non editare)
-├── compile.py              ← genera connectors.json (lanciato da scripts/compile.py)
-├── index.html              ← Catalogo UI (legge connectors.json via fetch)
-├── oauth/
-│   └── show.html           ← OAuth callback receiver
-├── gmail/                   ← Un connector per cartella
-│   ├── fragment.json        ← Frammento dell'indice (id, name, type, ..., SENZA files[])
-│   ├── connector.json       ← Configurazione tecnica (mcp_config, auth.deliver, ...)
-│   ├── gmail_mcp_server.py  ← Script MCP
-│   ├── gmail_oauth_setup.py ← Script setup OAuth
-│   ├── requirements.txt     ← Dipendenze Python
-│   ├── icon_sm.svg          ← Icona piccola (48×48)
-│   └── icon_lg.svg          ← Icona grande (es. 96×96)
-├── email/
-│   ├── fragment.json
-│   ├── connector.json
-│   ├── email_mcp_server.py
-│   ├── verify.py
-│   ├── requirements.txt
-│   ├── icon_sm.svg
-│   └── icon_lg.svg
-└── ...
-```
+1. **Adding a new connector**:
+   - Create the folder `connectors//`
+   - Create `fragment.json` (id, name, type, scope, icons, auth, tools, ...)
+   - Create `connector.json` (technical config: mcp_config, launch_command, ...)
+   - Add the MCP script, icons, verify.py, requirements.txt
+   - Add the id to `connectors/index.json`
+   - Run `python3 scripts/compile.py`
 
-## Workflow locale
+2. **Modifying an existing connector**:
+   - Edit the files in the connector folder
+   - **Do not touch** `connectors.json` — it gets regenerated
+   - Run `python3 scripts/compile.py`
 
-1. **Aggiungere un nuovo connector**:
-   - Crea cartella `connectors//`
-   - Crea `fragment.json` (id, name, type, scope, icone, auth, tools, ...)
-   - Crea `connector.json` (config tecnica: mcp_config, launch_command, ...)
-   - Aggiungi script MCP, icone, verify.py, requirements.txt
-   - Aggiungi l'id a `connectors/index.json`
-   - Lancia `python3 scripts/compile.py`
-
-2. **Modificare un connector esistente**:
-   - Modifica i file nella cartella del connector
-   - **Non toccare** `connectors.json` — viene rigenerato
-   - Lancia `python3 scripts/compile.py`
-
-3. **Prima del deploy**:
+3. **Before deploying**:
    ```bash
-   python3 scripts/compile.py           # rigenera connectors.json con SHA-256 fresh
-   python3 scripts/compile.py --verify  # (opzionale) verifica che sia aggiornato
+   python3 scripts/compile.py           # regenerates connectors.json with fresh SHA-256s
+   python3 scripts/compile.py --verify  # (optional) verifies that it is up to date
    ```
 
-4. **Deploy sul server**:
+4. **Deploy to the server**:
    ```bash
    ssh dguiducci@skald-home-server /home/dguiducci/marketplace_deploy.sh
    ```
-   oppure con MCP SSH:
+   or with MCP SSH:
    ```bash
    # via mcp__ssh__exec alias "skald-home-server"
    /home/dguiducci/marketplace_deploy.sh
    ```
-   (Il deploy script fa `git pull` + `cp -r connectors/* /var/www/connectors.skaldagent.net/`)
+   (The deploy script does `git pull` + `cp -r connectors/* /var/www/connectors.skaldagent.net/`)
 
-5. Verifica su `https://connectors.skaldagent.net/`
+5. Verify on `https://connectors.skaldagent.net/`
 
 ## Fragment.json
 
-Vedi [docs/connector.manifest_guide.md](docs/connector.manifest_guide.md) per la guida completa
-alla creazione.
+See [docs/connector.manifest_guide.md](docs/connector.manifest_guide.md) for the complete
+creation guide.
 
-`fragment.json` contiene tutti i campi dell'entry di `connectors.json` **tranne** `files[]`.
-Questi sono i campi obbligatori:
+`fragment.json` contains all the fields of a `connectors.json` entry **except** `files[]`.
+These are the required fields:
 
-| Campo | Obbligatorio | Descrizione |
-|-------|-------------|-------------|
-| `id` | ✅ | Identificatore unico (match con folder name) |
-| `name` | ✅ | Nome visualizzato |
-| `type` | ✅ | `mcp_remote` o `mcp_local` |
-| `scope` | ✅ | `global` o `user` |
-| `icon_small` | ✅ | Path relativo dalla root del marketplace |
-| `icon_large` | ✅ | Path relativo dalla root del marketplace |
-| `user_description` | ✅ | Descrizione breve per la UI |
-| `requires` | ✅ | Array di enum requisiti |
-| `tags` | ✅ | Array di tag per filtraggio |
-| `folder` | ✅ | Nome della cartella del connector (match con `id`) |
-| `version` | ✅ | Intero per-connector, +1 a ogni modifica dei file |
-| `version_string` | ✅ | Semver (solo display) |
-| `version_release_date` | ✅ | Data ISO 8601 (solo display) |
-| `tools` | ✅ | Array di `{name, display_name}` per friendly names UI |
-| `auth` | opzionale | Configurazione autenticazione (se diversa da `"none"`) |
+| Field | Required | Description |
+|-------|----------|-------------|
+| `id` | ✅ | Unique identifier (matches the folder name) |
+| `name` | ✅ | Displayed name |
+| `type` | ✅ | `mcp_remote` or `mcp_local` |
+| `scope` | ✅ | `global` or `user` |
+| `icon_small` | ✅ | Path relative to the marketplace root |
+| `icon_large` | ✅ | Path relative to the marketplace root |
+| `user_description` | ✅ | Short description for the UI |
+| `requires` | ✅ | Array of requirement enums |
+| `tags` | ✅ | Array of tags for filtering |
+| `folder` | ✅ | Name of the connector folder (matches `id`) |
+| `version` | ✅ | Per-connector integer, +1 on every file change |
+| `version_string` | ✅ | Semver (display only) |
+| `version_release_date` | ✅ | ISO 8601 date (display only) |
+| `tools` | ✅ | Array of `{name, display_name}` for friendly UI names |
+| `auth` | optional | Authentication configuration (if other than `"none"`) |
 
-Nota: l'array `files[]` (con SHA-256 e size) viene aggiunto **automaticamente**
-da `compile.py` scansionando i file presenti nella cartella — non va mai scritto
-a mano.
+Note: the `files[]` array (with SHA-256 and size) is added **automatically**
+by `compile.py` by scanning the files present in the folder — it must never be
+written by hand.
 
-## Deploy su server remoto
+## Deploy to remote server
 
-Il server remoto è:
+The remote server is:
 
 - **Host**: skald-home-server (192.168.1.100 / 145.40.169.107)
 - **User**: dguiducci
 - **Path**: `/var/www/connectors.skaldagent.net/`
-- **Proprietario**: `caddy:caddy`
-- **Sudo**: richiesto per scrivere in `/var/www/`
+- **Owner**: `caddy:caddy`
+- **Sudo**: required to write in `/var/www/`
 
-## Connector attuali
+## Current connectors
 
-| ID | Nome | Tipo | Scope | Auth | Verify |
+| ID | Name | Type | Scope | Auth | Verify |
 |----|------|------|-------|------|--------|
 | `context7` | Context7 | `mcp_remote` | `global` | none | `verify.py` (MCP initialize probe) |
 | `gmaps` | Google Maps | `mcp_local` | `global` | api_key (env: `GOOGLE_MAPS_API_KEY`) | `verify.py` (Geocoding API probe) |
 | `exa` | Exa | `mcp_remote` | `global` | api_key (`{SECRET:exaApiKey}` in URL — optional, free tier) | `verify.py` (MCP initialize probe) |
 | `tavily` | Tavily | `mcp_remote` | `global` | api_key (`{SECRET:tavilyApiKey}` in URL) | `verify.py` (HTTP probe `/search`) |
 | `serpapi-flights` | SerpAPI Flights | `mcp_remote` | `global` | api_key (`{SECRET:serpapiApiKey}` in URL) | `verify.py` (MCP initialize probe) |
-| `gmail` | Gmail | `mcp_local` | `user` | oauth2 (Google) + deliver: `env/google_authorized_user` (via `GMAIL_CREDS_JSON`) | ⏳ Fase 2 — OAuth via loopback listener |
+| `gmail` | Gmail | `mcp_local` | `user` | oauth2 (Google) + deliver: `env/google_authorized_user` (via `GMAIL_CREDS_JSON`) | ⏳ Phase 2 — OAuth via loopback listener |
 | `gcal` | Google Calendar | `mcp_local` | `user` | oauth2 (Google) + deliver: `env/google_authorized_user` (via `GCAL_CREDS_JSON`) | `verify.py` (creds load + API probe) |
 | `drive` | Google Drive | `mcp_local` | `user` | oauth2 (Google) + deliver: `env/google_authorized_user` (via `DRIVE_CREDS_JSON`) | `verify.py` (creds load + Drive API probe) |
 | `email` | Email (IMAP/SMTP) | `mcp_local` | `user` | password (env) | `verify.py` (IMAP+SMTP probe) |
@@ -637,6 +536,8 @@ Il server remoto è:
 | `google-trends` | Google Trends | `mcp_local` | `global` | none | `verify.py` (trendspyg import probe) |
 | `wikipedia` | Wikipedia | `mcp_local` | `global` | none | — |
 | `whatsapp` | WhatsApp | `mcp_local` | `user` | qr | — |
-**Stato del verify-before-save in skald**: `exa`, `drive`, `email`, `tavily`, e `gcal` hanno `verify` completo
-(script + JSON output); `gmail` aspetta la Fase 2 (OAuth via loopback listener).
-Un connector senza `verify` viene attivato senza test — vedi § Senza verify.
+| `playwright` | Playwright | `mcp_local` | `global` | none | `verify.js` (headless Chromium launch probe) |
+
+**verify-before-save status in skald**: `exa`, `drive`, `email`, `tavily`, and `gcal` have a complete `verify`
+(script + JSON output); `gmail` awaits Phase 2 (OAuth via loopback listener).
+A connector without `verify` is activated without any test — see § Without verify.
diff --git a/connectors/connectors.json b/connectors/connectors.json
index e465d6c..f8ae69e 100644
--- a/connectors/connectors.json
+++ b/connectors/connectors.json
@@ -1245,6 +1245,164 @@
           "size": 5021
         }
       ]
+    },
+    {
+      "id": "playwright",
+      "name": "Playwright",
+      "type": "mcp_local",
+      "scope": "global",
+      "icon_small": "playwright/icon_sm.svg",
+      "icon_large": "playwright/icon_lg.svg",
+      "user_description": "Full browser automation \u2014 navigate, click, type, and read any web page with a real headless Chromium.",
+      "requires": [
+        "NODE"
+      ],
+      "tags": [
+        "browser",
+        "automation",
+        "mcp",
+        "local",
+        "playwright",
+        "web",
+        "scraping"
+      ],
+      "auth": {
+        "type": "none"
+      },
+      "folder": "playwright",
+      "version": 1,
+      "version_string": "1.0.0",
+      "version_release_date": "2026-08-19",
+      "tools": [
+        {
+          "name": "browser_close",
+          "display_name": "Close Browser"
+        },
+        {
+          "name": "browser_resize",
+          "display_name": "Resize Browser Window"
+        },
+        {
+          "name": "browser_console_messages",
+          "display_name": "Get Console Messages"
+        },
+        {
+          "name": "browser_handle_dialog",
+          "display_name": "Handle a Dialog"
+        },
+        {
+          "name": "browser_evaluate",
+          "display_name": "Evaluate JavaScript"
+        },
+        {
+          "name": "browser_file_upload",
+          "display_name": "Upload Files"
+        },
+        {
+          "name": "browser_drop",
+          "display_name": "Drop onto Element"
+        },
+        {
+          "name": "browser_find",
+          "display_name": "Find in Page Snapshot"
+        },
+        {
+          "name": "browser_fill_form",
+          "display_name": "Fill Form"
+        },
+        {
+          "name": "browser_press_key",
+          "display_name": "Press a Key"
+        },
+        {
+          "name": "browser_type",
+          "display_name": "Type Text"
+        },
+        {
+          "name": "browser_navigate",
+          "display_name": "Navigate to URL"
+        },
+        {
+          "name": "browser_navigate_back",
+          "display_name": "Go Back"
+        },
+        {
+          "name": "browser_network_requests",
+          "display_name": "List Network Requests"
+        },
+        {
+          "name": "browser_network_request",
+          "display_name": "Show Network Request"
+        },
+        {
+          "name": "browser_run_code_unsafe",
+          "display_name": "Run Playwright Code (Unsafe)"
+        },
+        {
+          "name": "browser_take_screenshot",
+          "display_name": "Take a Screenshot"
+        },
+        {
+          "name": "browser_snapshot",
+          "display_name": "Page Snapshot"
+        },
+        {
+          "name": "browser_click",
+          "display_name": "Click"
+        },
+        {
+          "name": "browser_drag",
+          "display_name": "Drag Mouse"
+        },
+        {
+          "name": "browser_hover",
+          "display_name": "Hover Mouse"
+        },
+        {
+          "name": "browser_select_option",
+          "display_name": "Select Option"
+        },
+        {
+          "name": "browser_tabs",
+          "display_name": "Manage Tabs"
+        },
+        {
+          "name": "browser_wait_for",
+          "display_name": "Wait For"
+        }
+      ],
+      "files": [
+        {
+          "path": "connector.json",
+          "sha256": "aa9e6ac53b7c7abaa595ce99fcf903e0fffffb28f385c4d9d5107d16be4c8978",
+          "size": 4031
+        },
+        {
+          "path": "icon_lg.svg",
+          "sha256": "90422e9cbda3f8ef0ec7c00fbe7d6769ba468aff81dbb5e6e48124ef245fd6a4",
+          "size": 5031
+        },
+        {
+          "path": "icon_sm.svg",
+          "sha256": "aa13f9f5ac5c734b869bd6b005347fd05dd70aa8979d197cb5c79ec8eabfb89d",
+          "size": 5031
+        },
+        {
+          "path": "index.js",
+          "sha256": "7bbdee60a27013fa3b4b139d90b83f3c72c25520693e76c6e8860d7085446dc8",
+          "size": 1537
+        },
+        {
+          "path": "package.json",
+          "sha256": "59ebf53dfda2126ab6ea352c6f91a8226b7d30dbd22d4056814450fb67b96ec7",
+          "size": 390
+        },
+        {
+          "path": "verify.js",
+          "sha256": "2588ce8dd872ba83ff4266353f95315288377c0d30651ac3bdaa561f6dfa3b23",
+          "size": 1987
+        }
+      ]
     }
   ]
 }
diff --git a/connectors/index.json b/connectors/index.json
index 65f803d..198134b 100644
--- a/connectors/index.json
+++ b/connectors/index.json
@@ -1 +1 @@
-["gmail", "gcal", "drive", "email", "exa", "firecrawl", "http-fetch", "serpapi-flights", "ssh", "tavily", "weather", "whatsapp", "wikipedia", "context7", "gmaps", "google-trends", "linkedin"]
+["gmail", "gcal", "drive", "email", "exa", "firecrawl", "http-fetch", "serpapi-flights", "ssh", "tavily", "weather", "whatsapp", "wikipedia", "context7", "gmaps", "google-trends", "linkedin", "playwright"]
diff --git a/connectors/playwright/connector.json b/connectors/playwright/connector.json
new file mode 100644
index 0000000..72304ef
--- /dev/null
+++ b/connectors/playwright/connector.json
@@ -0,0 +1,149 @@
+{
+  "id": "playwright",
+  "name": "Playwright",
+  "type": "mcp_local",
+  "requires": [
+    "NODE"
+  ],
+  "setup_instructions": [
+    "Requires Node.js 18+ on the host that runs the connector.",
+    "On install, the package postinstall downloads Chromium (~350 MB) into the Playwright browsers cache.",
+    "No configuration needed — the browser runs headless with an isolated in-memory profile: no cookies or login state are kept between sessions.",
+    "Security note: the default tool set includes browser_run_code_unsafe and browser_evaluate, which execute arbitrary JavaScript (RCE-equivalent). Enable only for trusted agents."
+  ],
+  "docs": [
+    {
+      "lang": "en",
+      "description": "Full browser automation with a real headless Chromium: navigate any web page, click, type, fill forms, handle dialogs, manage tabs, take accessibility snapshots and screenshots, and inspect network requests. Runs headless with an isolated in-memory profile (no state is kept between sessions). Includes browser_run_code_unsafe / browser_evaluate, which execute arbitrary JavaScript (RCE-equivalent) — enable only for trusted agents.",
+      "llm_short_description": "Browser automation — navigate web pages, click, type, fill forms, take accessibility snapshots and screenshots, manage tabs, and inspect network requests with a real headless Chromium."
+    }
+  ],
+  "auth": {
+    "type": "none"
+  },
+  "mcp_config": {
+    "command": "node",
+    "args": [
+      "index.js"
+    ],
+    "transport": "stdio"
+  },
+  "verify": {
+    "command": "node verify.js",
+    "timeout_secs": 30
+  },
+  "homepage": "https://playwright.dev",
+  "icon_small": "icon_sm.svg",
+  "icon_large": "icon_lg.svg",
+  "scope": "global",
+  "tags": [
+    "browser",
+    "automation",
+    "mcp",
+    "local",
+    "playwright",
+    "web",
+    "scraping"
+  ],
+  "version": 1,
+  "version_string": "1.0.0",
+  "version_release_date": "2026-08-19",
+  "tools": [
+    {
+      "name": "browser_close",
+      "display_name": "Close Browser"
+    },
+    {
+      "name": "browser_resize",
+      "display_name": "Resize Browser Window"
+    },
+    {
+      "name": "browser_console_messages",
+      "display_name": "Get Console Messages"
+    },
+    {
+      "name": "browser_handle_dialog",
+      "display_name": "Handle a Dialog"
+    },
+    {
+      "name": "browser_evaluate",
+      "display_name": "Evaluate JavaScript"
+    },
+    {
+      "name": "browser_file_upload",
+      "display_name": "Upload Files"
+    },
+    {
+      "name": "browser_drop",
+      "display_name": "Drop onto Element"
+    },
+    {
+      "name": "browser_find",
+      "display_name": "Find in Page Snapshot"
+    },
+    {
+      "name": "browser_fill_form",
+      "display_name": "Fill Form"
+    },
+    {
+      "name": "browser_press_key",
+      "display_name": "Press a Key"
+    },
+    {
+      "name": "browser_type",
+      "display_name": "Type Text"
+    },
+    {
+      "name": "browser_navigate",
+      "display_name": "Navigate to URL"
+    },
+    {
+      "name": "browser_navigate_back",
+      "display_name": "Go Back"
+    },
+    {
+      "name": "browser_network_requests",
+      "display_name": "List Network Requests"
+    },
+    {
+      "name": "browser_network_request",
+      "display_name": "Show Network Request"
+    },
+    {
+      "name": "browser_run_code_unsafe",
+      "display_name": "Run Playwright Code (Unsafe)"
+    },
+    {
+      "name": "browser_take_screenshot",
+      "display_name": "Take a Screenshot"
+    },
+    {
+      "name": "browser_snapshot",
+      "display_name": "Page Snapshot"
+    },
+    {
+      "name": "browser_click",
+      "display_name": "Click"
+    },
+    {
+      "name": "browser_drag",
+      "display_name": "Drag Mouse"
+    },
+    {
+      "name": "browser_hover",
+      "display_name": "Hover Mouse"
+    },
+    {
+      "name": "browser_select_option",
+      "display_name": "Select Option"
+    },
+    {
+      "name": "browser_tabs",
+      "display_name": "Manage Tabs"
+    },
+    {
+      "name": "browser_wait_for",
+      "display_name": "Wait For"
+    }
+  ]
+}
diff --git a/connectors/playwright/fragment.json b/connectors/playwright/fragment.json
new file mode 100644
index 0000000..84a285e
--- /dev/null
+++ b/connectors/playwright/fragment.json
@@ -0,0 +1,126 @@
+{
+  "id": "playwright",
+  "name": "Playwright",
+  "type": "mcp_local",
+  "scope": "global",
+  "icon_small": "playwright/icon_sm.svg",
+  "icon_large": "playwright/icon_lg.svg",
+  "user_description": "Full browser automation — navigate, click, type, and read any web page with a real headless Chromium.",
+  "requires": [
+    "NODE"
+  ],
+  "tags": [
+    "browser",
+    "automation",
+    "mcp",
+    "local",
+    "playwright",
+    "web",
+    "scraping"
+  ],
+  "auth": {
+    "type": "none"
+  },
+  "folder": "playwright",
+  "version": 1,
+  "version_string": "1.0.0",
+  "version_release_date": "2026-08-19",
+  "tools": [
+    {
+      "name": "browser_close",
+      "display_name": "Close Browser"
+    },
+    {
+      "name": "browser_resize",
+      "display_name": "Resize Browser Window"
+    },
+    {
+      "name": "browser_console_messages",
+      "display_name": "Get Console Messages"
+    },
+    {
+      "name": "browser_handle_dialog",
+      "display_name": "Handle a Dialog"
+    },
+    {
+      "name": "browser_evaluate",
+      "display_name": "Evaluate JavaScript"
+    },
+    {
+      "name": "browser_file_upload",
+      "display_name": "Upload Files"
+    },
+    {
+      "name": "browser_drop",
+      "display_name": "Drop onto Element"
+    },
+    {
+      "name": "browser_find",
+      "display_name": "Find in Page Snapshot"
+    },
+    {
+      "name": "browser_fill_form",
+      "display_name": "Fill Form"
+    },
+    {
+      "name": "browser_press_key",
+      "display_name": "Press a Key"
+    },
+    {
+      "name": "browser_type",
+      "display_name": "Type Text"
+    },
+    {
+      "name": "browser_navigate",
+      "display_name": "Navigate to URL"
+    },
+    {
+      "name": "browser_navigate_back",
+      "display_name": "Go Back"
+    },
+    {
+      "name": "browser_network_requests",
+      "display_name": "List Network Requests"
+    },
+    {
+      "name": "browser_network_request",
+      "display_name": "Show Network Request"
+    },
+    {
+      "name": "browser_run_code_unsafe",
+      "display_name": "Run Playwright Code (Unsafe)"
+    },
+    {
+      "name": "browser_take_screenshot",
+      "display_name": "Take a Screenshot"
+    },
+    {
+      "name": "browser_snapshot",
+      "display_name": "Page Snapshot"
+    },
+    {
+      "name": "browser_click",
+      "display_name": "Click"
+    },
+    {
+      "name": "browser_drag",
+      "display_name": "Drag Mouse"
+    },
+    {
+      "name": "browser_hover",
+      "display_name": "Hover Mouse"
+    },
+    {
+      "name": "browser_select_option",
+      "display_name": "Select Option"
+    },
+    {
+      "name": "browser_tabs",
+      "display_name": "Manage Tabs"
+    },
+    {
+      "name": "browser_wait_for",
+      "display_name": "Wait For"
+    }
+  ]
+}
diff --git a/connectors/playwright/icon_lg.svg b/connectors/playwright/icon_lg.svg
new file mode 100644
index 0000000..73c9f01
--- /dev/null
+++ b/connectors/playwright/icon_lg.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/connectors/playwright/icon_sm.svg b/connectors/playwright/icon_sm.svg
new file mode 100644
index 0000000..0d3f002
--- /dev/null
+++ b/connectors/playwright/icon_sm.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/connectors/playwright/index.js b/connectors/playwright/index.js
new file mode 100644
index 0000000..873a67b
--- /dev/null
+++ b/connectors/playwright/index.js
@@ -0,0 +1,33 @@
+// Skald connector entry point.
+//
+// The MCP server itself is the upstream `@playwright/mcp` package, pinned in
+// package.json and installed by Skald beside this file (`npm ci --omit=dev`,
+// falling back to `npm install --omit=dev`).
+//
+// This file exists because Skald launches a `mcp_local` connector as
+// ` `, where args[0] is rewritten to the absolute
+// path of a *shipped* file — so `npx @playwright/mcp` cannot be expressed:
+// args[0] would be taken as the entry file's name.
+//
+// The wrapper rewrites argv with the flags the server needs in a display-less
+// container (headless, isolated in-memory profile, no sandbox) and then
+// imports the package's CLI module, which self-executes at import time.
+// cli.js is not listed in the package's "exports" map, so it is resolved
+// from the exported package.json path instead of a package subpath import.
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+const require = createRequire(import.meta.url);
+const packageDir = dirname(require.resolve("@playwright/mcp/package.json"));
+
+process.argv = [
+  process.argv[0],
+  "playwright-mcp",
+  "--headless", // no display in the container/host session
+  "--isolated", // in-memory profile: no shared cookies/login state on disk
+  "--no-sandbox", // Chromium sandbox cannot run inside containers
+  ...process.argv.slice(2), // passthrough for any extra args
+];
+
+await import(pathToFileURL(join(packageDir, "cli.js")));
diff --git a/connectors/playwright/package.json b/connectors/playwright/package.json
new file mode 100644
index 0000000..f61cb37
--- /dev/null
+++ b/connectors/playwright/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "skald-connector-playwright",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "description": "Skald connector wrapper around the @playwright/mcp MCP server.",
+  "scripts": {
+    "postinstall": "node node_modules/@playwright/mcp/cli.js install-browser chromium"
+  },
+  "dependencies": {
+    "@playwright/mcp": "0.0.79"
+  },
+  "engines": {
+    "node": ">=18"
+  }
+}
diff --git a/connectors/playwright/verify.js b/connectors/playwright/verify.js
new file mode 100644
index 0000000..2279d04
--- /dev/null
+++ b/connectors/playwright/verify.js
@@ -0,0 +1,53 @@
+// Verify-before-save probe for the Playwright connector.
+//
+// Prints exactly ONE JSON object on stdout:
+//   {"ok": bool, "message": string, "details"?: object}
+// Exit code 0 on success, 1 on failure. All diagnostics go to stderr.
+//
+// This connector has no credentials; what can actually break at runtime is
+// the Chromium side: the binary must be present and launchable headless in
+// this environment (system libraries, sandbox restrictions). The probe
+// launches a real headless Chromium on about:blank and closes it.
+import { createRequire } from "node:module";
+import { existsSync } from "node:fs";
+import { basename } from "node:path";
+
+const require = createRequire(import.meta.url);
+
+const finish = (ok, message, details) => {
+  const out = { ok, message };
+  if (details) out.details = details;
+  process.stdout.write(JSON.stringify(out) + "\n");
+  process.exit(ok ? 0 : 1);
+};
+
+let chromium;
+let executablePath;
+try {
+  // Resolve playwright-core relative to @playwright/mcp (it is a dependency
+  // of the pinned package, not of this connector's package.json).
+  const mcpRequire = createRequire(require.resolve("@playwright/mcp/package.json"));
+  ({ chromium } = mcpRequire("playwright-core"));
+  executablePath = chromium.executablePath();
+} catch (e) {
+  finish(false, `Cannot resolve the Playwright installation: ${e.message}`);
+}
+
+if (!existsSync(executablePath)) {
+  finish(false, "The Chromium binary is not installed. Reinstall the connector dependencies (the postinstall step downloads it).", { executablePath });
+}
+
+let browser;
+try {
+  browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
+} catch (e) {
+  finish(false, `Chromium failed to launch: ${String(e.message).split("\n")[0]}`, { executablePath });
+}
+
+try {
+  const page = await browser.newPage();
+  await page.goto("about:blank");
+  finish(true, `Chromium launched headless successfully (${basename(executablePath)}).`);
+} finally {
+  await browser.close().catch(() => {});
+}
diff --git a/docs/bug-report-verify-docker-exec.md b/docs/bug-report-verify-docker-exec.md
new file mode 100644
index 0000000..e6d6035
--- /dev/null
+++ b/docs/bug-report-verify-docker-exec.md
@@ -0,0 +1,82 @@
+# Bug report: verify dei connector MCP in container fallisce con `exec: "-e": executable file not found`
+
+**Progetto**: skald-circle (non il marketplace)
+**File**: `crates/skald-core/src/mcp/verify.rs`
+**Introdotto da**: commit `e6c4e20` — "feat(mcp): OAuth per-user connectors (§15) — providers, PKCE copy-paste flow, env credential delivery"
+**Priorità**: alta — blocca l'attivazione di tutti i connector `mcp_local` + `scope: user` che hanno env/secret nel verify (LinkedIn ora, Email alla prossima riattivazione)
+
+---
+
+## 1. Sintomo
+
+Attivando il connector `linkedin` (mcp_local, scope user) dal frontend, il verify fallisce con:
+
+```
+Failed — OCI runtime exec failed: exec failed: unable to start container process: exec: "-e": executable file not found in $PATH
+```
+
+## 2. Causa radice
+
+In `crates/skald-core/src/mcp/verify.rs` (~righe 144-152) il comando `docker exec` viene costruito con il **nome del container PRIMA delle opzioni `-e`**:
+
+```rust
+VerifyTarget::Container { container, workdir } => {
+    let mut c = tokio::process::Command::new("docker");
+    c.arg("exec")
+        .arg("-w").arg(workdir)
+        .arg(container);                               // ← container name
+    inject_env_flags(&mut c, env_values, secret_values); // ← -e KEY=VAL DOPO il container
+    c.arg("sh").arg("-c").arg(&resolved);
+    c
+}
+```
+
+`inject_env_flags` (righe 285-293) aggiunge `cmd.arg("-e").arg(format!("{k}={v}"))` per ogni env/secret.
+
+Risultato: viene generato
+
+```
+docker exec -w   -e KEY=VAL -e KEY2=VAL2 sh -c "python3 verify.py"
+```
+
+La sintassi di `docker exec` è `docker exec [OPTIONS] CONTAINER COMMAND [ARG...]`: **dopo** il nome del container ogni argomento è interpretato come COMMAND. Docker prova quindi a eseguire l'eseguibile `-e` → errore OCI.
+
+## 3. Connector colpiti
+
+Il bug scatta solo quando `env_values`/`secret_values` non sono vuoti, cioè quando il manifest del connector dichiara `env[]` (o secret) e il verify gira **in container**:
+
+| Connector | Container? | Verify con env? | Colpito |
+|---|---|---|---|
+| linkedin | ✅ user | ✅ 2 secret + 3 env | ❌ si — errore visibile |
+| email | ✅ user | ✅ 8 env | ❌ si — già attivo, fallirà alla prossima riattivazione |
+| gcal / drive / gmail | ✅ user | credenziali OAuth via altro path | probabilmente no |
+| gmaps, context7, tavily… | host / global | VerifyTarget::Host | no |
+
+Nota: il path di **lancio del server MCP** in `crates/mcp-client/src/server.rs` (~righe 261-273) costruisce lo stesso comando **nell'ordine corretto** (`-i`, poi `-e …`, poi container) — usarlo come riferimento.
+
+## 4. Fix suggerito
+
+Spostare `inject_env_flags` **prima** di `.arg(container)` in `verify.rs`:
+
+```rust
+VerifyTarget::Container { container, workdir } => {
+    let mut c = tokio::process::Command::new("docker");
+    c.arg("exec").arg("-w").arg(workdir);
+    inject_env_flags(&mut c, env_values, secret_values); // ← spostato qui
+    c.arg(container);
+    c.arg("sh").arg("-c").arg(&resolved);
+    c
+}
+```
+
+Il comando generato diventa:
+
+```
+docker exec -w  -e KEY=VAL -e KEY2=VAL2  sh -c "python3 verify.py"
+```
+
+## 5. Verifica consigliata
+
+1. **Test unitario**: aggiungere un test che costruisca il comando per `VerifyTarget::Container` e verifichi l'ordine degli argomenti (`docker exec -w  -e K=V  sh -c …`). I test esistenti in `verify.rs` coprono solo `apply_placeholders`.
+2. **E2E**: riattivare `linkedin` dal frontend (deve superare il verify con il cookie li_at).
+3. **Regressione**: riattivare `email` (8 env) per confermare che il bug era latente anche lì.