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_readkeys its confirmation on the pair (stream, cut-off).setResourceKeysorts 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 fromorderedResourceKeyin mcp-approval 0.8.2, which fingerprints each part at its position.mark_articles,unsubscribe_feed,delete_category_or_labelandimport_opmlkey sets or single ids and stay onsetResourceKey.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 asPassword API mismatch for user ….get_user_infois 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 holdsid-token: writefor npm Trusted Publishing and ran a plainnpm ci, so every dependency's install hook ran with the OIDC token reachable from the job environment. It runsnpm ci --ignore-scriptslike the audit job and the Dockerfile already did — nothing in the tree declares a hook — andgh release createchecks the tag exists with--verify-tag.Tokens from the instance have a shape. The
Auth=line of the login answer went into theAuthorizationheader as it came; a carriage return or a NUL in it made undici throwHeaders.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_URLthat failed to parse was printed (redacted for userinfo), and one with an unknown scheme was printed asgot ${scheme}— a fifty-six-character key with a colon behind it is a valid URL whose scheme is the key.FRESHRSS_API_PASSWORDsits 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_articlesvalidates the ids before it asks. The dialog was raised and the key built on the ids as written, andassertArticleIdran 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;articleIdsin the answer is the validated form.Control characters and lone surrogates in three more places.
get_unread_countshanded feed titles on as they came, unlikelist_feeds;export_opmlpassed 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\ud800escape in the instance's JSON — produced a lone surrogate, whichString.fromCodePointdoes 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 errorfrom the SDK's check of the output schema, or as aTypeError.feed/1e300or a twenty-digit feed id (.int()refuses both);1e999inunread-count, whichJSON.parsehands over asInfinity;publishedpast ±8.64e12 seconds, whichtoISOStringthrows on; a number where a title, an author, a URL or an id belongs;itemsthat is not a list; a body that isnull. 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 upObject.prototype.constructorand the replace callback wrotefunction Object() { [native code] }into the text. The table is aMap.import_opmlmeasures 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_opmlquotes FreshRSS's answer like every other tool — throughupstreamText, cut and labelled, instead of the raw first 200 characters.Caller strings have ceilings at the schema.
since,untilandolder_than(64 —Date.parsewalks whatever it is given),continuation(256), article ids (64), category and label names (200), a feed URL (8192) and a title (1000).get_user_inforeads 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_URLare 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_idscarriesnotes, 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, whichserveStdionegotiates since 0.3.0's follow-up, the sealed state travels through the client, andmcp-approvalproved that an answer belonged to its question but not that it had not been used already.mark_all_as_readwithoutolder_thanhas the same resource key every time, so every replay landed.mcp-approval0.8.1 spends each state on its first answer;SECURITY.mdhad 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
Tincluded, 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;
urlandenclosures[].urlare now redacted likefeedUrl.Text the instance wrote reaches the model bounded and marked. The body of an upstream error,
quickadd'serror, an unexpected stream id and the answerexpectOkquotes 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_opmlpassed 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_labelsandremove_labelstake at most 50 names each.get_user_infoandsubscribe_feedbroke on a client that validates. Both answered through the marked result, which addsuntrustedandsource— two fields their closed output schemas do not name. A client that had loadedtools/listchecked the answer against the schema and threw aProtocolErroron 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_URLis 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
ELICITATIONvalue is echoed shortened and without control characters, in case what was set was a line pasted into the wrong variable.
Changed
mcp-publisherin 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
suspiciouscategory is on, and what it found is fixed: shadowed names in the article and feed tools, helpers scoped inside test suites, andsort()wheretoSorted()was meant. The build target moves to ES2023 for the latter, which every supported Node has.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.
[0.3.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 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: trueandsource: "freshrss"as fields. This server has always said so innotes, 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_labelandimport_opml. The two-callconfirm_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.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_articlesnow asks too — but only when it is about to mark something read. It carriesdestructiveHint: trueand 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 reasonmark_all_as_readis 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.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. It is read afterFRESHRSS_API_PASSWORDis wiped from the environment, so that exit cannot leave the password behind.A
docs/guide/approval.mdpage.SECURITY.mdstates 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 revision2026-07-28.The live suite now pins three refusals against a real FreshRSS as well as the happy paths: the SSRF guard on
subscribe_feedand onimport_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": truerather 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_opmlanswers{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 — andtruncatednow 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
structuredContentcannot.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.A
confirm_tokenthat 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
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 guard and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather 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.0is 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, andimport_opmlnow 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-approval0.7.1,mcp-tool-allowlist0.2.1,mcp-internal-hosts0.2.1,mcp-integration-harness0.2.0 andsvg-asset-set0.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 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
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:<arepeated 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_articlesresponse 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 ashttps://alice:p@ssw0rd@rss.example/feedcame back ashttps://***@ssw0rd@rss.example/feedfromlist_feedsand the OPML export — the exact disclosure the redaction exists to prevent.https://host/users/@aliceis still left alone.A hostname can no longer crowd out the
import_opmlconfirmation dialog. The hosts an OPML document points at were interpolated into the server's own question.URL.hostnamehas no length limit — IDNA is applied withVerifyDnsLength=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_ONLYis read tolerantly, as a protection switch should be. It was compared with=== 'true', so=1,=yes,=TRUEor 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,trueandyesnow all switch it on, in any casing, with surrounding whitespace ignored.FRESHRSS_INSECURE_TLSstays 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_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.FRESHRSS_API_PASSWORDandFRESHRSS_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.2.0] - 2026-08-27
Added
FRESHRSS_ALLOW_TOOLSandFRESHRSS_DENY_TOOLSchoose 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, andFRESHRSS_ALLOW_TOOLS=essentialselects 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/listand answerstools/callwith "tool not found" — the same cutFRESHRSS_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.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.0for a blocked name, and0.0.0.0/8classifies 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.0written 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.isIPaccepts::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 for7f00xyzjust as happily.
Security
- The metadata endpoints outside
169.254/16are refused as well:100.100.100.200(Alibaba Cloud) and192.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_feedno longer accepts a loopback or link-local address written as an IPv4-mapped IPv6 literal.URLcanonicaliseshttp://[::ffff:127.0.0.1]/into[::ffff:7f00:1]andhttp://[::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 as127.0.0.1and 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 aslocalhost. (GHSA-qqh2-7466-82f8)import_opmlnow checks the URLs in the document./subscription/importmakes FreshRSS subscribe to everyxmlUrland fetch it server-side — the same capabilitysubscribe_feedguards, reached through a door that had no check on it at all. EveryxmlUrlandhtmlUrlis 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.1or169.254.169.254no 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/"asxmlUrl="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_feedhands FreshRSS the parsed URL rather than the string it was given. Checking one document and forwarding another is what lethttp://ok.example.com\@127.0.0.1/feedpast: a URL parser reads its host asok.example.com, the fetcher splits at the@and connects to127.0.0.1. - An
xmlUrlbehind 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-dataand their siblings — are refused by name. They resolve to169.254.169.254on the instance and to nothing anywhere else, so resolving them is exactly what cannot catch them.
Changed
- The
import_opmlconfirmation 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
xmlUrlwritten as//host/pathis 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 waysubscribe_feedalready refused it: reading it ashttp://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>—<,<or<— 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.hostnamekeeps 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_enforcedwasfalse, 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-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, 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:imageat all, so links to it had no preview card anywhere.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The 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.- 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.ymlwith its reason, instead of living only as an@dependabot ignoreon 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 whenFRESHRSS_READ_ONLY=true. - Confirmation tokens for
mark_all_as_read,unsubscribe_feed,delete_category_or_labelandimport_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 inlist_feedsandexport_opml. subscribe_feedrefuses 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_opmlrefuses 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-mcpwith 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.