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