ssh: fix sudo failing with "password required" (v6 / 1.1.0)

The connector asked for a sudo password on every privileged call and
failed whenever nobody answered it, which is every unattended run.

- Always probe `sudo -n` first, even for aliases set to sudo="prompt".
  `_sudo_prefix` used to elicit unconditionally, so a host granting this
  user NOPASSWD still opened an Agent Inbox prompt; with no human there
  it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and
  surfaced as "sudo password required (user declined or timed out)".
  sudo refuses before running anything when it wants a password, so the
  probe is side-effect free.
- Strip a leading `sudo` from `command` and turn it into sudo=true.
  Agents write `exec(command="sudo systemctl restart x")`: with
  sudo=false that ran a tty-less sudo, with sudo=true it nested
  `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles
  -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone.
  sudo_user now implies sudo=true.
- Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and
  redirections are elevated too instead of only the first word.
- Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It
  is consulted only after `sudo -n` proved a password is needed, so on a
  NOPASSWD host it never lands in the command's own stdin.
- Actionable errors for every sudo failure mode, and a `hint` on a
  nested sudo we could not peel off.

General review of the same server:

- Drain stdout and stderr together and make timeout_sec a real
  wall-clock deadline. Both streams share one SSH channel window, so
  reading stdout to EOF first stalled once a chatty stderr filled it.
  Command stdin is now closed after the optional password.
- Queue messages that arrive while awaiting an elicitation reply instead
  of discarding them, so a concurrent tools/call is not lost.
- Record the client's `elicitation` capability at initialize and fail
  fast when it is absent rather than blocking on a prompt nobody can
  answer.
- Tolerate null/string integer arguments (depth, max_results,
  context_lines, timeout_sec).
- Realign the version across both manifests: fragment.json said 5/1.0.4
  while connector.json said 2/1.0.1, so the feed was permanently ahead
  of the installed version and offered an update forever.

Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the
single source of truth, docs/connector.manifest_guide.md retired to a
pointer, CLAUDE.md audited against the repo, compile.py docstring fixed,
and an opencode.json config.
This commit is contained in:
Daniele
2026-09-03 23:48:00 +01:00
parent 78c78d6039
commit d84ce13dab
10 changed files with 875 additions and 708 deletions
+31
View File
@@ -7,8 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- **ssh: `sudo` almost always failed with "sudo password required (user declined or timed out)" (v6 / 1.1.0).** Three independent causes, all fixed:
- **`sudo -n` is now always tried first**, even when the alias is configured `sudo: "prompt"`. Previously `prompt` went straight to `sudo -S` and *unconditionally* elicited a password — so a host where the login user has a `NOPASSWD:` rule still opened an Agent Inbox prompt, and any unattended run (scheduled agent, no human watching the Inbox) hit the client's 300 s elicitation deadline and got back `cancel`, i.e. the reported error. A password is now requested only after `sudo -n` has proved the host actually demands one; `sudo` never runs the command when it refuses for a missing password, so the probe is side-effect free.
- **A leading `sudo` in `command` is stripped and turned into `sudo=true`.** Agents routinely write `exec(command="sudo systemctl restart x")`. With `sudo=false` that ran a bare `sudo` on a tty-less channel ("a terminal is required"); with `sudo=true` it nested `sudo -S … sudo …`, where the *inner* sudo prompts on a tty it does not have. The prefix (with `-u USER`, `-n`, `-S`, `-E`, `-H`, `-i`, `-k`, `-p PROMPT`, `--` and friends) is now peeled off and expressed as `sudo=true` + `sudo_user`; an unrecognised flag leaves the command untouched rather than mangling it. `sudo_user` alone also implies `sudo=true`.
- **Under sudo the command now runs as `sh -c '<command>'`**, so pipes and redirections are privileged too — `sudo tee`/`sudo … > file` behaves as written instead of running the redirection as the login user.
- Errors are now actionable: sudo-disabled, `nopasswd`-but-password-required, password-rejected (the cached password is discarded so the next call re-prompts) and no-password-available each say what to change. A command that failed on a *nested* sudo we could not peel off (e.g. `cd /x && sudo …`) comes back with a `hint` telling the agent to use `sudo=true` instead.
### Added
- **ssh: `SSH_MCP_SUDO_PASSWORD`** — optional, secret, non-interactive sudo password for unattended runs where nobody can answer the Inbox prompt (mirrors the existing `SSH_MCP_KEY_PASSPHRASE`). Consulted only after `sudo -n` has shown the host requires a password, so on a NOPASSWD host it is never fed to sudo's stdin — where it would have landed in the command's own stdin instead.
### Changed ### Changed
- **ssh: version realigned to 6 / 1.1.0 in both manifests.** `fragment.json` carried `5` / `1.0.4` while `connector.json` carried `2` / `1.0.1`. Skald takes the installed version from the manifest and the feed version from the index, so the index was permanently 3 ahead: the connector advertised an update forever and re-installed to `2` every time.
- **ssh: stdout and stderr are drained together, and `timeout_sec` is a real wall-clock deadline.** Both streams share one SSH channel window, so reading stdout to EOF first stalled as soon as a chatty stderr filled that window (a >2 MB stderr deadlocked the call until the timeout). Command stdin is now always closed after the optional sudo password, so a remote command that reads stdin sees EOF instead of hanging.
- **ssh: messages arriving while the server waits for an elicitation reply are queued, not dropped.** A concurrent `tools/call` used to be discarded silently, leaving the client to time out on a request the server had thrown away.
- **ssh: the client's `elicitation` capability is recorded at `initialize`.** Without it the server no longer blocks on a prompt nobody can answer — it fails immediately and says so.
- **ssh: tool descriptions rewritten** to tell the agent explicitly not to put `sudo` in `command`, and to describe `prompt` as "tries `sudo -n` first, prompts only if the host demands it". Integer arguments (`depth`, `max_results`, `context_lines`, `timeout_sec`) now tolerate `null` and numeric strings instead of raising an internal error.
- **Docs: `CONNECTOR_MANIFEST_GUIDE.md` is now the single source of truth, rewritten against the client implementation.** `~/projects/skald-circle` dropped its copy and references this file instead, so the guide was re-verified line by line against `src/frontend/api/{marketplace,mcp}.rs` and `crates/skald-core/src/mcp/{mod,install,verify,oauth}.rs`. What was wrong and is now fixed:
- **It told the author to hand-write `connectors.json` with `sha256sum`** (§1a, §8) and never mentioned `fragment.json`, `index.json` or `scripts/compile.py` — i.e. it described a workflow this repo abandoned. The build pipeline is now §1 in full, and `connectors.json` is documented as generated.
- **`tools[]` was said to go in `fragment.json`.** The client parses no `tools` field on the index entry — a block placed only there is inert. It must be in `connector.json` (this is why `google-trends`' four display names never reached the UI).
- **`auth` was said to resolve "the same way whether it appears in the index entry or the manifest".** The index's `auth` is never parsed; only the manifest's is, with `requires` as the sole coarse fallback.
- **`auth.type: "password"` was undocumented** while `email` ships it — the client recognizes only `none`/`api_key`/`oauth2`/`qr`/`ssh_key` and silently normalizes everything else to `none`.
- **Placeholder substitution was overstated.** It happens in exactly two places (`mcp_config.url` and `verify.command`) with *different* miss behaviour (literal token + api_key fallback vs empty string), and never in `mcp_config.env` — so an `env[].name` must be the real environment variable name.
- **`verify` was described as an `api_key`/`none` feature with a 15 s default.** Any auth type may use it; the script must be a shipped file whose basename appears in the command; the runtime applies a fixed 20 s and ignores `timeout_secs`.
- **`deliver: {as: "file"}` was labelled "legacy"** — it is rejected at activation, unimplemented.
- Added: the "who reads what" authority table (index vs manifest), the flat-folder constraint (`compile.py` does not recurse, so a subdirectory's files are silently unshipped), the version-desync trap (manifest wins, index-lower kills updates forever), exact dependency-install commands and paths, host assets not copied into containers, and the client's hard limits (8 MiB/file, path-safety rejects, no-`files[]` refusal). Also fixed an unbalanced code fence that swallowed the end of the file.
- **Docs: `docs/connector.manifest_guide.md` retired** to a pointer at the root guide — the duplicate copy is what allowed the drift.
- **Docs: `CLAUDE.md` audited against the repo.** Added § The consuming project (the client is the separate Rust repo at `~/projects/skald-circle`, with a map of the files that implement each part of the format) and corrected the same inaccuracies listed above where they also appeared here: placeholder scope and miss behaviour, verify workdir (`./connectors/<id>/` on the host, not `./scripts/<id>/`), verify timeout, `tools[]` location, `deliver: file`, `auth.type: "password"`, plus new invariants for version desync and flat folders.
- **`scripts/compile.py`: docstring corrected** — it claimed `connector.json` was auto-excluded from `files[]`, the opposite of what `EXCLUDE_FILES` does.
- **Docs: `SKALD.md` merged into `CLAUDE.md` and removed.** The two files had drifted apart — `CLAUDE.md` still described a hand-maintained `connectors.json` (pre-`compile.py`), an rsync deploy, 5 connectors, and a `files[]` that excluded `connector.json`. `CLAUDE.md` is now the single authoritative spec, carrying every `SKALD.md` section (full schemas for `connectors.json` / `fragment.json` / `connector.json`, reserved enums, `auth`/`deliver`/`env`/`verify` fields, placeholder syntax, icon conventions, file integrity, local workflow, deploy) corrected against the actual repo state: 18 connectors, the `compile.py` pipeline, and `connector.json` included in the hashed `files[]`. `CLAUDE.md` now also names `CONNECTOR_MANIFEST_GUIDE.md` (repo root) as the authoritative connector-authoring spec. - **Docs: `SKALD.md` merged into `CLAUDE.md` and removed.** The two files had drifted apart — `CLAUDE.md` still described a hand-maintained `connectors.json` (pre-`compile.py`), an rsync deploy, 5 connectors, and a `files[]` that excluded `connector.json`. `CLAUDE.md` is now the single authoritative spec, carrying every `SKALD.md` section (full schemas for `connectors.json` / `fragment.json` / `connector.json`, reserved enums, `auth`/`deliver`/`env`/`verify` fields, placeholder syntax, icon conventions, file integrity, local workflow, deploy) corrected against the actual repo state: 18 connectors, the `compile.py` pipeline, and `connector.json` included in the hashed `files[]`. `CLAUDE.md` now also names `CONNECTOR_MANIFEST_GUIDE.md` (repo root) as the authoritative connector-authoring spec.
## 2026-08-24 ## 2026-08-24
+86 -37
View File
@@ -21,21 +21,39 @@ The only tooling is `scripts/compile.py`, which regenerates the index.
`main` is the **release** branch — only production-ready code lands here. Development/alpha versions will `main` is the **release** branch — only production-ready code lands here. Development/alpha versions will
live on separate branches in the future. live on separate branches in the future.
### The consuming project
The application that consumes this marketplace — the code that installs, verifies, and runs these
connectors — lives in a **separate repository at `~/projects/skald-circle`** (Rust workspace: `crates/`,
`Cargo.toml`, `blueprint/`, `agents/`). It is the client for everything documented here: it fetches
`connectors.json`, checks each file against its pinned SHA-256, resolves `auth` / `env` / `verify`, and
launches the MCP servers. The parts worth reading when a manifest question comes up:
| Question | File in `~/projects/skald-circle` |
|----------|-----------------------------------|
| How the feed is parsed, hashed, installed | `src/frontend/api/marketplace.rs` |
| Activation, the env form, OAuth/QR handoff | `src/frontend/api/mcp.rs` |
| Server spec, URL placeholders, transports | `crates/skald-core/src/mcp/mod.rs` |
| Folder layout, container copy, deps install | `crates/skald-core/src/mcp/install.rs` |
| Verify runner + `{ENV:}`/`{SECRET:}` engine | `crates/skald-core/src/mcp/verify.rs` |
It **no longer keeps a copy** of the spec: it references
[CONNECTOR_MANIFEST_GUIDE.md](CONNECTOR_MANIFEST_GUIDE.md) in this repo, which is the single source of
truth for the connector format. Edit the format here and nowhere else.
### The spec ### The spec
The marketplace spec ships **inside this project**: [CONNECTOR_MANIFEST_GUIDE.md](CONNECTOR_MANIFEST_GUIDE.md) The marketplace spec ships **inside this project**: [CONNECTOR_MANIFEST_GUIDE.md](CONNECTOR_MANIFEST_GUIDE.md)
at the repo root is the authoritative, step-by-step specification for producing a correct connector — at the repo root is the authoritative, step-by-step specification for producing a correct connector —
the two documents, the MCP-over-stdio server contract, friendly tool names, placement & risk vocabulary, the folder layout, the three source documents + the compiler, which document the client reads each field
the `auth.type` variants (`api_key` / `oauth2` / `qr`), dependency installation, verify-before-save, from, the MCP-over-stdio server contract, friendly tool names, placement & risk vocabulary, the
versioning, and the new-connector checklist. Give this file to any agent that generates new connectors, `auth.type` variants, the `env[]` form and placeholder engines, dependency installation,
and update it whenever the connector format changes. verify-before-save, versioning, the limits the client enforces, and the new-connector checklist. Give
this file to any agent that generates new connectors, and update it whenever the connector format
changes — it is the **only** copy, referenced by `~/projects/skald-circle` rather than duplicated there.
Consumers outside this repo (e.g. `~/projects/skald-circle`) read that same file as the format reference. [docs/connector.manifest_guide.md](docs/connector.manifest_guide.md) is a retired copy, now a pointer to
the root guide. Do not edit it.
[docs/connector.manifest_guide.md](docs/connector.manifest_guide.md) is an earlier copy of the same guide;
it is the only one that documents the `fragment.json` + `scripts/compile.py` build pipeline, which
`CONNECTOR_MANIFEST_GUIDE.md` does not yet cover. Until the root guide absorbs that section, the compile
workflow is described below in § Architecture and § Local workflow.
## Architecture ## Architecture
@@ -157,7 +175,7 @@ connectors/
| `version` | ✅ | Per-connector integer, +1 on every file change | | `version` | ✅ | Per-connector integer, +1 on every file change |
| `version_string` | ✅ | Semver (display only) | | `version_string` | ✅ | Semver (display only) |
| `version_release_date` | ✅ | ISO 8601 date (display only) | | `version_release_date` | ✅ | ISO 8601 date (display only) |
| `tools` | | Array of `{name, display_name}` for friendly UI names | | `tools` | optional | Array of `{name, display_name}` for friendly UI names. **Documentary here** — Skald reads `tools[]` from `connector.json` (see § Friendly tool names) |
| `auth` | optional | Authentication configuration (if other than `"none"`) | | `auth` | optional | Authentication configuration (if other than `"none"`) |
The `files[]` array is added **automatically** by `compile.py` from the files present in the folder — it The `files[]` array is added **automatically** by `compile.py` from the files present in the folder — it
@@ -294,11 +312,15 @@ Structure describing how the connector handles authentication:
{"type": "oauth2", "provider": "google", "scopes": ["…"], {"type": "oauth2", "provider": "google", "scopes": ["…"],
"deliver": {"as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON"}} "deliver": {"as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON"}}
// OAuth2 with file-based deliver (legacy) // OAuth2 with file-based deliver — ❌ NOT IMPLEMENTED: Skald rejects `as: "file"` at
// activation with an explicit error. Never ship it.
{"type": "oauth2", "provider": "google", "scopes": ["…"], {"type": "oauth2", "provider": "google", "scopes": ["…"],
"deliver": {"as": "file", "format": "google_authorized_user", "path": "{secrets}/gmail_creds.json"}} "deliver": {"as": "file", "format": "google_authorized_user", "path": "{secrets}/gmail_creds.json"}}
// Password / app-password provided via environment variables // Password / app-password provided via environment variables — ⚠️ `password` is NOT one of
// the values Skald recognizes (`none`/`api_key`/`oauth2`/`qr`/`ssh_key`); it normalizes to
// `none`. `email` works only because its credentials travel through `env[]`. Prefer
// `api_key` or `none` for new connectors.
{"type": "password", "delivery": "env"} {"type": "password", "delivery": "env"}
// QR-code pairing at runtime (WhatsApp) // QR-code pairing at runtime (WhatsApp)
@@ -314,9 +336,9 @@ Declares **how** Skald delivers the obtained OAuth credential to the MCP server
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `as` | ✅ | `"file"` (on disk) or `"env"` (environment variable) | | `as` | ✅ | `"env"` (environment variable). `"file"` parses but is **rejected at activation** — unimplemented |
| `format` | ✅ | Name of the serialization — e.g. `"google_authorized_user"` (Google JSON that `from_authorized_user_file` reads), `"refresh_token"`, `"access_token"` | | `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`. | | `path` | `as=file` only | Unused while file delivery is unimplemented |
| `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. | | `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. |
The feed NEVER contains `client_id`, `client_secret`, the endpoint URL, or `redirect_uri`. These are The feed NEVER contains `client_id`, `client_secret`, the endpoint URL, or `redirect_uri`. These are
@@ -364,8 +386,7 @@ The `email` connector is the reference example. `tavily`, `gmaps`, and `linkedin
## Placeholder syntax (unified) ## Placeholder syntax (unified)
Every value skald must fill at runtime with user-provided data uses **one of two tokens**, wherever it Every value skald must fill at runtime with user-provided data uses **one of two tokens**:
appears (URL, `mcp_config.env`, `verify.command`):
| Token | Meaning | Example | | Token | Meaning | Example |
|-------|---------|---------| |-------|---------|---------|
@@ -373,17 +394,27 @@ appears (URL, `mcp_config.env`, `verify.command`):
| `{SECRET:NAME}` | Sensitive variable (password, API key, token) | `{SECRET:EMAIL_PASSWORD}` | | `{SECRET:NAME}` | Sensitive variable (password, API key, token) | `{SECRET:EMAIL_PASSWORD}` |
`NAME` is the `name` field declared in the `env[]` array. skald collects the values via a form (masking `NAME` is the `name` field declared in the `env[]` array. skald collects the values via a form (masking
`{SECRET:}` fields), injects them as environment into the MCP server / verify process, and substitutes `{SECRET:}` fields) and injects them as environment into the MCP server / verify process.
the tokens in the manifest.
**Substitution happens in exactly two places** — there is no general template engine:
| Where | Engine | Unknown `NAME` resolves to |
|-------|--------|----------------------------|
| `mcp_config.url` (remote connectors) | `skald-core/src/mcp/mod.rs` | `{SECRET:x}` falls back to the connector's api_key; otherwise the token is **left in the URL literally** |
| `verify.command` | `skald-core/src/mcp/verify.rs` | the **empty string** |
⚠️ **`mcp_config.env` is NOT substituted.** What reaches a local server's environment is the form's
`env[]` values keyed by their `name`, verbatim — so `env[].name` must be exactly the variable name the
script reads. The `mcp_config.env` map survives only as a legacy fallback for the form schema (bare key
names); if it is ever the sole env source its `{ENV:…}` strings are injected literally. `email`, `ssh`
and `linkedin` still carry such a map — harmless today, but do not rely on it to rename a variable.
Rules: Rules:
- Unrecognized tokens (`{secrets}/…`, legacy `{key}`, `{env:NAME}`) are **deprecated**: skald does not - Unrecognized tokens (`{secrets}/…`, `{env:NAME}`) are **deprecated**: skald does not substitute them
substitute them and the manifest must be updated. and the manifest must be updated. Legacy `{key}` still resolves to the api_key in a URL.
- `{SECRET:<auth.param>}` is reserved for the primary key when `auth.type = "api_key"` (e.g. Tavily: - `{SECRET:<auth.param>}` is reserved for the primary key when `auth.type = "api_key"` (e.g. Tavily:
`?tavilyApiKey={SECRET:tavilyApiKey}`). skald also treats that value as the API key for bearer/header `?tavilyApiKey={SECRET:tavilyApiKey}`). skald also treats that value as the API key for bearer/header
routing. routing, and stops sending it as a bearer header once it has been spent on the URL.
- 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 ### Deprecations
@@ -408,7 +439,7 @@ the activation, to confirm the credentials just entered actually work.
| Field | Required | Description | | 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 | | `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 | | `timeout_secs` | optional | Declared for the record, but **not yet plumbed through**: the runtime applies a fixed 20 s to every verify (`VERIFY_TIMEOUT_SECS` in `src/frontend/api/mcp.rs`). Keep the probe well under that |
### Output convention ### Output convention
@@ -431,10 +462,12 @@ fails). **Never print credentials** in `message`/`details`.
### Where to put the script ### Where to put the script
If `command` references a file (e.g. `verify.py`), the file must be saved in the connector folder If `command` references a file (e.g. `verify.py`), the file must be saved in the connector folder
(`<id>/verify.py`); `compile.py` then picks it up into `files[]` with its SHA-256 and size. skald (`<id>/verify.py`); `compile.py` then picks it up into `files[]` with its SHA-256 and size. skald finds
downloads it, verifies the hash against the index, and makes it available at the same path as the main the script by matching a **basename from `files[]`** against the command string, downloads it, verifies
server (container for `mcp_local`, `./scripts/<id>/` on the host for `mcp_remote`). A `verify` command the hash against the index, and runs it in the connector's own directory — inside the container
may also be fully inline (see `firecrawl`, which uses a `node -e "…"` one-liner). `skald-{userid}` (`~/.skald/mcp/<name>/`) for a per-user connector, on the host in `./connectors/<id>/`
for a global one. A `verify` command may also be fully inline (see `firecrawl`, which uses a
`node -e "…"` one-liner) — with no basename match, nothing extra is fetched.
### Without `verify` ### Without `verify`
@@ -448,15 +481,20 @@ Every MCP tool must expose a friendly name for the Skald UI. Two ways, in order
1. **Via the MCP script (preferred)** — add `"title": "Friendly Name"` to each tool definition returned by 1. **Via the MCP script (preferred)** — add `"title": "Friendly Name"` to each tool definition returned by
`tools/list`. Works for all local scripts (Python/Node) that we control. `tools/list`. Works for all local scripts (Python/Node) that we control.
2. **Via the manifest (fallback)** — add `"tools": [{"name": "…", "display_name": "…"}]` in 2. **Via the manifest (fallback)** — add `"tools": [{"name": "…", "display_name": "…"}]` to
`fragment.json` **and** in `connector.json`. Used only for remote connectors or external packages **`connector.json`**. Used only for remote connectors or external packages (e.g.
(e.g. `npx -y firecrawl-mcp`). `npx -y firecrawl-mcp`).
**Resolution order** used by Skald: **Resolution order** used by Skald:
1. `tools[].display_name` from the manifest 1. `tools[].display_name` from the manifest
2. `title` from the MCP server's `tools/list` 2. `title` from the MCP server's `tools/list`
3. Automatic prettify of the raw name (`send_message` → "Send Message") 3. Automatic prettify of the raw name (`send_message` → "Send Message")
⚠️ **Skald reads `tools[]` from `connector.json` only** — the index entry carries no `tools` field in
the client's parser, so a block that lives only in `fragment.json` is inert (that is the state of
`google-trends` today, which is harmless because its script also sets `title`). Mirroring the block into
`fragment.json` is optional and purely documentary.
As of 2026-07-21 all marketplace connectors carry `title` in the script or `tools[]` in the manifest. As of 2026-07-21 all marketplace connectors carry `title` in the script or `tools[]` in the manifest.
## MCP server conventions ## MCP server conventions
@@ -486,9 +524,14 @@ Connector-specific notes:
- **Tavily** — `mcp_remote`/`global`; API key declared as an `env[]` secret and templated into the URL as - **Tavily** — `mcp_remote`/`global`; API key declared as an `env[]` secret and templated into the URL as
`{SECRET:tavilyApiKey}`. `{SECRET:tavilyApiKey}`.
- **SSH** — `mcp_local`/`user`; stores aliases in `~/.ssh_aliases.json` (auto-managed, 0600). Auth - **SSH** — `mcp_local`/`user`; stores aliases in `~/.ssh_aliases.json` (auto-managed, 0600). Auth
per-alias: key/agent (default) or elicited password. Sudo via `nopasswd` or elicited password per-alias: key/agent (default) or elicited password. Sudo always probes `sudo -n` first (free on a
(`sudo -S`). No setup-time credentials: `auth.type: "none"` with no `verify`. All `SSH_MCP_*` env vars NOPASSWD host, and side-effect free elsewhere since sudo refuses before running the command), then
are optional with defaults. falls back to `sudo -S` with a password from `SSH_MCP_SUDO_PASSWORD` or MCP elicitation; a leading
`sudo` inside `command` is stripped and turned into `sudo=true`, and under sudo the command runs as
`sh -c '…'` so pipes/redirections are privileged too. Elicitation needs a human in the Agent Inbox
within 300 s (`ELICITATION_DEADLINE` in `skald-core/src/elicitation/mod.rs`) — unattended runs must
rely on NOPASSWD or `SSH_MCP_SUDO_PASSWORD`. No setup-time credentials: `auth.type: "none"` with no
`verify`. All `SSH_MCP_*` env vars are optional with defaults.
## Current connectors ## Current connectors
@@ -539,9 +582,15 @@ A connector without `verify` is activated without any test — see § Without ve
- **Never edit `connectors.json` by hand.** It is generated. Edit `fragment.json` / `index.json` / - **Never edit `connectors.json` by hand.** It is generated. Edit `fragment.json` / `index.json` /
the connector files, then run `python3 scripts/compile.py`. the connector files, then run `python3 scripts/compile.py`.
- **Bump `version` (+1) and `version_string` on every file change** to a connector, in both - **Bump `version` (+1) and `version_string` on every file change** to a connector, in both
`fragment.json` and `connector.json`, and keep `version_release_date` current. `fragment.json` and `connector.json`, and keep `version_release_date` current. When the two disagree
**the manifest wins** and Skald only logs a `marketplace feed version desync` warning — and if the
index carries the *lower* number, the strict `feed > installed` comparison can never fire again and
the connector silently stops offering updates. `compile.py` does not check this.
- **Keep `id`, `name`, `type`, `scope`, `tags`, `requires`, and `auth` consistent** between - **Keep `id`, `name`, `type`, `scope`, `tags`, `requires`, and `auth` consistent** between
`fragment.json` and `connector.json`. `fragment.json` and `connector.json`. Only the manifest's `auth` is parsed by Skald; the index's is
documentary, with `requires` as the sole coarse fallback (`OAUTH` → oauth, `API_KEY` → api_key).
- **Keep the connector folder flat.** `compile.py` scans only the folder's top level, so any file in a
subdirectory is silently absent from `files[]` — never downloaded, never installed.
- **Adding a `verify` script means shipping the file in the connector folder** and recompiling — skald - **Adding a `verify` script means shipping the file in the connector folder** and recompiling — skald
refuses to run a script whose SHA-256 is not pinned in the index. refuses to run a script whose SHA-256 is not pinned in the index.
- **Always run `python3 scripts/compile.py` before deploying**; a stale hash breaks integrity - **Always run `python3 scripts/compile.py` before deploying**; a stale hash breaks integrity
+398 -202
View File
@@ -1,156 +1,253 @@
# Skald Connector Authoring Guide # Skald Connector Authoring Guide
Instructions for generating a **correct connector** for the Skald marketplace **This file is the single source of truth for the connector format.** It lives in the
(`https://connectors.skaldagent.net`). Give this file to the agent that produces marketplace repo (`connectors.skaldagent.net`) and is referenced — not copied — by the
new connectors. Skald application repo (`~/projects/skald-circle`). Change the format here, and here only.
A connector is a folder served by the marketplace. Skald installs it, verifies Give this file to any agent that produces new connectors.
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 A connector is a folder served by the marketplace. Skald installs it, verifies every file
(per-user connector, blueprint §6/§7). 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).
Everything below marked **"the client enforces"** was checked against the implementation in
`skald-circle` (`src/frontend/api/marketplace.rs`, `src/frontend/api/mcp.rs`,
`crates/skald-core/src/mcp/{mod,install,verify,oauth}.rs`).
--- ---
## 1. The two documents ## 0. Folder layout
### 1a. The root index — `connectors.json` One folder per connector, named exactly like its `id`, **flat**:
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 connectors/myconn/
SHA-256 digests.** Skald refuses any file whose bytes do not match. ├── fragment.json ← index entry (compiler input, never served as-is)
├── connector.json ← the manifest Skald reads
├── server.py ← the entry file (or index.js…)
├── requirements.txt ← or package.json
├── verify.py ← optional
├── icon_sm.svg
└── icon_lg.svg
```
**Keep the folder flat.** The client accepts a relative sub-path (`pkg/server.py`) but
`scripts/compile.py` only scans the folder's **top level**: files in a subdirectory are
silently left out of `files[]`, never downloaded, and the connector breaks at runtime with
no error anywhere. If a tree is genuinely needed, teach the compiler to recurse first.
---
## 1. The three source documents (+ one compiler)
You maintain three files by hand. A fourth, `connectors.json`, is **generated**.
### 1a. `connectors/index.json` — the order
A flat JSON array of folder ids, in display order:
```json
["gmail", "gcal", "myconn"]
```
This is the compiler's input list. A connector missing from it does not exist.
### 1b. `connectors/<id>/fragment.json` — the index entry
The connector's entry in the compiled index, **with every field except `files[]`** (the
compiler adds that one).
```jsonc ```jsonc
{ {
"version": 1, "id": "whatsapp", // unique slug = folder name
"connectors": [ "name": "WhatsApp",
{ "version": 8, // INTEGER build number — the update key (§7)
"id": "whatsapp", // unique slug = folder name "version_string": "2.2.0", // semver, display only
"name": "WhatsApp", "version_release_date": "2026-08-23", // ISO date, display only
"version": 1, // INTEGER build number — the update key7) "type": "mcp_local", // mcp_local | mcp_remote 3)
"version_string": "2.0.1", // semver, display only "scope": "user", // user | global (§3)
"version_release_date": "2026-07-19", // ISO date, display only "icon_small": "whatsapp/icon_sm.png", // relative to the FEED ROOT, not the folder
"type": "mcp_local", // mcp_local | mcp_remote (see §3) "icon_large": "whatsapp/icon_lg.png",
"scope": "user", // user | global (see §3) "user_description": "Send and read WhatsApp messages from your linked account.",
"icon_small": "whatsapp/icon_sm.svg", "requires": ["NODE"], // API_KEY | ENV | NODE | PYTHON | OAUTH | DOCKER
"icon_large": "whatsapp/icon_lg.svg", "tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
"user_description": "Send and read WhatsApp messages from your linked account.", "folder": "whatsapp", // defaults to id
"requires": ["NODE"], // human hint: NODE | PYTHON | OAUTH | API_KEY "auth": { "type": "qr" }, // informational here — see the table in §1e
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"], "tools": [ ] // informational here — see §2a
"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** Note the **two icon vocabularies**: `icon_small` / `icon_large` in the index are relative to
the feed root (`whatsapp/icon_sm.png`), while `files[]` and `connector.json` name them from
inside the folder (`icon_sm.png`). The client reconciles the two, but only records an icon
whose file was actually installed — so **icons must be shipped in the folder**, never hot-
linked.
- `files[].path` is relative to the connector folder. List **every** file the ### 1c. `connectors/<id>/connector.json` — the manifest
connector ships (server code, `package.json`/`requirements.txt`, icons, and the
`connector.json` itself). A missing or mismatched digest fails the install.
- Compute `sha256` over the exact bytes served: `sha256sum <file>`.
- Do **not** list `node_modules/` or any generated deps — those are installed on
the box, not shipped (see §5).
- `size` is optional but recommended.
### 1b. The per-connector manifest — `<folder>/connector.json` The richer document, fetched per connector and mapped into Skald's catalog.
The richer document. Fetched per connector and mapped into Skald's catalog.
```jsonc ```jsonc
{ {
"id": "whatsapp", "id": "whatsapp",
"name": "WhatsApp", "name": "WhatsApp",
"version": 1, // INTEGER build number — the update key (§7) "version": 8, // must match fragment.json (§7)
"version_string": "2.0.1", // semver, display only "version_string": "2.2.0",
"version_release_date": "2026-07-19", // ISO date, display only "version_release_date": "2026-08-23",
"type": "mcp_local", "type": "mcp_local",
"scope": "user", "scope": "user",
"auth": { "type": "qr" }, // none | api_key | oauth2 | qr (see §4) "requires": ["NODE"],
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
"auth": { "type": "qr" }, // none | api_key | oauth2 | qr | ssh_key (§4)
"launch_command": "node index.js", // human-readable; mcp_config is what runs
"transport": "stdio", // stdio | streamable-http (may also sit in mcp_config)
"mcp_config": { "mcp_config": {
"command": "node", // interpreter (local) … "command": "node", // interpreter (local) …
"args": ["index.js"], // … args[0] MUST name the entry file "args": ["index.js"] // … args[0] MUST name the entry file
"transport": "stdio" // stdio (local) | streamable-http (remote)
}, },
"dependencies": ["@whiskeysockets/baileys@7.0.0-rc.14"], // display only (§5)
"setup_instructions": ["Scan the QR code with WhatsApp → Linked devices"],
"docs": [{ "docs": [{
"lang": "en", "lang": "en",
"description": "Human blurb shown in the UI.", "description": "Human blurb shown in the UI.",
"llm_short_description": "One line the model reads to decide whether to use this connector." "llm_short_description": "One line the model reads to decide whether to use this connector."
}], }],
"env": [], // form fields the user fills (see §4b) "env": [], // form fields the user fills (§4b)
"tools": [ // OPTIONAL — friendly UI names per tool (§2a) "verify": { "command": "python3 verify.py", "timeout_secs": 20 }, // optional (§6)
{ "name": "send_message", "display_name": "Send Message" } "tools": [{ "name": "send_message", "display_name": "Send Message" }], // optional (§2a)
], "homepage": "https://web.whatsapp.com",
"homepage": "https://…", "icon_small": "icon_sm.png", // relative to the FOLDER here
"icon_small": "icon_sm.svg", // relative to the folder here "icon_large": "icon_lg.png"
"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 **`mcp_config.args[0]` is load-bearing for a local connector:** it is how Skald learns which
learns which file to run. At activation Skald rewrites it to the file's path file to run — an install fails outright without it. At activation Skald rewrites it to the
inside the user's container (`/root/.skald/mcp/<name>/<entry>`), so keep it a file's path inside the user's container (`/root/.skald/mcp/<name>/<entry>`), so keep it a
plain relative filename (`index.js`, `server.py`, `pkg/server.py`). plain relative filename (`index.js`, `server.py`).
**`transport` may sit either at the top level or inside `mcp_config`** — the client reads
`mcp_config.transport` first, then the top-level one, then infers (`remote``http`,
otherwise `stdio`). Both spellings are in use in this repo; pick one per connector and keep
it consistent between the two files. `streamable-http` and `http` both normalise to HTTP;
anything unrecognised silently becomes stdio, which for a remote connector means trying to
spawn a command that does not exist.
**`llm_short_description` is model-facing:** it is the one-liner injected into the LLM's
system prompt so the model knows what this connector does. Keep it short and functional
("Weather — current conditions, 16-day forecast, and AQI data for any location"), **not** a
list of tools (the model discovers those after `activate_tools`).
### 1d. `connectors/connectors.json` — the compiled index (generated)
**Never edit this file by hand.** `scripts/compile.py` reads `index.json`, loads each
`fragment.json`, scans the folder's real files, computes their SHA-256 + size, and writes the
result:
```bash
python3 scripts/compile.py # regenerate
python3 scripts/compile.py --verify # fail if the committed index is stale
```
The compiler excludes `fragment.json`, `connectors.json`, `index.json`, `compile.sh`,
`compile.py`, `update_hashes.py`, `.DS_Store` and every subdirectory. **Everything else is
hashed, including `connector.json`.**
```jsonc
"files": [
{ "path": "index.js", "sha256": "…", "size": 21258 },
{ "path": "package.json", "sha256": "…", "size": 302 },
{ "path": "connector.json", "sha256": "…", "size": 620 },
{ "path": "icon_sm.png", "sha256": "…", "size": 306 }
]
```
- `files[].path` is relative to the connector folder.
- Digests and sizes are computed by the compiler. **Never write them by hand.**
- The index is the **signable root**: it is the one document that names a connector's files
and their digests. The client refuses any file whose bytes do not match, all-or-nothing —
a mismatch leaves nothing on disk.
- Do **not** ship `node_modules/` or vendored wheels (§5).
### 1e. Who reads what — the authority table
The client hydrates a card from **two** documents, and they are not interchangeable. Getting
this wrong is the most common way a correct-looking connector misbehaves.
| Field | Read from | Notes |
| --- | --- | --- |
| `id`, `folder`, `tags` | **index** (`fragment.json`) | `folder` defaults to `id` |
| `name` | index, falling back to manifest | |
| `user_description` | **index**, falling back to `docs[0].description` | the human blurb |
| `icon_small`, `icon_large` | **index** | feed-root-relative; the file must be in `files[]` |
| `files[]` | **index** (manifest's is a legacy fallback) | the trust root |
| `requires` | manifest, falling back to index | |
| `type`, `scope` | manifest, falling back to index | keep them identical |
| `version` trio | **manifest wins**, index is the fallback | a mismatch is logged as a desync warning (§7) |
| `auth` | **manifest only** | the index's `auth` is *never parsed*; only `requires` is used as a coarse fallback |
| `tools[]` | **manifest only** | a `tools[]` that lives only in `fragment.json` does nothing (§2a) |
| `env[]`, `verify`, `mcp_config`, `docs`, `dependencies`, `setup_instructions`, `homepage` | **manifest only** | |
So: `auth` and `tools[]` in `fragment.json` are documentation for the catalog page and for
whoever reads the index — harmless, worth keeping in sync, but **the manifest is what runs**.
--- ---
## 2. Server contract (MCP over stdio) ## 2. Server contract (MCP over stdio)
A **local** connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It A **local** connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It MUST handle:
MUST handle:
- `initialize``{ protocolVersion, capabilities: { tools: {} }, serverInfo }` - `initialize``{ protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo }`
- `notifications/initialized` → no response - `notifications/initialized`**no response**
- `tools/list``{ tools: [ { name, description, inputSchema } ] }` - `ping``{}`
- `tools/list``{ "tools": [ { name, description, inputSchema, title? } ] }` — an
**object**, not a bare array
- `tools/call``{ content: [ { type: "text", text } ], isError? }` - `tools/call``{ content: [ { type: "text", text } ], isError? }`
**stdout is reserved for JSON-RPC only.** Send all logs/diagnostics to **stderr**. **Any message without an `id` is a notification and must produce no output at all.**
Anything a library prints to stdout (a logger, a banner) corrupts the protocol — Answering one desynchronises a strict client.
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` + **stdout is reserved for JSON-RPC only.** Send all logs/diagnostics to **stderr**. Anything a
`transport: "streamable-http"`); no code runs on the box. library prints to stdout (a logger, a banner) corrupts the protocol — silence it (Baileys/pino
→ a silent logger; Python → `print(…, file=sys.stderr)`). Guard stdout writes with a lock if
the server has background threads.
Report tool failures as a normal result carrying `isError: true` with a readable message —
not as a JSON-RPC error, and not as a "successful" result with an `{"status":"error"}` body
the model cannot distinguish from data.
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 ### 2a. Friendly tool names (`tools[]`) — optional
Raw MCP tool names are ugly in the chat UI (`search_files`, `send_message`). The Raw MCP tool names are ugly in the chat UI (`search_files`, `send_message`). Two ways to fix
optional top-level `tools[]` block gives each one a human title shown as the tool that, in order of preference:
card's heading:
1. **`title` in your `tools/list` entries** (preferred for servers we control) — Skald uses it
automatically, no manifest change needed.
2. **The manifest's `tools[]` block** — for remote connectors and third-party packages
(`npx -y firecrawl-mcp`) whose `tools/list` we cannot edit:
```jsonc ```jsonc
"tools": [ "tools": [
{ "name": "send_message", "display_name": "Send Message" }, { "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`. - `name` — the **raw** tool name exactly as the server returns it from `tools/list`.
- `display_name` — the friendly card title (English only; not internationalized). - `display_name` — the friendly card title (English only; not internationalized).
**Resolution order** for a tool's card title is **`tools[].display_name` → the MCP **Resolution order:** `tools[].display_name` → the MCP `title` field → a prettified raw name
`title` field → a prettified raw name**. So you have two ways to set a friendly (`send_message` → "Send Message").
name, and can skip `tools[]` entirely:
1. **This block** — the authoritative override, curated in the manifest. ⚠️ **`tools[]` is read from `connector.json`.** The client parses no `tools` field on the
2. **The MCP `title` field** — if your `tools/list` entries already carry a index entry, so a block placed only in `fragment.json` is inert. Put it in the manifest;
`title` (MCP 2025-06-18+), Skald uses it automatically; no manifest change mirroring it into `fragment.json` is optional and purely documentary.
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 **Icons are per connector, not per tool.** Every tool shows its connector's `icon_small`;
connector's own `icon_small`; there is no per-tool icon field. Only list a tool in there is no per-tool icon. Partial `tools[]` lists are fine — unlisted tools fall through.
`tools[]` when its prettified name isn't good enough — partial lists are fine
(unlisted tools fall through to steps 23).
--- ---
@@ -158,44 +255,87 @@ connector's own `icon_small`; there is no per-tool icon field. Only list a tool
| Manifest | Meaning | | Manifest | Meaning |
| --- | --- | | --- | --- |
| `scope: "user"` | runs **once per user**, inside their container. Personal creds. | | `scope: "user"` | runs **once per user**, inside their container. Personal creds. Stored as `per_user`. |
| `scope: "global"` | runs **once for the household**, on the host. Shared, stateless. Admin enables it with a key. | | `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_local"` | ships code that will **execute on the box** — installing needs the admin `mcp.register_local_script` capability (RCE-bearing act, §14). Stored as `local_script`. |
| `type: "mcp_remote"` | just an HTTP URL; no local code. | | `type: "mcp_remote"` | just an HTTP URL; no local code. Stored as `remote`. |
Pick the narrowest: a personal messaging/email/calendar connector is Pick the narrowest: a personal messaging/email/calendar connector is `scope: "user"`; a shared
`scope: "user"`; a shared search API is `scope: "global"`. search API is `scope: "global"`. Both axes **fail closed** — an unreadable `scope` becomes
`per_user`, an unreadable `type` becomes `local_script`, the answer that demands *more*
authority.
`requires[]` is a human hint rendered as tags on the catalog page: `API_KEY`, `ENV`, `NODE`,
`PYTHON`, `OAUTH`, `DOCKER`. The client only inspects it as a fallback when the manifest
declares no `auth` (`OAUTH` → oauth, `API_KEY` → api_key). `SECRETS_DIR` is **removed** — the
`secrets/` folder is no longer part of the model.
--- ---
## 4. Authentication (`auth.type`) ## 4. Authentication (`auth.type`)
### 4a. The recognized values
| `auth.type` | Flow | Ships | | `auth.type` | Flow | Ships |
| --- | --- | --- | | --- | --- | --- |
| `none` | nothing to sign in | — | | `none` | nothing to sign in | — |
| `api_key` | user pastes a key/secret into a form | an `env[]` schema (§4b) | | `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) | | `oauth2` | browser consent → token injected as an env var | `provider` + `scopes` + `deliver` (§4c) |
| `qr` | server shows a QR, user scans with a phone | a `login_status` tool (§4d) | | `qr` | server shows a QR, user scans with a phone | a `login_status` tool (§4d) |
| `ssh_key` | reserved for key-based remote access | — |
### 4b. `api_key` — the `env[]` schema **Anything else normalises to `none`.** `email` declares `auth.type: "password"`, which the
client does not recognise and therefore treats as `none`; that connector works only because
its credentials arrive through `env[]` like any other form field. Prefer `api_key` or `none`
for new connectors and let `env[]` carry the credential.
Each entry drives one form field **and** is injected as an env var / URL token to `auth.delivery` (`header` | `query` | `env`) is surfaced to the admin UI. For a remote
the server: connector the **URL placeholder is what actually routes the key** (§4b) — the `param` name is
not read by the client.
### 4b. `env[]` — the activation form, and what reaches the process
Each entry drives one form field, and the collected values are injected into the server
process environment:
```jsonc ```jsonc
"env": [{ "env": [{
"name": "tavilyApiKey", "name": "EMAIL_IMAP_HOST", // ← the ACTUAL environment variable name
"label": "Tavily API key", "label": "IMAP host",
"description": "Create one at https://app.tavily.com.", "description": "IMAP server hostname (e.g. imap.gmail.com).",
"required": true, "required": true,
"secret": true, // rendered masked, stored encrypted "secret": false, // true → masked in the form, stored encrypted
"example": "tvly-xxxxxxxx" "example": "imap.gmail.com",
"default": "" // non-required fields only
}] }]
``` ```
The server reads each value from `process.env.<name>` (or `os.environ`). For a > **`name` must be exactly the environment variable your server reads.** The process receives
**remote** connector that wants the key in the URL, use a placeholder: > the form's `name` → value map verbatim; there is no renaming layer. `mcp_config.env` is
`"url": "https://mcp.example.com/?key={SECRET:tavilyApiKey}"`. > **not** substituted at runtime — it survives only as a legacy fallback for the form schema,
> and if it is ever the sole env source its `{ENV:…}` strings are injected *literally*. Do not
> use it to map one name onto another.
The server reads each value from `os.environ` / `process.env`. Declare `requires: ["ENV"]`
when the connector needs user-supplied config.
**Placeholders.** Two tokens, and exactly two places where they are substituted:
| Token | Meaning |
| --- | --- |
| `{ENV:NAME}` | non-sensitive value (host, port, username…) |
| `{SECRET:NAME}` | sensitive value (password, API key, token) |
| Substituted in | Engine behaviour on an unknown name |
| --- | --- |
| `mcp_config.url` (remote connectors) | `{SECRET:x}` falls back to the connector's api_key; if that is absent the token is **left in the URL literally**, so a misconfiguration is visible |
| `verify.command` (§6) | replaced with the **empty string** |
Nowhere else — not in `mcp_config.env`, not in `args`. Legacy `{key}` still resolves to the
api_key in URLs; `{env:NAME}` and `{secrets}/…` are dead and are never substituted.
Remote example: `"url": "https://mcp.tavily.com/mcp/?tavilyApiKey={SECRET:tavilyApiKey}"`
with a matching `env[]` entry named `tavilyApiKey`.
### 4c. `oauth2` — provider consent ### 4c. `oauth2` — provider consent
@@ -208,20 +348,27 @@ The server reads each value from `process.env.<name>` (or `os.environ`). For a
} }
``` ```
The manifest names **only** the provider slug, scopes, and how the obtained token The manifest names **only** the provider slug, the scopes, and how the obtained token is
is delivered — never client secrets or endpoint URLs (those are admin-entered, delivered — never client ids, client secrets, endpoint URLs or redirect URIs, which are
kept off the public feed). Skald handles PKCE + code exchange and injects the admin-entered and stay off the public feed. Skald runs PKCE + code exchange and injects the
credential as the named env var. `format`: `google_authorized_user` (Google) or credential as the named env var.
`refresh_token`. Today only `as: "env"` is wired.
### 4d. `qr` / interactive device login — the generic contract - `deliver.as`: **only `"env"` is implemented**`"file"` is rejected at activation with an
explicit error rather than half-working. Do not ship it.
- `deliver.format`: `google_authorized_user` (the JSON `from_authorized_user_file` reads) or
`refresh_token`.
- `deliver.env` must **not** also appear in `mcp_config.env` — Skald injects it at runtime.
- An OAuth connector activates into a **pending** row; nothing starts until the consent
round-trip completes. The admin must have configured the provider first, or activation
fails early with a clear message.
For a connector whose credential is produced by **scanning/pairing** (WhatsApp ### 4d. `qr` / interactive device login
today), there is no code to paste. The rule:
> **Expose one extra tool, `login_status`, returning a JSON object** (as the For a connector whose credential is produced by **scanning/pairing** (WhatsApp today) there is
> `text` of a normal text result). Skald calls it directly (never the agent) and a no code to paste. The contract:
> login panel polls it.
> **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 ```jsonc
// login_status result text (a JSON string): // login_status result text (a JSON string):
@@ -232,117 +379,166 @@ today), there is no code to paste. The rule:
} }
``` ```
- `activate` on a `qr` connector inserts a **pending** row and **starts the - Activating a `qr` connector inserts a **pending** row and **starts the server** (so it can
server** (so it can produce the QR), then hands off to the login panel. produce the QR), then hands off to the login panel.
- The panel polls `POST /api/mcp/login/status`; when `state == "ready"` the - The panel polls `POST /api/mcp/login/status`; when `state == "ready"` the connector is marked
connector is marked ready and starts automatically on later logins. ready and starts automatically on later logins.
- Also expose a `logout` tool (clears the session, forces a fresh QR) — the panel - Also expose a **`logout`** tool (clears the session, forces a fresh QR) — the panel calls it
calls it via `POST /api/mcp/login/reset` to re-link a different phone. 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 - The **credential is the on-disk session**, not a token. Persist it **inside the connector's
connector's own directory** (e.g. `./auth/` next to the entry file). That folder own directory** (e.g. `./auth/` next to the entry file). That folder lives under the bind-
lives under the bind-mounted home, so it survives container recreates and mounted home, so it survives container recreates and connector updates. Never store it
connector updates. Never store it under a shared/global path. under a shared or 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 ## 5. Dependencies (node & python) — how they get installed
**Do not ship `node_modules/` or vendored wheels.** Declare deps as a standard **Do not ship `node_modules/` or vendored wheels.** Declare deps as a standard manifest **file**
manifest **file** and Skald installs them inside the container: and Skald installs them where the server will run:
- **node:** ship a `package.json` with a `dependencies` map. Skald runs - **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 `npm ci --omit=dev --no-audit --no-fund`, falling back to `npm install --omit=dev …` when
dir. `node_modules/` resolves automatically beside the entry file. there is no lockfile. `node_modules/` resolves automatically beside the entry file.
- **python:** ship a `requirements.txt`. Skald installs it with - **python:** ship a `requirements.txt`. Skald runs
`pip install --target .pydeps` and puts `.pydeps` on the server's `PYTHONPATH`. `python3 -m pip install --break-system-packages --target .pydeps -r requirements.txt` and
puts `.pydeps` on the server's `PYTHONPATH`.
This runs at activation **and** on every startup, guarded by a **content hash** of A single install has a **300 s ceiling**; past that it fails rather than hanging a login.
the connector's source files:
This runs at activation **and** on every startup, guarded by a **content hash** of the
connector's source files (`.skald-install.lock`):
- first activation / a brand-new container → full install, - first activation / a brand-new container → full install,
- a connector **update** (any shipped file changed) → re-copy + re-install, - a connector **update** (any shipped file changed) → re-copy + re-install,
- unchanged → skipped in microseconds. - unchanged → skipped in microseconds.
So you never write install steps into the manifest — just ship the dep file, list So you never write install steps into the manifest — just ship the dep file. The manifest's
it in the index with its SHA-256, and set `requires: ["NODE"]` / `["PYTHON"]` as a `dependencies[]` and `setup_instructions[]` are **display metadata** for the admin card; they
human hint. Pin versions in `package.json` / `requirements.txt` for reproducible install nothing. Set `requires: ["NODE"]` / `["PYTHON"]` as a human hint. Pin versions for
installs. Keep the dep tree lean (containers are slim; avoid native-heavy reproducible installs, and keep the tree lean (containers are slim; prefer a pure-JS/Python
packages where a pure alternative exists — e.g. Baileys instead of a browser). library over a native-heavy one — e.g. Baileys instead of a browser).
**Host assets are not copied into the container.** Icons (`.svg/.png/.jpg/.webp/.gif/.ico`) and
`connector.json` stay on the host, so a server must never expect to read them at runtime.
Everything else in the folder — entry file, deps file, helper modules, `verify.py` — is copied.
Where the files land:
| | Host | Per-user container |
| --- | --- | --- |
| installed folder | `<workdir>/connectors/<id>/` | `~/.skald/mcp/<name>/` (`/root/.skald/mcp/<name>/`) |
| python deps | `connectors/<id>/.pydeps` | `~/.skald/mcp/<name>/.pydeps` |
--- ---
## 6. Verify-before-save (optional but recommended) ## 6. Verify-before-save (optional, strongly recommended)
Ship a `verify.py` / verify snippet and reference it: Ship a `verify.py` / `verify.js` (or an inline snippet) and reference it:
```jsonc ```jsonc
"verify": { "command": "python3 verify.py", "timeout_secs": 15 } "verify": { "command": "python3 verify.py", "timeout_secs": 20 }
``` ```
It runs with the collected env/secret injected and must print **one JSON object** It runs **after** the user fills the form and **before** the activation is persisted, with the
on stdout: `{"ok": bool, "message": string, "details"?: object}`, exit 0 on collected env/secrets injected and `{ENV:…}` / `{SECRET:…}` substituted into the command.
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.) **Output contract** — one JSON object on stdout and nothing else:
```json
{"ok": true, "message": "IMAP and SMTP authentication successful", "details": {"imap": "…"}}
{"ok": false, "message": "IMAP login failed: INVALID_CREDENTIALS"}
```
Exit 0 on success. If the JSON fails to parse the client falls back to the exit code (0 = ok)
and shows stderr. A timeout counts as a failure. **Never print credentials** in `message` or
`details`.
Rules the client enforces:
- **The script must be one of the connector's shipped files.** The client resolves it by
matching a **basename from `files[]`** against the command string — so `python3 verify.py`
works because `verify.py` is in `files[]`. A script that is not shipped is never downloaded
and activation fails; an inline command with no matching basename (firecrawl's `node -e "…"`)
is fine and runs as-is.
- **Where it runs:** inside the user's container, in the connector's directory, for a
`per_user` connector; on the host in `connectors/<id>/` for a `global` one.
- **Timeout:** declare `timeout_secs` for the record, but the runtime currently applies a fixed
**20 s** to every verify. Keep the probe well under that.
- Any `auth.type` may use verify — it is not limited to `api_key`. A `qr` connector needs none:
its `login_status` is the live check.
**Without `verify` there is no test at all.** Activation goes straight to `ready` — including
for remote connectors, which get no handshake fallback. The manifest author decides.
--- ---
## 7. Versioning & updates ## 7. Versioning & updates
Three fields, in **both** the index entry and the `connector.json`, kept identical: Three fields, in **both** `fragment.json` and `connector.json`, kept identical:
| field | type | role | | field | type | role |
| --- | --- | --- | | --- | --- | --- |
| `version` | **integer** | monotonic build number, **per connector** — the machine comparison key | | `version` | **integer** | monotonic build number, per connector — the machine comparison key |
| `version_string` | string (semver) | display only | | `version_string` | string (semver) | display only |
| `version_release_date` | ISO date `YYYY-MM-DD` | 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 - `version` is a **number, not a string** (`8`, not `"8"` or `"2.2.0"`). **`+1` on every
`1` for the first release under this scheme; **`+1` on every change** to any change** to any shipped file **or** to any manifest metadata (description, icons,
shipped file **or to any manifest metadata** (description, icons, `version_string`). `version_string`). Never reuse or decrement.
Never reuse or decrement. - **The integer is the only "is there an update?" signal**, compared strictly
- Skald stores the installed `version` and compares it to the feed's: a strictly (`feed > installed`). `version_string`, icons and `llm_short_description` are never
greater feed `version` shows **"update available"** in the marketplace, and the compared, so changing them without bumping the integer is **invisible** — no "update
Install button becomes **Update**. Clicking it re-downloads the files and rewrites available" badge. This is the classic trap.
the catalog row. - **The manifest wins over the index** when the two disagree, and the disagreement is only
- **The integer is the *only* "is there an update?" signal** — it is compared visible as a log line (`marketplace feed version desync`). If the index carries the *lower*
strictly (`feed > installed`). `version_string` (semver), icons and number and the manifest the higher one, the strict comparison can never fire again and the
`llm_short_description` are **never** compared, so a change to any of them that connector silently stops offering updates. Keep them equal — `compile.py` does not check
does not also bump the integer is **invisible**: no "update available" badge this for you.
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:** - **Two propagation paths, do not conflate them:**
- *Per-user code + deps* (the scripts, `package.json`/`requirements.txt`) reconcile - *Per-user code + deps* reconcile on a **content hash** of the source files (§5), so new
on a **content-hash** of the source files (§5), so new code lands at each user's code lands at each user's next login even without a reinstall.
next login even without a reinstall. - *Catalog metadata* (`llm_short_description` → the model's prompt, icons, friendly name) is
- *Catalog metadata* (`llm_short_description` → the model's prompt, icons, friendly **not** in that hash. It lives in the catalog row and is rewritten only by an explicit
name) is **not** in that hash — it lives in the catalog row and is rewritten only reinstall/Update, which re-pulls the current feed (never the 300 s browse cache) and
by an explicit **reinstall/Update**. On reinstall Skald re-pulls the current feed restarts enabled global servers and every live user's copy with the fresh description.
(never the browse cache) and pushes the new description live: enabled global - So: to ship a new `llm_short_description`, **bump the integer** so the admin sees "update
servers restart with it, and every logged-in user who activated the connector has available" and clicks Update. Nothing auto-propagates a description change.
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 ## 8. Hard limits the client enforces
1. Folder `myconn/` with: entry file, `connector.json`, deps file - **Digest mismatch → refused**, all-or-nothing: nothing is written unless every file verifies.
(`package.json`/`requirements.txt`), `icon_sm.svg`, `icon_lg.svg`, - **8 MiB per file.** A `files[].size` above that is rejected before the download starts.
optional `verify.*`. - **Path safety.** A `files[].path` or `mcp_config.args[0]` that is absolute, contains `..`,
2. Server speaks MCP over stdio (§2); **stdout = JSON-RPC only**. a backslash or a colon is rejected outright.
3. `mcp_config.args[0]` names the entry file. - **A local connector with no `files[]` cannot be installed** ("refusing to install
4. Correct `type` + `scope` (§3) and `auth.type` (§4). unverifiable code"), and neither can one whose only file is `connector.json`.
5. For `qr`: implement `login_status` (+ `logout`), persist the session under the - **`mcp_config.args[0]` is mandatory** for `mcp_local`.
connector dir (§4d). - The feed is fetched as `GET /connectors.json` plus one `GET /<folder>/connector.json` per
6. Deps declared as a file, **not** vendored (§5). entry, cached 300 s for browsing; installs always refetch. A manifest that fails to load
7. Add the entry to `connectors.json` with a correct `sha256` for **every** file. degrades that one card to whatever the index said — it never fails the listing, which is
8. Bump `version`. exactly how a broken manifest hides.
```
---
## 9. Checklist for a new connector
1. Create `connectors/<id>/` — flat — with: entry file, `connector.json`, `fragment.json`,
deps file (`package.json` / `requirements.txt`), `icon_sm.*`, `icon_lg.*`, optional
`verify.*`.
2. Server speaks MCP over stdio (§2); **stdout = JSON-RPC only**; notifications answered with
silence; `title` on every tool.
3. `mcp_config.args[0]` names the entry file; `transport` set consistently.
4. Correct `type` + `scope` (§3) and `auth.type` (§4); `auth`, `type`, `scope`, `tags`,
`requires` and the version trio **identical** in both documents.
5. `env[].name` = the real environment variable name (§4b).
6. For `qr`: implement `login_status` + `logout`, persist the session under the connector dir
(§4d).
7. Deps declared as a file, **not** vendored (§5).
8. `docs[0].llm_short_description` says what the connector *does*, not which tools it has.
9. Add `"<id>"` to `connectors/index.json`.
10. Bump `version` (+1), `version_string`, `version_release_date` in **both** documents.
11. Run `python3 scripts/compile.py`, then `python3 scripts/compile.py --verify`.
12. Record the change in `CHANGELOG.md` in the same commit, then commit and deploy.
+7 -7
View File
@@ -619,14 +619,14 @@
"type": "none" "type": "none"
}, },
"folder": "ssh", "folder": "ssh",
"version": 5, "version": 6,
"version_string": "1.0.4", "version_string": "1.1.0",
"version_release_date": "2026-07-21", "version_release_date": "2026-09-03",
"files": [ "files": [
{ {
"path": "connector.json", "path": "connector.json",
"sha256": "13ffbfaafab3092f5cc9e273a253f06ba50a5b89334ddd22bf1359dbd5b8feb7", "sha256": "a8bbc923bedf484f3d3bdd24e77aeaac81209e42b17f96b4abd15ed0c8c3dbe8",
"size": 3986 "size": 4494
}, },
{ {
"path": "icon_lg.svg", "path": "icon_lg.svg",
@@ -645,8 +645,8 @@
}, },
{ {
"path": "ssh_mcp_server.py", "path": "ssh_mcp_server.py",
"sha256": "68509094a35578ffaaf357e13b55e1bfcf397f16a3b993719c4c3b97ab2dc798", "sha256": "ff3fb49dfd41eba6bd1cbfe5b314007876ce382a3a55fb28b96a03fa01fe292f",
"size": 50193 "size": 60367
} }
] ]
}, },
+13 -4
View File
@@ -63,6 +63,14 @@
"required": false, "required": false,
"secret": true, "secret": true,
"example": "" "example": ""
},
{
"name": "SSH_MCP_SUDO_PASSWORD",
"label": "Sudo password (non-interactive override)",
"description": "Sudo password used when the remote host demands one and nobody can answer the Agent Inbox prompt (unattended/scheduled runs). Leave empty to always ask interactively. Only used after 'sudo -n' has proved the host really requires a password.",
"required": false,
"secret": true,
"example": ""
} }
], ],
"setup_instructions": [ "setup_instructions": [
@@ -90,7 +98,8 @@
"SSH_MCP_CONNECT_TIMEOUT": "{ENV:SSH_MCP_CONNECT_TIMEOUT}", "SSH_MCP_CONNECT_TIMEOUT": "{ENV:SSH_MCP_CONNECT_TIMEOUT}",
"SSH_MCP_LOGIN_PW_TTL": "{ENV:SSH_MCP_LOGIN_PW_TTL}", "SSH_MCP_LOGIN_PW_TTL": "{ENV:SSH_MCP_LOGIN_PW_TTL}",
"SSH_MCP_SUDO_PW_TTL": "{ENV:SSH_MCP_SUDO_PW_TTL}", "SSH_MCP_SUDO_PW_TTL": "{ENV:SSH_MCP_SUDO_PW_TTL}",
"SSH_MCP_KEY_PASSPHRASE": "{SECRET:SSH_MCP_KEY_PASSPHRASE}" "SSH_MCP_KEY_PASSPHRASE": "{SECRET:SSH_MCP_KEY_PASSPHRASE}",
"SSH_MCP_SUDO_PASSWORD": "{SECRET:SSH_MCP_SUDO_PASSWORD}"
} }
}, },
"homepage": "", "homepage": "",
@@ -109,7 +118,7 @@
"auth": { "auth": {
"type": "none" "type": "none"
}, },
"version": 2, "version": 6,
"version_string": "1.0.1", "version_string": "1.1.0",
"version_release_date": "2026-07-21" "version_release_date": "2026-09-03"
} }
+3 -3
View File
@@ -21,7 +21,7 @@
"type": "none" "type": "none"
}, },
"folder": "ssh", "folder": "ssh",
"version": 5, "version": 6,
"version_string": "1.0.4", "version_string": "1.1.0",
"version_release_date": "2026-07-21" "version_release_date": "2026-09-03"
} }
+297 -75
View File
@@ -24,12 +24,22 @@ Elicited login secrets are kept only in this process's RAM with a short TTL
they are dropped on an authentication failure so the next attempt re-prompts. they are dropped on an authentication failure so the next attempt re-prompts.
sudo (two methods per alias, set on ``add_alias``): sudo (two methods per alias, set on ``add_alias``):
* ``nopasswd`` — ``sudo -n``: non-interactive, fails fast if NOPASSWD is not * ``nopasswd`` — ``sudo -n`` only: non-interactive, fails fast with an
configured on the host (no hung channel). No secret stored anywhere. explicit message if NOPASSWD is not configured on the host (no hung
* ``prompt`` — ``sudo -S``: the password is requested on demand via **MCP channel). No secret stored anywhere.
elicitation** (Skald shows a masked field in the Agent Inbox), fed to * ``prompt`` — ``sudo -n`` is **still tried first**; only if the host really
sudo's stdin, kept only in this process's RAM with a short TTL, never sent demands a password is one requested via **MCP elicitation** (Skald shows a
to the LLM and never written to disk. masked field in the Agent Inbox), fed to sudo's stdin, kept only in this
process's RAM with a short TTL, never sent to the LLM and never written to
disk. ``SSH_MCP_SUDO_PASSWORD`` is a non-interactive override for
unattended runs (no human to answer the Inbox prompt).
``exec``/``systemd`` never nest sudo: a leading ``sudo`` (with its usual flags,
including ``-u USER``) is stripped from ``command`` and turned into
``sudo=true``, so an agent that writes ``sudo systemctl restart x`` gets the
same, working behaviour as ``sudo=true`` + ``systemctl restart x``. Under sudo
the command runs as ``sh -c '<command>'`` so pipes and redirections are also
privileged.
Connections are pooled per alias with lazy TTL eviction. Host keys are verified Connections are pooled per alias with lazy TTL eviction. Host keys are verified
against ``~/.ssh/known_hosts`` (unknown hosts are rejected unless the alias was against ``~/.ssh/known_hosts`` (unknown hosts are rejected unless the alias was
@@ -66,6 +76,10 @@ LOGIN_PW_TTL = int(os.environ.get("SSH_MCP_LOGIN_PW_TTL", "300")) # in-RAM lo
CONNECT_TIMEOUT = int(os.environ.get("SSH_MCP_CONNECT_TIMEOUT", "15")) CONNECT_TIMEOUT = int(os.environ.get("SSH_MCP_CONNECT_TIMEOUT", "15"))
DEFAULT_CMD_TIMEOUT = int(os.environ.get("SSH_MCP_COMMAND_TIMEOUT", "120")) DEFAULT_CMD_TIMEOUT = int(os.environ.get("SSH_MCP_COMMAND_TIMEOUT", "120"))
# Non-interactive sudo password (unattended runs, where nobody can answer the
# elicitation prompt in the Agent Inbox). Empty/unset ⇒ elicitation only.
SUDO_PASSWORD_ENV = os.environ.get("SSH_MCP_SUDO_PASSWORD") or None
# Mirror the native list_files skip set so remote listings match local ones. # Mirror the native list_files skip set so remote listings match local ones.
SKIP_DIRS = {"target", ".git", "node_modules", ".venv", "__pycache__", "secrets"} SKIP_DIRS = {"target", ".git", "node_modules", ".venv", "__pycache__", "secrets"}
@@ -107,13 +121,32 @@ def readline() -> dict | None:
_eid = itertools.count(1) _eid = itertools.count(1)
# Messages received while blocked on an elicitation reply — replayed by the main
# loop instead of being dropped, so a concurrent tools/call is never lost.
_deferred: list[dict] = []
# Whether the client advertised the `elicitation` capability at initialize. A
# client without it can never supply a password, so we fail fast with a useful
# message instead of blocking on a request nobody will answer.
_client_can_elicit = True
def next_message() -> dict | None:
"""Next inbound message: deferred ones first, then stdin."""
if _deferred:
return _deferred.pop(0)
return readline()
def elicit(message: str, requested_schema: dict) -> dict: def elicit(message: str, requested_schema: dict) -> dict:
"""Send an ``elicitation/create`` request and block until the reply arrives. """Send an ``elicitation/create`` request and block until the reply arrives.
Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). While Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). Other
waiting, any other inbound message is ignored (v1: serial processing). inbound messages are queued in ``_deferred`` and handled once the reply
lands (v1: still serial, but nothing is discarded).
""" """
if not _client_can_elicit:
return {"action": "cancel", "_reason": "unsupported"}
eid = f"ssh-elicit-{next(_eid)}" eid = f"ssh-elicit-{next(_eid)}"
send({ send({
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -124,10 +157,11 @@ def elicit(message: str, requested_schema: dict) -> dict:
while True: while True:
msg = readline() msg = readline()
if msg is None: if msg is None:
return {"action": "cancel"} return {"action": "cancel", "_reason": "disconnected"}
if msg.get("id") == eid: if msg.get("id") == eid:
return msg.get("result", {"action": "cancel"}) return msg.get("result", {"action": "cancel"})
log(f"ignoring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}") log(f"deferring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}")
_deferred.append(msg)
def _ok(req_id: Any, result: Any) -> dict: def _ok(req_id: Any, result: Any) -> dict:
@@ -141,6 +175,17 @@ def _text_result(req_id: Any, text: str, is_error: bool = False) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "result": res} return {"jsonrpc": "2.0", "id": req_id, "result": res}
def _int(args: dict, key: str, default: int) -> int:
"""Integer argument tolerant of null / numeric strings (LLMs send both)."""
v = args.get(key)
if v is None or v == "":
return default
try:
return int(v)
except (TypeError, ValueError):
return default
# ── Alias store (auto-managed, 0600) ─────────────────────────────────────────── # ── Alias store (auto-managed, 0600) ───────────────────────────────────────────
def _load_aliases() -> dict: def _load_aliases() -> dict:
@@ -216,6 +261,13 @@ def _login_password(alias: str, kind: str = "login") -> str | None:
return None return None
def _no_secret_reason() -> str:
"""Why an elicited secret never arrived — the two cases look identical to the
caller but need very different fixes from the user."""
return ("this MCP client does not support elicitation" if not _client_can_elicit
else "the user declined it or the request timed out")
def _clear_login_pw(alias: str) -> None: def _clear_login_pw(alias: str) -> None:
"""Drop any cached login password / passphrase for ``alias``.""" """Drop any cached login password / passphrase for ``alias``."""
for k in [k for k in _login_pw_cache if k.startswith(f"{alias}:")]: for k in [k for k in _login_pw_cache if k.startswith(f"{alias}:")]:
@@ -259,7 +311,8 @@ def _connect(cfg: dict, paramiko):
password = _login_password(alias, "login") password = _login_password(alias, "login")
if password is None: if password is None:
raise ToolError( raise ToolError(
f"login password required for alias '{alias}' (user declined or timed out)" f"login password required for alias '{alias}' but none was provided "
f"({_no_secret_reason()})"
) )
def attempt(passphrase): def attempt(passphrase):
@@ -301,7 +354,8 @@ def _connect(cfg: dict, paramiko):
passphrase = _login_password(alias, "passphrase") passphrase = _login_password(alias, "passphrase")
if passphrase is None: if passphrase is None:
raise ToolError( raise ToolError(
f"key passphrase required for alias '{alias}' (user declined or timed out)" f"key passphrase required for alias '{alias}' but none was provided "
f"({_no_secret_reason()}) — set SSH_MCP_KEY_PASSPHRASE for unattended runs"
) )
return attempt(passphrase) return attempt(passphrase)
except (paramiko.AuthenticationException, paramiko.SSHException) as e: except (paramiko.AuthenticationException, paramiko.SSHException) as e:
@@ -380,34 +434,89 @@ def _get_sftp(alias: str):
def _run_with_stdin(client, command: str, timeout: int, stdin_data: str | None = None): def _run_with_stdin(client, command: str, timeout: int, stdin_data: str | None = None):
"""Run a remote command; return (stdout, stderr, exit_code). Raises on timeout.""" """Run a remote command; return (stdout, stderr, exit_code).
stdout and stderr are drained **together**: they share one SSH channel
window, so reading stdout to EOF first stalls as soon as a chatty stderr
fills that window. ``timeout`` is a wall-clock deadline for the whole run
(not an idle timeout), and stdin is always closed so commands that read it
see EOF instead of hanging.
"""
chan = client.get_transport().open_session(timeout=CONNECT_TIMEOUT)
try: try:
chan_in, chan_out, chan_err = client.exec_command(command, timeout=timeout) chan.settimeout(timeout)
if stdin_data is not None: chan.exec_command(command)
try: try:
chan_in.write(stdin_data) if stdin_data:
chan_in.flush() chan.sendall(stdin_data.encode())
except Exception: chan.shutdown_write()
pass except Exception as e: # closed early by the remote end
out = chan_out.read().decode("utf-8", "replace") log(f"stdin write failed: {e}")
err = chan_err.read().decode("utf-8", "replace")
code = chan_out.channel.recv_exit_status() out, err = bytearray(), bytearray()
return out, err, code deadline = time.time() + timeout
settled = 0
while True:
idle = True
while chan.recv_ready():
out += chan.recv(65536)
idle = False
while chan.recv_stderr_ready():
err += chan.recv_stderr(65536)
idle = False
if not idle:
settled = 0
continue
if chan.exit_status_ready():
# Exit status can arrive before the last data. Wait for the
# remote EOF, or — should it close without one — for three
# consecutive empty polls, rather than truncating the output.
if chan.eof_received or chan.closed or settled >= 3:
break
settled += 1
if time.time() > deadline:
raise ToolError(f"command timed out after {timeout}s")
time.sleep(0.02)
return (out.decode("utf-8", "replace"),
err.decode("utf-8", "replace"),
chan.recv_exit_status())
except socket.timeout: except socket.timeout:
raise ToolError(f"command timed out after {timeout}s") raise ToolError(f"command timed out after {timeout}s")
finally:
try:
chan.close()
except Exception:
pass
# ── sudo ─────────────────────────────────────────────────────────────────────── # ── sudo ───────────────────────────────────────────────────────────────────────
def _sudo_password(alias: str) -> str | None: def _cached_sudo_password(alias: str) -> str | None:
"""Return the sudo password for ``alias`` from RAM cache, or elicit it. """Live RAM-cache entry for ``alias``, if any.
Never persisted. Returns None if the user declines/cancels/times out. Deliberately ignores ``SSH_MCP_SUDO_PASSWORD``: a cache hit lets ``_run_sudo``
skip its ``sudo -n`` probe, and on a NOPASSWD host sudo would then not read
the password line at all — feeding it straight into the command's stdin. A
RAM entry only ever exists because sudo already demanded a password once.
"""
cached = _sudo_pw_cache.get(alias)
if cached and (time.time() - cached[1] <= SUDO_PW_TTL):
return cached[0]
return None
def _sudo_password(alias: str) -> str | None:
"""Return the sudo password for ``alias`` from RAM cache / env, or elicit it.
Never persisted. Returns None if the user declines/cancels/times out, or if
the client cannot elicit at all.
""" """
now = time.time() now = time.time()
cached = _sudo_pw_cache.get(alias) cached = _cached_sudo_password(alias)
if cached and (now - cached[1] <= SUDO_PW_TTL): if cached is not None:
return cached[0] return cached
if SUDO_PASSWORD_ENV:
return SUDO_PASSWORD_ENV
result = elicit( result = elicit(
f"Enter the sudo password for SSH alias '{alias}'.", f"Enter the sudo password for SSH alias '{alias}'.",
@@ -430,21 +539,119 @@ def _sudo_password(alias: str) -> str | None:
return None return None
def _sudo_prefix(alias: str, cfg: dict, sudo_user: str | None): # sudo refusing to run because it wants a password. sudo prints these *instead*
"""Build the sudo prefix for ``cfg``. Returns (prefix, stdin_password). # of running the command, so probing with `-n` is always side-effect free.
_SUDO_NEEDS_PW = (
"a password is required", "no password was provided", "a terminal is required",
"no tty present", "askpass",
)
# sudo ran but the password we fed it was wrong.
_SUDO_BAD_PW = ("sorry, try again", "incorrect password attempt", "authentication failure")
Raises ToolError when sudo is disabled or the password is unavailable.
def _sudo_says(err: str, code: int, markers: tuple) -> bool:
"""True if a `sudo:`-prefixed stderr line matches one of ``markers``."""
if code == 0:
return False
for line in err.splitlines():
low = line.strip().lower()
if low.startswith("sudo:") and any(m in low for m in markers):
return True
return False
# Leading `sudo` (plus its common flags) that an agent put in `command` itself.
# Anything unrecognised simply doesn't match and is left untouched.
_SUDO_OPT = (
r"""(?:-p\s*(?:'[^']*'|"[^"]*"|\S+)""" # -p PROMPT (takes an argument)
r"|--prompt(?:=|\s+)\S+"
r"|-[EHnSbik]+" # flag bundles without arguments
r"|--(?:preserve-env|set-home|non-interactive|stdin|login|shell|background|remove-timestamp))"
)
_LEADING_SUDO = re.compile(
rf"""^\s*(?:/usr/bin/|/bin/)?sudo
(?:\s+{_SUDO_OPT})*
(?:\s+(?:-u\s*|--user(?:=|\s+))(?P<user>[A-Za-z0-9_.\-]+))?
(?:\s+{_SUDO_OPT})*
(?:\s+--)?
\s+(?P<rest>\S.*)$""",
re.X | re.S,
)
def _split_leading_sudo(command: str) -> tuple[str, bool, str | None]:
"""Split a leading ``sudo …`` off ``command``.
Returns ``(command_without_sudo, had_sudo, sudo_user)``. Agents routinely
write ``sudo systemctl restart x``; running that through the sudo machinery
would nest a second sudo, whose password prompt has no tty and dies. So the
prefix is peeled off here and expressed as ``sudo=true`` instead.
"""
m = _LEADING_SUDO.match(command)
if not m:
return command, False, None
rest = m.group("rest").strip()
if rest.startswith("-"):
# A sudo option we don't know about — stripping here would hand sudo a
# mangled command line, so leave the whole thing untouched.
return command, False, None
return rest, True, m.group("user")
def _sudo_wrap(flags: str, sudo_user: str | None, command: str) -> str:
"""``sudo <flags> [-u user] sh -c '<command>'`` — the whole command line,
pipes and redirections included, runs with the elevated privileges."""
u = f"-u {shlex.quote(sudo_user)} " if sudo_user else ""
return f"sudo {flags} {u}sh -c {shlex.quote(command)}"
def _run_sudo(alias: str, cfg: dict, client, command: str,
sudo_user: str | None, timeout: int):
"""Run ``command`` under sudo. Returns (stdout, stderr, exit_code).
``sudo -n`` is always attempted first: on a host that grants this user
NOPASSWD it succeeds outright, so no password is ever requested — which is
the only thing that works in unattended runs, where nobody is watching the
Agent Inbox. A password is elicited only when the host actually demands one.
""" """
method = (cfg.get("sudo") or {}).get("method", "prompt") method = (cfg.get("sudo") or {}).get("method", "prompt")
u = f"-u {shlex.quote(sudo_user)} " if sudo_user else ""
if method == "none": if method == "none":
raise ToolError(f"sudo is disabled for alias '{alias}'") raise ToolError(
if method == "nopasswd": f"sudo is disabled for alias '{alias}' — re-add it with sudo='prompt' to enable it"
return f"sudo -n {u}", None )
pw = _sudo_password(alias)
pw = _cached_sudo_password(alias) if method != "nopasswd" else None
if pw is None: if pw is None:
raise ToolError("sudo password required (user declined or timed out)") out, err, code = _run_with_stdin(
return f"sudo -S -p '' {u}", pw client, _sudo_wrap("-n", sudo_user, command), timeout)
if not _sudo_says(err, code, _SUDO_NEEDS_PW):
return out, err, code
if method == "nopasswd":
raise ToolError(
f"sudo on '{alias}' requires a password, but the alias is configured with "
f"sudo='nopasswd' (which only ever runs 'sudo -n' and never prompts). "
f"Re-add the alias with sudo='prompt', or grant this user a NOPASSWD rule "
f"in the remote /etc/sudoers."
)
pw = _sudo_password(alias)
if pw is None:
raise ToolError(
f"sudo password required for alias '{alias}' but none was provided "
f"({_no_secret_reason()}). Either answer the sudo prompt in the Agent Inbox, or — for "
f"unattended runs — set SSH_MCP_SUDO_PASSWORD in the connector settings, or "
f"grant this user NOPASSWD sudo on the host."
)
out, err, code = _run_with_stdin(
client, _sudo_wrap("-S -p ''", sudo_user, command), timeout, pw + "\n")
if _sudo_says(err, code, _SUDO_BAD_PW) or _sudo_says(err, code, _SUDO_NEEDS_PW):
_sudo_pw_cache.pop(alias, None) # drop it so the next call re-prompts
raise ToolError(
f"sudo password rejected on '{alias}'"
+ (" (from SSH_MCP_SUDO_PASSWORD)" if SUDO_PASSWORD_ENV else
" — the cached password was discarded, retry to be asked again")
)
return out, err, code
# ── SFTP helpers ─────────────────────────────────────────────────────────────── # ── SFTP helpers ───────────────────────────────────────────────────────────────
@@ -628,7 +835,7 @@ def _tool_list_files(args: dict) -> str:
alias, path = args.get("alias"), args.get("path") alias, path = args.get("alias"), args.get("path")
if not alias or not path: if not alias or not path:
return "Error: 'alias' and 'path' are required" return "Error: 'alias' and 'path' are required"
max_depth = int(args.get("depth", 3)) max_depth = _int(args, "depth", 3)
dirs_only = bool(args.get("dirs_only", False)) dirs_only = bool(args.get("dirs_only", False))
sftp = _get_sftp(alias) sftp = _get_sftp(alias)
@@ -675,8 +882,8 @@ def _tool_grep_files(args: dict) -> str:
if not alias or not path or pattern is None: if not alias or not path or pattern is None:
return "Error: 'alias', 'path' and 'pattern' are required" return "Error: 'alias', 'path' and 'pattern' are required"
mode = args.get("output_mode", "content") mode = args.get("output_mode", "content")
ctx = min(int(args.get("context_lines", 0) or 0), 10) ctx = min(_int(args, "context_lines", 0), 10)
maxr = int(args.get("max_results", 100)) maxr = _int(args, "max_results", 100)
client = _get_client(alias) client = _get_client(alias)
flags = _grep_flags(args) flags = _grep_flags(args)
@@ -840,32 +1047,38 @@ def _tool_exec(args: dict) -> str:
return "Error: 'alias' and 'command' are required" return "Error: 'alias' and 'command' are required"
sudo = bool(args.get("sudo", False)) sudo = bool(args.get("sudo", False))
sudo_user = args.get("sudo_user") sudo_user = args.get("sudo_user")
timeout = int(args.get("timeout_sec", DEFAULT_CMD_TIMEOUT)) timeout = _int(args, "timeout_sec", DEFAULT_CMD_TIMEOUT)
cfg = _find_alias(alias) cfg = _find_alias(alias)
if not cfg: if not cfg:
return f"Error: unknown alias '{alias}'" return f"Error: unknown alias '{alias}'"
pw = None # A `sudo` the agent typed into `command` is the same intent as sudo=true —
wrapped = command # honour it here rather than nesting a second, tty-less sudo remotely.
if sudo: command, inline_sudo, inline_user = _split_leading_sudo(command)
prefix, pw = _sudo_prefix(alias, cfg, sudo_user) if inline_sudo:
wrapped = prefix + command sudo = True
sudo_user = sudo_user or inline_user
if sudo_user:
sudo = True # `sudo -u X` is meaningless without sudo itself
if not command.strip():
return "Error: 'command' is empty"
client = _get_client(alias) client = _get_client(alias)
try: if sudo:
chan_in, chan_out, chan_err = client.exec_command(wrapped, timeout=timeout) out, err, code = _run_sudo(alias, cfg, client, command, sudo_user, timeout)
if pw is not None: else:
try: out, err, code = _run_with_stdin(client, command, timeout)
chan_in.write(pw + "\n")
chan_in.flush() result = {"stdout": out, "stderr": err, "exit_code": code}
except Exception: if _sudo_says(err, code, _SUDO_NEEDS_PW):
pass # A sudo buried mid-command (e.g. `cd /x && sudo …`) that we could not
out = chan_out.read().decode("utf-8", "replace") # peel off. Don't silently re-run the whole line as root — tell the LLM.
err = chan_err.read().decode("utf-8", "replace") result["hint"] = (
code = chan_out.channel.recv_exit_status() "this command used sudo internally and sudo asked for a password on a "
except socket.timeout: "tty it does not have. Re-run it with sudo=true and no 'sudo' inside "
return f"Error: command timed out after {timeout}s" "`command` (with sudo=true the whole command line runs as root)."
return json.dumps({"stdout": out, "stderr": err, "exit_code": code}) )
return json.dumps(result)
def _tool_systemd(args: dict) -> str: def _tool_systemd(args: dict) -> str:
@@ -884,11 +1097,8 @@ def _tool_systemd(args: dict) -> str:
parts: list[str] = [] parts: list[str] = []
if action != "status": if action != "status":
prefix, pw = _sudo_prefix(alias, cfg, None) out, err, code = _run_sudo(
out, err, code = _run_with_stdin( alias, cfg, client, f"systemctl {action} {qsvc}", None, DEFAULT_CMD_TIMEOUT)
client, f"{prefix}systemctl {action} {qsvc}", DEFAULT_CMD_TIMEOUT,
(pw + "\n") if pw else None,
)
parts.append(f"$ systemctl {action} {service} (exit {code})") parts.append(f"$ systemctl {action} {service} (exit {code})")
if out.strip(): if out.strip():
parts.append(out.strip()) parts.append(out.strip())
@@ -1064,7 +1274,7 @@ TOOLS = [
"auth": {"type": "string", "enum": ["key", "password"], "auth": {"type": "string", "enum": ["key", "password"],
"description": "Login auth. key: SSH key/agent (default). password: login password asked on demand via elicitation, kept only in RAM."}, "description": "Login auth. key: SSH key/agent (default). password: login password asked on demand via elicitation, kept only in RAM."},
"sudo": {"type": "string", "enum": ["nopasswd", "prompt", "none"], "sudo": {"type": "string", "enum": ["nopasswd", "prompt", "none"],
"description": "How sudo authenticates. Use 'prompt' unless you KNOW otherwise it is the safe default: runs 'sudo -S' and asks the user for the sudo password on demand via elicitation, so it works on any host where the login user is a normal sudoer. Only pick 'nopasswd' when the remote /etc/sudoers actually grants THIS user passwordless sudo (a NOPASSWD: rule): it runs 'sudo -n' and NEVER prompts, so on a normal host every sudo call fails immediately with 'a password is required'. 'none' disables sudo. Default prompt."}, "description": "How sudo authenticates. Keep the default 'prompt' unless you KNOW otherwise: it tries 'sudo -n' first (so a host with a NOPASSWD rule never prompts) and only asks the user for the sudo password via elicitation if the host actually demands one. 'nopasswd' runs 'sudo -n' only and NEVER prompts — pick it just to forbid prompting, since on a host without a NOPASSWD rule every sudo call then fails. 'none' disables sudo entirely. Default prompt."},
"accept_new_host_key": {"type": "boolean", "description": "Trust the host key on first connect (TOFU). Default false."}, "accept_new_host_key": {"type": "boolean", "description": "Trust the host key on first connect (TOFU). Default false."},
}, },
"required": ["alias", "hostname", "username"], "required": ["alias", "hostname", "username"],
@@ -1161,14 +1371,20 @@ TOOLS = [
{ {
"name": "exec", "name": "exec",
"title": "Execute Command", "title": "Execute Command",
"description": "Run a command on the remote host. Set sudo=true to run via sudo (method per alias).", "description": (
"Run a command on the remote host. For anything needing root, set sudo=true and "
"write the command WITHOUT a 'sudo' prefix — with sudo=true the entire command "
"line (pipes and redirections included) runs as root. A leading 'sudo' left in "
"`command` is stripped and treated as sudo=true anyway, but a 'sudo' in the middle "
"of a command (e.g. 'cd /x && sudo …') has no tty and will fail."
),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"alias": _ALIAS, "alias": _ALIAS,
"command": {"type": "string", "description": "Shell command."}, "command": {"type": "string", "description": "Shell command. Do NOT prefix it with 'sudo' — use the sudo argument."},
"sudo": {"type": "boolean", "description": "Run via sudo (default false)."}, "sudo": {"type": "boolean", "description": "Run the whole command as root, via sudo (default false)."},
"sudo_user": {"type": "string", "description": "Target user for sudo -u (optional)."}, "sudo_user": {"type": "string", "description": "Target user for sudo -u (optional; implies sudo)."},
"timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."}, "timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."},
}, },
"required": ["alias", "command"], "required": ["alias", "command"],
@@ -1248,10 +1464,16 @@ def handle_message(msg: dict) -> dict | None:
req_id = msg.get("id") req_id = msg.get("id")
if method == "initialize": if method == "initialize":
# Remember whether the client can collect input mid-call: without the
# elicitation capability we must never block on a password prompt.
global _client_can_elicit
caps = (msg.get("params") or {}).get("capabilities") or {}
_client_can_elicit = "elicitation" in caps
log(f"client elicitation capability: {'yes' if _client_can_elicit else 'no'}")
return _ok(req_id, { return _ok(req_id, {
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {"tools": {}}, "capabilities": {"tools": {}},
"serverInfo": {"name": "ssh", "version": "1.0.0"}, "serverInfo": {"name": "ssh", "version": "1.1.0"},
}) })
if method == "notifications/initialized": if method == "notifications/initialized":
return None return None
@@ -1283,7 +1505,7 @@ def main() -> None:
log("starting SSH MCP server") log("starting SSH MCP server")
try: try:
while True: while True:
msg = readline() msg = next_message()
if msg is None: if msg is None:
break break
resp = handle_message(msg) resp = handle_message(msg)
+7 -378
View File
@@ -1,380 +1,9 @@
# Skald Connector Authoring Guide # Skald Connector Authoring Guide — moved
Instructions for generating a **correct connector** for the Skald marketplace This copy has been retired. The authoring spec now lives, in a single place, at
(`https://connectors.skaldagent.net`). Give this file to the agent that produces [`CONNECTOR_MANIFEST_GUIDE.md`](../CONNECTOR_MANIFEST_GUIDE.md) in the repo root — it absorbed
new connectors. everything this file documented (the `fragment.json` + `scripts/compile.py` build pipeline)
plus the client behaviour verified against `~/projects/skald-circle`.
A connector is a folder served by the marketplace. Skald installs it, verifies Keeping two copies is what let them drift in the first place. Edit the root file only; the
every file against a SHA-256 pinned in the index, then either runs it on the host history of this one is in git.
(global connector) or copies it into the user's container and runs it there
(per-user connector, blueprint §6/§7).
---
## 1. The two documents (+ one build script)
### 1a. The compiled root index — `connectors.json`
**This file is auto-generated.** Do not edit it by hand. It is produced by
[`scripts/compile.py`](../scripts/compile.py), which reads `index.json` + the
`fragment.json` in each connector folder, scans the physical files for SHA-256
digests, and writes the final index.
The output schema looks like this:
```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.
- SHA-256 digests and file sizes are **computed automatically** by `compile.py`.
Never write them by hand.
- The excluded files (`fragment.json`, `connectors.json`, `index.json`,
`.DS_Store`) are handled by `compile.py` — you don't need to think about them.
- Do **not** list `node_modules/` or any generated deps — those are installed on
the box, not shipped (see §5).
- `size` is optional but the compiler includes it.
#### How the index is built
Instead of editing `connectors.json` directly, you maintain **two lightweight
source files** and run one command:
1. **`connectors/index.json`** — a flat JSON array of folder ids in display
order: `["gmail", "gcal", "myconn", …]`
2. **`connectors/<id>/fragment.json`** — the connector's index entry with
**every field except `files[]`** (same schema as above minus that array).
3. **`python3 scripts/compile.py`** — reads `index.json`, loads each
`fragment.json`, scans the folder for real files, computes SHA-256, and
writes `connectors.json`.
The `tools[]` block (friendly UI names, §2a) goes into `fragment.json`.
### 1b. The per-connector manifest — `<folder>/connector.json`
The richer document. Fetched per connector and mapped into Skald's catalog.
```jsonc
{
"id": "whatsapp",
"name": "WhatsApp",
"version": 1, // INTEGER build number — the update key (§7)
"version_string": "2.0.1", // semver, display only
"version_release_date": "2026-07-19", // ISO date, display only
"type": "mcp_local",
"scope": "user",
"auth": { "type": "qr" }, // none | api_key | oauth2 | qr (see §4)
"mcp_config": {
"command": "node", // interpreter (local) …
"args": ["index.js"], // … args[0] MUST name the entry file
"transport": "stdio" // stdio (local) | streamable-http (remote)
},
"docs": [{
"lang": "en",
"description": "Human blurb shown in the UI.",
"llm_short_description": "One line the model reads to decide whether to use this connector."
}],
"env": [], // form fields the user fills (see §4b)
"tools": [ // OPTIONAL — friendly UI names per tool (§2a)
{ "name": "send_message", "display_name": "Send Message" }
],
"homepage": "https://…",
"icon_small": "icon_sm.svg", // relative to the folder here
"icon_large": "icon_lg.svg",
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"]
}
```
**`mcp_config.args[0]` is load-bearing for a local connector:** it is how Skald
learns which file to run. At activation Skald rewrites it to the file's path
inside the user's container (`/root/.skald/mcp/<name>/<entry>`), so keep it a
plain relative filename (`index.js`, `server.py`, `pkg/server.py`).
**`llm_short_description` is model-facing:** it's the one-liner injected into
the LLM's system prompt so the model knows what this connector does. Keep it
short and functional ("Weather — current conditions, 16-day forecast, and AQI
data for any location"), **not** a list of tools (the model discovers tools
after `activate_tools`).
---
## 2. Server contract (MCP over stdio)
A **local** connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It
MUST handle:
- `initialize``{ protocolVersion, capabilities: { tools: {} }, serverInfo }`
- `notifications/initialized` → no response
- `tools/list``{ tools: [ { name, description, inputSchema } ] }`
- `tools/call``{ content: [ { type: "text", text } ], isError? }`
**stdout is reserved for JSON-RPC only.** Send all logs/diagnostics to **stderr**.
Anything a library prints to stdout (a logger, a banner) corrupts the protocol —
silence it (e.g. Baileys/pino → a silent logger; Python → `print(…, file=sys.stderr)`).
A **remote** connector is an HTTP MCP endpoint (`mcp_config.url` +
`transport: "streamable-http"`); no code runs on the box.
### 2a. Friendly tool names (`tools[]`) — optional
Raw MCP tool names are ugly in the chat UI (`search_files`, `send_message`). The
optional top-level `tools[]` block gives each one a human title shown as the tool
card's heading:
```jsonc
"tools": [
{ "name": "send_message", "display_name": "Send Message" },
{ "name": "list_chats", "display_name": "List Chats" },
{ "name": "download_media", "display_name": "Download Media" }
]
```
- `name` — the **raw** tool name exactly as your server returns it from `tools/list`.
- `display_name` — the friendly card title (English only; not internationalized).
**Resolution order** for a tool's card title is **`tools[].display_name` → the MCP
`title` field → a prettified raw name**. So you have two ways to set a friendly
name, and can skip `tools[]` entirely:
1. **This block** — the authoritative override, curated in the manifest.
2. **The MCP `title` field** — if your `tools/list` entries already carry a
`title` (MCP 2025-06-18+), Skald uses it automatically; no manifest change
needed. `tools[]` wins if both are present.
3. If neither is set, Skald title-cases the raw name (`send_message` → "Send
Message").
**Icons are per connector, not per tool.** Every tool of a connector shows that
connector's own `icon_small`; there is no per-tool icon field. Only list a tool in
`tools[]` when its prettified name isn't good enough — partial lists are fine
(unlisted tools fall through to steps 23).
---
## 3. Placement & risk vocabulary (what the words mean)
| Manifest | Meaning |
| --- | --- |
| `scope: "user"` | runs **once per user**, inside their container. Personal creds. |
| `scope: "global"` | runs **once for the household**, on the host. Shared, stateless. Admin enables it with a key. |
| `type: "mcp_local"` | ships code that will **execute on the box** — installing needs the admin `mcp.register_local_script` capability (RCE-bearing act, §14). |
| `type: "mcp_remote"` | just an HTTP URL; no local code. |
Pick the narrowest: a personal messaging/email/calendar connector is
`scope: "user"`; a shared search API is `scope: "global"`.
---
## 4. Authentication (`auth.type`)
| `auth.type` | Flow | Ships |
| --- | --- | --- |
| `none` | nothing to sign in | — |
| `api_key` | user pastes a key/secret into a form | an `env[]` schema (§4b) |
| `oauth2` | browser consent → paste code back | `auth.provider` + `auth.scopes` + `auth.deliver` (§4c) |
| `qr` | server shows a QR, user scans with a phone | a `login_status` tool (§4d) |
### 4b. `api_key` — the `env[]` schema
Each entry drives one form field **and** is injected as an env var / URL token to
the server:
```jsonc
"env": [{
"name": "tavilyApiKey",
"label": "Tavily API key",
"description": "Create one at https://app.tavily.com.",
"required": true,
"secret": true, // rendered masked, stored encrypted
"example": "tvly-xxxxxxxx"
}]
```
The server reads each value from `process.env.<name>` (or `os.environ`). For a
**remote** connector that wants the key in the URL, use a placeholder:
`"url": "https://mcp.example.com/?key={SECRET:tavilyApiKey}"`.
### 4c. `oauth2` — provider consent
```jsonc
"auth": {
"type": "oauth2",
"provider": "google", // slug into the admin's sign-in providers
"scopes": ["https://www.googleapis.com/auth/gmail.modify"],
"deliver": { "as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON" }
}
```
The manifest names **only** the provider slug, scopes, and how the obtained token
is delivered — never client secrets or endpoint URLs (those are admin-entered,
kept off the public feed). Skald handles PKCE + code exchange and injects the
credential as the named env var. `format`: `google_authorized_user` (Google) or
`refresh_token`. Today only `as: "env"` is wired.
### 4d. `qr` / interactive device login — the generic contract
For a connector whose credential is produced by **scanning/pairing** (WhatsApp
today), there is no code to paste. The rule:
> **Expose one extra tool, `login_status`, returning a JSON object** (as the
> `text` of a normal text result). Skald calls it directly (never the agent) and a
> login panel polls it.
```jsonc
// login_status result text (a JSON string):
{
"state": "connecting" | "need_scan" | "ready" | "logged_out",
"qr": "data:image/png;base64,…", // present ONLY while state == need_scan
"message": "human-readable line"
}
```
- `activate` on a `qr` connector inserts a **pending** row and **starts the
server** (so it can produce the QR), then hands off to the login panel.
- The panel polls `POST /api/mcp/login/status`; when `state == "ready"` the
connector is marked ready and starts automatically on later logins.
- Also expose a `logout` tool (clears the session, forces a fresh QR) — the panel
calls it via `POST /api/mcp/login/reset` to re-link a different phone.
- The **credential is the on-disk session**, not a token. Persist it **inside the
connector's own directory** (e.g. `./auth/` next to the entry file). That folder
lives under the bind-mounted home, so it survives container recreates and
connector updates. Never store it under a shared/global path.
Skald resolves `auth.type: "qr"` the same way whether it appears in the index
entry or the manifest.
---
## 5. Dependencies (node & python) — how they get installed
**Do not ship `node_modules/` or vendored wheels.** Declare deps as a standard
manifest **file** and Skald installs them inside the container:
- **node:** ship a `package.json` with a `dependencies` map. Skald runs
`npm ci --omit=dev` (falling back to `npm install --omit=dev`) in the connector
dir. `node_modules/` resolves automatically beside the entry file.
- **python:** ship a `requirements.txt`. Skald installs it with
`pip install --target .pydeps` and puts `.pydeps` on the server's `PYTHONPATH`.
This runs at activation **and** on every startup, guarded by a **content hash** of
the connector's source files:
- first activation / a brand-new container → full install,
- a connector **update** (any shipped file changed) → re-copy + re-install,
- unchanged → skipped in microseconds.
So you never write install steps into the manifest — just ship the dep file, list
it in the index with its SHA-256, and set `requires: ["NODE"]` / `["PYTHON"]` as a
human hint. Pin versions in `package.json` / `requirements.txt` for reproducible
installs. Keep the dep tree lean (containers are slim; avoid native-heavy
packages where a pure alternative exists — e.g. Baileys instead of a browser).
---
## 6. Verify-before-save (optional but recommended)
Ship a `verify.py` / verify snippet and reference it:
```jsonc
"verify": { "command": "python3 verify.py", "timeout_secs": 15 }
```
It runs with the collected env/secret injected and must print **one JSON object**
on stdout: `{"ok": bool, "message": string, "details"?: object}`, exit 0 on
success. Used for `api_key`/`none` connectors to test creds before activating.
(A `qr` connector needs no verify — its `login_status` is the live check.)
---
## 7. Versioning & updates
Three fields, in **both** the index entry and the `connector.json`, kept identical:
| field | type | role |
| --- | --- | --- |
| `version` | **integer** | monotonic build number, **per connector** — the machine comparison key |
| `version_string` | string (semver) | display only |
| `version_release_date` | ISO date `YYYY-MM-DD` | display only |
- `version` is a **number, not a string** (`1`, not `"1"` or `"2.0.1"`). Start at
`1` for the first release under this scheme; **`+1` on every change** to any
shipped file **or to any manifest metadata** (description, icons, `version_string`).
Never reuse or decrement.
- Skald stores the installed `version` and compares it to the feed's: a strictly
greater feed `version` shows **"update available"** in the marketplace, and the
Install button becomes **Update**. Clicking it re-downloads the files and rewrites
the catalog row.
- **The integer is the *only* "is there an update?" signal** — it is compared
strictly (`feed > installed`). `version_string` (semver), icons and
`llm_short_description` are **never** compared, so a change to any of them that
does not also bump the integer is **invisible**: no "update available" badge
appears. This is the common trap — a "content-only" edit (e.g. a better
`llm_short_description`) that forgets the integer.
- **Two propagation paths, do not conflate them:**
- *Per-user code + deps* (the scripts, `package.json`/`requirements.txt`) reconcile
on a **content-hash** of the source files (§5), so new code lands at each user's
next login even without a reinstall.
- *Catalog metadata* (`llm_short_description` → the model's prompt, icons, friendly
name) is **not** in that hash — it lives in the catalog row and is rewritten only
by an explicit **reinstall/Update**. On reinstall Skald re-pulls the current feed
(never the browse cache) and pushes the new description live: enabled global
servers restart with it, and every logged-in user who activated the connector has
it restarted with the fresh `llm_short_description` — no re-login needed.
- So: to ship a new `llm_short_description`, **bump the integer** (so the admin sees
"update available") and the admin clicks **Update**. Nothing auto-propagates a
description change.
- `version_string` and `version_release_date` are display metadata only — never
compared. (Migration note: replace any legacy string `"version": "2.0.1"` with
the integer `version` + `version_string`.)
---
## 8. Checklist for a new connector
1. Folder `myconn/` with: entry file, `connector.json`, deps file
(`package.json`/`requirements.txt`), `icon_sm.svg`, `icon_lg.svg`,
optional `verify.*`.
2. Create `fragment.json` inside the folder (same schema as the index entry
without `files[]` — include `tools[]` if needed).
3. Add `"myconn"` to `connectors/index.json`.
4. Run `python3 scripts/compile.py` (auto-generates `connectors.json` with
fresh SHA-256 digests).
5. Server speaks MCP over stdio (§2); **stdout = JSON-RPC only**.
6. `mcp_config.args[0]` names the entry file.
7. Correct `type` + `scope` (§3) and `auth.type` (§4).
8. For `qr`: implement `login_status` (+ `logout`), persist the session under the
connector dir (§4d).
9. Deps declared as a file, **not** vendored (§5).
10. Bump `version`.
11. Run `python3 scripts/compile.py --verify` to confirm the index is fresh,
then commit.
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CLAUDE.md"],
"references": {
"docs": {
"path": "CONNECTOR_MANIFEST_GUIDE.md",
"description": "Format used by the marketplace. The marketplace is used by ~/projects/skald-circle"
}
},
"permission": {
"bash": {
"cargo *": "allow",
"./run.sh": "allow",
"./run-docker.sh": "allow",
"./run-log.sh": "allow",
"./backup.sh": "allow",
"rg *": "allow",
"git status": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"*": "ask"
},
"external_directory": { "*": "allow" }
},
"tool_output": { "max_lines": 200, "max_bytes": 16384 }
}
+6 -2
View File
@@ -13,8 +13,12 @@ Reads:
Produces: Produces:
- connectors/connectors.json — full index with files[] and SHA-256 - connectors/connectors.json — full index with files[] and SHA-256
Auto-excluded: fragment.json, connector.json, compile.sh, compile.py, Auto-excluded: fragment.json, connectors.json, index.json, compile.sh, compile.py,
update_hashes.py, .DS_Store, and scripts/ directory. update_hashes.py, .DS_Store, and every subdirectory. `connector.json` IS hashed —
it ships with the connector and the index is what pins it.
Note: only the folder's top level is scanned, so a connector must keep its files
flat; anything in a subdirectory never reaches files[].
""" """
from __future__ import annotations from __future__ import annotations