Changelog
[0.6.0] - 2026-09-07
Added
- A demo GIF in the README and on the documentation home page, recorded from
docs/demo.tapewith no credentials: the tool list, the same list narrowed by theessentialpreset, and the startup abort a mistyped tool name produces. - The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - Server
instructions. Results carry anuntrustedmarker, but that is read after the fact — this is the channel a model sees before it calls anything. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.
Changed
- The tool reference marks the
essentialpreset and the tools that ask a person before they act, per tool rather than only in the introduction. A test keeps both sets in step with the code. homepageinpackage.jsonpoints at the documentation site rather than at the README anchor on GitHub. It is what npm shows next to the package, and every one of these servers has had a documentation site for weeks.- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller. list_clientsreports what it could not read. An entry in the client list that is not a record cannot be a client and is left out; how many were left out is now askippedfield rather than silence.
Security
- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - Approval keys bound to positions.
create_clientkeyed its approval on the set {name, expiry}, and a set has no positions: a client name is free text, so a name that spells a date paired with an expiry that spells the name sorted to the same key, and a token obtained for one pairing also confirmed the other. The key now comes fromorderedResourceKeyin mcp-approval 0.8.2, which binds each part to its place.update_clientstays onsetResourceKeyon purpose: its parts are the numeric id plus self-labelledfield=valuepairs, which is a set by nature — no swap of two parts describes a different edit, and an id cannot be mistaken for a labelled pair. - The metrics password is redacted.
GET /api/admin/generalcarries the argon2 hash of the metrics token asmetricsPassword, and the redaction list matched field names exactly:passwordmatchedpasswordand notmetricsPassword, so the hash reached the model throughget_server_info— a tool whose own description promises that passwords are redacted. A key is now sensitive by what it ends in, on the name with_and-removed and lower-cased, so every<prefix>Password,<prefix>Secret,<prefix>Tokenand<prefix>PrivateKeythe instance invents is covered without anybody having to guess wg-easy's naming.keyis deliberately not a suffix — it would takepublicKeyand every*_keyidentifier with it. - Response bodies have a ceiling, and the status is read before the body. Every answer was
await response.text()with no bound: an instance that never stops sending — or whatever answers in its place underWG_EASY_INSECURE_TLS, or on a hostnameWG_EASY_URLreaches by a typo — was a process that never answered again. The request timeout does not help, it is spent once the headers arrive. A success is now refused above 8 MiB (a declaredcontent-lengthbefore a byte is read, otherwise the reader is cancelled at the ceiling), and a failure is read under its own 64 KiB ceiling that cuts instead of refusing — so a401behind a reverse proxy's login page is still a401with a credential hint, rather than "the response was too large". - A refused login is repeated from memory for ten seconds, not retried. The wg-easy API takes the admin credentials on every request, so every tool call is a login attempt, and
401is the one answer a model reads as transient and retries. The answer now comes back from memory with a note saying so and when the next attempt is possible. Only401; a403is a permission, not a guess. - What the instance wrote is cleaned on its way to the model. Client names, DNS entries and endpoints are free text somebody typed, and so are the field names of a record that is passed through loosely. C0 and C1 control characters, DEL and the BiDi override and isolate characters are removed from all of them — an ESC starts a terminal escape sequence in whatever renders the transcript, and a right-to-left override reorders the line around it. Error bodies are labelled
(untrusted text from the instance)and cut at 200 characters, which no result budget measured before. The two file tools are the exception and stay byte-exact: a.confand a QR-code SVG have to work as configurations, so control characters in them are named in awarningfield rather than removed. - Caller input has a length. Eight tool parameters were spliced into a query string or a request body with no ceiling. Names and filters are bounded at 200 characters, addresses and dates at 64, list parameters at 64 entries, and
mtuandpersistentKeepaliveat the ranges those fields actually have. The client id is bounded in its pattern ([0-9]{1,15}, a safe integer by construction):/^\d+$/looks like validation but four hundred nines are digits, andNumberof them isInfinity, which went out asGET /api/client/Infinity. WG_EASY_URLis stored as it was parsed, and no diagnostic echoes a value. The environment string was kept whole, so a query or fragment left on the end was glued in front of every request path; the origin and path are now stored. Removing the trailing slashes was quadratic — 60 000 of them with a character behind measured 1.2 s — and is an index walk. The non-http(s) message no longer prints the scheme, because a hexadecimal key with a colon after it is a URL whose scheme is the key, and theELICITATIONmessage quotes only a short word-shaped value and describes anything else by its length: both sit next to the credentials in every compose file.- The supply chain around a release. The publish job holds
id-token: writefor npm Trusted Publishing and installed withnpm ci, so every dependency's lifecycle hook could run while that credential was available — it is--ignore-scriptsnow.mcp-publisherwas fetched fromreleases/latest/downloadin the job that holds the registry OIDC token and is pinned tov1.8.1with its published sha256 checked. Pull requests getactions/dependency-review-action, which checks what a change adds rather than the tree as it stands. And the runtime image dropped npm but kept corepack and yarn, which nothing here runs. - The security documents describe the server that exists. Both argued the replay path away with "does not set
supportedProtocolVersions, so it takes the SDK's default list, which ends at2025-11-25" — untrue since the entry point moved toserveStdio, which negotiates both eras, and the sentence outlived the change by months. They now name the mechanism that actually answers for it, the nonce mcp-approval spends on the first answer, and a test holds them to it.
Fixed
prepublishOnlyruns the linter and the test suite again. It had been reduced totypecheck && build, sonpm publishfrom a workstation would have shipped a package whose tests were never run — the one moment that check matters most. CI was unaffected and stays the real gate; this closes the local path.- One malformed record no longer takes a whole listing down. Every response was read with a TypeScript cast, which checks nothing, while the SDK validates each answer against the tool's output schema before it leaves. An
idspelled as a string, a1e999intransferRx(whichJSON.parsereads asInfinityandz.number()refuses) or a numericnamein a single row answered the wholelist_clientswithOutput validation errorand no cause — every good record lost because of one bad one. Each typed field is now read with a check of that exact type and left out when it does not hold, which is what "we did not get a usable value" means in a record where every field is optional. - The result budget measures the text it actually sends. It measured the compact serialisation and emitted the indented one plus the untrusted-data paragraph: 53 329 characters measured against 86 949 emitted for the same client list, so the 60 000-character ceiling held for a string nobody received.
- Three tools declared an output schema their own answer could break.
z.objectemitsadditionalProperties: false, and the budget attaches atruncatedfield thatget_client_config,get_client_qrcodeandget_server_infodid not declare — so a QR code past the ceiling, which is an ordinary answer at 70 kB, was refused by every client that had loadedtools/list, on the success path only. The harness lists the tools now, which is what makes that class of defect visible at all; the third one was found by the property test rather than by reading. - A record field named
truncatedno longer overwrites the truncation note. The upstream record was spread over the note rather than under it, so the instance could contradict the sentence that says the answer was shortened. get_client_configandget_client_qrcodeno longer answer[object Object]. Both wrapped the body inString(), so a JSON error object from a proxy became a configuration file containing the words "object Object". What is not a string is now an error that names what arrived instead.- A body of
nullno longer fails a guarded tool with a JavaScript error.clientNameread.nameoff whatever came back, inside the arguments of the dialog — sodelete_clientagainst an instance whose proxy answerednullreported "Cannot read properties of null" instead of asking, or of saying what went wrong. - The one-time link is read back with its types. A numeric token or expiry in the joined row left as a number and failed the whole answer; either is now the
warningcase, which already says the link is live on the instance whatever could be read back. redactSecretsno longer loses a__proto__key. It built its result without[key] = …, and__proto__is legal JSON and an own property afterJSON.parse: the assignment ran the prototype setter, dropped the field and replaced the prototype of the object that left the function with whatever the instance sent. The boundary drops the key outright — nothing downstream carries it faithfully in both channels — and the budget writes its slots withdefineProperty.
[0.5.0] - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result.The untrusted-data marker travels with it as
untrusted: trueandsource: "wg-easy"fields, not only as a sentence in the text: a client that reads the structured half and ignores the text would otherwise receive free-form client names, DNS entries and endpoints with no framing at all, and the framing is the guard.delete_clientis the only tool without the marker — it reports an id this server was given, not anything the instance sent.What wg-easy sends is described with every field optional and unknown fields allowed; only what this server builds is exact. The SDK validates every result against the schema before it goes out, so a stricter shape would turn a wg-easy release that adds a field into a tool that fails outright.
A person is asked before four operations, not just told about one. Where the MCP client supports elicitation,
create_client,update_client,delete_clientandgenerate_one_time_linkraise a real dialog that the model cannot answer on its behalf. Where it does not, they fall back to the two-callconfirm_token— and say which of the two happened rather than implying somebody approved.The three new ones are not there because they destroy something.
create_clientissues a credential that reaches every network behind the VPN;update_clientcan move an address or widenserverAllowedIps;generate_one_time_linkmints a URL that hands out a private key without authentication. Its own annotation had said "which is why the tool is guarded instead" since 0.3.0, and it was not.The
update_clientapproval is bound to the exact edit, not to the client: approving a rename does not license a later call that widens the routes.
Changed
The advertised schemas avoid spellings that are legal JSON Schema and still get a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes
"additionalProperties": truerather than the empty schema{}zod emits for it; a value that was left untyped is declared as what it really is; and a nullable field is written asanyOfbranches rather than"type": ["string", "null"], which several clients read as a single type and then drop. What the tools accept and return is unchanged; only the way the schema says so is.Three tools answer in a new shape.
list_clientsreturns{count, clients}instead of a bare array,get_client_configreturns{configuration}instead of the raw.conftext, andget_client_qrcodereturns{svg}instead of the raw markup.All three for one reason: an output schema whose root is not an object is served to a 2025-era client rewritten as
{result: …}, so each of those tools would have answered in two different shapes depending on which protocol revision the client spoke.An oversized answer is shortened as an object, not cut as a string. The longest text field is shortened first — which is what keeps an over-budget QR code and a client with a 40 kB name usable — then list entries are dropped, and a
truncatedfield names each field that was cut with what survived and what was there. Cutting the serialized JSON at a byte offset produced text that no longer parses, which a text block tolerates andstructuredContentcannot.Where neither pass leaves anything to give, the result is now an error rather than a half-answer.
generate_one_time_linkreportscreated: truein place ofsuccess: true, and its two read-back failures answer in that same shape with awarningfield instead of a bare sentence. The link exists on the instance in all three cases, and answering that in three different shapes is how a reader ends up believing nothing happened.delete_clientanswers{deleted: <id>}rather than a sentence.The two-call
confirm_tokenprompt is an error result. The operation was asked for and did not happen, and a tool that declares an output schema may not answer withoutstructuredContentunless the result is an error. The text is unchanged and still carries the token.BREAKING: the confirmation parameter of
delete_clientis nowconfirm_token, notconfirmToken. A caller that passes the old name gets a schema error. The prompt tells a model which argument to send, so it has to name the one the schema declares — and the whole family spells it the same way.Deleting no longer keeps its own token table. It uses
mcp-approval, like the other fourteen servers: same five-minute lifetime, same one-use token, same binding to the exact target — but the dialog comes with it, and the timing comparison and the sealed request state are maintained in one place rather than fourteen.A confirmation prompt now shows the client's name on a labelled line under the "supplied by the caller, not by this server" heading. A dialog that says only "Delete client 5?" is not something a person can act on; a name in the server's own sentence would read as the server vouching for it.
ELICITATIONswitches the dialog off —falsesends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one
export ELICITATION=falsereaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neithertruenorfalsestops the server, whereWG_EASY_INSECURE_TLSbeside it fails off on a typo: this is the only variable here that defaults to on. It is read afterWG_EASY_USERNAMEandWG_EASY_PASSWORDare wiped from the environment, so that exit cannot leave them behind.The startup log now also reports
WG_EASY_READ_ONLY, which it never did, andmissingConfigMessagenames the four optional variables it used to omit.A
docs/guide/approval.mdpage.SECURITY.mdand the security guide now say what the confirmation proves: binding to one operation with one set of arguments, not freshness. No replay defence is built, because the sealing key is per process, the two-call token is single-use, andrequestStateonly crosses the wire on protocol revision2026-07-28, which this server does not offer — it takes the SDK's default list, which ends at2025-11-25. The section names what would have to change for that to stop being true.Runs on MCP SDK 2.0. The wire protocol is unchanged for existing clients: the server negotiates the same revision it always did, and a client that worked before works now. What changed is the package layout behind it —
@modelcontextprotocol/sdksplit intocore,serverandclient, and the deprecated Authorization Server helpers are not installed at all.The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code. It is on TypeScript 7 now. Neither is visible to anyone running the server.The tool filter, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-internal-hostsandsvg-asset-setrather than from copies kept in this repository — 719 fewer lines here, and one place to fix each of them. All three have no runtime dependencies of their own.The shared libraries move to
mcp-approval0.7.1,mcp-tool-allowlist0.2.1,mcp-internal-hosts0.2.1,mcp-integration-harness0.2.0 andsvg-asset-set0.2.0. The harness change is visible in the suite: where a security path asserted only that a call failed, it now has to say why —expectError: trueis also satisfied by a schema rejection, so a renamed argument used to keep such a test green while the guard it names went unreached.stdio is served through
serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wiredStdioServerTransportserved it.
Fixed
generate_one_time_linkreported failure on every successful call. It minted the link and then read it back withGET /api/client/{id}— but wg-easy joins the one-time link onto the client row in its list query and not in its single-client query, so that read answersoneTimeLink: nullfor a client that has a live link. The tool then said "the link value was not returned by the API", and its own description explained that away as wg-easy 15.4.0 answering HTTP 500.It does not. Against a real 15.4.0 the POST answers
200 {"success":true}, the row is written, andGET /cnf/<token>serves the peer's configuration unauthenticated for five minutes. The tool now reads the list, returns the link with itsexpiresAt— and where the read-back itself fails, says the link was created and points at the UI where it can be revoked, instead of returning a bare transport error a model reads as "nothing happened". The integration suite now fetches the minted URL with no credentials and asserts it hands back the private key.clientIdis no longerNumber().z.coerce.number()accepted anythingNumber()accepts:{clientId: true}addressed client 1,{clientId: ["3"]}addressed client 3. It now takes an integer or a decimal string and rejects the rest.WG_EASY_READ_ONLYaccepts1,trueandyes, trimmed and case-insensitively, where it used to require the exact stringtrue. It is the switch that fails towards the restriction, soWG_EASY_READ_ONLY=1silently leaving the write tools registered is the one outcome it must not have.WG_EASY_INSECURE_TLSkeeps the exact-match rule for the same reason read the other way round: a typo there fails towards relaxed certificates.docs/guide/faq.mdsaid read-only mode was not possible ("Not today … this server registers all eleven tools unconditionally").WG_EASY_READ_ONLYhas existed since 0.4.0, and the stale answer was wrong in the unsafe direction.get_clientno longer returns the client's WireGuard private key. wg-easy's single-client endpoint carriesprivateKeyandpreSharedKeyin full, and onlyget_server_infowas filtering — so asking about a VPN client put that client's key into the model's context and therefore into the transcript, where it outlives any decision to stop using it.list_clientsdoes not carry the key on 15.4.0, which is what made this easy to miss; the filter is applied to both anyway rather than to the one endpoint that happens to need it today.get_client_configandget_client_qrcodestill return keys unredacted, deliberately: handing a peer its configuration is what they are for, and somebody asked.Found by the new integration suite, against a real wg-easy.
An entry in
WG_EASY_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.WG_EASY_PASSWORDandWG_EASY_ALLOW_TOOLSare adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.
Security
enable_clientnow asks a person.update_client({clientId, enabled: true})has always raised the dialog;enable_clientdid the same state change with a bare POST. Whether re-arming a peer was guarded came down to which of the two tools the model reached for — and under the recommendedWG_EASY_ALLOW_TOOLS=essential, only the ungated one was registered at all.It is not on the list because it destroys something. The key pair it re-arms is already installed on the peer, so nothing further has to be handed over for that peer to reach every network behind the VPN. This server's own catalogue calls
disable_clientthe recommended reversible revocation; the undo of a revocation cannot be the cheaper call.disable_clientstays ungated, and is now the only write tool that never asks: it can only withdraw access.WG_EASY_READ_ONLYno longer leaves key disclosure standing.get_client_configandget_client_qrcodereturn a client'sPrivateKeyin the clear. Both counted as read tools, so the one coarse switch an operator has for putting this server in front of a less trusted session changed nothing about them:list_clientsfollowed byget_client_configyields one ready-to-use VPN configuration per peer, in the transcript, unconfirmed.They are still reads, and their
readOnlyHint: trueis unchanged and honest — nothing on the instance changes. What changed is which set they are in. Read-only mode now registerslist_clients,get_clientandget_server_infoonly, and the two are out ofessentialas well. Where a session should also hand out configurations, name the tool:WG_EASY_ALLOW_TOOLS=essential,get_client_config. That is the rule the catalogue already applied todelete_client— the variant that cannot be taken back has to be named — applied to disclosure rather than destruction.The essential preset is six tools now, not eight.
A live one-time link is no longer handed out by
list_clients. wg-easy puts the link token on every row ofGET /api/client, andGET /cnf/<token>returns the whole configuration — private key included — with no login at all. So an ungated read tool that survives read-only mode was returning a working, unauthenticated download URL for every client whose link had not yet expired. The token is now redacted like other key material;expiresAtis not, because knowing that a link is live is what a listing is for.generate_one_time_linkstill returns the value, deliberately: somebody approved it.
[0.4.0] - 2026-08-27
Added
WG_EASY_ALLOW_TOOLSandWG_EASY_DENY_TOOLSchoose which of the 11 tools are registered. Both take comma-separated tool names or a prefix with a trailing*, the allow list decides what is in and the deny list is subtracted from it, andWG_EASY_ALLOW_TOOLS=essentialselects a curated eight —get_server_info,list_clients,get_client,create_client,get_client_config,get_client_qrcode,enable_client,disable_client. A model picks the right tool far more reliably from eight than from eleven, and every visible tool costs context on every request. Nothing changes for an installation that sets neither.A filtered tool is not registered at all, so it is absent from
tools/listand answerstools/callwith "tool not found" — the same cutWG_EASY_READ_ONLYalready makes, not a second, weaker one.An entry that matches no tool stops the server at startup, naming the entry and listing the real names, rather than being ignored: an ignored typo leaves a tool missing from
tools/listwith nothing pointing at the cause.
Changed
- The README now carries the same eight badges, in the same order, as every other MCP server in this family, all of them reading from npm rather than hard-coded; the opening follows one shape; and the standalone "Full documentation" line is gone, because the docs badge three lines above it points at the same page.
Fixed
- The container image no longer ships OpenSSL 3.5.7-r0, which carries CVE-2026-14456 (denial of service via unbounded memory growth). The pinned
node:24-alpinedigest is already the newest one; Alpine's fixed 3.5.8-r0 has simply not been rebuilt into it yet, so the runtime stage now upgradeslibcrypto3andlibssl3by name. Upgrading those two rather than running a blanketapk upgradekeeps the rest of the image exactly as the digest pins it. The step can go once the base image ships the fix.
[0.3.3] - 2026-08-26
Changed
- The check that decides whether
WG_EASY_URLpoints somewhere local — and therefore whether sending a credential over plainhttpis worth warning about — now uses the same host classifier as the other MCP servers in this family, insrc/hosts.ts. The string comparison it replaces missed several spellings of the same address:http://[::ffff:127.0.0.1], whichURLcanonicalises to[::ffff:7f00:1]before any check sees it, andlocalhost.with its root label. It also treated127.example.comas loopback, because it matched on the127.prefix, and so stayed quiet about a plain-http URL to a public host.
Nothing else changes: this server has no tool that takes a URL, so there is no request whose target a caller can choose.
[0.3.2] - 2026-08-18
Fixed
- The Basic Auth username and password are no longer left in the environment when
WG_EASY_URLis unset.loadConfigdeleted them only at the very end, behind the early return for a missing URL, so in that state they stayed inprocess.envfor the whole process lifetime — readable in/proc/<pid>/environand inherited by every child process. The deletion now happens before any branch. - A malformed
WG_EASY_URLis no longer echoed into the log. That branch fires precisely when the variable does not hold a URL, which most often means a credential was pasted into the wrong variable. http://[::1]:…no longer produces the "plain http to a non-local host" warning.URL.hostnamekeeps the brackets around an IPv6 literal, so the loopback check never matched that notation.
[0.3.1] - 2026-08-18
Fixed
- The architecture diagram no longer depends on the reader's operating system. It carried a
prefers-color-schemeblock, which resolves against the OS rather than the theme toggle of GitHub or npm — so dark-mode readers on a light OS got the light artwork on a dark page. The README now uses<picture>, which is resolved against the page, and the<img>that npm falls back to brings its own card instead of a media query. - The diagram said the server registers ten tools. It registers eleven; the documentation was corrected in a previous release but the drawing was not.
docs/.vitepress/config.tspointedog:imageat/og.png, which did not exist — the documentation site had no link preview at all. The file is generated now.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand. docs/public/og.pngis generated at exactly 1280x640, GitHub's recommended size for a social preview, instead of being drawn by hand.- The TypeScript major is now parked in
.github/dependabot.ymlwith its reason, instead of living only as an@dependabot ignoreon the closed PR #5 — that state is invisible to anyone reading the config and is lost if the PR is reopened.
[0.3.0] - 2026-08-16
Added
Dockerfile(multi-stage, non-root, stdio entrypoint) and.dockerignore, so registries that build and introspect the server in a container no longer have to guess a build.- Multi-arch container images (
linux/amd64,linux/arm64) published toghcr.io/ni-c/wg-easy-mcpwith an SBOM and build provenance.server.jsonnow lists the OCI package alongside the npm one. - Documentation site at wg-easy-mcp.ni-c.de: guide, per-tool reference, environment variables and changelog.
- CI additions: CodeQL, a Trivy scan of the image on both architectures, and the GHCR publish job.
mainnow requires all of them. CONTRIBUTING.md, issue forms and GitHub Discussions.
Changed
- Missing
WG_EASY_URL/WG_EASY_USERNAME/WG_EASY_PASSWORDno longer exit at startup. The server completes the MCP handshake and lists its tools without credentials; they are required when a tool actually calls the API, which then fails with the same setup instructions as before. URL validation still exits, since a bad URL can leak the credentials. - Payloads returned by the wg-easy API now carry an explicit untrusted-data marker and are capped at 60 000 characters, with the truncation notice naming the call that fetches the rest. Client names, DNS entries and endpoints are free-form strings, so they are marked as data rather than instructions. Server-composed messages, including the delete confirmation, stay unmarked.
- The runtime image no longer contains npm. The entrypoint is plain
node, and npm's vendored dependency tree was the sole source of the container scan's HIGH/CRITICAL findings. typescript6.0.3,typescript-eslint8.67.0.
Security
WG_EASY_URLcontaining embedded credentials (user:password@host) is now rejected at startup. They bypassed the environment wipe inloadConfig, were prefixed onto every request path and were echoed verbatim in the startup log.
[0.2.2] - 2026-08-11
Added
- Listed in the official MCP Registry as
io.github.ni-c/wg-easy-mcp; the release workflow publishes registry updates automatically via GitHub OIDC (server.json,mcpNamefield). - npm provenance attestations for published packages.
- CodeQL default setup scanning.
Changed
- The repository is now public.
- Dependency majors: zod 4 (first release including it; vitest 4 and eslint 10 in the dev toolchain).
[0.2.1] - 2026-08-11
Added
- Release workflow: pushing a
v*tag runs the test suite, publishes to npm via trusted publishing (OIDC, no token) and creates a GitHub release from the changelog entry. - CI: weekly scheduled runs,
npm auditjob (fails on high/critical), coverage reporting with thresholds on the Node 24 run. - Dependabot updates for npm dependencies (minor/patch grouped) and pinned GitHub Actions.
- Tests for the configuration loader (URL validation, credential cleanup, plain-http warning).
[0.2.0] - 2026-08-11
Security-hardening release based on an internal code audit.
Changed
- Breaking:
delete_clientnow uses a two-step confirmation-token flow (confirmTokenparameter) instead ofconfirm=true. The first call returns a random, short-lived token; only a second call with that token deletes the client. The client name is no longer echoed in tool responses. WG_EASY_INSECURE_TLSnow uses a request-scoped undici dispatcher instead of settingNODE_TLS_REJECT_UNAUTHORIZED=0process-wide.get_server_inforedacts secret fields (privateKey,preSharedKey,password, session/TOTP secrets) from admin API responses.- Upstream error bodies are truncated to 2000 characters and HTML error pages are omitted from error results.
WG_EASY_URLis validated (http/https only); a warning is printed for plain-http URLs to non-local hosts. README examples switched tohttps://.- API requests now have a 15 s timeout and no longer follow redirects.
- Credentials are removed from
process.envafter loading the configuration. - Tool descriptions of
get_client_config,get_client_qrcodeandgenerate_one_time_linknow flag their output as sensitive. - CI: least-privilege
permissions, actions pinned to commit SHAs, Node matrix 22/24. Minimum supported Node.js version raised to 22 (20 is EOL).
[0.1.0] - 2026-08-05
Added
- Initial release targeting the wg-easy v15 REST API (Basic Authentication).
- Client management tools:
list_clients,get_client,create_client,update_client(partial updates via get-merge-post),enable_client,disable_client,delete_client(guarded by aconfirmparameter),get_client_config,get_client_qrcode,generate_one_time_link. get_server_infoaggregating/api/information,/api/admin/generaland/api/admin/interfacewith per-section error tolerance.- Configuration via
WG_EASY_URL,WG_EASY_USERNAME,WG_EASY_PASSWORD, optionalWG_EASY_INSECURE_TLS.