Skip to content

ntfy mcp server changelog

io.github.cyanheads/ntfy-mcp-server

7 changes recorded. MCP Harbor re-reads this listing from the official registry every six hours and asks the live server what it exposes, and keeps what changed between one look and the next.

  • server.json 2 fields changed
    env_vars
    NTFY_SERVERS, NTFY_BASE_URL, NTFY_DEFAULT_TOPIC, NTFY_AUTH_TOKEN, NTFY_AUTH_USERNAME, NTFY_AUTH_PASSWORD, NTFY_REQUEST_TIMEOUT_MS, NTFY_MAX_RETRIES, NTFY_BLOCK_PRIVATE_HOSTS, NTFY_ENABLE_EMOJI_TAGS, MCP_LOG_LEVEL, LOGS_DIR, OTEL_ENABLED NTFY_SERVERS, NTFY_BASE_URL, NTFY_DEFAULT_TOPIC, NTFY_AUTH_TOKEN, NTFY_AUTH_USERNAME, NTFY_AUTH_PASSWORD, NTFY_REQUEST_TIMEOUT_MS, NTFY_MAX_RETRIES, NTFY_BLOCK_PRIVATE_HOSTS, NTFY_CLAMP_LIMITS, NTFY_ENABLE_EMOJI_TAGS, MCP_LOG_LEVEL, LOGS_DIR, OTEL_ENABLED
    version
    2.4.0 2.4.1

  • AGENTS.md +27 −17

    https://raw.githubusercontent.com/cyanheads/ntfy-mcp-server/HEAD/AGENTS.md

    − **Version:** 2.3.7− **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`− **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1 (via the framework)− **Zod:** ^4.6.6− - **Side effects that leave the notification drawer ask the user first.** A clear/delete, or a publish carrying `email` / `call` / a `broadcast` or `http` action, routes through `confirmAction` before the upstream call. The gate is a multi-round-trip flow that redeems a server record: asking stores `{ operation, clientId, subject, target, contentHash }` in `ctx.state` under a random id (600 s TTL) and returns `input_required` carrying an `elicitation/create` request that names the exact target, − import { literal } from '@/mcp-server/tools/utils/markdown-literal.js';−       when: 'Auth required for the target topic.',−       if (isAuthCode(getCode(err))) {−         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${literal(topic)}`);− import { literal } from '@/mcp-server/tools/utils/markdown-literal.js';−       when: 'Auth required for the target topic.',−       if (isAuthCode(code)) {−         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${literal(params.topic)}`,− Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it. Keep `skills/` free for skills meant f− **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.− | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |− `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was + **Version:** 2.4.0+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (via the framework)+ **Zod:** ^4.6.7+ - **Side effects that leave the notification drawer ask the user first.** A clear/delete, or a publish carrying `email` / `call` / a `broadcast` or `http` action, routes through `confirmAction` before the upstream call. The gate is a multi-round-trip flow that redeems a server record: asking stores `{ operation, clientId, subject, target, contentHash }` in `ctx.state` under a random id (600 s TTL) and returns `input_required` carrying an `elicitation/create` request that names the exact target, +       when: 'ntfy refused access to the topic with its own 401 or 403 error.',+       // Keyed on ntfy's own error envelope, never the status: a proxy's HTML 403 stays unlabeled.+       // Values in a thrown message are quoted plain text — the framework escapes Markdown there.+       if (isAuthRejection(err)) {+         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic "${topic}"`);+       when: 'ntfy refused access to the topic with its own 401 or 403 error.',+       if (isAuthRejection(err)) {+         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic "${params.topic}"`,+ Two skill trees live at the project root, written for different readers:+ | Tree | For | Written by |+ |:-----|:----|:-----------|+ | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent by default; usually none is needed |+ Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.+ A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, the published `skills/` tree (run by devcheck) |+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name; `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y ntfy-mcp-server`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable: + - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass+ - [ ] If the server publishes a skill in root `skills/`: `lint:packaging` passes, and `field-test` verified every gate and identifier claim in it against live tool behavior

  • server.json 1 field changed
    version
    2.3.7 2.4.0

  • AGENTS.md +29 −15

    https://raw.githubusercontent.com/cyanheads/ntfy-mcp-server/HEAD/AGENTS.md

    − **Version:** 2.3.6− **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.13`− **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0 (via the framework)− **Zod:** ^4.6.5− - **Auth scope is the configured `NTFY_BASE_URL`.** When a tool's `base_url` argument differs from the configured base, `NtfyService` strips the auth header before sending — never widen this to "always forward credentials" without explicit operator opt-in.−         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${topic}`);− import { forbidden } from '@cyanheads/mcp-ts-core/errors';−       const raw = await getNtfyService().fetch(−       return { topic: params.topic, messages: raw, /* … */ };−       if (isAuthCode(getCode(err))) {−         throw forbidden(getMessage(err) || `Forbidden for topic ${params.topic}`, {−           recovery: { hint: 'Try an unprotected topic.' },−         }, { cause: err });− **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — the single source of truth for the agen− - [ ] ntfy-specific: per-call `base_url` overrides go out unauthenticated when they differ from the configured base — never widen this without explicit operator opt-in+ **Version:** 2.3.7+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`+ **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1 (via the framework)+ **Zod:** ^4.6.6+ - **Auth scope is the registered base URL.** Credentials bind to the base they were configured with (`NTFY_BASE_URL`, or each `NTFY_SERVERS` entry); when a tool's `base_url` argument matches no registered base, `NtfyService` sends no auth header — never widen this to "always forward credentials" without explicit operator opt-in.+ import { literal } from '@/mcp-server/tools/utils/markdown-literal.js';+         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${literal(topic)}`);+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';+ import { literal } from '@/mcp-server/tools/utils/markdown-literal.js';+   errors: [+     { reason: 'forbidden_topic', code: JsonRpcErrorCode.Forbidden,+       when: 'Auth required for the target topic.',+       recovery: 'Try an unprotected topic; if this topic must stay protected, ask the operator to configure ntfy auth.' },+     { reason: 'rate_limited', code: JsonRpcErrorCode.RateLimited,+       when: 'ntfy answered 429 and was still answering it when retries ran out.',+       retryable: true,+       recovery: "Wait for ntfy's rate-limit window to pass before reading the topic again." },+   ],+       const { messages, upstreamTruncated } = await getNtfyService().fetch(+       return { topic: params.topic, messages, upstreamTruncated, /* … */ };+       const code = getCode(err);+       if (isRateLimitedCode(code)) throw ctx.fail('rate_limited', getMessage(err));+       if (isAuthCode(code)) {+         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${literal(params.topic)}`,+           undefined, { cause: err });+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — the single source of truth for the agen+     load-env-file.ts                    # Loads ./.env; index.ts imports it first+       markdown-literal.ts               # Render values as literal Markdown (code span / fence)+ - [ ] ntfy-specific: per-call `base_url` overrides go out unauthenticated when they match no registered server — never widen this without explicit operator opt-in

  • server.json 2 fields changed
    env_vars
    NTFY_SERVERS, NTFY_BASE_URL, NTFY_DEFAULT_TOPIC, NTFY_AUTH_TOKEN, NTFY_AUTH_USERNAME, NTFY_AUTH_PASSWORD, NTFY_REQUEST_TIMEOUT_MS, NTFY_MAX_RETRIES, NTFY_BLOCK_PRIVATE_HOSTS, MCP_LOG_LEVEL, LOGS_DIR, OTEL_ENABLED NTFY_SERVERS, NTFY_BASE_URL, NTFY_DEFAULT_TOPIC, NTFY_AUTH_TOKEN, NTFY_AUTH_USERNAME, NTFY_AUTH_PASSWORD, NTFY_REQUEST_TIMEOUT_MS, NTFY_MAX_RETRIES, NTFY_BLOCK_PRIVATE_HOSTS, NTFY_ENABLE_EMOJI_TAGS, MCP_LOG_LEVEL, LOGS_DIR, OTEL_ENABLED
    version
    2.3.6 2.3.7

  • AGENTS.md +16 −15

    https://raw.githubusercontent.com/cyanheads/ntfy-mcp-server/HEAD/AGENTS.md

    − **Version:** 2.3.5− **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`− **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0 (via the framework)− - **Side effects that leave the notification drawer ask the user first.** A clear/delete, or a publish carrying `email` / `call` / a `broadcast` or `http` action, routes through `confirmAction` before the upstream call. The gate is a multi-round-trip flow: the first call returns `input_required` carrying an `elicitation/create` request that names the exact target, and the operation runs only when the retried call carries an approval. A declined, cancelled, or unparseable response fails with `con−         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${topic}`, {−           ...ctx.recoveryFor('forbidden_topic'),−         });− | `ctx.requestInput` | Suspend the handler and ask the caller for more input — never returns. Always present, on every transport and both protocol eras. Reached through `confirmAction()`, never called directly from a handler: that helper centralizes the boolean confirmation schema and the reading of the reply. |− | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. Responses are never re-validated by the SDK, so `confirmAction` passes its schema to `accepted()` and treats the value as untrusted. |− | `ctx.fail(reason, ...)` | Throw a typed contract failure declared in the tool's `errors[]` array. Pair with `ctx.recoveryFor(reason)` to attach the declared `recovery` hint to the wire payload. |− | `ctx.requestId` | Unique request ID. Surfaces in logs and error payloads. |− **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — the single source of truth for the agen−   if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));− | `bun run test` | Run the Vitest test suite |− **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of w+ **Version:** 2.3.6+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.13`+ **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0 (via the framework)+ - **Side effects that leave the notification drawer ask the user first.** A clear/delete, or a publish carrying `email` / `call` / a `broadcast` or `http` action, routes through `confirmAction` before the upstream call. The gate is a multi-round-trip flow that redeems a server record: asking stores `{ operation, clientId, subject, target, contentHash }` in `ctx.state` under a random id (600 s TTL) and returns `input_required` carrying an `elicitation/create` request that names the exact target, + - **Consent records live where every instance a retry reaches can read them.** The default `STORAGE_PROVIDER_TYPE=in-memory` suits one process; a multi-instance HTTP deployment needs `filesystem`, `supabase`, or `cloudflare-d1` — never `cloudflare-kv`, whose eventual consistency lets a replay outrun the delete. Redeeming stops sequential replays only: `ctx.state` has no atomic take (cyanheads/mcp-ts-core#593), so concurrent retries carrying one id can each proceed, and ntfy offers no idempotency+         throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${topic}`);+ | `ctx.requestInput` | Suspend the handler and ask the caller for more input — never returns. Always present, on every transport and both protocol eras. Reached through `confirmAction()`, never called directly from a handler: that helper centralizes the boolean confirmation schema, the consent record, and the reading of the reply. |+ | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. A response can arrive on a call nothing asked, so `confirmAction` reads one only behind a redeemed consent record; the SDK never re-validates responses, so it also passes its schema to `accepted()` and treats the value as untrusted. |+ | `ctx.state` | Tenant-scoped key-value storage. `confirmAction` keeps consent records here under `consent/<uuid>`. |+ | `ctx.auth` | Authenticated caller (`clientId`, `sub`), bound into each consent record. `undefined` on stdio and under `MCP_AUTH_MODE=none`, where every caller is one principal. |+ | `ctx.fail(reason, ...)` | Throw a typed contract failure declared in the tool's `errors[]` array. The framework puts the entry's `recovery` on the wire as `data.recovery.hint` when the throw carries none. |+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — the single source of truth for the agen+   if (!item) throw ctx.fail('no_match', `No item ${input.id}`);+ | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |+ **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewri

  • server.json 1 field changed
    version
    2.3.5 2.3.6