Wtmcp
MCP server with a language-agnostic plugin system. Plugins communicate over JSON-lines on stdin/stdout. Core handles auth, HTTP proxying, caching, and TOON output encoding for token savings
Ask AI about Wtmcp
Powered by Claude Β· Grounded in docs
I know everything about Wtmcp. Ask me about installation, configuration, usage, or troubleshooting.
0/500
Reviews
Documentation
wtmcp
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.
Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 β
ββββββββββββββββββ
Features
- 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
- 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
Security
wtmcp enforces security at the core level so plugins don't need to implement their own auth, input validation, or network restrictions.
HTTP Proxy & SSRF Prevention
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
Sandboxing (optional)
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 build
When 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.
Rate Limiting
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"
HTTP Retries
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 retry
Cache Limits
Per-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 sweep
Audit Logging
Structured 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]
Prompt Injection Defense
- 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)
Credential Isolation
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
Building and Running
make build
# Run with a workdir (default: ~/.config/wtmcp)
./wtmcp --workdir ~/.config/wtmcp
The 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
Writing Plugins
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.
Minimal Example (bash)
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: true
handler.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 + "!")}}'
API Plugin Example (Python)
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: true
handler.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")}})
Key Concepts
- 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. - Auth variants: a single plugin can support multiple auth methods (e.g., Cloud Basic + Server Bearer + Kerberos) with auto-detection.
Plugin Management
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-all
Results 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.
OAuth Plugin Management
Plugin authentication (particularly for OAuth-enabled plugins) is managed through the wtmcpctl command-line utility. See README-wtmcpctl.md for usage instructions and setup.
Encrypted Credentials
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.
Quick Start
# 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
wtmcp
Encrypted files can be safely committed to git, shared, or backed up. Anyone who obtains them still needs the vault password to decrypt.
Password Sources
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.
Multi-Password Support (Vault IDs)
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.env
Configure per-ID password files in config.yaml:
secrets:
vault_password_file: ~/.vault-pass # default
vault_ids:
prod: ~/.vault-pass-prod
dev: ~/.vault-pass-dev
Per-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.
Diagnostics
wtmcp check
Reports 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)
Migrating Existing Files
- 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.
Reloading Encrypted Credentials
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.
Security Notes
- 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.
Credential File Encryption
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.
wtmcpctl vault Commands
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.env
Password is sourced from --vault-password-file, WTMCP_VAULT_PASSWORD
env var, config.yaml, or interactive prompt (with echo suppression).
Included Plugins
Google Plugins
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-gmail | Email reading and sending |
All Google plugins require OAuth2 authentication. See README-wtmcpctl.md for setup instructions.
Jira Plugin
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.
Progressive Tool Discovery
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: progressive
Plugin authors mark key tools with visibility: primary in
plugin.yaml. All other tools default to deferred. See
docs/plugin-guide.md for details.
Testing
# 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-files
Project Layout
cmd/
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
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
License
This project is licensed under the GNU General Public License v3.0. See LICENSE for the full text.
