Changelog
[Unreleased]
Fixed
- A
__proto__key in a document ntfy sends is dropped, at every depth. It used to reach the text block but vanish fromstructuredContent, because the client parses that against the output schema and zod sets a prototype on that name instead of a field. Both channels now carry the same document.
[0.3.0] - 2026-09-07
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. - 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. actions/dependency-review-actionon every pull request.npm auditchecks the tree as it is; this checks the change, so a vulnerable dependency is answered on the pull request rather than after it is merged.
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. - The loopback check behind the plain-HTTP warning comes from
mcp-internal-hostsinstead of a copy inconfig.ts. Same classifier the rest of the family uses, same behaviour — one fewer place to keep 25 lines of hand-written IPv6 normalisation correct. NTFY_URLis stored asorigin + pathnamerather than as the environment string. A query or a fragment can no longer be glued in front of every path the server builds, and what was dropped is named on the startup line.timeis optional in a message view, withtime_unavailablebeside it when ntfy's timestamp is missing or outside the rangeDatecan hold. Reporting the absence beats inventing a timestamp, and both beat theRangeErrorthat used to take the whole listing with it.NTFY_TOPICSaccepts at most 256 entries. The list is walked once per access grant whenlist_usersprojects them, so a longer one is a cost on every call rather than a restriction.
Security
- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - Approval keys bound to positions.
manage_user_accessis confirmed on (username, topic, action) andupdate_messageon (topic, message id), and in both the vocabularies overlap: a username is a legal topic name, a twelve-character message id is one too. A key built from a sorted set of those parts would let a token issued for "grant alice read_only on topic deploy" also confirm "grant deploy read_only on topic alice", and a token for revising messagea…on topicb…also confirm the pair the other way round. This server never had that bug — it carried its own positionaltupleResourceKey— but the local copy is gone: both tools now build their keys withorderedResourceKeyfrom mcp-approval 0.8.2, which prefixes every part with its index before fingerprinting.create_useranddelete_useranddelete_messagesstay onsetResourceKey, where a single value or a genuine set is what is confirmed. - The credential can no longer reach the model. A credential travels in an
Authorizationheader, and the HTTP layer's refusal of a malformed header value quotes the value — verified on undici 8.10 and on Node's globalfetch. That refusal is an ordinary rejected promise, which a tool handler turns into a tool result, so anNTFY_TOKENwith a line break in the middle of it — a wrapped paste, or$(cat token)of a wrapped file — put the whole token in the model's context. Both halves are closed:loadConfigrefuses a credential that cannot travel in a header at startup, naming the variable, the length and the position of the offending character and never the value; and every request checks the header before sending, which also covers aConfigbuilt withoutloadConfig. update_messagebinds the content it will write. The confirmation used to be keyed on the topic and the notification id alone, deliberately, so that fixing a typo did not need a second dialog. The gap that left is between the two legs of the fallback token: the first call is answered with a token bound to (topic, id), and the second presents it with whatever content it likes — and the tool carries the whole content schema,actionsincluded, where anhttpbutton fires from the recipient's device with a method, headers and body chosen by the caller. A confirmation obtained for a corrected typo could be redeemed for a call that added a button. The content is now part of the key, and the dialog names the fields it will replace throughdetails, so the caller's text appears on its own labelled line instead of inside this server's sentence.create_userbinds itstierthe same way.- Nothing ntfy sends is trusted for its shape. Every response used to be a TypeScript cast —
JSON.parse(...) as NtfyMessage— and an exported interface with a docblock on every field reads like a check without being one. Atimethat is a word or1e999answeredRangeError: Invalid time valueout oftoISOString(), andtags: 7answeredtags.slice is not a function; both were thrown from inside amapover a listing, so one message made every other message in the answer unreachable. Anid,titleorpriorityof the wrong type broke the tool's ownoutputSchemainstead, which the SDK refuses asOutput validation errorfor the whole call. A newboundary.tsreads each field for what it is, and each caller decides per field: omit it, skip the entry and count it, or say in a sentence that it was unusable — never widen the schema. A property test drives every read tool from arbitrary JSON, including the1e999that only exists in the serialised text. - Control characters and lone surrogates are removed from everything reported. There was no cleaner at all: the per-field cap bounded length and nothing else, so an escape sequence in a notification title — which anybody who knows a topic name can publish — was a terminal control sequence in the host's log file and in whatever renders the result, and a lone surrogate survived
JSON.stringifyas an escape only to raiseUnicodeEncodeErrorin a client encoding the result to UTF-8. A cut can produce one on its own by landing between the halves of a pair, so every cut is followed bytoWellFormed(). Applied to titles, bodies, tags, action buttons, usernames, grant patterns, token labels and theget_server_infosections; tab, line feed and carriage return are kept, because those are content. - A refused login is not sent again straight away. ntfy keeps a failed-authentication limiter per visitor address —
authLimiterinserver/visitor.go, spent bymaybeAuthenticateinserver/server_auth.goon every 401 it answers — and once its budget is gone the instance answers42909to every request from that address, healthy traffic included. Every read tool here is annotated read-only, idempotent and cheap and answers a wrong credential with "check NTFY_TOKEN", which is exactly what a model retries;check_topic_accessalone could spend ten of the thirty tokens in one call. A 401 is now remembered for ten seconds and repeated from memory with a note saying when the next real attempt is possible. Only a 401: a 403 is ntfy saying the account exists and may not have this topic, which costs the limiter nothing and is the per-topic answercheck_topic_accessexists to report. mcp-publisheris pinned and its checksum verified. Both jobs that publish to the MCP registry holdid-token: writeand fetched the binary fromreleases/latest/download, piped straight intotar— whatever the upstream served that day, executed with the OIDC token available. Pinned tov1.8.1and checked against the sha256 that release published.- The runtime image no longer ships yarn or corepack. npm had been removed by hand; the other two package managers the base image carries had not, which is easy to miss because nothing references them.
Fixed
- The result budget measured a string nobody received.
list_messagescounted a compactJSON.stringifyof the envelope and then emitted the same value indented, behind a marker sentence — between a fifth and several times larger. The budget now measures the rendering. Three tools had no ceiling at all:get_server_infopassed four instance documents through whole,get_accountthelimits,statsand token metadata, andlist_usersan unbounded username and grant list per account, so a single call could answer with several megabytes. Each now has a ceiling of its own that shrinks or reports rather than sending, and the shrinking loop no longer re-serialises the whole envelope once per dropped entry. - The status of a response is decided before its body is read. Both request paths read the body under the success ceiling first, so a reverse proxy answering a 401 with a two-megabyte login page surfaced as "ntfy returned more than 2000000 bytes", thrown as a plain
Errorfrom inside the reader — no status, so no typed error, so no credential hint, no admin note, and nothing forcheck_topic_accessto report per topic. Error bodies now have their own 64 KiB ceiling that cuts instead of refusing. - A message from ntfy no longer reaches the model as this server's words.
get_server_info's per-section failures and the per-id results ofpublish_message,mark_messages_readanddelete_messagesquotederror.messageverbatim — which is not always this server's sentence: undici quotes a header value it refuses, and Node's TLS layer quotes the certificate's names, chosen by whatever answered on the port. check_topic_accesshas a budget for the call, not only per request. Ten topics at the fifteen-second request timeout is two and a half minutes on a tool the annotations call cheap. What is not reached inside thirty seconds is reported asnot_checkedrather than left out — an absent entry reads as an answer.- A stream line that is valid JSON but not an object no longer fails the poll.
null,42and[1,2]are all legal NDJSON, and reading.eventoff one threw out of the listing. The same for an error body: reading.codeoff anullthrew from inside a constructor. - Diagnostics no longer echo a value that might be the secret.
ELICITATIONprinted whatever it was given in full, andNTFY_URLprinted the scheme of a value it refused — a 56-character hexadecimal key with a colon after it is a valid URL whose scheme is the key. Only short, word-shaped values are quoted now; everything else is described by its length. - The trailing-slash strip on
NTFY_URLwas quadratic.url.replace(/\/+$/, '')is retried from every position of a run that is not at the end of the string: 122 ms, 577 ms and 2233 ms for 20 000, 40 000 and 80 000 slashes followed by one more character. An index walk replaces it, and alinear-timesuite now holds every such function at the largest input it can be given.
[0.2.0] - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result.The four tools that report what someone else wrote —
list_messages,get_message,get_account,list_users— carryuntrusted: trueandsource: "ntfy"as fields of the object as well as in the text. A client that reads the structured half and ignores the text would otherwise receive a publisher's title and body with no framing at all, and the framing is the guard.get_server_infodoes not carry the marker: its sections are the instance's own configuration and counters.What this server builds is described exactly; what it passes on from ntfy is declared as an object with no fixed shape. The SDK validates every result against the schema before it goes out, so a schema stricter than the data would turn an upstream release that adds a field into a tool that fails outright rather than one that reports a field nobody expected.
Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call
confirm_tokenremains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.create_usernow asks too. It destroys nothing, which is why it was not guarded and whydestructiveHintcannot say what is wrong with it: bringing an account into existence is a change to who may reach this instance. It is the mirror image ofdelete_user, which was guarded from the start, and its own annotation had claimed "which is why it is guarded" while it was not.The password stays out of it — out of the prompt, which is read back by a person and by a model, and out of the token's binding. A consequence worth knowing: on the fallback path a token issued for one password works with another, because what the approval is about is the account name.
Publishing deliberately stays unguarded. Sending a notification destroys nothing and reaches people who cannot un-receive it — an outbound effect, not a destructive one.
NTFY_TOPICSis the control for that.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 afterNTFY_TOKENandNTFY_PASSWORDare wiped from the environment, so that exit cannot leave a credential behind.A
docs/guide/approval.mdpage.
Changed
The advertised schemas avoid a spelling that is legal JSON Schema and still gets a tool refused, or its constraint silently dropped, by some MCP clients: 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.
manage_user_accessreports theactionit was asked for on every outcome, not only onrevoke. Granting and revoking answered in two different shapes before; the shape is now one.get_accountagainst an ntfy that answers with something other than a JSON object returns{}rather than that value. An allowlist keeps nothing it does not understand, and passing the value back forwarded exactly the shape the allowlist exists to filter.The two-call
confirm_tokenprompt is an error result. The operation was asked for and did not happen, and a tool that declares an output schema may not answer withoutstructuredContentunless the result is an error. The text is unchanged and still carries the token.Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.
The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.The tool filter, the confirmation store and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approvalandsvg-asset-setrather than from copies kept here — 740 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.A
confirm_tokenthat does not match is now refused with the reason — invalid, expired, or issued for different arguments — instead of being answered with a fresh prompt. The second is self-healing when a token merely expired and silent when the token was issued for something else, which is the case the binding exists to catch.An entry in
NTFY_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.NTFY_TOKENandNTFY_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.A duplicated comment block in
manage_user_access— the paragraph explaining why its resource key is a tuple appeared twice.
Security
NTFY_TOPICSnow bounds the access tools, which it had only claimed to.manage_user_accesspassed itstopicargument straight into the request body, sotopic: "*"handed an account permanent read-write access to every topic on the instance — from a server the operator had restricted to one. A grant pattern is now resolved against the list, and where the list is set a wildcard is refused outright: a pattern also covers topics that do not exist yet, and no finite allowlist covers that. The refusal happens before the approval dialog, so a pattern that will not be accepted never becomes a question somebody might tick.list_usershad the same hole from the other side.GET /v1/usersis the one endpoint on ntfy that answers "which topics exist here", for every account at once, and its grant patterns were forwarded verbatim — so a server restricted toalertswas handing back the names of every other topic on the box, and a topic name on ntfy is a bearer credential. Grants are now restated against the allowed topics: a wildcard grant appears once per allowed topic it covers, one that covers none of them is dropped, and the account itself is still listed. The tool's owntopicfilter is resolved against the list too.update_messagenow asks a person. From ntfy 2.16 an update replaces the notification on the subscribers' devices, so the text they were shown survives nowhere but this server's cache — and the tool carries the whole content schema,actionsincluded, where anhttpbutton fires from the recipient's phone with a method, headers and body the caller chose. One unguarded call could turn a delivered alert into a button that calls something. It is gated exactly likedelete_messages, on a fingerprint of(topic, sequence_id).The new content is deliberately not part of that fingerprint. What is confirmed is the notification; binding the text would ask again for every corrected typo while proving nothing, since the replacement is only reachable through the same tool call.
Publishing stays unguarded, and the argument for that now stops where it should. Sending a notification destroys nothing; replacing one that people already have does.
get_accountanswers from an allowlist instead of a denylist. It removed the access token values andsync_topicand spread the rest, which meantphone_numbers, thebillingblock with its Stripe identifiers, and thereservationsandsubscriptionsarrays — each a list of topic names — all reached the transcript, while the README listed those very features under "not implemented, on purpose". The tool now projectsusername,role,tier,limits,stats,languageand each token'slabel,last_accessandexpires, and drops everything else. A field a newer or forked ntfy adds is dropped without an edit here, which is the property a denylist cannot have.NTFY_READ_ONLYaccepts1andyes, in any case. It compared against the literal stringtrue, soNTFY_READ_ONLY=1registered every write tool while the operator believed the server was read-only — and said nothing, because nothing is printed for a variable that parsed to false. A switch that adds a protection is now parsed generously;NTFY_INSECURE_TLS, which removes one, still takes nothing but the exact word. A genuine typo such as=turestill fails open, because the default isfalse.The residual risk that an approval proves binding and not freshness is now written down rather than implied. The sealed state carries no nonce, so a retried leg can run an approved operation twice; every guarded tool here is idempotent in effect, and
publish_message— the one operation that genuinely acts twice — is unguarded and has no idempotency key on ntfy's side. SeeSECURITY.md.
[0.1.0] - 2026-08-29
Added
- Initial release: an MCP server for ntfy, covering publishing, reading the message cache, and user and topic-access administration.
- A multi-architecture container image at
ghcr.io/ni-c/ntfy-mcp(amd64 and arm64), published with an SBOM and build provenance. It runs as an unprivileged user with no npm in the runtime layer and speaks stdio only, so it needs-iand exposes no port. - Thirteen tools. Six read:
list_messages,get_message,check_topic_access,get_server_info,get_account,list_users. Seven write:publish_message,update_message,mark_messages_read,delete_messages,create_user,delete_user,manage_user_access. NTFY_ALLOW_TOOLSandNTFY_DENY_TOOLSnarrow the tool list by name or by a trailing-*prefix, andNTFY_ALLOW_TOOLS=essentialselects a curated six that cover publishing and verifying end to end.NTFY_TOPICSnames the topics the server may touch. The first entry is the default when a tool omits one, which keeps a topic name — a bearer secret on a public instance — out of the tool arguments; the list also bounds every read and write tool.- Basic authentication (
NTFY_USERNAME/NTFY_PASSWORD) alongside access tokens (NTFY_TOKEN), becausentfy user addproduces a username and password rather than a token. Setting both forms is refused at startup rather than resolved by a precedence rule.
Security
NTFY_READ_ONLYdefaults tofalse, unlike the same variable in imap-mcp, where it defaults totrue. ntfy exists to publish; a read-only default would ship a notification server that cannot notify. Because of that direction, only the literal stringtruedisables the write tools — a typo leaves them enabled. The destructive tools are gated behind confirmation tokens and ntfy's own permissions instead, andNTFY_ALLOW_TOOLS=essentialor aNTFY_DENY_TOOLSlist is the recommended hardening.delete_messages,delete_userandmanage_user_accessrequire a server-generated confirmation token bound to a fingerprint of the exact target, so a confirmation for one target cannot execute another or a longer list.- Access tokens are removed from
get_accountoutput. ntfy returns every token of the account in plaintext there, which would otherwise put a live credential into the conversation transcript. The account'ssync_topicis removed for the same reason — a topic name is a bearer secret. click,icon,attachand every action button URL are restricted tohttp:andhttps:. ntfy stores whatever it is given, and these URLs are opened by the recipient's device rather than by the server.- Publishing cannot send email or place a phone call, and no tool creates, reads or exchanges an ntfy access token.