Changelog
[Unreleased]
Fixed
- A
__proto__key from the wiki no longer makes the two channels disagree. The text block kept it, while the client's schema parse ofstructuredContentturned it into a prototype assignment and lost it. The key is now dropped at every depth, checked after cleaning, so both channels carry the same answer.
[0.3.0] - 2026-09-07
Security
- A heading line can no longer stall the server. The ATX heading pattern behind
get_pagewithmode="outline"orsection=was cubic in the whitespace of a line:# x, four thousand spaces and aycost 8.6 seconds on the thread that serves every request, and a page body may be five megabytes. Headings are parsed by a hand-written scan, andtest/linear-time.test.tsholds every scan over wiki text at its ceiling. - The token is never quoted back.
WIKIJS_TOKENis trimmed and checked for shape at startup. A line break in the middle of a wrapped paste used to reach the HTTP layer, whose refusal quotes the wholeAuthorizationvalue — token included — into the tool result. The message names the variable, the length and the position, never the value;assertHeaderValuerepeats the check at the header itself. - Status before body. A failed answer's body is read under a 64 KiB ceiling that cuts rather than refuses, after the status has been read. A 401 behind a reverse proxy's two-megabyte login page used to surface as "exceeds the 32 MB ceiling" — the size, not the status, and no word about credentials.
- The asset scope walk has a budget. Placing an asset folder under
WIKIJS_ALLOWED_PATHSlists at most 256 folders in 30 seconds and then refuses, the way an unfound folder does. Only the depth was bounded before, and a wide tree turned one upload into thousands of requests. - SECURITY.md argued from a transport the code no longer uses. It said the sealed dialog answer never leaves the process because
StdioServerTransportpins2025-11-25; the server has served both protocol revisions throughserveStdiosince 0.2.0. The section now says what the single-use nonce of mcp-approval 0.8.1 promises, and what it does not — a restart forgets spent states. - The publish job installs with
--ignore-scriptswhile it holds the OIDC token, andgh release createverifies the tag. Pull requests getdependency-review-action. The image no longer ships yarn, corepack or the lockfile. - Every string in a result is cleaned of C0/C1 control characters and lone surrogates, page bodies included; format characters (bidi marks, zero-width joiners) stay, because in a wiki they are content. Error messages the instance wrote — GraphQL errors, HTTP error bodies — are cleaned the same way and set off under a line saying who wrote them, as
responseResult.messagealready was. list_users,search_users,get_userandget_groupanswer marked as untrusted: a user's name, location and job title are profile fields the account writes for itself.create_page,update_pageandcreate_useranswer with ids, paths and timestamps rather than the record as returned, whosetitleornamethe instance wrote.- The budget's shortener remembers what it cut by position, not by a marker at the end of the value. A comment or page version whose text ended in
… (5 more characters omitted)was never shortened, and the tool answered an error for that one item. - Credentials in URLs are redacted wherever the URL sits in a string — a storage target's
status.messageis git's stderr and names the remote mid-sentence — not only when the whole value is a URL. - Diagnostics describe rather than quote: the scheme of a rejected
WIKIJS_URL(a hexadecimal key followed by a colon is a URL), an unrecognisedELICITATIONvalue, andWIKIJS_ALLOWED_PATHSentries that are not path-shaped are reported by length. A token pasted into the neighbouring variable is no longer printed at startup or in a scope refusal. - The content-type table is a
Map:report.constructorpasses the filename rule, and the object literal it indexed answeredObjectwhere a media type was promised. Result copies are built withObject.fromEntries, so a__proto__key in the backend's JSON is a field rather than a prototype. - Ids are read at the boundary rather than cast;
errors: [null]in a GraphQL answer is a refusal rather than a TypeError;grep_pagesskips a listing entry without a string path instead of throwing on it.idParamis bounded to a 32-bit integer, an edit'sold_textandnew_textto the page ceiling,list_page_history.pageto a million.
Added
- 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
- 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. docs/reference/tools.mdis written by hand again. It used to be generated from the registered tools, which kept it in step with the code at the price of a page nobody could edit:--checkcompared it byte for byte, so every line had to be derivable and a paragraph about how an endpoint really behaves had nowhere to go. A test now asserts what the generator guaranteed — the page documents exactly the tools that exist, marks exactly theessentialpreset, and marks exactly the tools that ask a person first — and leaves the prose to a person.- The loopback check behind the plain-HTTP warning comes from
mcp-internal-hostsinstead of a copy inconfig.ts. Same classifier the rest of the family uses, same behaviour — one fewer place to keep 25 lines of hand-written IPv6 normalisation correct.
[0.2.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 — which twenty-nine of them made unavoidable, since they answered with a sentence. The sentence stays, in the text block.The tools that report wiki content carry
untrusted: trueandsource: "wikijs"as fields, not only as a preamble in the text. Page text, titles, descriptions and comments are written by anyone with edit rights, so a client that reads the structured half must not get them unframed. The list follows the call sites: a tool is marked exactly when it already routed its answer through the untrusted wrapper.Wiki.js records are described as open objects with the top-level keys this server builds. A self-hosted Wiki.js is any 2.x release, and a strict shape would turn a field one adds into a tool that fails outright.
Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call
confirm_tokenremains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.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, where theWIKIJS_*booleans beside it fail off on a typo: this is the only variable here that defaults to on. It is read afterWIKIJS_TOKENandWIKIJS_API_KEYare wiped from the environment, so that exit cannot leave a credential behind.A
docs/guide/approval.mdpage, and a 👤 marker in the generated tool reference that is read off the registered schema rather than from a list kept beside it.
Changed
The advertised schemas avoid a spelling that is legal JSON Schema and still gets 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. What the tools accept and return is unchanged; only the way the schema says so is.update_page's stale-read refusal answers{page_id, written: false, conflict: {you_saw, it_is_now}, note}in addition to its sentence. It is still not an error result — anisErrorwould make a client surface it as a failure, when what it is is an instruction for recovering.A result too large to shrink is now an error rather than an envelope saying so. The envelope was a different shape from what the tool declares it returns, which the SDK refuses.
The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. The text is unchanged and still carries the token.The integration compose file publishes Wiki.js on
WIKIJS_PORT(default 3010) instead of a hardcoded 3010, so a workstation that already runs something there does not need a patched compose file.Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.
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.The tool filter, the confirmation store and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approvalandsvg-asset-setrather than from copies kept here — 571 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.WIKIJS_READ_ONLYnow accepts1andyesas well astrue, trimmed and case-insensitively.WIKIJS_INSECURE_TLSdeliberately still takes only the exact word: a switch that adds a protection should honour anything an operator plausibly meant by "on", and one that removes a protection should not.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.
Removed
flush_page_cache,rebuild_page_treeandrebuild_search_indexno longer ask for a confirmation, and no longer declareconfirm_tokenat all — a caller that still sends one gets a schema error rather than silence.They were gated because they are instance-wide and slow, and that is the wrong argument: nothing is lost by any of them. The cost is time, not content, and a dialog in front of an operation that loses nothing is how people learn to tick without reading — which spends exactly the attention
purge_page_historyneeds. What each of them does cost is now stated in its description.
Fixed
A confirmation for
update_groupdid not cover the group's name or its login redirect, although the mutation replaces both. A token issued for a change to the page rules would execute a call that also renamed the group — to "Administrators", say, which is what every administration view and every laterlist_groupsanswer then reports — and repointedredirectOnLogin, where Wiki.js sends a member once it has authenticated them. Both are now bound, and named in the prompt.create_userhad the same gap forpassword,provider_key,must_change_passwordandsend_welcome_email.Found by reading; kept out by
test/confirmation-binding.test.ts, which varies every argument of every gated tool in turn and requires the token to break — and which fails when a gated tool gains an argument nobody accounted for.A refusal written by Wiki.js itself went into the model's context unbounded and unmarked.
responseResult.messageis as far upstream as a database driver — a comment with a nullreplyTocomes back with the whole INSERT statement in it — and that one error path skipped the 2000-character cap the other three go through. It is capped now, and set off under a line saying the upstream wrote it, rather than folded into this server's own sentence. The error slug is reduced to the shape a slug has, so it cannot open a line of its own beside that marker.grep_pagesappliedpath_prefixafter the 200-page ceiling instead of before it. On a wiki whose most recently updated pages are all underblog/, a search underdocs/answered "0 pages scanned, 0 matched" without reading a single one of the pages that did match. The ceiling exists to bound the fetch, so it now applies to the pages that survive the filter; the accompanying note counts those too, since that is the number a caller can act on.identifier()let the bidi control characters through, including RIGHT-TO-LEFT OVERRIDE. Its rule listed four invisible characters and the class has rather more, somove_pagewould show a destination path that rendered backwards from the one it was about to write. The rule is now stated as the Unicode categories it always meant (Cc,Cf,Zl,Zp,Zs) rather than as a list of code points.WIKIJS_ALLOWED_PATHSdid not cover four of the tools the documentation said it covered.flush_page_cache,rebuild_page_treeandrebuild_search_indexact on every page in the wiki and now refuse while the variable is set, like the two destructive maintenance tools already did — they lose nothing, but "the whole wiki" is outside any prefix. Andrender_pageis now placed against the prefix like every other page write: it changes no content, but it rewrites the stored HTML and bumpsupdatedAt, which is the fieldupdate_pagereads to detect somebody else's edit.An entry in
WIKIJS_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.WIKIJS_API_KEYandWIKIJS_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.
[0.1.2] - 2026-08-30
Fixed
The container and documentation badges rendered as broken images. Both were mis-escaped: shields.io reads a literal hyphen as a field separator, so
ghcr.io-ni--c%2Fwikijs-mcp-bluesplits in the wrong place and the endpoint answers 404. Theni--chalf was escaped correctly in both, which is what made it easy to miss. This release is what carries the fix to the package page — a README only reaches npmjs.com with a publish.The issue forms shipped with their template placeholders unresolved. All three contact links pointed at
github.com/ni-c//…, so both "Question or discussion" and "Report a vulnerability privately" led to a 404 — the second being the one that matters, since the alternative a reporter reaches for is a public issue. The forms also asked for a " version" and a " API endpoint"; they now name wikijs-mcp, Wiki.js and its GraphQL schema.
[0.1.1] - 2026-08-30
Fixed
list_pagesandgrep_pagesreturned far fewer pages than asked for, with nothing saying so. Wiki.js'pages.listjoins the tag table and applieslimitto the joined rows, not to the pages — a page with three tags eats three of them — so asking for 50 on a 62-page wiki returned between 13 and 23 depending on the sort order. A short answer was indistinguishable from a small wiki. Both tools now bound the list themselves, andlist_pagesreports how many pages matched as well as how many are shown.- The demo recording is now actually shown in the README and on the documentation home page. It was recorded and checked in, and nothing referenced it.
[0.1.0] - 2026-08-30
Added
- Initial release: MCP server for Wiki.js 2.x, with 62 tools covering pages, history, tags, assets, comments, users, groups, system settings and maintenance.
grep_pages, which searches the actual text of pages. Wiki.js' default search engine ("Database - Basic") indexes only titles and descriptions, sosearch_pagescannot find anything written inside a page — it now reports which engine is active and says so.- A concurrent-edit guard on
update_page. It compares against the moment the caller read the page, refuses to overwrite somebody else's save, and can be overridden deliberately withforce. - Surgical edits:
update_pagetakeseditswith a find-and-replace contract that requires each anchor to match exactly once, rather than silently editing the first occurrence. get_pagemodes for metadata, an outline, a named section, and a character window, so a large page can be read without filling the context.diff_page_versions, a unified diff between two stored versions or between a version and the live page.upload_asset, which Wiki.js 2.x has no GraphQL mutation for.WIKIJS_ALLOWED_PATHS, confining the writing tools to page path prefixes, matched by path segment.WIKIJS_READ_ONLY,WIKIJS_ALLOW_TOOLS/WIKIJS_DENY_TOOLSwith anessentialpreset, and confirmation tokens on every destructive or administrative operation.