Add Email (IMAP/SMTP) and SSH Remote Access connectors

- CLAUDE.md: developer guide for the marketplace repo
- email/: generic IMAP+SMTP connector (stdlib only, config via env)
  - env[] array, auth.type: password, verify.py (IMAP+SMTP probe)
- ssh/: SSH Remote Access connector ported from Skald, modified
  - ALIASES_FILE → ~/.ssh_aliases.json (was ./secrets/...)
  - optional env[] (TTL/timeout tunables)
  - auth.type: none (per-alias auth at runtime via elicitation)
- tavily/: restructured with env[] (tavilyApiKey), verify.py, auth block
- connectors.json: added email, ssh, and tavily verify.py entries
- SKALD.md: documented env[], verify, password auth, SSH connector
This commit is contained in:
2026-07-16 22:29:33 +01:00
parent dedd09d7c7
commit 1caba6946c
15 changed files with 3420 additions and 8 deletions
+77
View File
@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
The **Skald Connectors Marketplace** — a catalog of tested connectors (adapters that let Skald agents talk to external services). It is **not an application**: there is no build system, package manager, or test suite. The repo is a set of JSON manifests, static HTML, icons, and standalone Python MCP scripts that are served as-is over HTTP.
`SKALD.md` is the authoritative spec (written in Italian) — read it before changing any schema or manifest.
## Architecture
Two-tier manifest model with a single trust root:
- **`connectors/connectors.json`** — the **index** and single root of trust. Lists every connector and, per connector, a `files[]` array with the `sha256` + `size` of each shipped file. It has **no hash of itself**, and the `files[]` arrays **do not include `connector.json`** — both are treated as unsigned manifests. Intended to be digitally signed in the future.
- **`connectors/<id>/connector.json`** — per-connector **technical manifest** for activation (launch command, transport, `mcp_config`, `auth`, `dependencies`, `docs`). One folder per connector; folder name matches the connector `id`.
- **`connectors/index.html`** — catalog UI. Fetches `/connectors.json` at absolute path and links to `/<folder>/`, so it only works when served from the site root (not opened as a `file://`).
Connector taxonomy (see SKALD.md for full enums):
- `type`: `mcp_remote` (hosted MCP server reached by URL, e.g. Tavily) vs `mcp_local` (script run client-side, e.g. Gmail).
- `scope`: `global` (one shared config) vs `user` (per-user auth/instance).
- `requires`: prerequisites like `API_KEY`, `OAUTH`, `PYTHON`, `NODE`, `DOCKER`, `ENV`. (`SECRETS_DIR` is **deprecated** — the `secrets/` folder is gone from the model; connectors must declare their inputs as `ENV`/`SECRET`.)
**Unified placeholder syntax.** Every value skald must fill at runtime uses one of two tokens, anywhere it appears (URL, `mcp_config.env`, `verify.command`):
- `{ENV:NAME}` — a non-sensitive value (host, port, username…), declared in the top-level `env[]` array.
- `{SECRET:NAME}` — a sensitive value (password, API key, token), also declared in `env[]` with `secret: true`.
Legacy tokens `{key}`, `{env:NAME}`, `{secrets}/…` are **deprecated** and skald no longer substitutes them. The api-key of an `auth.type = "api_key"` connector is exposed as `{SECRET:<auth.param>}` (e.g. Tavily: `?tavilyApiKey={SECRET:tavilyApiKey}`). See SKALD.md § Sintassi placeholder.
**Config via `env[]` (`env` manifest field):** a connector whose `requires` includes `ENV` declares its required environment variables in a top-level `env` array in `connector.json` — each entry has `name`, `label`, `description`, `required`, `secret`, and optional `default`/`example`. skald renders a real form from this schema (masking `secret: true` fields, marking `required`, using `example` as placeholder) and injects the collected values into the process environment at launch; the server reads `os.environ`. Nothing is written to disk. The `email` connector is the reference example (see SKALD.md § Campo env). **`tavily` now also declares `env[]`** (its API key as a `secret` field).
**Verify-before-save (`verify` manifest field).** A connector may declare a `verify` step — a shell command skald runs *after* the user fills the form and *before* persisting the activation, to confirm the credentials actually work:
```json
"verify": { "command": "python3 verify.py", "timeout_secs": 20 }
```
The command runs in the same sandbox as the server (container `skald-{userid}` for `mcp_local` user, host for `mcp_remote` global), with the collected env/secret injected. It must print one JSON object on stdout — `{"ok": bool, "message": string, "details"?: object}` — and exit 0 on success. If `verify` is absent, no test runs and the activation is direct. See SKALD.md § Campo verify. `email` and `tavily` ship a `verify.py`; `gmail` will get one in the OAuth phase.
**MCP servers** (`connectors/gmail/gmail_mcp_server.py`, `connectors/email/email_mcp_server.py`) are hand-rolled JSON-RPC 2.0 servers over stdio — no MCP SDK / FastMCP. Shared conventions: **stdout is reserved for JSON-RPC**, all logging goes to stderr, a lock guards stdout writes, a background thread emits `event/new_email` push notifications, and each server exposes the same tool shape (`TOOLS` manifest + `TOOL_DISPATCH` + `handle_request`). Mirror this structure for new local connectors.
- **Gmail** uses the Gmail API with OAuth; today it still reads its token from `./secrets/gmail_creds.json` (override `GMAIL_CREDS_PATH`) generated by `gmail_oauth_setup.py`. The `secrets/` path is **deprecated** — the OAuth phase (skald Fase 2) will migrate Gmail to `{ENV:}`/`{SECRET:}` and a loopback-listener flow. Push = History API polling. `verify` is not yet wired for Gmail.
- **Email** is a generic IMAP+SMTP connector, **stdlib-only (no dependencies)**, configured entirely from `{ENV:}`/`{SECRET:}` env vars (no `secrets/`), works with any provider. Push = IMAP IDLE with a 60s polling fallback; the request thread and the watcher thread each hold their own IMAP connection (imaplib is not thread-safe). Note: imaplib does not quote SEARCH arguments, so values with spaces must be wrapped via `_q()`. Ships a `verify.py` (IMAP+SMTP login probe).
- **Tavily** is `mcp_remote`/`global`; its API key is declared as an `env[]` secret (`tavilyApiKey`) and templated into the URL as `{SECRET:tavilyApiKey}`. Ships a `verify.py` (minimal `/search` POST probe).
- **SSH** is `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 (`sudo -S`). No setup-time credentials: `auth.type: "none"` with no `verify` (activation is direct). All env vars (`SSH_MCP_*`) are optional with defaults. Ships `requirements.txt` (paramiko).
## Critical invariants
- **Editing any shipped connector file (script, icon, requirements, `verify.py`) requires updating its `sha256` AND `size` in `connectors.json`.** A stale hash breaks integrity verification on the client. `connector.json` and `connectors.json` themselves are not hashed, so editing them needs no hash update.
- **Adding a connector** means changes in two places: a new entry in `connectors.json` (with all file hashes) *and* a new `connectors/<id>/connector.json`. Keep `id`, `scope`, `type`, `tags`, and `requires` consistent between the two.
- **A new `verify.py` must be added to `files[]`** (with `sha256`+`size`) exactly like any other shipped file — skald refuses to run an unverified script.
- The `secrets/` directory is **deprecated** in the model (credentials flow through `{ENV:}`/`{SECRET:}`). Gmail still uses it locally pending the OAuth migration; that path is gitignored — never commit credentials or OAuth tokens.
## Commands
Recompute a file hash (do this before deploy for every changed file; SKALD.md mentions a `scripts/update_hashes.py` helper that does not exist in the repo yet):
```bash
python3 -c "import hashlib; print(hashlib.sha256(open('connectors/gmail/gmail_mcp_server.py','rb').read()).hexdigest())"
```
Preview the catalog locally (must serve from the `connectors/` root so `/connectors.json` resolves):
```bash
cd connectors && python3 -m http.server 8000 # then open http://localhost:8000/
```
Set up and run the Gmail connector:
```bash
pip install -r connectors/gmail/requirements.txt
python3 connectors/gmail/gmail_oauth_setup.py # opens browser for OAuth, writes token
python3 connectors/gmail/gmail_mcp_server.py # speaks JSON-RPC on stdin/stdout
```
Deploy to the production server (Caddy, path `/var/www/connectors.skaldagent.net/`):
```bash
rsync -avz --delete ./connectors/ dguiducci@skald-server:/var/www/connectors.skaldagent.net/
# then fix ownership/permissions on the server:
sudo chown -R caddy:caddy /var/www/connectors.skaldagent.net/
sudo find /var/www/connectors.skaldagent.net/ -type f -exec chmod 644 {} \;
```
Remote git: `https://git.skaldagent.net/dguiducci/skald-connectors.git` (branch `main`). Live site: `https://connectors.skaldagent.net/`.
+142 -6
View File
@@ -2,6 +2,8 @@
_Updated: 2026-07-16_
**Remote**: `https://git.skaldagent.net/dguiducci/skald-connectors.git` (branch: `main`)
## Cos'è
Il **marketplace** è il catalogo dei connector testati per Skald. Ogni connector è un adattatore che permette a Skald di interfacciarsi con un servizio esterno (API, email, calendario, ricerca, messaggistica, ecc.).
@@ -149,7 +151,8 @@ Configurazione tecnica per l'attivazione del connector.
| `icon_large` | ✅ | Nome file icona nella cartella locale |
| `launch_command` | solo `mcp_local` | Comando per avviare il server MCP |
| `transport` | solo `mcp_local` | `stdio` (default) |
| `dependencies` | consigliato | Dipendenze Python/Node |
| `dependencies` | consigliato | Dipendenze Python/Node (array vuoto se solo stdlib) |
| `env` | se `requires` include `ENV` | Variabili d'ambiente che l'utente deve fornire (schema per la UI) — vedi § Campo env |
| `setup_instructions` | consigliato | Passi per configurare il connector |
| `mcp_config` | solo `mcp_local` | Configurazione per l'MCP client |
| `homepage` | opzionale | URL del servizio |
@@ -180,7 +183,8 @@ Configurazione tecnica per l'attivazione del connector.
| `DOCKER` | Richiede Docker Engine |
| `NODE` | Richiede Node.js runtime |
| `PYTHON` | Richiede Python 3 |
| `SECRETS_DIR` | Richiede file di credenziali in `secrets/` |
| `SECRETS_DIR` | **Deprecato** — la cartella `secrets/` è rimossa dal modello; i connector devono usare `ENV`/`SECRET` (vedi § Sintassi placeholder) |
| `ENV` | Richiede variabili d'ambiente (dichiarate nel campo `env` del manifest) |
## Campo auth
@@ -196,10 +200,137 @@ Struttura che descrive come il connector gestisce l'autenticazione:
// OAuth2
{"type": "oauth2", "provider": "google", "scopes": ["...", "..."]}
// Password / app-password fornita via variabili d'ambiente
{"type": "password", "delivery": "env"}
// Nessuna autenticazione
{"type": "none"}
```
## Campo env (variabili d'ambiente)
Usato quando `requires` include `ENV`. È un **array** che dichiara le variabili
d'ambiente che l'utente deve fornire per far funzionare il connector; **nessuna
credenziale finisce su disco né in `secrets/`** — l'host raccoglie i valori,
obbliga la compilazione dei campi obbligatori, e li inietta come environment
nel processo del server MCP al lancio. Il server le legge da `os.environ`.
```json
"env": [
{
"name": "EMAIL_IMAP_HOST", // nome della variabile d'ambiente
"label": "IMAP host", // etichetta per la UI
"description": "IMAP server hostname (es. imap.gmail.com)",
"required": true, // se true, l'host deve obbligare la compilazione
"secret": false, // se true, la UI la maschera e la tratta come segreto
"example": "imap.gmail.com" // placeholder/esempio (opzionale)
},
{
"name": "EMAIL_PASSWORD",
"label": "Password / app password",
"description": "Password o app-password del provider",
"required": true,
"secret": true,
"default": "" // valore di default se non obbligatorio (opzionale)
}
]
```
| Campo | Obbligatorio | Descrizione |
|-------|-------------|-------------|
| `name` | ✅ | Nome della variabile d'ambiente (UPPER_SNAKE_CASE) |
| `label` | ✅ | Etichetta breve per la UI |
| `description` | ✅ | Testo di aiuto |
| `required` | ✅ | Se `true`, l'host obbliga l'utente a fornire un valore |
| `secret` | consigliato | Se `true`, valore sensibile (mascherato, non loggato) |
| `default` | opzionale | Valore usato se non fornito (solo per non obbligatorie) |
| `example` | opzionale | Placeholder di esempio per la UI |
## Sintassi placeholder (unificata)
Ogni valore che skald deve riempire a runtime con un dato fornito dall'utente
usa **uno di due token**, ovunque compaia (URL, `mcp_config.env`, `verify.command`):
| Token | Significato | Esempio |
|-------|-------------|---------|
| `{ENV:NAME}` | Variabile non sensibile (hostname, porta, username…) | `{ENV:EMAIL_IMAP_HOST}` |
| `{SECRET:NAME}` | Variabile sensibile (password, API key, token) | `{SECRET:EMAIL_PASSWORD}` |
`NAME` è il nome dichiarato nell'array `env[]` (campo `name`). skald raccoglie
i valori tramite un form (maschera i `{SECRET:}`), li inietta come environment
nel processo del server MCP / verify, e sostituisce i token nel manifest.
Regole:
- I token non riconosciuti (es. `{secrets}/…`, legacy `{key}`, `{env:NAME}`)
sono **deprecati**: skald non li sostituisce e il manifest va aggiornato.
- `{SECRET:<auth.param>}` è riservato alla chiave primaria quando
`auth.type = "api_key"` (es. Tavily: `{SECRET:tavilyApiKey}`). skald tratta
quel valore anche come API key per il routing bearer/header.
- Un token `{ENV:X}` o `{SECRET:X}` la cui `X` non è nell'`env[]` del manifest
viene sostituito con stringa vuota (l'host non può indovinarlo).
### Deprecations
| Token | Stato | Sostituzione |
|-------|-------|--------------|
| `{key}` | ❌ deprecato | `{SECRET:<auth.param>}` |
| `{env:NAME}` | ❌ deprecato | `{ENV:NAME}` |
| `{secrets}/…` | ❌ deprecato | Il connector deve dichiarare il path come `{ENV:…}` (la cartella `secrets/` è rimossa dal modello) |
## Campo verify (test prima del salvataggio)
Dichiara un comando shell che skald esegue **dopo** che l'utente ha compilato
il form e **prima** di persistere l'attivazione. Serve a verificare che le
credenziali appena inserite funzionino davvero.
```json
"verify": {
"command": "python3 verify.py",
"timeout_secs": 20
}
```
| Campo | Obbligatorio | Descrizione |
|-------|-------------|-------------|
| `command` | ✅ | Comando shell. Gira nello stesso sandbox del server: container `skald-{userid}` per `mcp_local` user, host per `mcp_remote` global. Le env/secret dichiarati sono iniettate |
| `timeout_secs` | opzionale | Default 15. skald killa il processo allo scadere |
### Convenzione output
Il comando deve stampare **un singolo oggetto JSON su stdout** e nient'altro:
```json
{"ok": true, "message": "IMAP and SMTP authentication successful", "details": {"imap": "...", "smtp": "..."}}
{"ok": false, "message": "IMAP login failed: INVALID_CREDENTIALS"}
```
| Campo | Tipo | Descrizione |
|-------|------|-------------|
| `ok` | bool | `true` = test passato |
| `message` | string | Messaggio mostrato all'utente (mai loggare secret qui dentro) |
| `details` | object | Opzionale, dettagli strutturati mostrati in `<pre>` |
Exit code: 0 su successo, ≠ 0 su fallimento (skald usa l'exit code come
fallback se il parse JSON fallisce). **Mai stampare credenziali** nel
`message`/`details`.
### Dove mettere lo script
Se `command` referenzia un file (es. `verify.py`), il file va:
1. Aggiunto all'array `files[]` in `connectors.json` (con `sha256` e `size`)
2. Salvato nella cartella del connector (`<id>/verify.py`)
skald lo scarica, ne verifica lo SHA-256 contro l'indice, e lo rende disponibile
nello stesso path del server principale (container per i `mcp_local`, dir
`./scripts/<id>/` sull'host per i `mcp_remote`).
### Senza verify
Se `verify` manca, skald **non esegue nessun test** — l'attivazione è diretta
come oggi. Il connector va in `auth_state='ready'` senza verifica. Per i
`mcp_remote` non c'è fallback handshake: l'autore del manifest decide se vuole
il test scrivendo `verify`.
## Convenzioni icone
- **Formato**: SVG per icone vettoriali (meglio per retina/zoom), PNG per raster
@@ -256,7 +387,12 @@ sudo find /var/www/connectors.skaldagent.net/ -type f -exec chmod 644 {} \;
## Connector attuali
| ID | Nome | Tipo | Scope |
|----|------|------|-------|
| `tavily` | Tavily | `mcp_remote` | `global` |
| `gmail` | Gmail | `mcp_local` | `user` |
| ID | Nome | Tipo | Scope | Auth | Verify |
|----|------|------|-------|------|--------|
| `tavily` | Tavily | `mcp_remote` | `global` | api_key (`{SECRET:tavilyApiKey}` in URL) | `verify.py` (HTTP probe `/search`) |
| `gmail` | Gmail | `mcp_local` | `user` | oauth2 (Google) | ⏳ Fase 2 — il flusso OAuth non è ancora cablato in skald |
| `email` | Email (IMAP/SMTP) | `mcp_local` | `user` | password (env) | `verify.py` (IMAP+SMTP probe) |
| `ssh` | SSH Remote Access | `mcp_local` | `user` | none (auth runtime per-alias) | — (nessun setup credential) |
**Stato del verify-before-save in skald**: `email` e `tavily` hanno `verify` completo
(script + JSON output); `gmail` aspetta la Fase 2 (OAuth via loopback listener).
Un connector senza `verify` viene attivato senza test — vedi § Senza verify.
+91
View File
@@ -48,6 +48,92 @@
}
]
},
{
"id": "email",
"name": "Email (IMAP/SMTP)",
"type": "mcp_local",
"scope": "user",
"icon_small": "email/icon_sm.svg",
"icon_large": "email/icon_lg.svg",
"user_description": "Read, search, organize, and send email over IMAP/SMTP — any provider, no OAuth. Real-time push notifications on new mail.",
"requires": [
"PYTHON",
"ENV"
],
"tags": [
"email",
"mcp",
"local",
"imap",
"smtp"
],
"folder": "email",
"files": [
{
"path": "email_mcp_server.py",
"sha256": "e8e0efc7d07be0d743f73ae80b15c924a763e57fc19000b1ef1e98237465da0f",
"size": 54522
},
{
"path": "icon_lg.svg",
"sha256": "94128b8ab2a3e701d042b6294c46bf0581152a3fe066e2fb415433e964f6935b",
"size": 308
},
{
"path": "icon_sm.svg",
"sha256": "05b8d18f8233503bba4b6668dc6c83b2e5d65cb26cfc4901366e2fa43d90737a",
"size": 306
},
{
"path": "verify.py",
"sha256": "5bd16cf4c1e2fc8b5b58083d12be0999c06dcc48ab52c3db3a531025771f6ff7",
"size": 3395
}
]
},
{
"id": "ssh",
"name": "SSH Remote Access",
"type": "mcp_local",
"scope": "user",
"icon_small": "ssh/icon_sm.svg",
"icon_large": "ssh/icon_lg.svg",
"user_description": "Full SSH remote server access — files, commands, systemd, upload/download. Aliases manage host config; auth via key/agent or elicited password.",
"requires": [
"PYTHON"
],
"tags": [
"ssh",
"mcp",
"local",
"remote",
"server",
"sysadmin"
],
"folder": "ssh",
"files": [
{
"path": "ssh_mcp_server.py",
"sha256": "fbd7aa9dfad0da57f4b3d9d437c76fd978145c8364ed5fdfdfc3ba7ccfaf117b",
"size": 49764
},
{
"path": "requirements.txt",
"sha256": "fa65d44e3d3219c79e5272ef7123352b5d71a35871ede721e66422c897b94e0e",
"size": 14
},
{
"path": "icon_sm.svg",
"sha256": "57e9e5de2ec56548a8a3821bb29c7f75c8a02f45ae236c8ae3388bb0be9b4a81",
"size": 254
},
{
"path": "icon_lg.svg",
"sha256": "84dc85685be0fff4cae1c2554b5fe958dd94455e182117c7470838b3cfca6ba1",
"size": 432
}
]
},
{
"id": "tavily",
"name": "Tavily",
@@ -75,6 +161,11 @@
"path": "icon_sm.png",
"sha256": "92962ea1d49f272665262d55ecdea929972d3efab5261fc6ffea632803fceaf8",
"size": 24264
},
{
"path": "verify.py",
"sha256": "35a4f3b518c79363893c0438b1932b68e2dc5b4b61303037984488f6967b73a4",
"size": 2057
}
]
}
+131
View File
@@ -0,0 +1,131 @@
{
"id": "email",
"name": "Email (IMAP/SMTP)",
"version": "1.0.0",
"type": "mcp_local",
"launch_command": "python3 email_mcp_server.py",
"transport": "stdio",
"requires": [
"PYTHON",
"ENV"
],
"dependencies": [],
"env": [
{
"name": "EMAIL_IMAP_HOST",
"label": "IMAP host",
"description": "IMAP server hostname for reading mail (e.g. imap.gmail.com, outlook.office365.com, imap.mail.me.com).",
"required": true,
"secret": false,
"example": "imap.gmail.com"
},
{
"name": "EMAIL_IMAP_PORT",
"label": "IMAP port",
"description": "IMAP-over-SSL port. Almost always 993.",
"required": false,
"secret": false,
"default": "993",
"example": "993"
},
{
"name": "EMAIL_SMTP_HOST",
"label": "SMTP host",
"description": "SMTP server hostname for sending mail (e.g. smtp.gmail.com, smtp.office365.com, smtp.mail.me.com).",
"required": true,
"secret": false,
"example": "smtp.gmail.com"
},
{
"name": "EMAIL_SMTP_PORT",
"label": "SMTP port",
"description": "SMTP port: 465 for implicit SSL, 587 for STARTTLS.",
"required": false,
"secret": false,
"default": "465",
"example": "465"
},
{
"name": "EMAIL_SMTP_SECURITY",
"label": "SMTP security",
"description": "How to secure the SMTP connection: 'ssl' (port 465), 'starttls' (port 587), or 'plain'. If omitted, inferred from the port.",
"required": false,
"secret": false,
"default": "ssl",
"example": "ssl"
},
{
"name": "EMAIL_USERNAME",
"label": "Username / email address",
"description": "Login username, usually the full email address.",
"required": true,
"secret": false,
"example": "me@example.com"
},
{
"name": "EMAIL_PASSWORD",
"label": "Password / app password",
"description": "Account password. Providers with 2FA (Gmail, iCloud, Yahoo, Outlook) require an app-specific password, not your normal login password.",
"required": true,
"secret": true,
"example": "abcd efgh ijkl mnop"
},
{
"name": "EMAIL_FROM",
"label": "From address",
"description": "Address to put in the From header when sending. Defaults to the username if omitted.",
"required": false,
"secret": false,
"example": "me@example.com"
}
],
"setup_instructions": [
"No files on disk and no OAuth: configuration is entirely via environment variables.",
"Set EMAIL_IMAP_HOST, EMAIL_SMTP_HOST, EMAIL_USERNAME and EMAIL_PASSWORD (required).",
"For providers with 2FA (Gmail, iCloud, Yahoo, Outlook), generate an app-specific password and use it as EMAIL_PASSWORD.",
"Optionally set EMAIL_IMAP_PORT (993), EMAIL_SMTP_PORT (465/587), EMAIL_SMTP_SECURITY (ssl/starttls) and EMAIL_FROM.",
"Run the 'status' tool to verify IMAP and SMTP both authenticate."
],
"docs": [
{
"lang": "en",
"description": "Generic email connector over IMAP + SMTP. Works with any provider (Gmail, Outlook/Office 365, iCloud, Yahoo, Fastmail, self-hosted, corporate) using standard protocols and an app password — no Google Cloud Console, no OAuth. Read, search, organise and send mail, download attachments, and receive real-time push notifications when new mail arrives (IMAP IDLE, with polling fallback). Standard library only, zero dependencies.",
"llm_short_description": "Email MCP server (IMAP/SMTP, any provider). Config via env vars. Tools: status, list_messages, get_message, get_thread, list_folders, modify_message, send_message, get_profile, create_folder, download_attachments. Emits event/new_email push notifications on new INBOX mail."
}
],
"mcp_config": {
"command": "python3",
"args": [
"email_mcp_server.py"
],
"env": {
"EMAIL_IMAP_HOST": "{ENV:EMAIL_IMAP_HOST}",
"EMAIL_IMAP_PORT": "{ENV:EMAIL_IMAP_PORT}",
"EMAIL_SMTP_HOST": "{ENV:EMAIL_SMTP_HOST}",
"EMAIL_SMTP_PORT": "{ENV:EMAIL_SMTP_PORT}",
"EMAIL_SMTP_SECURITY": "{ENV:EMAIL_SMTP_SECURITY}",
"EMAIL_USERNAME": "{ENV:EMAIL_USERNAME}",
"EMAIL_PASSWORD": "{SECRET:EMAIL_PASSWORD}",
"EMAIL_FROM": "{ENV:EMAIL_FROM}"
}
},
"verify": {
"command": "python3 verify.py",
"timeout_secs": 20
},
"homepage": "",
"icon_small": "icon_sm.svg",
"icon_large": "icon_lg.svg",
"scope": "user",
"tags": [
"email",
"mcp",
"local",
"imap",
"smtp"
],
"auth": {
"type": "password",
"delivery": "env"
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#2563eb"/><rect x="20" y="30" width="56" height="36" rx="5" fill="#fff"/><path d="M22 34l26 18 26-18" fill="none" stroke="#2563eb" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 308 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="#2563eb"/><rect x="10" y="15" width="28" height="18" rx="3" fill="#fff"/><path d="M11 17l13 9 13-9" fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 306 B

+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Verify the Email connector credentials by attempting IMAP and SMTP logins.
Reads the EMAIL_* environment variables (the same set the MCP server consumes),
opens one IMAP and one SMTP connection, attempts LOGIN on each, and prints a
single JSON object on stdout:
{"ok": true, "message": "...", "details": {"imap": "...", "smtp": "..."}}
{"ok": false, "message": "...", "details": {"imap": "...", "smtp": "..."}}
Exit code is 0 on success, 1 on any failure. No credentials are ever printed.
stdlib only (imaplib, smtplib, ssl) — mirrors the email_mcp_server.py constraint.
"""
import imaplib
import json
import os
import smtplib
import ssl
import sys
def _result(ok, message, **extra):
print(json.dumps({"ok": ok, "message": message, **extra}))
sys.exit(0 if ok else 1)
def _imap():
host = os.environ.get("EMAIL_IMAP_HOST", "").strip()
port = int(os.environ.get("EMAIL_IMAP_PORT", "993") or "993")
user = os.environ.get("EMAIL_USERNAME", "").strip()
pw = os.environ.get("EMAIL_PASSWORD", "")
if not host or not user or not pw:
return False, "EMAIL_IMAP_HOST, EMAIL_USERNAME and EMAIL_PASSWORD are required"
try:
ctx = ssl.create_default_context()
with imaplib.IMAP4_SSL(host, port, ssl_context=ctx) as imap:
imap.login(user, pw)
imap.select("INBOX", readonly=True)
return True, f"IMAP login to {host}:{port} successful"
except Exception as e:
return False, f"IMAP login to {host}:{port} failed: {e}"
def _smtp():
host = os.environ.get("EMAIL_SMTP_HOST", "").strip()
port = int(os.environ.get("EMAIL_SMTP_PORT", "465") or "465")
sec = os.environ.get("EMAIL_SMTP_SECURITY", "").strip().lower()
user = os.environ.get("EMAIL_USERNAME", "").strip()
pw = os.environ.get("EMAIL_PASSWORD", "")
if not host or not user or not pw:
return False, "EMAIL_SMTP_HOST, EMAIL_USERNAME and EMAIL_PASSWORD are required"
if not sec:
sec = "starttls" if port in (587, 25) else "ssl"
try:
ctx = ssl.create_default_context()
if sec == "ssl":
with smtplib.SMTP_SSL(host, port, context=ctx, timeout=15) as s:
s.login(user, pw)
elif sec == "starttls":
with smtplib.SMTP(host, port, timeout=15) as s:
s.starttls(context=ctx)
s.login(user, pw)
else:
with smtplib.SMTP(host, port, timeout=15) as s:
s.login(user, pw)
return True, f"SMTP login to {host}:{port} ({sec}) successful"
except Exception as e:
return False, f"SMTP login to {host}:{port} failed: {e}"
def main():
imap_ok, imap_msg = _imap()
smtp_ok, smtp_msg = _smtp()
if imap_ok and smtp_ok:
_result(True, "IMAP and SMTP authentication successful",
details={"imap": imap_msg, "smtp": smtp_msg})
if imap_ok and not smtp_ok:
_result(False, f"IMAP ok but SMTP failed: {smtp_msg}",
details={"imap": imap_msg, "smtp": smtp_msg})
if smtp_ok and not imap_ok:
_result(False, f"SMTP ok but IMAP failed: {imap_msg}",
details={"imap": imap_msg, "smtp": smtp_msg})
_result(False, f"Both IMAP and SMTP failed: {imap_msg} | {smtp_msg}",
details={"imap": imap_msg, "smtp": smtp_msg})
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
{
"id": "ssh",
"name": "SSH Remote Access",
"version": "1.0.0",
"type": "mcp_local",
"launch_command": "python3 ssh_mcp_server.py",
"transport": "stdio",
"requires": [
"PYTHON"
],
"dependencies": [
"paramiko>=3.4"
],
"env": [
{
"name": "SSH_MCP_POOL_TTL",
"label": "Connection pool TTL (seconds)",
"description": "How long an idle SSH connection stays open before being closed. Default: 300 (5 min).",
"required": false,
"secret": false,
"default": "300",
"example": "300"
},
{
"name": "SSH_MCP_COMMAND_TIMEOUT",
"label": "Command timeout (seconds)",
"description": "Max time a remote command can run before being killed. Default: 120.",
"required": false,
"secret": false,
"default": "120",
"example": "120"
},
{
"name": "SSH_MCP_CONNECT_TIMEOUT",
"label": "Connect timeout (seconds)",
"description": "Max time to wait for the SSH handshake. Default: 15.",
"required": false,
"secret": false,
"default": "15",
"example": "15"
},
{
"name": "SSH_MCP_LOGIN_PW_TTL",
"label": "Login password cache TTL (seconds)",
"description": "How long an elicited login password stays cached in RAM. Default: 300.",
"required": false,
"secret": false,
"default": "300",
"example": "300"
},
{
"name": "SSH_MCP_SUDO_PW_TTL",
"label": "Sudo password cache TTL (seconds)",
"description": "How long an elicited sudo password stays cached in RAM. Default: 300.",
"required": false,
"secret": false,
"default": "300",
"example": "300"
},
{
"name": "SSH_MCP_KEY_PASSPHRASE",
"label": "SSH key passphrase (non-interactive override)",
"description": "Passphrase for an encrypted private key. Overrides elicitation. Use only in automated environments.",
"required": false,
"secret": true,
"example": ""
}
],
"setup_instructions": [
"Install dependencies: pip install -r requirements.txt",
"No OAuth or API key needed: host authentication is managed at runtime via add_alias.",
"Add an alias: mcp__ssh__add_alias(alias=\"my-server\", hostname=\"...\", username=\"...\", auth=\"key\")",
"The server stores aliases in ~/.ssh_aliases.json (auto-managed, never hand-edit).",
"Optional: tune TTLs via SSH_MCP_* environment variables (see env[] above)."
],
"docs": [
{
"lang": "en",
"description": "Full SSH remote server access: read/write files, grep, execute commands, manage systemd services, upload/download files and directories, and probe system info — all over SFTP and SSH with the same output format as Skald's native tools. Aliases manage host config (hostname, port, user, auth method, sudo policy). Login passwords and sudo passwords are elicited on demand and kept only in RAM with configurable TTL. No OAuth, no API keys, no secrets on disk.",
"llm_short_description": "SSH MCP server: 13 tools (list/add/remove_alias, read_file, list_files, grep_files, edit_file, replace_lines, exec, upload, download, sysinfo, systemd). Auth via key/agent or elicited password. Sudo via nopasswd or elicited password. Connection pooling with lazy TTL eviction."
}
],
"mcp_config": {
"command": "python3",
"args": [
"ssh_mcp_server.py"
],
"env": {
"SSH_MCP_POOL_TTL": "{ENV:SSH_MCP_POOL_TTL}",
"SSH_MCP_COMMAND_TIMEOUT": "{ENV:SSH_MCP_COMMAND_TIMEOUT}",
"SSH_MCP_CONNECT_TIMEOUT": "{ENV:SSH_MCP_CONNECT_TIMEOUT}",
"SSH_MCP_LOGIN_PW_TTL": "{ENV:SSH_MCP_LOGIN_PW_TTL}",
"SSH_MCP_SUDO_PW_TTL": "{ENV:SSH_MCP_SUDO_PW_TTL}",
"SSH_MCP_KEY_PASSPHRASE": "{SECRET:SSH_MCP_KEY_PASSPHRASE}"
}
},
"homepage": "",
"icon_small": "icon_sm.svg",
"icon_large": "icon_lg.svg",
"scope": "user",
"tags": [
"ssh",
"mcp",
"local",
"remote",
"server",
"terminal",
"sysadmin"
],
"auth": {
"type": "none"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
<rect width="96" height="96" rx="14" fill="#1e293b"/>
<text x="48" y="55" font-family="monospace" font-size="40" fill="#22d3ee" text-anchor="middle" font-weight="bold">SSH</text>
<line x1="20" y1="70" x2="76" y2="70" stroke="#334155" stroke-width="2"/>
<rect x="28" y="60" width="40" height="2" rx="1" fill="#22d3ee" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect width="48" height="48" rx="8" fill="#1e293b"/>
<text x="24" y="30" font-family="monospace" font-size="22" fill="#22d3ee" text-anchor="middle">SSH</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
paramiko>=3.4
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -4,12 +4,27 @@
"version": "1.0.0",
"type": "mcp_remote",
"mcp_config": {
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey={key}",
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey={SECRET:tavilyApiKey}",
"transport": "streamable-http"
},
"requires": [
"API_KEY"
"API_KEY",
"ENV"
],
"env": [
{
"name": "tavilyApiKey",
"label": "Tavily API key",
"description": "Your Tavily API key (find it at https://app.tavily.com). Used both as the MCP URL query parameter and for the verification request.",
"required": true,
"secret": true,
"example": "tvly-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
],
"verify": {
"command": "python3 verify.py",
"timeout_secs": 15
},
"docs": [
{
"lang": "en",
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Verify the Tavily API key by making a minimal search request.
Reads TAVILY_API_KEY from the environment (the key skald collected from the
user and substituted into the {SECRET:tavilyApiKey} placeholder), POSTs a
minimal search to https://api.tavily.com/search, and prints a single JSON
object on stdout:
{"ok": true, "message": "Tavily API key is valid"}
{"ok": false, "message": "Tavily API key is invalid or unauthorized"}
Exit code is 0 on success, 1 on any failure. The API key is never printed.
stdlib only (urllib).
"""
import json
import os
import sys
import urllib.error
import urllib.request
def _result(ok, message):
print(json.dumps({"ok": ok, "message": message}))
sys.exit(0 if ok else 1)
def main():
api_key = os.environ.get("tavilyApiKey", "").strip()
if not api_key:
# Also accept the UPPER_SNAKE form some users may type.
api_key = os.environ.get("TAVILY_API_KEY", "").strip()
if not api_key:
_result(False, "No Tavily API key provided (tavilyApiKey env var is empty)")
body = json.dumps({
"api_key": api_key,
"query": "skald connectivity check",
"max_results": 1,
"search_depth": "basic",
}).encode("utf-8")
req = urllib.request.Request(
"https://api.tavily.com/search",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if 200 <= resp.status < 300:
_result(True, "Tavily API key is valid")
_result(False, f"Tavily returned HTTP {resp.status}")
except urllib.error.HTTPError as e:
if e.code in (401, 403):
_result(False, "Tavily API key is invalid or unauthorized "
f"(HTTP {e.code})")
_result(False, f"Tavily returned HTTP {e.code}: {e.reason}")
except Exception as e:
_result(False, f"Tavily verify request failed: {e}")
if __name__ == "__main__":
main()