MCP server with a language-agnostic plugin system. Plugins are simple executables (Python, bash, or any language) that communicate with the core over JSON-lines on stdin/stdout. The core handles auth, HTTP proxying, caching, and output encoding so plugins stay minimal.
┌─────────────────────────────────────────────────┐
│ wtmcp (Go) │
│ │
│ MCP Server ─── Plugin Manager ─── HTTP Proxy │
│ (mcp-go) Discovery Auth inject │
│ Lifecycle SSRF protect │
│ Dispatch TLS verify │
│ Rate limit │
│ │
│ Audit Log ─── Cache Store ─── Auth Providers │
│ (JSON) (memory/fs) Bearer, Basic, │
│ Kerberos, OAuth2 │
│ Sandbox (optional) │
│ Landlock + cgroups + netns │
└────────┬──────────────────────────┬─────────────┘
│ stdio (MCP/JSON-RPC) │ stdin/stdout (JSON-lines)
┌────┴────┐ ┌─────┴──────────┐
│ AI │ │ Plugins │
│ Client │ │ Zero deps │
└─────────┘ │ No HTTP libs │
│ No auth code │
└────────────────┘
- Plugin protocol: JSON-lines over stdin/stdout, any language
- Auth: Bearer, Basic, Kerberos/SPNEGO, OAuth2 with token refresh, auto-detection from available credentials
- HTTP proxy: Auth injection, domain validation, TLS enforcement, binary response encoding, multipart upload support
- Cache: In-memory store with namespace isolation and TTL
- File I/O: Atomic writes, path confinement, size limits, per-plugin output directories, source_path handoff for large files
- Output: TOON encoding for ~40% token savings (optional)
- Plugin setup: Manifest-declared wizard metadata for CLI tooling
- Progressive discovery: Tools default to deferred; only primary
tools are loaded into model context. Deferred tools are
discoverable via
tool_searchand called directly through MCP - Encrypted credentials: Ansible Vault encrypted env.d files, auto-detected and decrypted transparently at startup
wtmcp enforces security at the core level so plugins don't need to implement their own auth, input validation, or network restrictions.
All plugin HTTP traffic goes through the core proxy. No plugin makes direct network connections.
- SSRF-safe dialer validates resolved IPs at connection time — blocks private, loopback, link-local, multicast, and IPv6-mapped IPv4 addresses
- Domain allowlisting per plugin — only declared domains are reachable
- Credential stripping on cross-domain redirects — Authorization, Cookie, and API key headers are removed when redirected to a different host
- Dangerous headers stripped from plugin-crafted requests (Host, Proxy-Authorization, X-Forwarded-For, etc.)
- HTTPS enforcement for authenticated requests; mTLS support with certificate chain verification
- Userinfo URL rejection —
user:pass@hostURLs are blocked
Build with -tags sandbox to enable OS-level plugin isolation via
arapuca:
- Landlock LSM filesystem confinement — plugins can only read/write declared paths
- cgroup v2 resource limits — memory, CPU, PIDs, file size (configurable per-plugin)
- Network namespace isolation — plugins cannot make direct connections; all traffic routes through the core proxy
- OOM detection and resource usage reporting after process exit
# Build with sandbox support (requires libarapuca)
make build-sandbox
# Default build works without libarapuca
make buildWhen sandbox is enabled in config but the binary lacks the tag, the server refuses to start with a clear error. When config uses the default (sandbox enabled implicitly), the server starts with a warning.
Token-bucket rate limiting with configurable per-plugin, per-domain, and global limits. Defaults: 120 req/min per plugin, 600 req/min global.
http:
rate_limit:
default: "120/m"
global: "600/m"
per_plugin:
jira: "60/m"
per_domain:
api.github.com: "30/m"Automatic retry with exponential backoff for transient upstream
failures. Only idempotent methods (GET, HEAD, OPTIONS, PUT,
DELETE) are retried — POST/PATCH are never retried. Respects
Retry-After headers (clamped to 30s). Context-aware: the tool
call timeout naturally caps total retry duration.
http:
retries:
max: 3 # retries (not counting initial)
backoff: exponential # 1s, 2s, 4s... capped at 30s
retry_on: [500, 502, 503, 504] # status codes to retryPer-plugin entry limits with LRU eviction. Entries exceeding
max_entry_size are rejected. Background cleanup removes expired
entries at the configured interval.
cache:
max_entries_per_plugin: 10000 # LRU eviction when exceeded
max_entry_size: 1048576 # 1MB max per entry
cleanup_interval: 60s # expired entry sweepStructured JSON audit log with UUIDv7 correlation IDs:
- Tool call events: plugin, tool, parameters (scrubbed), duration
- Elicitation events: plugin, tool, action (accept/decline/cancel/ error/unsupported)
- HTTP proxy events: method, host, path, status, response size
- Credential scrubbing: field names (password, token, secret),
JWT detection (
eyJprefix), high-entropy string detection - Configurable output: file (0600 permissions) and/or stdout
audit:
log_file: logs/audit.log
stdout: false
scrub_fields: [password, token, secret, api_key, authorization]- MCP Elicitation (enabled by default) prompts the user for
confirmation before executing any write tool. The confirmation
message shows the tool name and scrubbed parameters. Clients
that lack elicitation support are blocked by default
(
elicitation_strict: true). Disable elicitation entirely withsecurity.elicitation: false, or allow fallthrough for clients without elicitation support withsecurity.elicitation_strict: false - Output framing (enabled by default) with per-session
cryptographic nonce — injected tags in plugin output are detected
and escaped. Disable with
security.tag_tool_output: false - MCP Audience annotation set to
[assistant]on all tool results (always active) - JSON Schema validation on every tool call from compiled plugin YAML
- Write tool convention: included plugins default to
dry_run=truein their schemas, requiring explicit opt-out. This is a plugin-level convention, not core-enforced - Read-only mode enforced at three layers: tool registration, disabled stubs, and runtime rejection
security:
elicitation: true # confirm before write tools (default: true)
elicitation_strict: true # block writes if client lacks elicitation (default: true)
tag_tool_output: true # nonce-based output tagging (default: true)Plugin processes receive only the credentials they need.
- Scoped env.d — each plugin receives only its credential group's variables
- Filtered environment — allowlist of safe system vars passed to plugins (PATH, HOME, LANG, TZ, TMPDIR, XDG_* dirs, etc.)
- File permission enforcement — env.d files and directories require 0600/0700 (SSH-style)
- Symlink rejection on credential files, CA certs, and env.d entries
- Vault password zeroing — decryption keys cleared from memory after use (best-effort; Go's GC may retain copies)
- Memory-backed secure files — decrypted credentials stored
via
memfd_create, never touch disk
make build
# Run with a workdir (default: ~/.config/wtmcp)
./wtmcp --workdir ~/.config/wtmcpThe workdir layout:
~/.config/wtmcp/
config.yaml Core config (optional)
.env Environment variables
env.d/*.env Additional env files
plugins/
jira/
plugin.yaml Plugin manifest
handler.py Plugin executable
A plugin is a directory with a manifest (plugin.yaml) and a handler
executable. The core discovers plugins, starts handlers as child
processes, and routes tool calls over stdin/stdout using JSON-lines.
See docs/plugin-guide.md for the full guide with examples in multiple languages.
A oneshot plugin that runs the handler once per tool call:
plugin.yaml:
name: hello
version: "1.0.0"
description: "A greeting plugin"
execution: oneshot
handler: ./handler.sh
tools:
- name: hello_world
description: "Says hello to someone"
params:
name:
type: string
default: "World"
description: "Who to greet"
enabled: truehandler.sh:
#!/bin/bash
read -r INPUT
ID=$(echo "$INPUT" | jq -r '.id')
NAME=$(echo "$INPUT" | jq -r '.params.name // "World"')
echo "{}" | jq -c --arg id "$ID" --arg name "$NAME" \
'{id: $id, type: "tool_result", result: {message: ("Hello, " + $name + "!")}}'A persistent plugin that calls an API through the core's HTTP proxy. The handler stays running and processes multiple tool calls. Auth headers are injected automatically — the plugin never sees tokens.
plugin.yaml:
name: myapi
version: "1.0.0"
description: "Example API plugin"
execution: persistent
handler: ./handler.py
services:
auth:
type: bearer
token: "${MY_API_TOKEN}"
http:
base_url: "${MY_API_URL}"
tools:
- name: myapi_get_status
description: "Get API status"
params: {}
- name: myapi_search
description: "Search the API"
params:
query:
type: string
required: true
enabled: truehandler.py:
#!/usr/bin/env python3
import json, sys
def _send(msg):
print(json.dumps(msg, separators=(",", ":")), flush=True)
def _recv():
line = sys.stdin.readline()
if not line:
sys.exit(0)
return json.loads(line.strip())
def http(method, path, query=None):
msg = {"id": "1", "type": "http_request", "method": method, "path": path}
if query:
msg["query"] = query
_send(msg)
resp = _recv()
return resp.get("status", 0), resp.get("body", {})
def get_status(_params):
status, body = http("GET", "/status")
return body
def search(params):
status, body = http("GET", "/search", query={"q": params["query"]})
return body
TOOLS = {"myapi_get_status": get_status, "myapi_search": search}
while True:
msg = _recv()
if msg.get("type") == "init":
_send({"id": msg["id"], "type": "init_ok"})
elif msg.get("type") == "shutdown":
_send({"id": msg["id"], "type": "shutdown_ok"})
break
elif msg.get("type") == "tool_call":
fn = TOOLS.get(msg.get("tool"))
if fn:
result = fn(msg.get("params", {}))
_send({"id": msg["id"], "type": "tool_result", "result": result})
else:
_send({"id": msg["id"], "type": "tool_result",
"error": {"code": "unknown_tool", "message": msg.get("tool")}})- Oneshot plugins are spawned per tool call. Simplest to write.
- Persistent plugins start once and handle many calls via a main loop.
- HTTP proxy: plugins send
http_requestmessages, the core makes the call with auth and returnshttp_response. No HTTP library needed. - Cache: plugins send
cache_get/cache_setmessages. The core manages storage and TTL. - File I/O: plugins send
file_write/file_readmessages. The core handles path confinement, atomic writes, and permission enforcement. - Auth variants: a single plugin can support multiple auth methods (e.g., Cloud Basic + Server Bearer + Kerberos) with auto-detection.
Plugins can be reloaded at runtime without restarting the server.
From an AI assistant:
plugin_reload(name="jira")
plugin_list()
From a terminal (control directory):
touch ~/.config/wtmcp/control/commands/reload-jira
touch ~/.config/wtmcp/control/commands/reload-allResults appear in ~/.config/wtmcp/control/results/. The server writes
its PID to ~/.config/wtmcp/control/mcp.pid for process tracking.
MCP clients are automatically notified when tools or resources change.
Plugin authentication (particularly for OAuth-enabled plugins) is managed through the wtmcpctl command-line utility. See README-wtmcpctl.md for usage instructions and setup.
env.d files can be encrypted with Ansible Vault for at-rest protection. The server auto-detects encrypted files by magic header and decrypts them transparently at startup. Plugins receive plaintext credentials as usual — no plugin changes needed.
# Create a vault password file (umask prevents brief permission race)
(umask 077 && openssl rand -base64 32 > ~/.vault-pass)
# Tell wtmcp where the password file is
# (add to ~/.config/wtmcp/config.yaml)
# secrets:
# vault_password_file: ~/.vault-pass
# Encrypt an env.d file
ansible-vault encrypt --vault-password-file ~/.vault-pass \
~/.config/wtmcp/env.d/jira.env
# Start the server — decrypts automatically
wtmcpEncrypted files can be safely committed to git, shared, or backed up. Anyone who obtains them still needs the vault password to decrypt.
The vault password is resolved in priority order:
WTMCP_VAULT_PASSWORDenvironment variable (CI/CD convenience)WTMCP_VAULT_PASSWORD_FILEenvironment variable (path to file)secrets.vault_password_filein config.yaml (recommended)
For production and workstations, prefer file-based passwords. Env vars are intended for CI/CD pipelines where mounting a file is inconvenient.
Ansible Vault 1.2 supports labeled passwords (vault IDs). Different env.d files can use different passwords:
# Encrypt with a vault ID label
ansible-vault encrypt --vault-id prod@~/.vault-pass-prod \
~/.config/wtmcp/env.d/jira.envConfigure per-ID password files in config.yaml:
secrets:
vault_password_file: ~/.vault-pass # default
vault_ids:
prod: ~/.vault-pass-prod
dev: ~/.vault-pass-devPer-ID env vars are also supported: WTMCP_VAULT_PASSWORD_PROD,
WTMCP_VAULT_PASSWORD_DEV.
If no per-ID password is found, the server falls back to the default password chain automatically.
wtmcp checkReports vault password status and per-group encryption details (only encrypted groups are shown):
vault password: file (~/.vault-pass)
- jira (encrypted, vault 1.1, decryption ok)
- snyk (encrypted, vault 1.2 id=prod, decryption failed)
- Create a vault password file (see Quick Start)
- Configure
secrets.vault_password_filein config.yaml - Encrypt one env.d file:
ansible-vault encrypt --vault-password-file ~/.vault-pass env.d/jira.env - Verify:
wtmcp checkshould show "decryption ok" - Repeat for remaining files
- Optionally commit encrypted files to git
A single env.d directory can mix plaintext and encrypted files. Migrate incrementally — one file at a time.
If env.d files were previously committed in plaintext, encrypting
them does not remove the plaintext from git history. Rotate the
affected credentials after migrating and consider using
git filter-repo to remove the old plaintext from history.
Credential changes take effect on plugin_reload without a server
restart. The vault password is re-read from its source on each
reload, so password rotations are picked up automatically.
- Ansible Vault uses AES-256-CTR with PBKDF2-SHA256 (10,000
iterations). Use strong passwords (20+ characters or
openssl rand -base64 32) to compensate for the low iteration count. - Back up your vault password file. Losing it means permanent loss of access to encrypted credentials. Store a copy in a separate secure location.
- Ansible Vault is a practical improvement for development and CI/CD. Regulated environments requiring key rotation, audit logging, or FIPS-validated crypto should use HashiCorp Vault or cloud KMS.
In addition to env.d files, credential files in
credentials/<group>/ can also be vault-encrypted. Supported
files:
client-credentials.json(OAuth2 client credentials)- TLS
client_certandclient_keyPEM files
Token files (token-*.json) are not encrypted — they are
auto-rotated, short-lived, and derived from the client credentials.
Encrypted credential files are decrypted to memory-backed file descriptors (memfd on Linux, unlinked tmpfile on macOS) so decrypted content never touches persistent storage. Plugins receive the same file paths as usual — no plugin changes needed.
Encrypt and decrypt files without requiring ansible-vault:
# Encrypt a file
wtmcpctl vault encrypt env.d/jira.env
# Encrypt with vault ID
wtmcpctl vault encrypt --vault-id prod env.d/jira.env
# Decrypt a file
wtmcpctl vault decrypt env.d/jira.env
# Verify decryption without writing
wtmcpctl vault decrypt --check env.d/jira.env
# View decrypted content without modifying the file
wtmcpctl vault view env.d/jira.envPassword is sourced from --vault-password-file, WTMCP_VAULT_PASSWORD
env var, config.yaml, or interactive prompt (with echo suppression).
Google plugins provide access to Google Workspace services using OAuth2 authentication:
| Plugin | Description |
|---|---|
| google-drive | File metadata, search, and export |
| google-calendar | Calendar events and management |
| google-docs | Document retrieval, summarization, and editing |
| google-gmail | Email reading and sending |
All Google plugins require OAuth2 authentication. See README-wtmcpctl.md for setup instructions.
The included Jira plugin covers read, write, sprint, and export operations:
| Category | Examples |
|---|---|
| Read | jira_search, jira_get_myself, jira_get_transitions |
| Write | jira_create_issue, jira_add_comment, jira_assign_issue |
| Sprint | jira_list_available_sprints, jira_get_sprint_issues |
| Export | jira_export_sprint_data, jira_download_attachment |
All write tools default to dry_run=true. Cloud-aware (ADF format,
accountId assignments). Auth variants: Cloud Basic, Server Bearer,
Server Kerberos.
By default (tools.discovery: full), all tools are loaded into the
model's context. With progressive discovery, only primary tools are
loaded; deferred tools are discoverable via tool_search.
Enable in config.yaml:
tools:
discovery: progressivePlugin authors mark key tools with visibility: primary in
plugin.yaml. All other tools default to deferred. See
docs/plugin-guide.md for details.
# Go core tests
go test ./...
# Go core tests with race detector
go test -race ./...
# Sandbox tests (requires libarapuca)
make test-sandbox
# Python plugin tests
.venv/bin/pytest tests/ -v
# All pre-commit checks
pre-commit run --all-filescmd/
wtmcp/ MCP server entry point
wtmcpctl/ Plugin management CLI tool
internal/
audit/ Structured JSON audit logging
auth/ Auth providers (bearer, basic, kerberos, oauth2)
cache/ Key-value cache with TTL
config/ Env var resolution, YAML config
fileio/ Secure file I/O (atomic writes, path confinement)
encoding/ TOON output encoding
google/ Google OAuth helper (shared by Google plugins)
plugin/ Manager, manifest, transport, dispatch
protocol/ Wire protocol message types
proxy/ HTTP proxy with SSRF prevention
ratelimit/ Token-bucket rate limiting
sandbox/ OS-level plugin isolation (optional)
secrets/ Vault decryption, secure file descriptors
server/ MCP server, output framing, tool index
stats/ Per-tool call statistics
plugins/
google-drive/ Google Drive plugin (Go)
google-calendar/ Google Calendar plugin (Go)
google-gmail/ Gmail plugin (Go)
jira/ Jira plugin (Python, zero external deps)
confluence/ Confluence plugin (Python)
gitlab/ GitLab plugin (Python)
tests/
plugins/ Plugin unit tests
docs/
plugin-guide.md Plugin development guide
wtmcpctl.md OAuth management tool guide
This project is licensed under the GNU General Public License v3.0. See LICENSE for the full text.