Changelog
0.5.0 - 2026-09-07
Security
- The access token cannot reach a tool result through the HTTP layer.
OPENGIST_TOKENhad no shape check, so a token with a line break inside it — a paste wrapped onto two lines — reachedfetch, whose refusal isHeaders.append: "Bearer …" is an invalid header value.with the whole value quoted, and the generic error path put that sentence into the tool result. Verified on Node 24's global fetch and on undici 8.10. The value is now trimmed and checked at startup (printable ASCII, at most 1024 characters; the message names the length and the position of the offending character, never the value), every header is checked again in front of every request, and whatever the transport still throws has the token redacted out of it. - Nothing the instance sends is trusted by type. Every response used to be a TypeScript cast. A
filesentry that wasnull, acontentthat was a number, acommitsorforksthat was a string, atitlethat was a number threw aTypeErrorout of the projection; alike_countof1e999(Infinityafter parsing), atopicswith a number in it, a commitversionthat was a number, anX-Pageheader of1e300or aLinkheader with a four-hundred-digit page failed the output schema for the whole listing. All of it is reachable from the instance, from a proxy in front of it, or from whatever a mistypedOPENGIST_URLlands on. A new boundary (src/boundary.ts) reads every record field by field: a field of the wrong type is absent, a count is finite, an identifier has its shape, a display string is cut at 2000 characters with a note, related gists are read one level deep, and an entry that is not an object is counted and skipped rather than fatal. A property test drives every read tool through the server with shaped and arbitrary JSON. get_gist_filedeclared an output schema it did not keep. The result carriesoffset, the schema did not name it, and the schema is closed — so every client that validates structured content (the official SDK client does, once it has listed the tools) refused every successful call with a protocol error. Found the moment the test harness started listing tools before calling them, which it now does in every suite.- A visibility word the instance chose could switch the widening guard off. The rank lookup was an object literal keyed by the instance's
visibilitystring;constructoror__proto__answered with a function, the comparison wasNaN, and a change from that topublicwas not a widening. Anything outside the three known words now ranks asprivate, so a change away from it asks. - The instance's words stay out of prompts, notes and request paths. The confirmation for
delete_gistquotedvisibilityandcreated_atas received;update_gistquoted the current visibility; the previous-revision note andget_gist_file's revision lookup used a commit id that had never been checked, and the lookup spliced it into the raw-file path. Visibility is validated to the three words orunknown, a timestamp is quoted only in ISO shape, and a commit id has to be four to forty hexadecimal characters to be used at all. - Every string that leaves is cleaned. Titles, descriptions, topics, filenames, git author names, the content type and file bodies went into the model context with escape sequences intact, and a
sliceat a character budget could cut a surrogate pair in half — a lone surrogate that some clients cannot encode. One walk over every result strips C0 (except tab, line feed and carriage return), C1 and DEL and repairs lone surrogates; where that touches a file body, the result says how many characters were removed from which file. - The status is read before the body. A
401behind a reverse proxy's multi-megabyte login page surfaced as "the answer was larger than 8388608 bytes and was refused", never as a 401 with the credential hint. The status decides first; an error body is read under its own 64 KiB ceiling, cut rather than refused, stripped of control characters, and labelled as the instance's text. - Diagnostics describe a value rather than print it.
ELICITATIONechoed an unrecognised value in full, and the URL check printed the scheme of a value that had one — both variables sit next to the token in every compose file, and a fifty-character hexadecimal key with a colon after it is a valid URL whose scheme is the key. Both now say how long the value is. - URLs the instance sends lose their credentials.
html_url,clone_url,ssh_urlandavatar_urlare redacted up to the last@before the path. - Filenames the instance chose are cleaned and counted in error messages. The "no such file" refusal quoted the near match and every existing filename as received — twenty thousand of them, for one
git push, into one error result the budget never sees. At most twenty are listed, cleaned and cut, with the rest counted. - CI reviews the dependency change of a pull request (
dependency-review-action,fail-on-severity: high), the GitHub release verifies its tag, the integration job installs with--ignore-scriptslike every other job, and the runtime image no longer carries yarn or the lockfile. - 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.
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.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.src/is no longer published either. This package was the only one in the family shipping it, which is what had made its maps resolve; the two belong together, and neither is something an installed server needs. The sources are on GitHub, tagged per release.
Fixed
OPENGIST_URLis stored from the parsed URL (origin plus path) rather than as the raw string, so a query string or fragment is dropped with a warning instead of being glued in front of every request path; trailing slashes come off in a counted loop rather than through/\/+$/, which cost 1.6 seconds at 80 000 of them.- The result ceiling is measured on the indented text block that is emitted, not on the compact form — the same value, two to three times the characters.
- Caller strings have ceilings:
confirm_token(64),expiresAt(RFC 3339 shape, no longer echoed when malformed), a file body increate_gistandupdate_gist(1 000 000 characters), andsearch_gists'sinlist (4). - The insecure-TLS path has a test of its own: undici's fetch and the relaxed dispatcher are used under the switch and not otherwise.
SECURITY.mdargued from a transport the code stopped using in 0.4.0 ("this server offers only 2025-era revisions") and asked for an at-most-once record thatmcp-approval0.8.1 has since provided. Rewritten against the current source.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.
0.4.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.Every tool that reports gist content carries
untrusted: trueandsource: "opengist"as fields. This server has always said so innotes, which is prose in a list — a client can read it but not check it, and the field is what makes it checkable.check_gist_like,set_gist_likeanddelete_gistare without it: their answer is an id they were given and a boolean, and a marker on those would be noise.
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; 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.A result too large even after file contents are dropped is now an error. It used to answer with the JSON cut at the ceiling — unparseable, but visible — and that is not something
structuredContentcan carry, nor something the SDK would accept against the schema the tool declares.The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays, and a tool with an output schema may not answer withoutstructuredContentunless the result is an error. The text is unchanged and still carries the token.Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call token remains 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. This covers all four guarded tools:
create_gist,update_gist,delete_gist_filesanddelete_gist.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, on a line of its own rather than folded into the connection message people grep for a URL, 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 off on a typo would leave the dialog running while the operator believed it was off. It is read afterOPENGIST_TOKENis wiped from the environment, so that exit cannot leave the token behind.A
docs/guide/approval.mdpage.BREAKING: the confirmation parameter is now
confirm_token, notconfirmToken. A caller that sends the old name is told the argument is unknown. This server names every parameter in camelCase, so the old spelling fitted its neighbours — but the confirmation parameter belongs to the family rather than to Opengist, and the prompt text now comes frommcp-approval, which names itconfirm_tokenverbatim. A schema spelling it differently would hand the model an instruction its own schema rejects.The confirmation prompt is a plain result rather than an error. Asking a question is not a failure, and the rest of the family answers it this way.
A
confirm_tokenthat does not match its arguments is refused with the reason instead of being answered with a fresh prompt. The binding is unchanged: a confirmation issued for one gist still cannot delete another.delete_gistfetches the gist on every call rather than only when building a prompt, so the counts shown are the same whichever way the answer arrives — and the gist is confirmed to still exist before it is destroyed.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, 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 — 652 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.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
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
OPENGIST_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.OPENGIST_TOKENandOPENGIST_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.MAX_RESULT_BYTESis a ceiling again. The fallback for an oversized result only replaced file contents, so a payload whose bulk sat anywhere else — twenty thousand filenames, five hundred fork summaries, a hundred long descriptions — was announced as truncated and returned in full anyway. Megabytes reached the model that way. The result is now cut outright when stripping is not enough, and the number of file entries and fork entries a single gist detail may carry is capped like the commit list already was, each with a note naming the tool that pages through the rest.A file named
constructor,toString,valueOf,hasOwnPropertyor__proto__is handled like any other. The payload builder tested for presence against an object literal, so those names answered with an inheritedObject.prototypemember: a rename onto such a file passed the collision check and destroyed it, and a write to one was refused as a duplicate that did not exist. Filenames come out of the gist, and all five are legal ones.OPENGIST_READ_ONLYaccepts1andyesas well astrue, in any casing. A switch that takes capability away is read generously on purpose: the exact string comparison this replaces left every write tool registered without saying a word.OPENGIST_INSECURE_TLSgrants something instead, so there the strict comparison stays — an unrecognised value must fail towards verifying.
Security
update_gistasks before publishing a title or a description. The gate tested for file operations, so a call that changed nothing else went straight to the PATCH — on a public gist, with a client that could have shown a dialog and was never given one. A title and a description are content out of the model's context exactly like a file body is,create_gisthas always fingerprinted all three together, and on a public gist they are the part a reader sees without opening a file. The prompt names what is about to be published and still quotes none of it.SECURITY.mdstates what an approval proves and what it does not: binding to one operation with one set of arguments, but not freshness. The two-call token is single-use, and on a 2025-era connection the dialog answer never leaves the process — the residual case, and what to do about it, is written down against the day this server serves a protocol revision where it does.
[0.3.0] - 2026-08-27
Added
OPENGIST_ALLOW_TOOLSandOPENGIST_DENY_TOOLSchoose which of the 14 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, andOPENGIST_ALLOW_TOOLS=essentialselects a curated seven —list_gists,search_gists,get_gist,get_gist_file,create_gist,update_gist,delete_gist. A model picks the right tool far more reliably from seven than from fourteen, 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 cutOPENGIST_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.2.4] - 2026-08-26
Changed
- The check that decides whether
OPENGIST_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.2.3] - 2026-08-18
Fixed
- A malformed
OPENGIST_URLis no longer echoed into the log. That branch fires precisely when the variable does not hold a URL, which most often means the token was pasted into the wrong variable — and it then landed verbatim in the MCP host's log.
0.2.2 - 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.
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 #1 — that state is invisible to anyone reading the config and is lost if the PR is reopened.
0.2.1 - 2026-08-16
Fixed
- The documented value for the
scopeparameter oflist_gistsandsearch_gistswas wrong: it ismine, notown. The docs, the getting-started smoke test and the demo tape were written from the tool descriptions instead of the schema, and a live call against a real instance rejected the value they showed.
Changed
homepagepoints at opengist-mcp.ni-c.de rather than the README anchor, so the npm page links to the documentation.- Published from CI through npm Trusted Publishing, so this release carries build provenance. 0.2.0 was published by hand and does not.
0.2.0 - 2026-08-16
Added
- A
Dockerfileand.dockerignore. The image is digest-pinned, runs as the unprivilegednodeuser undertiniand carries the MCP Registry ownership label. Multi-arch images (amd64, arm64) with an SBOM and build provenance are published toghcr.io/ni-c/opengist-mcp. - A documentation site at opengist-mcp.ni-c.de with a guide, the full tool reference and the security notes.
- CI now runs CodeQL and a Trivy scan of the container image on both architectures in addition to the test matrix and
npm audit. The runtime image ships without npm and corepack, whose vendored dependency trees were the only source of HIGH/CRITICAL findings on it.
Changed
- The server now starts without
OPENGIST_URL/OPENGIST_TOKEN: it completes the MCP handshake and lists its tools, and only a tool call fails with the setup instructions. Registries and sandbox inspectors start the server without secrets and could not enumerate the tools before. An invalid URL still exits, since that one could send the token to the wrong host. get_userreturns an allowlisted set of fields instead of the raw API object, so fields Opengist may add later cannot appear in the model context on their own. A response that is not a user object is reported as an error naming the likely cause instead of being passed through.
Security
- Publishing content now needs a confirmation token.
create_gistwithvisibility: "public"or"unlisted"refuses the first call and returns a single-use token, the same gate that widening an existing gist's visibility already had. Creating a public gist is the stronger of the two primitives — the content comes straight out of the model's context rather than from something already stored — and a requiredvisibilityfield only prevents an accidental public default, not a directed one.update_gistgets the same gate when it writes files into a gist that is already public or unlisted; a call that makes the gist private in the same breath is not a disclosure and stays ungated. update_gistvalidates its file operations before asking for a confirmation, so a call that could never succeed no longer costs a confirmation round-trip and no token is ever issued for one.- The access token is now removed from
process.envbefore any early return, not after the URL has been parsed. The credential-less start path is exactly the one where a token is set and something else is wrong — a typo in the URL, a half-filled config — and it used to leave the token readable in/proc/<pid>/environ. - Truncation and binary-content notes refer to a file by its index in the returned
filesarray instead of quoting its name. A filename is written by whoever created the gist; interpolated into server-voice prose it could close the quoting and forge what reads as operator guidance. The name is still available to the model as thefilenamefield of the entry, where it is data rather than prose. - The untrusted-metadata marker now also fires for the titles, descriptions and topics of embedded forks and of the gist a gist was forked from — those are written by other users, so a gist with no metadata of its own could carry theirs through unmarked. Commit author names get their own marker, and
change_statusis reduced to its four documented keys instead of being passed through. - Responses are refused past 8 MB, by
content-lengthwhere one is declared and while reading otherwise. Every per-tool budget trims data that is already resident as a string, so without this ceiling a hostile instance could exhaust memory before any of them was consulted. - Confirmation tokens are compared in constant time, and the effect fingerprints they are bound to use the full SHA-256 digest instead of its first 64 bits.
- The insecure-TLS dispatcher is now selected only for requests whose origin matches the configured instance, independent of the
redirect: 'error'that already prevented cross-origin hops. - The confirmation token of
update_gistis now bound to the entire effect of the call, not just the new visibility. A confirmation obtained for "make this public" could previously be replayed with additionalfileOps,titleordescriptionattached, so the user approved disclosure and silently got content changes as well. The refusal now also names the other changes the same call would make. delete_gist_filesno longer echoes the filenames it is about to delete into the confirmation message, and the "unknown filename" error no longer echoes the requested or the existing filenames. Both are attacker-influenceable text in a message that a model reads — the rule the other confirmations already followed.- The "nothing we send may be read as a deletion" invariant is now backed by a measurement instead of an assumption. Verified against Opengist on 2026-08-15: on update, an entry with
content: ""keeps the file and empties it; only an entry that isnullor carries neithercontentnorfilenamedeletes. On create, by contrast, a file with empty content is silently dropped — which is whycreate_gistrequires non-empty content andupdate_gistdoes not. - Gist titles, descriptions and topics are now tagged as untrusted input in
list_gists,list_gist_forks,search_gistsand the gist detail. Only file contents carried that note before, whilelist_gistswithscope: "public"returns the metadata of every gist on the instance — usually the first call of a session. - The release workflow installs
mcp-publisherfrom a pinned version verified against a SHA-256 checksum instead ofreleases/latest, and runsnpm ci --ignore-scriptsin the job that holds the npm Trusted Publishing credential.
0.1.0 - 2026-08-11
Added
- Initial release: MCP server for the Opengist REST API.
- Read tools:
list_gists(own, public, liked, forked, and per user),search_gists,get_gist,get_gist_file,list_gist_commits,list_gist_forks,get_user,check_gist_like. - Write tools:
create_gist,update_gist,delete_gist_files,delete_gist,fork_gist,set_gist_like. OPENGIST_READ_ONLY=trueregisters only the read tools.OPENGIST_INSECURE_TLS=trueaccepts self-signed certificates, scoped to the Opengist connection instead of process-wide.
Security
- Irreversible operations (
delete_gist,delete_gist_files, widening a gist's visibility) require a server-generated, single-use confirmation token that expires after five minutes. The token fordelete_gist_filesis bound to the exact set of filenames, so a confirmation cannot be replayed for a larger set. - Confirmation messages never echo gist titles, descriptions, topics or filenames, which are user-supplied text and could be used to manufacture a confirmation.
- The raw Opengist
filesmap is never exposed as a tool input, because an entry that isnullor carries neithercontentnorfilenamedeletes the file.update_gisttakes explicitwrite/renameoperations and asserts that nothing it sends can be read as a deletion;delete_gist_filesis the only tool that deletes files. - A write to a filename that does not exist is refused unless
allowCreateis set, and the refusal names a case-insensitive near match. - Responses that carry file content are tagged as untrusted input.
- Requests refuse redirects, carry a 30 s timeout, and URL-encode path parameters that are additionally validated against
./..and control characters. - Upstream error bodies are truncated at 2000 characters and HTML error pages are dropped instead of being pushed into the model context.
- The access token is removed from
process.envafter startup, and a URL containing credentials is rejected.