{
  "markdown": "# wg-easy-mcp\n\n<!-- badges: start -->\n\n[![CI](https://img.shields.io/github/actions/workflow/status/ni-c/wg-easy-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/wg-easy-mcp/actions/workflows/ci.yml)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/wg-easy-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/wg-easy-mcp)\n<a href=\"https://socket.dev/npm/package/wg-easy-mcp\"><img src=\"https://socket.dev/api/badge/npm/package/wg-easy-mcp\" alt=\"Socket supply-chain report\" height=\"20\"></a>\n[![Glama score](https://glama.ai/mcp/servers/ni-c/wg-easy-mcp/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/wg-easy-mcp)\n<br>\n[![npm version](https://img.shields.io/npm/v/wg-easy-mcp)](https://www.npmjs.com/package/wg-easy-mcp)\n[![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Fwg--easy--mcp-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/wg-easy-mcp/pkgs/container/wg-easy-mcp)\n[![HTTP via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-4f46e5?logo=modelcontextprotocol&logoColor=white)](https://mcp-hub.ni-c.de)\n<br>\n[![docs](https://img.shields.io/badge/docs-wg--easy--mcp.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://wg-easy-mcp.ni-c.de)\n[![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)\n<!-- badges: end -->\n\nA [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for administering [wg-easy](https://github.com/wg-easy/wg-easy) (WireGuard Easy) instances.\n\nLets MCP clients like Claude Code, Claude Desktop or Codex manage your WireGuard VPN: list, create, update, enable/disable and delete clients, fetch configuration files and QR codes, and inspect the server status — all through the wg-easy v15 REST API.\n\nEleven tools is the ceiling, not the floor: `WG_EASY_ALLOW_TOOLS=essential`\nregisters a curated six instead, and a model picks the right tool far more\nreliably from six than from eleven — see\n[choosing which tools load](#choosing-which-tools-load).\n\n<!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub\n     picks the variant that matches its own theme toggle. npm strips <picture> and\n     <source> when it sanitises the README and keeps the <img>, which is why that\n     fallback brings its own dark card instead of relying on a media query. -->\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://wg-easy-mcp.ni-c.de/architecture-dark.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"https://wg-easy-mcp.ni-c.de/architecture-light.svg\">\n  <img src=\"https://wg-easy-mcp.ni-c.de/architecture.svg\" alt=\"An MCP client speaks stdio to wg-easy-mcp, which calls the wg-easy v15 REST API over HTTPS with Basic Authentication\" width=\"800\">\n</picture>\n\n![Listing the tools, narrowing them to the essential preset, and the startup abort a mistyped tool name produces](https://wg-easy-mcp.ni-c.de/demo.gif)\n\n## What makes it different\n\n**The full client lifecycle over the wg-easy v15 REST API**, including `.conf`\nfiles, QR codes and one-time download links.\n\n**Partial updates merge.** An update reads the current client state and changes\nonly the fields you named, instead of overwriting the rest with defaults.\n\n**`disable_client` stays ungated on purpose.** Every other write asks a person\nfirst through MCP elicitation; that one only ever withdraws access, and making it\nharder would be making the safe move the slow one.\n\n## Requirements\n\n- Node.js ≥ 22\n- A running **wg-easy v15+** instance\n- **2FA (TOTP) must be disabled** for the account used by this server — the wg-easy API only supports Basic Authentication and does not work with 2FA enabled\n\n> **Note:** The wg-easy REST API is [not yet declared stable](https://wg-easy.github.io/wg-easy/latest/advanced/api/) and may change between releases. This server targets wg-easy v15.\n\n## Configuration\n\nConfiguration is provided via environment variables:\n\n| Variable               | Required | Description                                                                             |\n| ---------------------- | -------- | --------------------------------------------------------------------------------------- |\n| `WG_EASY_URL`          | yes      | Base URL of the wg-easy web UI, e.g. `https://vpn.example.com:51821`                    |\n| `WG_EASY_USERNAME`     | yes      | Username of a wg-easy admin account                                                     |\n| `WG_EASY_PASSWORD`     | yes      | Password of that account                                                                |\n| `WG_EASY_INSECURE_TLS` | no       | Set to `true` to accept self-signed TLS certificates (scoped to the wg-easy connection) |\n| `WG_EASY_ALLOW_TOOLS`  | no       | Comma-separated tool names, `list_*` prefixes, or `essential` for a curated preset      |\n| `WG_EASY_DENY_TOOLS`   | no       | Same syntax; removed from whatever `WG_EASY_ALLOW_TOOLS` left                           |\n| `ELICITATION`          | no       | `false` replaces the approval dialog with the two-call token. **Not prefixed**          |\n\n> **Use `https://`.** With a plain-`http` URL the Basic Auth credentials and all\n> WireGuard private keys travel unencrypted; the server prints a warning unless\n> the host is local. For self-signed certificates prefer a proper internal CA\n> over `WG_EASY_INSECURE_TLS`.\n\nWithout credentials the server still starts and lists its tools (so registries\nand inspectors can introspect it), but every tool call fails with setup\ninstructions instead of reaching the wg-easy API.\n\n### Choosing which tools load\n\n`WG_EASY_ALLOW_TOOLS` and `WG_EASY_DENY_TOOLS` take comma-separated tool names;\na trailing `*` matches a whole family. `essential` is a curated preset of\nsix: `get_server_info`, `list_clients`, `get_client`, `create_client`, `enable_client`, `disable_client`.\n\n`get_client_config`, `get_client_qrcode` and `generate_one_time_link` are not in\nit, and neither is `delete_client`: all four either destroy something\nirreversibly or hand out a peer's private key. Name them where you want them.\n\n```sh\nWG_EASY_ALLOW_TOOLS=essential\nWG_EASY_ALLOW_TOOLS=essential,get_client_config\nWG_EASY_ALLOW_TOOLS=list_clients,get_client_config\nWG_EASY_DENY_TOOLS=delete_client,create_client\n```\n\nAn entry that matches no tool aborts startup and names it, so a typo cannot\nsilently hide a tool — an absent tool is not something anyone traces back to an\nenvironment variable. A filtered tool is never registered, so it is absent from\n`tools/list` and unknown to `tools/call` alike, exactly like a write tool under\n`WG_EASY_READ_ONLY`.\n\nIf you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de)\nis the other answer — its `/hub` endpoint replaces every server's tools with six\nmeta-tools.\n\n## Installation\n\n### Claude Code\n\n```bash\nclaude mcp add wg-easy -s user \\\n  -e WG_EASY_URL=https://vpn.example.com:51821 \\\n  -e WG_EASY_USERNAME=admin \\\n  -e WG_EASY_PASSWORD=your-password \\\n  -- npx -y wg-easy-mcp\n```\n\n### Claude Desktop\n\nAdd to your `claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"wg-easy\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"wg-easy-mcp\"],\n      \"env\": {\n        \"WG_EASY_URL\": \"https://vpn.example.com:51821\",\n        \"WG_EASY_USERNAME\": \"admin\",\n        \"WG_EASY_PASSWORD\": \"your-password\"\n      }\n    }\n  }\n}\n```\n\n### Codex\n\nAdd to your `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.wg-easy]\ncommand = \"npx\"\nargs = [\"-y\", \"wg-easy-mcp\"]\nenv = { WG_EASY_URL = \"https://vpn.example.com:51821\", WG_EASY_USERNAME = \"admin\", WG_EASY_PASSWORD = \"your-password\" }\n```\n\n### From source\n\n```bash\ngit clone https://github.com/ni-c/wg-easy-mcp.git\ncd wg-easy-mcp\nnpm install\nnpm run build\n# then use `node /path/to/wg-easy-mcp/dist/index.js` as the command\n```\n\n### Docker\n\nA multi-arch image (`linux/amd64`, `linux/arm64`) with an SBOM and build provenance is published to GitHub Container Registry:\n\n```bash\ndocker run -i --rm \\\n  -e WG_EASY_URL=https://vpn.example.com:51821 \\\n  -e WG_EASY_USERNAME=admin \\\n  -e WG_EASY_PASSWORD=your-password \\\n  ghcr.io/ni-c/wg-easy-mcp:latest\n```\n\nThe image talks MCP over stdio, so clients need `docker run -i` (no port is\nexposed):\n\n```json\n{\n  \"mcpServers\": {\n    \"wg-easy\": {\n      \"command\": \"docker\",\n      \"args\": [\n        \"run\",\n        \"-i\",\n        \"--rm\",\n        \"-e\",\n        \"WG_EASY_URL\",\n        \"-e\",\n        \"WG_EASY_USERNAME\",\n        \"-e\",\n        \"WG_EASY_PASSWORD\",\n        \"ghcr.io/ni-c/wg-easy-mcp:latest\"\n      ],\n      \"env\": {\n        \"WG_EASY_URL\": \"https://vpn.example.com:51821\",\n        \"WG_EASY_USERNAME\": \"admin\",\n        \"WG_EASY_PASSWORD\": \"your-password\"\n      }\n    }\n  }\n}\n```\n\n### Through mcp-hub\n\nA client that cannot spawn a local process — ChatGPT connectors, Claude on the web,\nCursor, LibreChat — reaches wg-easy-mcp through [mcp-hub](https://mcp-hub.ni-c.de): one\ncontainer serves many stdio MCP servers over Streamable HTTP, with an OAuth 2.1 login\nbehind a single password and long-lived tokens for the clients that cannot do OAuth. Its\n`/hub` endpoint puts every server behind six meta-tools, so one connector reaches all of\nthem without N×tool schemas in the model's context, and it speaks both protocol revisions\n— a question this server asks travels through it to the person at the far end.\n\nIts `/config/mcp.json` uses Claude Code's format, so the entry is the one you already\nhave:\n\n```json\n{\n  \"mcpServers\": {\n    \"wg-easy\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"wg-easy-mcp\"],\n      \"env\": { \"WG_EASY_ALLOW_TOOLS\": \"essential\" },\n      \"denyTools\": [\"delete_client\"]\n    }\n  }\n}\n```\n\n`allowTools` and `denyTools` there are the hub's **own** per-server filter, which is not\nthe same thing as `*_ALLOW_TOOLS` in `env` — the difference, and the mistake it invites,\nare in the [client guide](https://wg-easy-mcp.ni-c.de/guide/clients#through-mcp-hub).\n\n## Tools\n\n| Tool                        | Description                                                                            |\n| --------------------------- | -------------------------------------------------------------------------------------- |\n| `list_clients`              | List all WireGuard clients with status and traffic statistics                          |\n| `get_client`                | Get the full details of a single client                                                |\n| `create_client` 👤          | Create a new client (`name`, optional `expiresAt`)                                     |\n| `update_client` 👤          | Update a client; only the provided fields are changed                                  |\n| `enable_client` 👤          | Let a client connect again — re-arms a key pair already installed on the peer          |\n| `disable_client`            | Block a client; it keeps its configuration and keys                                    |\n| `delete_client` 👤          | Permanently delete a client                                                            |\n| `get_client_config`         | Get the client's WireGuard `.conf` file                                                |\n| `get_client_qrcode`         | Get the client configuration as a QR code (SVG)                                        |\n| `generate_one_time_link` 👤 | Generate a one-time config download link, valid five minutes                           |\n| `get_server_info`           | Release/update status, general settings and interface configuration (secrets redacted) |\n\n👤 asks a person through MCP elicitation · falls back to a two-call\n`confirm_token` where the client cannot show a dialog.\n\n### Structured output\n\nEvery tool declares an `outputSchema` and answers with `structuredContent`\nalongside the text block, so a client can use the result without parsing prose:\n\n```jsonc\n{\n  \"untrusted\": true,\n  \"source\": \"wg-easy\",\n  \"count\": 2,\n  \"clients\": [{ \"id\": 1, \"name\": \"laptop\", \"enabled\": true }],\n}\n```\n\nThe `untrusted` marker is a field and not only a sentence in the text, because a\nclient that reads the structured half and ignores the text would otherwise get\nfree-form client names, DNS entries and endpoints with no framing at all. Every\ntool carries it except `delete_client`, which reports an id this server was\ngiven and nothing that came back from the instance.\n\nThree answers changed shape to fit, and all three for the same reason: a schema\nwhose root is not an object is served to a 2025-era client rewritten as\n`{result: …}`, so the tool would answer differently depending on who asked.\n\n| Tool                | Was              | Is                 |\n| ------------------- | ---------------- | ------------------ |\n| `list_clients`      | a bare array     | `{count, clients}` |\n| `get_client_config` | the `.conf` text | `{configuration}`  |\n| `get_client_qrcode` | the SVG markup   | `{svg}`            |\n\nAn oversized answer is now shortened as an **object** rather than cut as a\nstring: the longest text field is shortened first, then list entries are\ndropped, and a `truncated` field says what was cut and how much there was. A\ndocument sliced at a byte offset is not a smaller answer, it is an unparseable\none — and the two channels have to carry the same value.\n\nWhat wg-easy sends is described with every field optional and unknown fields\nallowed; only what this server builds is exact. The SDK validates each result\nagainst its schema before it goes out, so a stricter shape would turn a wg-easy\nrelease that adds a field into a tool that fails outright.\n\n### Safety\n\n- **Five tools ask a person, not just the model.** `create_client`,\n  `update_client`, `enable_client`, `delete_client` and `generate_one_time_link`\n  raise a real dialog through MCP elicitation where the client supports it. Only\n  one of the five destroys anything — the others issue a VPN credential, re-arm\n  one, can widen a route, and mint an unauthenticated URL that hands out a\n  private key. `disable_client` is the one write tool that never asks: it can\n  only withdraw access. Where the\n  client cannot show a dialog they fall back to a random token valid for 5\n  minutes and bound to the exact target (for `update_client`, to the exact\n  edit), which proves the call was made twice with the same arguments and\n  nothing more. `ELICITATION=false` takes that fallback deliberately; it never\n  removes the guard. See\n  [Asking a person](https://wg-easy-mcp.ni-c.de/guide/approval).\n- **Key material is redacted everywhere it is not the point.** A field name is matched by its **suffix** — `password`, `passwd`, `passphrase`, `secret`, `token`, `apiKey`, `privateKey`, `preSharedKey`, plus anything starting with `totp` — so `metricsPassword`, which carries the argon2 hash of the metrics token, is covered along with every other `<prefix>Secret` wg-easy invents. (`key` is not a suffix: it would take `publicKey` with it.) Values are replaced with `[redacted]` at every nesting level — in `get_server_info`'s admin responses, which carry the WireGuard _server_ key, and in `list_clients` and `get_client`, which carry each client's _own_ key. Live one-time-link tokens are redacted from the same two, because `GET /cnf/<token>` serves the whole configuration with no login at all; `expiresAt` survives, so a listing still shows that a link is live. `get_client_config`, `get_client_qrcode` and `generate_one_time_link` are the deliberate exceptions: handing a peer its configuration is what they are for, and somebody asked.\n- Everything the wg-easy API returns carries an explicit **untrusted-data marker** and a 60 000-character budget, measured on the text that is actually emitted. Client names, DNS entries and endpoints are free-form strings, so they are marked as data to report rather than instructions to follow, control characters and BiDi overrides are stripped from them (field names included), and a single oversized field cannot flood the model's context.\n- **Nothing the instance sends is taken on trust.** Every field an output schema types is checked at the boundary and left out when it does not hold, so one record with a string `id` or a `1e999` cannot take a whole listing down with `Output validation error`. Entries that are not client records at all are counted in `skipped` rather than dropped in silence.\n- **Response bodies have a ceiling** (8 MiB, refused on a declared `content-length` before a byte is read) and the **status is read before the body**, so a `401` behind a large proxy page is still a `401`. A refused login is repeated from memory for ten seconds rather than retried.\n- A `WG_EASY_URL` containing embedded credentials (`user:password@host`) is rejected at startup — they would otherwise be echoed in the startup log and prefixed onto every request. Only its origin and path are kept, and no startup diagnostic echoes a value back.\n- Upstream error bodies are labelled as untrusted, stripped of control characters and cut at 200 characters; HTML error pages (reverse proxies) are dropped before being returned to the MCP client.\n- Caller input has a length: names and filters at 200 characters, addresses at 64, list parameters at 64 entries, and a client id bounded in its pattern rather than after `Number()`.\n- `WG_EASY_INSECURE_TLS` only relaxes certificate validation for the wg-easy connection — it does not disable TLS verification process-wide.\n- **`WG_EASY_READ_ONLY=true` registers `list_clients`, `get_client` and `get_server_info`, and nothing else.** `get_client_config` and `get_client_qrcode` are reads and still not in that set: what they read is a client's private key in the clear, and a read-only mode that leaves key disclosure standing is not the mode its name promises.\n- Tools carry MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so hosts can apply appropriate permission policies.\n- Keep in mind that `get_client_config` and `get_client_qrcode` return the client's **private key**, and a `generate_one_time_link` URL allows an unauthenticated config download — treat tool output as sensitive.\n\nThe full trust model is in [SECURITY.md](SECURITY.md) and, in prose, at [wg-easy-mcp.ni-c.de/guide/security](https://wg-easy-mcp.ni-c.de/guide/security).\n\n## Not exposed, on purpose\n\n**wg-easy v15 or newer only.** Older versions expose a different, session-based\nAPI that this server does not implement.\n\n**No server administration.** The tools cover the client lifecycle; the instance's\nown configuration, its admin accounts and its host stay outside the tool list.\n\n## Safety\n\n- Five tools ask a person first, through MCP elicitation: `create_client`,\n  `update_client`, `enable_client`, `delete_client` and\n  `generate_one_time_link`. Only one of them destroys anything — the others are\n  on the list because `destructiveHint` is the wrong axis for what they do. A\n  new client is a credential that reaches every network behind the VPN,\n  `update_client` can widen `serverAllowedIps`, and `enable_client` re-arms a key\n  pair that is already installed on a peer.\n- The approval is bound to the exact edit, so approving a rename does not license\n  a later call that widens the routes.\n- `disable_client` deliberately stays ungated: it only ever withdraws access, and\n  making the safe move the slow one would be the wrong trade.\n- Client names, addresses and the instance's own strings are marked as untrusted\n  data, and oversized output is truncated with the omission stated.\n- `WG_EASY_READ_ONLY=true` registers the read tools and nothing else.\n\n## Documentation\n\nThe full guide, tool reference and security notes live at\n**[wg-easy-mcp.ni-c.de](https://wg-easy-mcp.ni-c.de)** (source in [`docs/`](docs/)).\n\n## Development\n\n```bash\nnpm install\nnpm run build     # compile TypeScript to dist/\nnpm test          # run the vitest test suite\nnpm run lint      # oxlint + prettier check\nnpm run test:coverage\n```\n\nCI runs the suite on Node 22 and 24 and adds `npm audit`, CodeQL and a Trivy scan of the container image on both architectures. See [CONTRIBUTING.md](CONTRIBUTING.md).\n\nThe documentation site lives in `docs/` with its own manifest:\n\n```bash\ncd docs && npm install && npm run dev\n```\n\n### Releasing\n\n1. Bump the version in `package.json` and add a `CHANGELOG.md` entry.\n2. Commit, then tag and push: `git tag -a vX.Y.Z -m \"vX.Y.Z\" && git push origin main vX.Y.Z`\n\nThe release workflow runs the test suite, publishes to npm (via [trusted publishing](https://docs.npmjs.com/trusted-publishers), no token, with provenance), creates a GitHub release from the changelog entry and updates the entry in the official [MCP Registry](https://registry.modelcontextprotocol.io) (`io.github.ni-c/wg-easy-mcp`, via GitHub OIDC). The container image is published to GHCR by the CI workflow on the same tag.\n\n`server.json` lists both an npm and an OCI package; the registry job syncs the version into both before publishing. If it ever fails, fix `main` and re-run `mcp-registry.yml` via `workflow_dispatch` — re-running the tag job checks out the old tree.\n\n## Releasing\n\nReleases are tag-driven. Bump `package.json`, move the `[Unreleased]` notes in\n`CHANGELOG.md` under the new version, commit, then:\n\n```sh\ngit tag -s vX.Y.Z -m \"vX.Y.Z\"\ngit push origin main vX.Y.Z\n```\n\nThe release workflow publishes to npm via Trusted Publishing (OIDC, with\nprovenance), pushes the multi-arch container image to GHCR, creates the GitHub\nrelease from the CHANGELOG section, and updates the entry in the official MCP\nregistry.\n\n## Contributing\n\nIssues, discussions and pull requests are welcome — see\n[CONTRIBUTING.md](CONTRIBUTING.md). For vulnerabilities please use\n[private reporting](https://github.com/ni-c/wg-easy-mcp/security/advisories/new)\nrather than a public issue; the policy is in [SECURITY.md](SECURITY.md).\n\n## License\n\n[MIT](LICENSE) © Willi Thiel\n",
  "bytes": 21784,
  "sha": "774047fb1f97021768238d6678f61430ce790334be836bef90221b1ce4498541",
  "repo_slug": "ni-c/wg-easy-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_ni_c_wg_easy_mcp_59f92fe6/readme"
}