Changelog
[0.4.0] - 2026-09-07
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.
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
- 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.
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.
set_documentandreplace_in_documentconfirm an ordered tuple — the pad, the old and the new content fingerprint; the pad,search,replaceand the match count. A sorted set key would have let a token issued forDEV → PRODalso confirmPROD → DEVon a pad where both occur equally often, and the pad is world-writable, so the count can be arranged. This server has kept the order in a localtupleResourceKeysince 0.3.0; the keys now come fromorderedResourceKeyin mcp-approval 0.8.2, which prefixes every part with its position, and the local copy is gone. - The frame limit is enforced before the frame is buffered, and the queue is bounded in bytes. The 1 MiB frame cap was checked on the assembled message, after undici had already held it — up to 128 MB, its default payload limit. It now travels to the WebSocket implementation as its payload limit, on both TLS settings, so a frame header announcing more fails the connection at the header. And the queue used to be capped by count alone: a thousand messages at the frame limit is a gigabyte, measured at 424 MB for four hundred, in a process mcp-hub allows 192 MB. It is now capped at 8 MiB of buffered messages as well. Both paths now go through undici's
WebSocketon a shared dispatcher; the plain path used the global one, which offers no such limit. A test drives the real transport against a loopback server that announces two megabytes and delivers sixty-four kilobytes. - SECURITY.md said the approval replay path was unreachable; it had been reachable since 0.3.0. The server has served protocol revision
2026-07-28over stdio sinceserveStdioarrived, and on that revision the sealed dialog answer crosses the wire. The tripwire test watchedSUPPORTED_PROTOCOL_VERSIONS, which is the list of legacy revisions and never carries the modern one, so it could not fire. The guard itself exists — mcp-approval ≥ 0.8.1 spends a nonce on the first answer — and is now tested directly against the approver this server builds (test/approval-replay.test.ts); the section is rewritten around what is tested. - What the instance wrote is cleaned before it reaches the model. Pad text, user names, the editor language and upstream error bodies had control characters passed through (an
ESC[sequence in a user name reachedactive_usersverbatim), and a cut at 100 or 200 000 UTF-16 units could split a surrogate pair and answer a lone surrogate, which a client encoding to UTF-8 refuses. C0, C1 and DEL are stripped (tab, newline and carriage return stay), every string is made well-formed after a cut, and the result budget is measured on the text as serialised — a pad of backslashes went out at twice the budget once JSON escaping had doubled it. - Frames from the instance are checked as objects.
nulland[…]are JSON; read as a message they answered the tool call withCannot read properties of null (reading 'Identity'). AUserInfoofnullthrew out of a destructuring. Operation components had to be numbers or strings, not integers, so1.5sliced half a character. And a History starting past the revision this client holds — a gap — set "history seen" without folding anything, which is the one state that lets a write skip its second-channel check. All four are refused with a sentence. codepointLengthagreed with[...text].lengthonly on well-formed text. A lone high surrogate — legal in the instance's JSON — swallowed the character after it, so every operation built from the count was one short of the document. It now pairs a high surrogate only with a low one that actually follows; a property test generates surrogates as units.- Server statistics are checked at the boundary.
start_time: 1e300threwRangeError: Invalid time valueout oftoISOString; a fractional or infinite count failed the answer's output validation with no field named. Counts must be non-negative safe integers and the timestamp inside whatDateholds; anything else is a sentence naming the field. - The status is decided before the body is read. A 502 with an error page past the 8 MB ceiling used to answer "more than the byte limit", without the status or its hint. The body of a failure is read under its own 64 KiB ceiling, cut rather than refused, and a body that cannot be read at all keeps the status.
- Startup diagnostics quote less.
RUSTPAD_URL must use http:// or https:// (got <scheme>)printed the scheme, and a hexadecimal key with a colon after it is a valid URL whose scheme is the key.ELICITATION … got "<value>"printed the value. The scheme is no longer printed; a value is quoted only when it is short and printable, otherwise described by its length. - The base URL is stored as parsed,
originplus path, so a stray space or an unencoded character inRUSTPAD_URLno longer reaches every request path and share link as typed. Trailing slashes are removed by a counted loop rather than/\/+$/, here and in the socket URL. confirm_tokenhas a ceiling (64 characters; the server issues 32).- The publish jobs pin
mcp-publisherto a release and check its published checksum rather than downloadingreleases/latestwhile holding an OIDC token;gh release createverifies the tag; the integration job installs with--ignore-scriptsand without persisting credentials; adependency-reviewjob fails a pull request that introduces a known high-severity vulnerability. - The runtime image no longer ships yarn or corepack. npm was removed; the two beside it were not.
Fixed
get_documentanswered an empty pad with anotethat its output schema did not declare, so a client that had listed the tools refused the answer as a protocol error. The harness now lists once per connection, which is what found it.- Denying
create_documentis documented for what it is: Rustpad has no create operation, a pad exists under any id the moment it is written to, andset_documentorappend_to_documenton a fresh id makes one just the same.
[0.3.0] - 2026-09-03
Added
Replacing a non-empty pad now asks 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.The dialog names the pad and the character counts on both sides. It never carries pad content: pads are world-writable, and the text would otherwise be written by whoever edited the pad last and read by whoever is deciding.
replace_in_documentnow asks too, but only whenreplace_allis about to change more than one occurrence. It carriesdestructiveHint: trueand with a broad enough search string can take out as much of a pad asset_documentdoes — which asks. A single, unique replacement still goes straight through: that is what the tool is for, and a dialog on every one of them is how people learn to tick without reading.The line is drawn on the count rather than on the flag, because the count is the mistake worth catching. The number comes from the same pass that builds the operations, so it is measured inside the open session: what the person is told is the pad as it stands, not as it stood when the model decided.
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: it is the only variable here that defaults to on, so failing open on a typo would leave the dialog running while the operator believed it was off.
Changed
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 makes 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, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather than from copies kept here — 850 fewer lines, and one place to fix each.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
The message after an unacknowledged edit no longer claims "the pad was left unchanged". The History echo is the only acknowledgement this protocol has, and its absence says nothing about whether the server applied the operation — it may have, and only the echo missed the deadline. The old wording invited exactly the retry that turns one
append_to_documentinto two; the new one says the outcome is unknown, points atget_document, and names the tool that must not simply be repeated.RUSTPAD_READ_ONLYnow accepts1andyesas well astrue, in any casing and with surrounding whitespace. A switch that protects something is read leniently:RUSTPAD_READ_ONLY=1used to register all five write tools against an instance the operator meant to protect, silently.RUSTPAD_INSECURE_TLSis unchanged and still compares against exactlytrue, because it removes a protection and a typo there has to fail the safe way.A
confirm_tokenthat does not match is now refused with the reason — invalid, expired, or issued for different arguments — instead of being answered with a fresh prompt. The second is self-healing when a token merely expired and silent when the token was issued for a different pad or a different replacement, which is the case the binding exists to catch.Confirmation tokens are compared with a constant-time comparison. The copy in this repository used
!==, which leaks through timing how much of a guess was right. Reaching a token still requires having received it in a previous tool result, so this closes a margin rather than a hole.An entry in
RUSTPAD_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back, so a value pasted into the wrong variable is not echoed into the client's log.
Security
An empty pad is now established rather than assumed. Rustpad sends no History message at all for a pad that was never written, so on the socket "this pad is empty" and "the history has not arrived yet" are the same silence — and the session stopped waiting after 300 ms of it. A slow instance, a database restore, a buffering proxy or round-trip time plus a TLS handshake was enough to make every tool see
text: ''for a full pad.Which is the one state the write tools treat as safe.
set_documentskipped its confirmation entirely — no dialog, no token, the two-step guard on the only destructive tool simply absent — and then wrote at revision 0, which the server transforms to sit beside the existing content rather than replacing it, while the reply said "0 → N characters".create_documenthad nothing to refuse,append_to_documentput its text at the top of the pad, andget_document_inforeportedlength_characters: 0. A guard that is present in every test and absent whenever the network is slow is worse than one that is missing, because nothing ever reports its absence.Every tool that acts on an empty pad now confirms it over
GET /api/text/{id}first — the same document, read over a channel that does not depend on the timing of a burst. On disagreement the call fails instead of writing. Only when no History arrived at all, so an ordinary concurrent edit, which the operational transform handles correctly, is still not a failure.The confirmation for
replace_in_documentno longer sortssearchandreplace. The sharedsetResourceKeyhashes its targets sorted, which is right for a set and wrong for this: the two strings come from the same vocabulary, so[pad, "DEV", "PROD", "2"]and[pad, "PROD", "DEV", "2"]produced the same key. A person who read "Search: DEV / Replace with: PROD" and ticked the box had also approved the exact reverse, and on a pad where both strings occur equally often the match count agreed as well — arrangeable by anyone, since pads are world-writable. An order-preserving key lives insrc/resource-key.ts; the library is unchanged, because it does what its name says.The confirmation for
set_documentnow binds the content it destroys, not only the replacement. The key was the pad plus a fingerprint of the new text, so the state the approval was given about did not enter it at all. Up to five minutes pass between the dialog and the second call against an instance with no authentication: a pad that read "TODO: buy milk" when the person approved "(14 characters)" could have grown by 40 kB before the token was quoted back, and the token was still accepted. Now it is refused and the question is asked again with the real numbers.A single History message can no longer hold the event loop indefinitely.
operations.lengthis chosen upstream and applying one operation costs O(document length), so a 1 MiB frame of 35 000 minimal entries against a full 256 KiB document measured at 84 seconds of synchronous work — during which the settle deadline, which is only checked around message handling, was never reached. The same run now ends at the deadline — 20 s by default, configurable — because that deadline is checked inside the folding loop.Not only adversarial: Rustpad replays a pad's whole operation history in one message on connect and never compacts it, so a heavily edited pad ran the same loop by accident. Which is also why the second guard, a cap on operations per message, sits above what a 1 MiB frame can carry rather than at a tighter, more satisfying number: a low count would refuse ordinary pads that work today, to save an adversary twenty seconds it can spend once per tool call either way.
[0.2.0] - 2026-08-27
Added
RUSTPAD_ALLOW_TOOLSandRUSTPAD_DENY_TOOLSchoose which of the 8 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, andRUSTPAD_ALLOW_TOOLS=essentialselects a curated five —get_document,get_document_info,create_document,set_document,append_to_document. A model picks the right tool far more reliably from five than from eight, 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 cutRUSTPAD_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.1.2] - 2026-08-26
Changed
- The check that decides whether
RUSTPAD_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.1.1] - 2026-08-19
Changed
- First release published through the tag-driven pipeline (npm Trusted Publishing with provenance, GHCR multi-arch image, MCP registry entry). Functionally identical to 0.1.0.
[0.1.0] - 2026-08-19
Added
- Initial release: MCP server for Rustpad, the self-hosted collaborative text editor. Read tools (
get_document,get_document_info,get_stats) over HTTP and the collaboration WebSocket; write tools (create_document,set_document,append_to_document,replace_in_document,set_language) speak Rustpad's operational-transformation protocol, so targeted edits leave concurrent edits elsewhere in the pad intact.