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
+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()