Skip to content

Changelog ​

0.3.2 - 2026-09-07 ​

Security ​

  • mcp-approval 0.8.2. 0.3.1 already shipped the single-use sealed dialog answer of 0.8.1, on npm and in the Docker image, which is built from the lockfile. 0.8.2 adds orderedResourceKey, taken up below.

  • Approval keys bound to positions. mark_all_as_read keys its confirmation on the pair (stream, cut-off). setResourceKey sorts its parts before fingerprinting — set semantics — so a token issued for stream A and cut-off B would also have confirmed the pair the other way round. FreshRSS issues no stream id that is all digits, so the swap could not be expressed through the tool, but the shape is a tuple and the key now comes from orderedResourceKey in mcp-approval 0.8.2, which fingerprints each part at its position. mark_articles, unsubscribe_feed, delete_category_or_label and import_opml key sets or single ids and stay on setResourceKey.

  • A refused login is not retried for ten seconds. Every tool call that needed a token was a fresh ClientLogin, and FreshRSS writes every refused one into its log as Password API mismatch for user …. get_user_info is annotated read-only, idempotent and cheap, and its 401 answer says "check the password" — which is what a model retries. A wrong password in the configuration was a burst of failed logins in the instance's log, and a reverse proxy's rate limit or a fail2ban jail keyed on that line locks the operator's own address out. A refused login (any status) is now remembered for ten seconds and repeated from memory, with a note saying so and when the next attempt is possible. The one retry after a 401 on a request is unchanged; its refusal is what starts the cooldown.

  • The publish job installs without running install hooks. release.yml's publish job holds id-token: write for npm Trusted Publishing and ran a plain npm ci, so every dependency's install hook ran with the OIDC token reachable from the job environment. It runs npm ci --ignore-scripts like the audit job and the Dockerfile already did — nothing in the tree declares a hook — and gh release create checks the tag exists with --verify-tag.

  • Tokens from the instance have a shape. The Auth= line of the login answer went into the Authorization header as it came; a carriage return or a NUL in it made undici throw Headers.append: "GoogleLogin auth=…" is an invalid header value — the instance's string, unlabelled and unbounded, into the model context. The write token rode in every form the same way. Both must be visible ASCII of at most 1024 characters; anything else is reported as "without a usable Auth token" / "not a write token".

  • The startup line does not print a value that is not a URL. A FRESHRSS_URL that failed to parse was printed (redacted for userinfo), and one with an unknown scheme was printed as got ${scheme} — a fifty-six-character key with a colon behind it is a valid URL whose scheme is the key. FRESHRSS_API_PASSWORD sits one line below this variable in every compose file, and a password pasted into the wrong line is exactly a value that does not parse. Only a value with :// is quoted, redacted and cut to 120 characters; anything else is described by its length.

  • mark_articles validates the ids before it asks. The dialog was raised and the key built on the ids as written, and assertArticleId ran afterwards — so the person was asked about a list that might not run, and an approval could be spent on a call that then failed. Validation now comes first and the key is built from the validated ids; articleIds in the answer is the validated form.

  • Control characters and lone surrogates in three more places.get_unread_counts handed feed titles on as they came, unlike list_feeds; export_opml passed the document through with whatever was in it (XML 1.0 forbids those characters, so a well-formed export is unchanged); and � in an article — or a \ud800 escape in the instance's JSON — produced a lone surrogate, which String.fromCodePoint does not refuse and every UTF-8 encoder on the client side does. Every string that leaves this server is well-formed now (toWellFormed), and a surrogate reference decodes to U+FFFD.

Fixed ​

  • What the instance sends is read at a boundary, not through a cast. Every response was a TypeScript cast, and one value of the wrong shape took the whole listing down — as Output validation error from the SDK's check of the output schema, or as a TypeError. feed/1e300 or a twenty-digit feed id (.int() refuses both); 1e999 in unread-count, which JSON.parse hands over as Infinity; published past ±8.64e12 seconds, which toISOString throws on; a number where a title, an author, a URL or an id belongs; items that is not a list; a body that is null. Objects, arrays, strings, finite numbers and safe integers are now decided at the boundary (src/boundary.ts), a field of the wrong shape is omitted and an element of the wrong shape is skipped, feed ids are bounded to fifteen digits, and display strings are cut at 2000 characters (URLs at 8192) so one oversized title cannot cost the listing. A property test feeds every read tool arbitrary JSON and shaped envelopes with arbitrary leaves through the whole server and asserts on the absence of those errors; the harness lists the tools once per connection, so every success path in the suite is checked against the listed schema.

  • The status is decided before the body is read. A 401 or a 5xx with a body past the 64 MiB ceiling answered "more than 67108864 bytes" — the size instead of the status, no hint about the credentials, and the one retry a 401 earns never ran. An error body is now read under its own 64 KiB ceiling, which cuts rather than refuses.

  • &constructor; in an article decoded to a function. The named-entity table was an object literal, so &constructor; looked up Object.prototype.constructor and the replace callback wrote function Object() { [native code] } into the text. The table is a Map.

  • import_opml measures the document in the bytes FreshRSS reads. The ceiling was 900 000 characters on the document as given; FreshRSS reads 1 048 576 bytes and drops the rest, and the document sent is the rewritten one — canonical URLs and XML escaping make it longer. A document that would be cut on the FreshRSS side is refused before the dialog, with the size it would be sent at.

  • import_opml quotes FreshRSS's answer like every other tool — through upstreamText, cut and labelled, instead of the raw first 200 characters.

  • Caller strings have ceilings at the schema. since, until and older_than (64 — Date.parse walks whatever it is given), continuation (256), article ids (64), category and label names (200), a feed URL (8192) and a title (1000).

  • get_user_info reads the account strings as strings, cleaned and cut to 200 characters; a number where the user name belongs no longer fails the schema.

  • Trailing slashes in FRESHRSS_URL are removed with a counted walk rather than /\/+$/, which is quadratic in the length of the run.

Changed ​

  • The runtime image no longer carries yarn, corepack or package-lock.json; nothing in it runs them.
  • list_article_ids carries notes, for the case where FreshRSS sends a continuation value larger than this server accepts.

0.3.1 - 2026-09-06 ​

Fixed ​

  • Article text conversion was quadratic on nested angle brackets. The fixpoint loop that 0.3.0's successor on main (#28) introduced to strip markup until nothing changed peeled one <> per pass: a body of 120 000 characters of < took 53 seconds per article, on the single thread that serves every other request, and any feed publisher could send it. Markup is now stripped in one counted pass, with a separator emitted behind a < that is kept as text whenever the thing after it is dropped — so no deletion can assemble an element out of pieces that were not one, which is the property the loop existed for. The same input takes four milliseconds. The property test that found the original injection still holds; the escaped form of the new shape is pinned beside the other linear-time cases.

  • An approval could be replayed for fifteen minutes. On protocol revision 2026-07-28, which serveStdio negotiates since 0.3.0's follow-up, the sealed state travels through the client, and mcp-approval proved that an answer belonged to its question but not that it had not been used already. mark_all_as_read without older_than has the same resource key every time, so every replay landed. mcp-approval 0.8.1 spends each state on its first answer; SECURITY.md had described the gap as unreachable on the grounds of a transport this server no longer uses, and now describes what is enforced and what a restart still forgets.

  • Response bodies from the instance are bounded. Every answer was read into memory whole; an instance — or whatever answers in its place under FRESHRSS_INSECURE_TLS — that never stopped sending had no ceiling. Bodies above 64 MiB are refused as they arrive, with the same "narrow the request" error the result ceiling gives. A declared length above it is refused before a byte is read.

  • The write token was not refreshed on the 401 retry. After an API password change both cached tokens are stale; the retry re-logged in and then resent the form it was first handed, old T included, so the first write after a password change failed with a hint about wrong credentials. The form is now built per attempt.

  • Article and enclosure URLs skipped the credential redaction that feed URLs get. A publisher who serves a paid feed with the credentials in its URLs tends to write the item links the same way; url and enclosures[].url are now redacted like feedUrl.

  • Text the instance wrote reaches the model bounded and marked. The body of an upstream error, quickadd's error, an unexpected stream id and the answer expectOk quotes are cut at 200 characters (2 000 for an error body), stripped of control characters, and labelled as untrusted text from the instance. Titles, authors and feed names lose their control characters the way article bodies already did.

  • import_opml passed on an absolute URL it could not parse. A value that names a scheme or an authority and still does not parse — a space in the host, a port out of range — was treated like a relative one and left for FreshRSS's fetcher to read its own way. It is now refused as malformed; only a value with neither is left alone.

  • Caller-supplied ids and lists are bounded. An article id is at most 20 decimal or 16 hexadecimal digits, which is what a 64-bit FreshRSS id is; add_labels and remove_labels take at most 50 names each.

  • get_user_info and subscribe_feed broke on a client that validates. Both answered through the marked result, which adds untrusted and source — two fields their closed output schemas do not name. A client that had loaded tools/list checked the answer against the schema and threw a ProtocolError on the success path of both; one that had not saw nothing wrong, which is why the suite did not either. Both are this server's own words and now answer as such, and a test loads the schemas before calling.

  • FRESHRSS_URL is stored in its parsed form. A stray space around the value, a query or a fragment used to be glued in front of the API path; the origin and path are kept, the rest is dropped with a warning.

  • A bad ELICITATION value is echoed shortened and without control characters, in case what was set was a line pasted into the wrong variable.

Changed ​

  • mcp-publisher in the release and registry workflows is pinned to a release and checked against its published checksum; the job holds an OIDC token, so what it runs has to be what was reviewed.

  • A dependency review runs on every pull request, failing on a high-severity advisory the change would introduce.

  • oxlint's suspicious category is on, and what it found is fixed: shadowed names in the article and feed tools, helpers scoped inside test suites, and sort() where toSorted() was meant. The build target moves to ES2023 for the latter, which every supported Node has.

  • The tool reference marks the essential preset 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.

  • homepage in package.json points 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, websiteUrl and icons now travel with name and version, so a client that shows a server to a person has something to show. All four were already in server.json for the registry and reached no client at all; a test compares the two so they cannot drift.
  • Server instructions. Results carry an untrusted marker, 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 a src/ this package does not ship — so a stack trace under that flag named a file nobody could open. dist/**/*.js is unchanged; the package is about a fifth smaller.

[0.3.0] - 2026-09-03 ​

Added ​

  • Every tool declares an outputSchema and answers with structuredContent beside the text block. A client no longer has to parse prose to use a result — which six of them made unavoidable, since they answered with a sentence. The sentence stays, in the text block.

    Every tool that reports feed content carries untrusted: true and source: "freshrss" as fields. This server has always said so in notes, which is prose in a list: a client can read it and cannot check it. Eight tools are without the marker, because their answer is entirely this server's own words — ids it was given, a sentence built from the arguments, the account it authenticates as.

  • The four tools that need a confirmation now ask the user, on clients that can show a prompt: unsubscribe_feed, mark_all_as_read, delete_category_or_label and import_opml. The two-call confirm_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.

  • Each of those prompts now says what will be lost, which three of the four never did: FreshRSS keeps no record of which articles were unread, a feed takes its stored articles with it, and a deleted category moves its feeds to the default rather than deleting them.

  • mark_articles now asks too — but only when it is about to mark something read. It carries destructiveHint: true and went through unannounced, and its own description claimed "All changes are reversible by calling this tool again with the opposite value." Three of the four are. Which of those articles were unread is not, and FreshRSS keeps no record of it — the same reason mark_all_as_read is guarded, over a caller-named list instead of a whole stream.

    Starring, unstarring, labelling and marking unread still go straight through. Asking about a star toggle as well would be how people learn to tick without reading. The approval is bound to the exact list of article_ids, so one obtained for three articles does not execute against thirty.

  • ELICITATION switches the dialog off — false sends 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=false reaches 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 neither true nor falsestops 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. It is read after FRESHRSS_API_PASSWORD is wiped from the environment, so that exit cannot leave the password behind.

  • A docs/guide/approval.md page.

  • SECURITY.md states what an approval binds — a decision to a request, not a decision to a moment — why the freshness gap that leaves is unreachable on this server today, and what would have to be built on the day it starts speaking protocol revision 2026-07-28.

  • The live suite now pins three refusals against a real FreshRSS as well as the happy paths: the SSRF guard on subscribe_feed and on import_opml, each asserted with its reason rather than with a bare "this failed", and a read-only server registering no write tool.

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": true rather than the empty schema {} zod emits for it; and a value that was left untyped is declared as what it really is. What the tools accept and return is unchanged; only the way the schema says so is.

  • export_opml answers {opml} instead of the document as the whole result. A schema whose root is a string is served to a 2025-era client rewritten as {result: …}, so the tool would have answered in two shapes depending on which revision the client spoke — and truncated now has somewhere to live.

  • A result too large even after article content is dropped is now an error. It used to answer with the JSON cut at the ceiling, which a text block tolerates and structuredContent cannot.

  • The two-call confirm_token prompt is an error result. What was asked for did not happen, which is what isError says. The text is unchanged and still carries the token.

  • A confirm_token that does not match its arguments is refused with the reason instead of being answered with a fresh prompt, and the confirmation prompt itself is a plain result rather than an error. Both are now the same in every server of the family.

  • 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 typescript below 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 guard and the documentation-asset generator now come from mcp-tool-allowlist, mcp-approval, mcp-internal-hosts and svg-asset-set rather than from copies kept here — 825 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.

    The SSRF guard is the notable one: what leaves is the classification and the resolving, including the two rules this repository contributed upstream — a resolver answering 0.0.0.0 is declining rather than pointing at the fetching host, and a name that does not resolve is passed on. What stays is the refusal, in FreshRSS's own words. The behaviour is unchanged, and import_opml now stops resolving as soon as one host is refused instead of spending the whole ten-second budget to reach the same answer.

  • The shared libraries move to mcp-approval 0.7.1, mcp-tool-allowlist 0.2.1, mcp-internal-hosts 0.2.1, mcp-integration-harness 0.2.0 and svg-asset-set 0.2.0.

  • stdio is served through serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the 2026-07-28 era is served it; until now its server/discover probe was answered with "Method not found" and only 2025-11-25 was 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-wired StdioServerTransport served it.

Fixed ​

  • A feed could stall the server with an article body that shows nothing. Markup was removed with replace(/<[^>]+>/g, '') inside a loop that repeated until the text stopped changing. Every < with no > behind it made [^>]+ run to the end of the input and then backtrack a character at a time, so an article could buy quadratic work with linear bytes — measured at 8.8 seconds per article for 122 048 bare <as, 40 seconds for a body of bare <s, on the single thread that also serves every other request. The reachable form of it needs no invalid markup at all: &lt;a repeated is escaped text, which nothing on the FreshRSS or SimplePie path has reason to touch, and the conversion decodes entities between its two passes.

    The conversion is now a single left-to-right scan: at a < it looks for the > that closes it, discards the span, and never re-reads what it deleted. The repeat-until-stable loop is gone with it — a scan cannot splice <scr<script> into a new tag, because it reads the fragment and the tag that follows it in one direction, which is what a browser's tokeniser does with the same bytes. The same payloads now take single-digit milliseconds.

  • The response budget is charged for the markup read, not the text returned. This was the amplifier under the entry above: a body that strips away to nothing produced no output, so nothing was debited, the budget stayed whole, and every one of the up to 100 articles in the same list_articles response was handed a full slice again. An article that fills its slice now costs the per-article limit it was given, whatever came out — which is what the README claimed of the budget all along.

  • A feed password containing an @ was published in half. The redaction matched up to the first @, but userinfo ends at the last one before the path, and FreshRSS does not percent-encode the password it stores. A feed stored as https://alice:p@ssw0rd@rss.example/feed came back as https://***@ssw0rd@rss.example/feed from list_feeds and the OPML export — the exact disclosure the redaction exists to prevent. https://host/users/@alice is still left alone.

  • A hostname can no longer crowd out the import_opml confirmation dialog. The hosts an OPML document points at were interpolated into the server's own question. URL.hostname has no length limit — IDNA is applied with VerifyDnsLength=false, so a single 5 000-character label parses and survives — so eight of them put tens of thousands of characters of readable, attacker-written text into the question, ahead of the consequence line, which every renderer would then push out of view. The question now states how many hosts there are; the names go on the caller-supplied line, where the approval library flattens and caps them.

  • FRESHRSS_READ_ONLY is read tolerantly, as a protection switch should be. It was compared with === 'true', so =1, =yes, =TRUE or a trailing space started a server with every write tool registered and said nothing about it — the operator asked for the guard and had no way to learn they had not been given one. 1, true and yes now all switch it on, in any casing, with surrounding whitespace ignored. FRESHRSS_INSECURE_TLS stays strict on purpose: that one lifts a protection, so a value nobody spelled exactly has to leave certificate validation on.

  • 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 FRESHRSS_ALLOW_TOOLS that is not tool-name-shaped is now redacted in the error rather than quoted back. FRESHRSS_API_PASSWORD and FRESHRSS_ALLOW_TOOLS are adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.

[0.2.0] - 2026-08-27 ​

Added ​

  • FRESHRSS_ALLOW_TOOLS and FRESHRSS_DENY_TOOLS choose which of the 16 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, and FRESHRSS_ALLOW_TOOLS=essential selects a curated seven — list_feeds, list_categories, get_unread_counts, list_articles, get_articles, mark_articles, mark_all_as_read. A model picks the right tool far more reliably from seven than from sixteen, 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/list and answers tools/call with "tool not found" — the same cut FRESHRSS_READ_ONLY already 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/list with 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-alpine digest 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 upgrades libcrypto3 and libssl3 by name. Upgrading those two rather than running a blanket apk upgrade keeps the rest of the image exactly as the digest pins it. The step can go once the base image ships the fix.

[0.1.6] - 2026-08-26 ​

Fixed ​

  • A feed whose domain a resolver sinkholes is no longer refused. Every ad blocker and DNS filter answers 0.0.0.0 for a blocked name, and 0.0.0.0/8 classifies as loopback — so 0.1.5 turned "your resolver blocks this domain" into "refusing to point FreshRSS at a loopback address", which was both wrong and unhelpful. A resolved unspecified address is now passed over; nothing can be fetched from it. 0.0.0.0 written into the URL itself is still refused, because that one does address the host.
  • An IPv6 scope id is stripped before the address is read. net.isIP accepts ::ffff:127.0.0.1%eth0, which made the dotted-quad fold miss its anchor and the address come out as routable. A URL cannot carry one, but a resolver answer can.
  • A group of an IPv6 literal that is not hexadecimal is rejected outright rather than handed to parseInt, which stops at the first character it dislikes and returns a number for 7f00xyz just as happily.

Security ​

  • The metadata endpoints outside 169.254/16 are refused as well: 100.100.100.200 (Alibaba Cloud) and 192.0.0.192 (Oracle's legacy endpoint). Neither is link-local by address, so no range check reaches them, but both are the same thing by purpose.

[0.1.5] - 2026-08-25 ​

Security ​

  • subscribe_feed no longer accepts a loopback or link-local address written as an IPv4-mapped IPv6 literal. URL canonicalises http://[::ffff:127.0.0.1]/ into [::ffff:7f00:1] and http://[::ffff:169.254.169.254]/ into [::ffff:a9fe:a9fe] before the guard saw them, so the string comparison found nothing it recognised and approved both — while every dual-stack client dials them as 127.0.0.1 and the cloud metadata service. Addresses are now reduced to the IPv4 address they carry and compared numerically, which also covers the IPv4-compatible, IPv4-translated and NAT64 forms. localhost. with the root label got past the same comparison and is now read as localhost. (GHSA-qqh2-7466-82f8)
  • import_opml now checks the URLs in the document. /subscription/import makes FreshRSS subscribe to every xmlUrl and fetch it server-side — the same capability subscribe_feed guards, reached through a door that had no check on it at all. Every xmlUrl and htmlUrl is now held to the same rule, entity references are resolved first (as libxml does), and a scheme other than http/https is refused rather than left to FreshRSS to open. (GHSA-qqh2-7466-82f8)
  • The attributes of an imported OPML document are read by walking it the way an XML parser does, not by searching for them with a regular expression. A regex pairs quotes by scanning raw text, so a literal xmlUrl=" planted inside a single-quoted attribute value or inside a comment made it pair the wrong quotes: it would have read a harmless decoy host while libxml, which parses the document on the FreshRSS side, read the loopback URL next to it. A document that does not scan as well-formed XML is now refused rather than guessed at.
  • A hostname that is not a literal address is resolved and its addresses are checked, so a DNS record pointing at 127.0.0.1 or 169.254.169.254 no longer walks around the guard. A name that cannot be resolved here is still passed on: the FreshRSS server may sit in a different network with its own resolver.
  • An imported OPML document is now sent with its encoding declaration rewritten to UTF-8, which is what the body is actually encoded as. libxml believes the declaration over the bytes, so <?xml version="1.0" encoding="UTF-7"?> made it read +AHg-mlUrl="http://127.0.0.1/" as xmlUrl="http://127.0.0.1/" — an attribute no check reading the document as text can see under that name. The same trick hid a <!DOCTYPE> from the declaration check, putting entity expansion and external entities back on the table.
  • The URLs that were checked are written back into the document before it is sent, the way subscribe_feed hands FreshRSS the parsed URL rather than the string it was given. Checking one document and forwarding another is what let http://ok.example.com\@127.0.0.1/feed past: a URL parser reads its host as ok.example.com, the fetcher splits at the @ and connects to 127.0.0.1.
  • An xmlUrl behind a namespace prefix (o:xmlUrl) is matched on its local name, which is the name libxml reports it under.
  • The names of the cloud metadata service — metadata.google.internal, instance-data and their siblings — are refused by name. They resolve to 169.254.169.254 on the instance and to nothing anywhere else, so resolving them is exactly what cannot catch them.

Changed ​

  • The import_opml confirmation prompt names the hosts the document would subscribe to instead of only counting its outline elements, and a document that will be refused no longer gets a confirmation token first.
  • An xmlUrl written as //host/path is read as the http URL it stands for, and a relative one is left to FreshRSS — both appear in real OPML exports and must not fail an otherwise valid import. feed:// is refused, the way subscribe_feed already refused it: reading it as http:// would quietly fetch over plaintext a feed that is served over https.

[0.1.4] - 2026-08-24 ​

Fixed ​

  • Article text no longer comes back with HTML in it. Entities were decoded after the tags had already been stripped, so any encoding of <script> — &lt;, &#60; or &#x3c; — was rebuilt verbatim in the output, event handler attributes along with it. Markup is now removed again after decoding, repeatedly, until nothing changes.
  • A <script> whose closing tag falls outside the parsed slice no longer delivers its JavaScript as article text, and a tag cut in half by that slice no longer survives as a fragment.

[0.1.3] - 2026-08-18 ​

Fixed ​

  • http://[::1]:… no longer produces the "plain http to a non-local host, the API password will be sent unencrypted" warning. URL.hostname keeps the brackets around an IPv6 literal, so the loopback check never matched that notation.

[0.1.2] - 2026-08-18 ​

Fixed ​

  • The architecture diagram and the demo recording were not displayed at all — on GitHub or on npm. GitHub Pages had never issued the TLS certificate for freshrss-mcp.ni-c.de (https_enforced was false, the only repository where it was), so every image embedded from that domain was proxied by camo and answered with 502. The certificate has been reissued and HTTPS is enforced.
  • The architecture diagram no longer depends on the reader's operating system. It carried a prefers-color-scheme block, 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, and this diagram painted an opaque white rectangle over the full canvas, which is the worst case there. 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 documentation site declared no og:image at all, so links to it had no preview card anywhere.

Changed ​

  • The diagram is generated from a single source, docs/assets/architecture.source.svg, by npm run assets. The rendered copies had already drifted apart; CI now fails if one of them is edited by hand.
  • docs/public/og.png is generated at exactly 1280x640, GitHub's recommended size for a social preview.
  • The demo recording is shown on the documentation home page as well, not only in the README, and is pinned to the content column so its width no longer depends on what the vhs tape happened to record.
  • The TypeScript major is now parked in .github/dependabot.yml with its reason, instead of living only as an @dependabot ignore on the closed PR #1.

[0.1.1] - 2026-08-17 ​

Changed ​

  • First release published by CI, so this is the first version carrying npm provenance attestations, a GitHub release generated from this file, and an entry in the MCP Registry. 0.1.0 was published by hand to claim the package name and is functionally identical; prefer this version if you verify provenance.

[0.1.0] - 2026-08-17 ​

Added ​

  • Initial release: MCP server for FreshRSS, speaking the Google Reader compatible API at /api/greader.php.
  • Read tools: get_user_info, list_feeds, list_categories, get_unread_counts, list_articles, get_articles, list_article_ids, export_opml.
  • Write tools: mark_articles, mark_all_as_read, subscribe_feed, update_feed, unsubscribe_feed, rename_category_or_label, delete_category_or_label, import_opml. Not registered when FRESHRSS_READ_ONLY=true.
  • Confirmation tokens for mark_all_as_read, unsubscribe_feed, delete_category_or_label and import_opml.
  • Plain-text conversion of article HTML with per-article and per-response size budgets.
  • Credentials embedded in feed URLs (https://user:password@host/feed, how FreshRSS stores HTTP-auth feeds) are redacted in list_feeds and export_opml.
  • subscribe_feed refuses loopback and link-local targets: FreshRSS fetches the URL server-side, so the tool would otherwise be an SSRF primitive reachable from text inside an article. Private LAN addresses stay allowed.
  • import_opml refuses documents with a <!DOCTYPE> or <!ENTITY> declaration, which is what carries entity-expansion and external-entity attacks into the FreshRSS server's XML parser.
  • Multi-architecture container image on ghcr.io/ni-c/freshrss-mcp with an SBOM and build provenance. npm is removed from the runtime image — it is unused there, and its vendored dependencies were the image's only HIGH/CRITICAL CVEs.
  • CI: lint, build and tests on Node 22 and 24, npm audit, CodeQL, and a Trivy scan of the image on amd64 and arm64. Releases publish to npm via Trusted Publishing with provenance and register with the MCP Registry.

Released under the MIT License.