# Developer Protocol **Server:** ntfy-mcp-server **Version:** 2.4.1 **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22` **Engines:** Bun ≥1.4.4, Node ≥24.0.0 **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (via the framework) **Zod:** ^4.6.7 > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only. > > **ntfy upstream API docs:** mirrored under `docs/ntfy/` — `publish.md`, `subscribe/api.md`, `emojis.md`, `examples.md`, `index.md`. See `docs/ntfy/SOURCES.md` for the pinned commit and refresh steps. --- ## What's Next? When the user asks what to do next, what's left, or needs direction, you can suggest relevant options based on the current project state. Some common next steps: 1. **Re-run the `setup` skill** — ensures CLAUDE.md, skills, structure, and metadata are populated and up to date with the current codebase 2. **Run the `design-mcp-server` skill** — if the tool/resource surface hasn't been mapped yet, work through domain design 3. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills 4. **Add services** — scaffold domain service integrations using the `add-service` skill 5. **Add tests** — scaffold tests for existing definitions using the `add-test` skill 6. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points 7. **Run `devcheck`** — lint, format, typecheck, and security audit 8. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation 9. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping 10. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest` Tailor suggestions to what's actually missing or stale — don't recite the full list every time. --- ## Core Rules - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch` for control flow. The narrow `try/catch` blocks in this codebase exist solely to translate upstream ntfy errors into typed contract failures via `ctx.fail()` before re-throwing; everything else bubbles for framework auto-classification. Use error factories (`notFound()`, `forbidden()`, `validationError()`, …) when no contract entry fits. - **Use `ctx.log`** for request-scoped logging. No `console` calls. - **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. - **`base_url` overrides are validated before they are dereferenced.** Absolute `http(s)` form is enforced unconditionally (`assertAbsoluteHttpUrl`, plus the advertised `BASE_URL_PATTERN` on all three tool schemas); the private-address guard and redirect refusal are opt-in behind `NTFY_BLOCK_PRIVATE_HOSTS` so LAN and stdio deployments keep working. Registered servers bypass the guard — that is the operator's lever for a deliberate private target, so keep the bypass set sourced from every `cfg.servers[]` entry, not from the credentialed subset. - **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, with only that id as `requestState`. Every call first reads and deletes the record its `requestState` names, and the operation runs only when that record equals what this call would confirm — same tool and manage action, same `ctx.auth` caller, same base URL + topic (+ `sequence_id`), same hash of the outbound request — and the answer is an accepted `confirm: true`. Any other round asks again under a fresh record: no `requestState`; an unknown, spent, expired, or malformed id; a record minted for another operation, tool, caller, target, or content. An answer on `ctx.inputs` alone never proceeds — a client can send one unprompted, or replay one. Against a matching record, a declined, cancelled, or unparseable response fails with `consent_declined` — and so does a refusal of any other shape, since `accepted()` collapses them all. There is no proceed-anyway branch, and `ctx.clientCapabilities` never skips the prompt: `ctx.requestInput` exists on every transport and both protocol eras, so the gate is fail-closed on Streamable HTTP as much as on stdio. A 2025-era client that declared no `elicitation.form` capability (a bare `elicitation: {}` counts as declaring it) is refused inside `ctx.requestInput`: the call fails with `InvalidRequest` (`-32600`), `data.reason: 'client_capability_missing'`, and a recovery hint naming the capability, before anything is sent upstream. A 2026-era request whose `clientCapabilities` lack elicitation is refused by the SDK the same way, as JSON-RPC error `-32021` carrying `data.requiredCapabilities` and no `reason`. - **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 key to pin the action to the record — a duplicate clear/delete re-emits its event, a duplicate publish delivers again. HTTP deployments should also set `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every instance), which rejects a forged or tampered `requestState` before the handler runs. - **The consent gate requires `MCP_SESSION_MODE=stateful` over HTTP.** Only the stateful arm keeps a live session per `Mcp-Session-Id`, and the SDK's legacy shim needs one to complete an `input_required` round for a 2025-era client. `src/index.ts` declares it with `createApp({ sessionMode: { default: 'stateful', require: 'stateful' } })`, so an HTTP start resolving to `stateless` fails with a `ConfigurationError` before any service is constructed (stdio is never refused). `auto` resolves to `stateful`, and every launch path still states the value outright — `.env.example`, the `Dockerfile` `ENV`, the `server.json` default — so the posture is declared, never inherited. Never set it to `stateless`, and never drop `require`. - **Everything above `confirmAction` in a gated handler runs twice.** The handler is re-entered from the top on the approval round, so keep that stretch free of side effects — config reads, validation, and prompt construction only. - **Need input the caller didn't supply?** `return ctx.requestInput(...)` and read `ctx.inputs` when the handler is re-entered. Never `await` for user input mid-handler. - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them. - **Secrets in env vars only** — never hardcoded. `NTFY_AUTH_TOKEN` is mutually exclusive with `NTFY_AUTH_USERNAME` / `NTFY_AUTH_PASSWORD`; the basic-auth pair must be set together. Validation enforces this at config load. - **Treat topic names as secrets.** Anyone who knows a topic name can publish or subscribe — surface that in tool descriptions and keep topic names out of every log record, at every level. Never put one in a thrown message (the framework logs every message) or a `ctx.log` field. Carry it in error `data` only under the key `topic`: `setup()` registers `topic` and `uri` with `sanitization.setSensitiveFields`, so every log sink writes them as `[REDACTED]` while the caller still receives `data.topic`. - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets. --- ## Patterns ### Tool `ntfy_search_emoji_tags` — minimal in-memory tool, illustrates the basic shape: ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { clampLimit, limitSchema } from '@/mcp-server/tools/utils/limit-clamp.js'; import { literal } from '@/mcp-server/tools/utils/markdown-literal.js'; import { getEmojiTagService } from '@/services/emoji-tags/emoji-tag-service.js'; export const ntfySearchEmojiTags = tool('ntfy_search_emoji_tags', { description: "Look up ntfy emoji tag short codes. Use the returned `tag` strings in `ntfy_publish_message`'s `tags` field…", annotations: { readOnlyHint: true, openWorldHint: false }, input: z.object({ query: z.string().optional().describe( 'Part of a tag name, matched case-insensitively; surrounding colons are dropped and spaces or hyphens match `_`. Or one emoji, which finds its own tag. Omit to list the reference from the start.', ), // No schema maximum by default: a larger value is served as the cap (NTFY_CLAMP_LIMITS). limit: limitSchema({ cap: 200, defaultLimit: 25, describe: (capRule) => `Maximum number of matches to return. Default 25. ${capRule} Page past it with \`offset\`.`, }), offset: z.number().int().min(0).default(0).describe('Number of matches to skip before returning `limit` rows.'), }), output: z.object({ matches: z.array(z.object({ tag: z.string().describe('Short code.'), emoji: z.string().describe('Rendered Unicode emoji.'), })).describe('Tag → emoji rows.'), }), // Agent-facing meta (normalized query, true total, truncation, empty-result // notice) rides `enrichment` — both client surfaces, no `format()` entry. enrichment: { effectiveQuery: z.string().optional().describe('The query as matched against tag names — trimmed, surrounding colons dropped, lowercased, spaces and hyphens read as `_`.'), totalCount: z.number().describe('Total matches before `limit`/`offset` were applied.'), truncated: z.boolean().describe('True when matches remain past this page.'), notice: z.string().optional().describe('Guidance when nothing matched or matches remain unseen.'), }, handler(input, ctx) { const { applied: limit } = clampLimit(input.limit, { cap: 200, tool: 'ntfy_search_emoji_tags' }, ctx); const { effectiveQuery, matches, total, truncated } = getEmojiTagService().search(input.query, limit, input.offset); if (effectiveQuery !== undefined) ctx.enrich.echo(effectiveQuery); ctx.enrich.total(total); ctx.enrich({ truncated }); if (matches.length === 0 && effectiveQuery !== undefined) { ctx.enrich.notice(`No emoji tags matched query ${literal(effectiveQuery)}. Send part of a tag name (\`warning\`, \`heavy check\`) or a single emoji (\`⚠️\`), not both; try a shorter substring, or omit the query to list the reference from the start.`); } return { matches }; }, format: (result) => [{ type: 'text', text: result.matches.length === 0 ? 'No emoji tags matched.' : `| Tag | Emoji |\n|:----|:------|\n${result.matches.map(m => `| ${literal(m.tag)} | ${m.emoji} |`).join('\n')}`, }], }); ``` `ntfy_publish_message` — typed error contract with upstream classification: ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode, validationError } from '@cyanheads/mcp-ts-core/errors'; import { getNtfyService } from '@/services/ntfy/ntfy-service.js'; export const ntfyPublishMessage = tool('ntfy_publish_message', { description: 'Send or update a push notification on an ntfy topic…', annotations: { openWorldHint: true }, input: /* … */, output: /* … */, errors: [ { reason: 'forbidden_topic', code: JsonRpcErrorCode.Forbidden, when: 'ntfy refused access to the topic with its own 401 or 403 error.', recovery: 'Try a public topic instead; if this topic must stay protected, ask the operator to provision ntfy auth credentials.' }, { reason: 'rate_limited', code: JsonRpcErrorCode.RateLimited, when: 'Upstream returned 429 after retries were exhausted.', retryable: true, recovery: "Wait the rate-limit window before retrying." }, ], async handler(input, ctx) { const topic = input.topic ?? getServerConfig().defaultTopic; if (!topic) { throw validationError('Topic is required when NTFY_DEFAULT_TOPIC is unset.', { recovery: { hint: 'Pass a `topic` argument or configure NTFY_DEFAULT_TOPIC.' }, }); } try { const response = await getNtfyService().publish({ topic, ...input }, { signal: ctx.signal }); return { id: response.id, /* … */ }; } catch (err) { // Keyed on ntfy's own error envelope, never the status: a proxy's HTML 403 is `not_ntfy_endpoint`, labelled by the service. // Values in a thrown message are quoted plain text — the framework escapes Markdown there. // A thrown message never names the topic: the framework logs every message. if (isAuthRejection(err)) { throw ctx.fail('forbidden_topic', getMessage(err) || 'ntfy refused access to the topic.'); } throw err; // Let framework auto-classify the rest } }, }); ``` ### Resource `ntfy://{topic}` — snapshot resource that delegates to the same service the polling tool uses: ```ts import { resource, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { getNtfyService } from '@/services/ntfy/ntfy-service.js'; export const ntfyTopicResource = resource('ntfy://{topic}', { name: 'ntfy-topic-snapshot', description: "Snapshot of a topic's last 20 messages from the past hour…", mimeType: 'application/json', params: z.object({ topic: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).describe('Topic name.'), }), errors: [ { reason: 'forbidden_topic', code: JsonRpcErrorCode.Forbidden, when: 'ntfy refused access to the topic with its own 401 or 403 error.', 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." }, ], async handler(params, ctx) { try { const { messages, upstreamTruncated } = await getNtfyService().fetch( { topic: params.topic, since: '1h' }, { signal: ctx.signal }, ); return { topic: params.topic, messages, upstreamTruncated, /* … */ }; } catch (err) { const code = getCode(err); if (isRateLimitedCode(code)) throw ctx.fail('rate_limited', getMessage(err)); if (isAuthRejection(err)) { throw ctx.fail('forbidden_topic', getMessage(err) || 'ntfy refused access to the topic.', undefined, { cause: err }); } throw err; } }, }); ``` ### Server config ```ts // src/config/server-config.ts — lazy-parsed, separate from framework config import { z } from '@cyanheads/mcp-ts-core'; import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config'; const ServerConfigSchema = z .object({ baseUrl: z.string().url().default('https://ntfy.sh').transform((u) => u.replace(/\/+$/, '')), defaultTopic: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional(), authToken: z.string().min(1).optional(), authUsername: z.string().min(1).optional(), authPassword: z.string().min(1).optional(), requestTimeoutMs: z.coerce.number().int().positive().default(15_000), maxRetries: z.coerce.number().int().min(0).default(3), clampLimits: z.stringbool().default(true), // NTFY_CLAMP_LIMITS — see `limit-clamp.ts` }) .superRefine((cfg, ctx) => { // token vs username/password are mutually exclusive; basic-auth pair must be set together. }); let _config: z.infer | undefined; export function getServerConfig() { _config ??= parseEnvConfig(ServerConfigSchema, { baseUrl: 'NTFY_BASE_URL', defaultTopic: 'NTFY_DEFAULT_TOPIC', authToken: 'NTFY_AUTH_TOKEN', /* … */ }); return _config; } ``` `parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`NTFY_AUTH_TOKEN`) rather than the internal path (`authToken`). It throws a `ConfigurationError` the framework catches and prints as a clean startup banner, and reads an empty string or a whole-value `${…}` placeholder as unset. This server's own loader (`loadFromEnv`) assembles the `NTFY_SERVERS` / single-server registry by hand, so it runs the raw env through `normalizeEnv` from `@cyanheads/mcp-ts-core/config` first to get the same treatment, and maps issue paths back to env var names itself. For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables. **`NTFY_CLAMP_LIMITS`** (`clampLimits`, default `true`) decides how `ntfy_fetch_messages` and `ntfy_search_emoji_tags` treat a `limit` above their per-call cap (100, 200). On, the schema advertises no maximum and `clampLimit` serves the cap, with a notice, a `Clamped limit` log line, and an `ntfy.limit.clamps` counter increment; off, `limitSchema` advertises and enforces `maximum: cap`. Both tools build their `limit` field with `limitSchema` from `src/mcp-server/tools/utils/limit-clamp.ts`, which reads the flag through `getServerConfig()` on every `tools/list` and every parse. `getServerConfig()` parses the environment once, on its first read — the schema emission the framework runs when a tool module loads — so the flag holds for the life of the process, and an invalid `NTFY_*` value still stops startup with the banner naming the variable. ### Server identity, instructions, and shutdown `createApp()` takes optional identity fields (`name`, `title`, `websiteUrl`, `description`, `icons`) forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`), and `instructions`, sent on every `initialize` as session-level context. This server sets `name`, `title` (the unscoped repo name), `skills`, `sessionMode`, and `instructions`; put deployment-wide orientation in `instructions` rather than repeating it across tool descriptions. `setup()` constructs the services. Anything long-lived it starts (a watcher, socket, or non-`unref()`'d timer) is released in `teardown(core)`, which runs after the transport stops and before the logger closes on every shutdown path; the server has none today. --- ## Context Handlers receive a unified `ctx` object. Key properties this server uses today (the framework exposes more — see `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md`): | Property | Description | |:---------|:------------| | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat every log line as client-visible — this is why no `ctx.log` call carries a topic name. | | `ctx.signal` | `AbortSignal` forwarded into `NtfyService` calls so client cancellations propagate to upstream HTTP. | | `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/`. | | `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.enrich(...)` | Accumulate agent-facing success-path context (empty-result notices, query/filter echo, pagination totals) declared in a tool's `enrichment` block — reaches both `structuredContent` and `content[]`; a no-op for a definition with no `enrichment` block. Helpers: `.notice()`, `.total()`, `.echo()`, `.truncated()`. | | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Never a reason to skip the consent prompt. | | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. | --- ## Errors Handlers throw — the framework catches, classifies, and formats. **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 agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request )`. An entry thrown below the handler body — `ntfy_publish_message`'s upstream reasons, raised in `classifyPublishError`, and every surface's `upstream_malformed` and `not_ntfy_endpoint`, raised in `NtfyService` — carries `thrownBy: 'service'` so `error-contract-unthrown` skips it; lint-only metadata, nothing at runtime reads it. `severity` logs a modeled outcome below `error` — `consent_declined` is `notice` on both gated tools, since a user saying no is not an incident. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring. ```ts errors: [ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query', recovery: 'Broaden the query or check the spelling and try again.' }, ], async handler(input, ctx) { const item = await db.find(input.id); if (!item) throw ctx.fail('no_match', `No item ${input.id}`); return item; } ``` **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture. Don't extract a shared `errors[]` constant or contract module to deduplicate; per-tool repetition is the intended cost of locality. **Fallback (no contract entry fits):** throw via factories or plain `Error`. ```ts // Error factories — explicit code import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; throw notFound('Item not found', { itemId }); throw serviceUnavailable('API unavailable', { url }, { cause: err }); // Plain Error — framework auto-classifies from message patterns throw new Error('Item not found'); // → NotFound throw new Error('Invalid query format'); // → ValidationError // McpError — when no factory exists for the code import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; throw new McpError(JsonRpcErrorCode.InitializationFailed, 'Connection failed', { pool: 'primary' }); ``` See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference. --- ## Structure ```text src/ index.ts # createApp() entry point config/ server-config.ts # NTFY_* env vars (Zod schema, lazy-parsed) load-env-file.ts # Loads ./.env; index.ts imports it first services/ ntfy/ ntfy-service.ts # HTTP client (publish, manage, fetch) base-url-guard.ts # base_url override validation + SSRF guard error-classifier.ts # Map upstream errors → contract reasons message-shape.ts # Normalize/truncate a message envelope types.ts # Domain types (NtfyMessage, NtfyAction, …) emoji-tags/ emoji-tag-service.ts # In-memory tag → emoji lookup data.generated.ts # Generated from docs/ntfy/emojis.md mcp-server/ tools/definitions/ ntfy-publish-message.tool.ts # Send/update a notification ntfy-manage-message.tool.ts # Clear/delete by sequence_id ntfy-fetch-messages.tool.ts # Poll cached messages with filters ntfy-search-emoji-tags.tool.ts # Look up emoji short codes tools/utils/ confirm-action.ts # Multi-round-trip consent gate for side effects limit-clamp.ts # `limit` schema + clamp, per NTFY_CLAMP_LIMITS markdown-literal.ts # Render values as literal Markdown (code span / fence) resources/definitions/ ntfy-topic.resource.ts # ntfy://{topic} snapshot ``` --- ## Naming | What | Convention | Example | |:-----|:-----------|:--------| | Files | kebab-case with suffix | `search-docs.tool.ts` | | Tool/resource/prompt names | snake_case | `search_docs` | | Directories | kebab-case | `src/services/doc-search/` | | Descriptions | Single string or template literal, no `+` concatenation | `'Search items by query and filter.'` | --- ## Skills 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. Available skills: | Skill | Purpose | |:------|:--------| | `setup` | Post-init project orientation | | `design-mcp-server` | Design tool surface, resources, and services for a new server | | `add-tool` | Scaffold a new tool definition | | `add-app-tool` | Scaffold an MCP App tool + paired UI resource | | `add-resource` | Scaffold a new resource definition | | `add-prompt` | Scaffold a new prompt definition | | `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 | | `add-service` | Scaffold a new service integration | | `add-test` | Scaffold test file for a tool, resource, or service | | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues | | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions | | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation | | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase | | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping | | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode | | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only | | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` | | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs | | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents | | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI | | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI | | `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns | | `api-auth` | Auth modes, scopes, JWT/OAuth | | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in | | `api-config` | AppConfig, parseConfig, env vars | | `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input | | `api-errors` | McpError, JsonRpcErrorCode, error patterns | | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` | | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in | | `api-services` | LLM, Speech, Graph services | | `api-testing` | createMockContext, test patterns | | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers | | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules | | `api-workers` | Cloudflare Workers runtime | **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase. When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`). --- ## Commands **Runtime:** Scripts shell out to `bun`. `npm run ` works too, but the scripts assume Bun is on the PATH. | Command | Purpose | |:--------|:--------| | `bun run build` | Compile TypeScript via `scripts/build.ts` | | `bun run rebuild` | Clean + build | | `bun run clean` | Remove build artifacts | | `bun run devcheck` | Lint + format + typecheck + security + changelog sync | | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update `, then `bun dedupe` | | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update `, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` | | `bun run tree` | Regenerate `docs/tree.md` | | `bun run list-skills` | Print the skill registry | | `bun run format` | Auto-fix formatting (Biome, safe fixes only) | | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior | | `bun run lint:mcp` | Validate MCP definitions against the spec (rule catalog: `api-linter` skill) | | `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` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install | | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) | | `bun run start:stdio` | Production mode (stdio) | | `bun run start:http` | Production mode (HTTP) | | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/.x/*.md` | | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) | | `bun run scripts/build-emoji-tags.ts` | Regenerate `src/services/emoji-tags/data.generated.ts` from `docs/ntfy/emojis.md` | **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it. --- ## Bundling `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 `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `AGENTS.md`, and `CLAUDE.md` at a package's root, and `SKILL.md` anywhere); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp`, which records the packing machine's paths — compiled `*.node` addons stay; and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, and config bases (`biome.json`, `vitest.config.base.mjs`, `config/tsconfig.base.json`), and `better-sqlite3`'s `deps/` and `src/` C sources. The agent-doc match is anchored to a package's root because a nested `skills/` can be runtime code: the MCP SDK imports its `dist/ext/skills/` modules at startup (cyanheads/mcp-ts-core#794). The selector lives in both `scripts/clean-mcpb.ts` and `scripts/lint-packaging.ts`, and `tests/scripts/clean-mcpb.test.ts` holds the pair together. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. **Adding an env var:** `server.json` always (registry discovery, `environmentVariables[]`). `manifest.json` only when the var is required with no default, or when bundle users should be prompted for it — then it needs both `mcp_config.env` and a `user_config` field. `lint:packaging` (run by `devcheck`) enforces exactly that pairing and checks the names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`. Optional vars carrying a default stay out of `manifest.json`, which is why `user_config` is empty today. **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `framework-skills/polish-docs-meta/references/readme.md`. --- ## Changelog Directory-based, grouped by minor series using the `.x` semver-wildcard convention. Source of truth is `changelog/.x/.md` (e.g. `changelog/2.0.x/2.0.0.md`) — one file per released version, shipped in the npm package. At release time, author the per-version file with a concrete version and date, then run `bun run changelog:build` to regenerate the rollup. `changelog/template.md` is a **pristine format reference** — never edited, never renamed, never moved. Read it to remember the frontmatter + section layout when scaffolding a new per-version file. `CHANGELOG.md` is a **navigation index** (header + link + one-line summary per version), regenerated by `bun run changelog:build`. Devcheck hard-fails on drift. Never hand-edit `CHANGELOG.md`. Each per-version file opens with YAML frontmatter: ```markdown --- summary: "One-line headline, ≤350 chars" # required — powers the rollup index breaking: false # optional — true flags breaking changes security: false # optional — true ONLY for a source-code security fix, never a dependency CVE bump --- # 0.1.0 — YYYY-MM-DD ... ``` `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section — set it only for a security fix in this server's *own source code*, never for a routine dependency or transitive CVE bump (record those under `## Dependencies`). When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`. `agent-notes` is an optional free-form field for maintenance agents processing the release downstream. Content here won't appear in the rendered CHANGELOG — it's consumed by agents running the `maintenance` skill. Use it for adoption instructions that don't fit the human-facing sections: new files to create, fields to populate, one-time migration steps. Omit entirely when there's nothing to say. **Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries — don't ship empty headers. **Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject omits the version number (GitHub prepends it). See `changelog/template.md` for the full format reference. --- ## Publishing **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/`, 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 rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions. --- ## Imports ```ts // Framework — z is re-exported, no separate zod import needed import { tool, z } from '@cyanheads/mcp-ts-core'; import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; // Server's own code — via path alias import { getMyService } from '@/services/my-domain/my-service.js'; ``` --- ## Checklist - [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`) - [ ] Optional nested objects: handler guards for empty inner values from form-based clients (`if (input.obj?.field && ...)`, not just `if (input.obj)`). When regex/length constraints matter, use `z.union([z.literal(''), z.string().regex(...).describe(...)])` — literal variants are exempt from `describe-on-fields`. - [ ] JSDoc `@fileoverview` + `@module` on every file - [ ] `ctx.log` for logging, `ctx.signal` forwarded to upstream HTTP calls - [ ] Handlers throw on failure — `ctx.fail()` for declared contract reasons, error factories or plain `Error` for everything else; no defensive try/catch (the only `try`/`catch` in this codebase translates upstream errors into contract reasons before re-throwing) - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data - [ ] Agent-facing meta (empty-result notices, query/filter echo, pagination totals) lives in an `enrichment` block populated via `ctx.enrich(...)`, not in `output` or `format()`-only text; enrichment keys disjoint from `output` - [ ] ntfy-specific: raw/domain/output schemas reviewed against real upstream sparsity/nullability before finalizing required vs optional fields - [ ] ntfy-specific: normalization and `format()` preserve uncertainty; do not fabricate facts from missing upstream data - [ ] ntfy-specific: tests include at least one sparse payload case with omitted upstream fields - [ ] ntfy-specific: per-call `base_url` overrides go out unauthenticated when they match no registered server — never widen this without explicit operator opt-in - [ ] Registered in `createApp()` arrays in `src/index.ts` - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing` - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.