ntfy-mcp-server 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CLAUDE.md +409 -0
  2. package/Dockerfile +98 -0
  3. package/README.md +167 -393
  4. package/changelog/1.0.x/1.0.0.md +13 -0
  5. package/changelog/1.0.x/1.0.1.md +11 -0
  6. package/changelog/1.0.x/1.0.2.md +12 -0
  7. package/changelog/1.0.x/1.0.3.md +17 -0
  8. package/changelog/1.0.x/1.0.4.md +12 -0
  9. package/changelog/1.0.x/1.0.6.md +22 -0
  10. package/changelog/2.0.x/2.0.0.md +57 -0
  11. package/changelog/template.md +93 -0
  12. package/dist/config/server-config.d.ts +41 -0
  13. package/dist/config/server-config.d.ts.map +1 -0
  14. package/dist/config/server-config.js +189 -0
  15. package/dist/config/server-config.js.map +1 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +22 -104
  19. package/dist/index.js.map +1 -0
  20. package/dist/mcp-server/resources/definitions/ntfy-topic.resource.d.ts +12 -0
  21. package/dist/mcp-server/resources/definitions/ntfy-topic.resource.d.ts.map +1 -0
  22. package/dist/mcp-server/resources/definitions/ntfy-topic.resource.js +58 -0
  23. package/dist/mcp-server/resources/definitions/ntfy-topic.resource.js.map +1 -0
  24. package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.d.ts +101 -0
  25. package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.js +408 -0
  27. package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.js.map +1 -0
  28. package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.d.ts +44 -0
  29. package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.d.ts.map +1 -0
  30. package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.js +133 -0
  31. package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.js.map +1 -0
  32. package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.d.ts +125 -0
  33. package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.js +411 -0
  35. package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.js.map +1 -0
  36. package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.d.ts +20 -0
  37. package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.d.ts.map +1 -0
  38. package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.js +69 -0
  39. package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.js.map +1 -0
  40. package/dist/services/emoji-tags/data.generated.d.ts +8 -0
  41. package/dist/services/emoji-tags/data.generated.d.ts.map +1 -0
  42. package/dist/services/emoji-tags/data.generated.js +1821 -0
  43. package/dist/services/emoji-tags/data.generated.js.map +1 -0
  44. package/dist/services/emoji-tags/emoji-tag-service.d.ts +28 -0
  45. package/dist/services/emoji-tags/emoji-tag-service.d.ts.map +1 -0
  46. package/dist/services/emoji-tags/emoji-tag-service.js +50 -0
  47. package/dist/services/emoji-tags/emoji-tag-service.js.map +1 -0
  48. package/dist/services/ntfy/error-classifier.d.ts +31 -0
  49. package/dist/services/ntfy/error-classifier.d.ts.map +1 -0
  50. package/dist/services/ntfy/error-classifier.js +65 -0
  51. package/dist/services/ntfy/error-classifier.js.map +1 -0
  52. package/dist/services/ntfy/ntfy-service.d.ts +42 -0
  53. package/dist/services/ntfy/ntfy-service.d.ts.map +1 -0
  54. package/dist/services/ntfy/ntfy-service.js +213 -0
  55. package/dist/services/ntfy/ntfy-service.js.map +1 -0
  56. package/dist/services/ntfy/types.d.ts +108 -138
  57. package/dist/services/ntfy/types.d.ts.map +1 -0
  58. package/dist/services/ntfy/types.js +6 -1
  59. package/dist/services/ntfy/types.js.map +1 -0
  60. package/package.json +76 -38
  61. package/server.json +224 -0
  62. package/dist/config/index.d.ts +0 -23
  63. package/dist/config/index.js +0 -111
  64. package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +0 -2
  65. package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +0 -133
  66. package/dist/mcp-server/resources/ntfyResource/index.d.ts +0 -12
  67. package/dist/mcp-server/resources/ntfyResource/index.js +0 -77
  68. package/dist/mcp-server/resources/ntfyResource/types.d.ts +0 -59
  69. package/dist/mcp-server/resources/ntfyResource/types.js +0 -8
  70. package/dist/mcp-server/server.d.ts +0 -40
  71. package/dist/mcp-server/server.js +0 -299
  72. package/dist/mcp-server/tools/ntfyTool/index.d.ts +0 -11
  73. package/dist/mcp-server/tools/ntfyTool/index.js +0 -124
  74. package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +0 -9
  75. package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +0 -307
  76. package/dist/mcp-server/tools/ntfyTool/types.d.ts +0 -252
  77. package/dist/mcp-server/tools/ntfyTool/types.js +0 -144
  78. package/dist/mcp-server/utils/registrationHelper.d.ts +0 -48
  79. package/dist/mcp-server/utils/registrationHelper.js +0 -63
  80. package/dist/services/ntfy/constants.d.ts +0 -37
  81. package/dist/services/ntfy/constants.js +0 -37
  82. package/dist/services/ntfy/errors.d.ts +0 -79
  83. package/dist/services/ntfy/errors.js +0 -134
  84. package/dist/services/ntfy/index.d.ts +0 -33
  85. package/dist/services/ntfy/index.js +0 -56
  86. package/dist/services/ntfy/publisher.d.ts +0 -66
  87. package/dist/services/ntfy/publisher.js +0 -229
  88. package/dist/services/ntfy/subscriber.d.ts +0 -81
  89. package/dist/services/ntfy/subscriber.js +0 -502
  90. package/dist/services/ntfy/utils.d.ts +0 -85
  91. package/dist/services/ntfy/utils.js +0 -410
  92. package/dist/types-global/errors.d.ts +0 -35
  93. package/dist/types-global/errors.js +0 -39
  94. package/dist/types-global/mcp.d.ts +0 -30
  95. package/dist/types-global/mcp.js +0 -25
  96. package/dist/types-global/tool.d.ts +0 -44
  97. package/dist/types-global/tool.js +0 -30
  98. package/dist/utils/errorHandler.d.ts +0 -98
  99. package/dist/utils/errorHandler.js +0 -271
  100. package/dist/utils/idGenerator.d.ts +0 -94
  101. package/dist/utils/idGenerator.js +0 -149
  102. package/dist/utils/index.d.ts +0 -13
  103. package/dist/utils/index.js +0 -16
  104. package/dist/utils/logger.d.ts +0 -38
  105. package/dist/utils/logger.js +0 -141
  106. package/dist/utils/rateLimiter.d.ts +0 -115
  107. package/dist/utils/rateLimiter.js +0 -180
  108. package/dist/utils/requestContext.d.ts +0 -68
  109. package/dist/utils/requestContext.js +0 -91
  110. package/dist/utils/sanitization.d.ts +0 -224
  111. package/dist/utils/sanitization.js +0 -367
  112. package/dist/utils/security.d.ts +0 -26
  113. package/dist/utils/security.js +0 -27
package/CLAUDE.md ADDED
@@ -0,0 +1,409 @@
1
+ # Developer Protocol
2
+
3
+ **Server:** ntfy-mcp-server
4
+ **Version:** 2.0.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.8.19`
6
+ **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
+ **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
8
+ **Zod:** ^4.4.3
9
+
10
+ > **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.
11
+ >
12
+ > **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.
13
+
14
+ ---
15
+
16
+ ## What's Next?
17
+
18
+ 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:
19
+
20
+ 1. **Re-run the `setup` skill** — ensures CLAUDE.md, skills, structure, and metadata are populated and up to date with the current codebase
21
+ 2. **Run the `design-mcp-server` skill** — if the tool/resource surface hasn't been mapped yet, work through domain design
22
+ 3. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills
23
+ 4. **Add services** — scaffold domain service integrations using the `add-service` skill
24
+ 5. **Add tests** — scaffold tests for existing definitions using the `add-test` skill
25
+ 6. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points
26
+ 7. **Run `devcheck`** — lint, format, typecheck, and security audit
27
+ 8. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation
28
+ 9. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping
29
+ 10. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest`
30
+
31
+ Tailor suggestions to what's actually missing or stale — don't recite the full list every time.
32
+
33
+ ---
34
+
35
+ ## Core Rules
36
+
37
+ - **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.
38
+ - **Use `ctx.log`** for request-scoped logging. No `console` calls.
39
+ - **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.
40
+ - **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.
41
+ - **Treat topic names as secrets.** Anyone who knows a topic name can publish or subscribe — surface that in tool descriptions and never log full topic names at info level when the topic is private.
42
+
43
+ ---
44
+
45
+ ## Patterns
46
+
47
+ ### Tool
48
+
49
+ `ntfy_search_emoji_tags` — minimal in-memory tool, illustrates the basic shape:
50
+
51
+ ```ts
52
+ import { tool, z } from '@cyanheads/mcp-ts-core';
53
+ import { getEmojiTagService } from '@/services/emoji-tags/emoji-tag-service.js';
54
+
55
+ export const ntfySearchEmojiTags = tool('ntfy_search_emoji_tags', {
56
+ description:
57
+ "Look up ntfy emoji tag short codes. Use the returned `tag` strings in `ntfy_publish_message`'s `tags` field…",
58
+ annotations: { readOnlyHint: true, openWorldHint: false },
59
+ input: z.object({
60
+ query: z.string().optional().describe('Substring to match (case-insensitive).'),
61
+ limit: z.number().int().positive().max(200).default(25).describe('Max matches.'),
62
+ }),
63
+ output: z.object({
64
+ matches: z.array(z.object({
65
+ tag: z.string().describe('Short code.'),
66
+ emoji: z.string().describe('Rendered Unicode emoji.'),
67
+ })).describe('Tag → emoji rows.'),
68
+ total: z.number().describe('Total matches before truncation.'),
69
+ truncated: z.boolean().describe('True when more matches existed than `limit`.'),
70
+ }),
71
+
72
+ handler(input) {
73
+ return getEmojiTagService().search(input.query, input.limit);
74
+ },
75
+
76
+ format: (result) => [{
77
+ type: 'text',
78
+ text: result.matches.length === 0
79
+ ? `No emoji tags matched (total: ${result.total}).`
80
+ : `${result.matches.map(m => `| \`${m.tag}\` | ${m.emoji} |`).join('\n')}`,
81
+ }],
82
+ });
83
+ ```
84
+
85
+ `ntfy_publish_message` — typed error contract with upstream classification:
86
+
87
+ ```ts
88
+ import { tool, z } from '@cyanheads/mcp-ts-core';
89
+ import { JsonRpcErrorCode, validationError } from '@cyanheads/mcp-ts-core/errors';
90
+ import { getNtfyService } from '@/services/ntfy/ntfy-service.js';
91
+
92
+ export const ntfyPublishMessage = tool('ntfy_publish_message', {
93
+ description: 'Send or update a push notification on an ntfy topic…',
94
+ annotations: { openWorldHint: true },
95
+ input: /* … */,
96
+ output: /* … */,
97
+
98
+ errors: [
99
+ { reason: 'forbidden_topic', code: JsonRpcErrorCode.Forbidden,
100
+ when: 'Auth required for the target topic.',
101
+ recovery: 'Try a public topic instead; if this topic must stay protected, ask the operator to provision ntfy auth credentials.' },
102
+ { reason: 'rate_limited', code: JsonRpcErrorCode.RateLimited,
103
+ when: 'Upstream returned 429 after retries were exhausted.',
104
+ retryable: true,
105
+ recovery: "Wait the rate-limit window before retrying." },
106
+ ],
107
+
108
+ async handler(input, ctx) {
109
+ const topic = input.topic ?? getServerConfig().defaultTopic;
110
+ if (!topic) {
111
+ throw validationError('Topic is required when NTFY_DEFAULT_TOPIC is unset.', {
112
+ recovery: { hint: 'Pass a `topic` argument or configure NTFY_DEFAULT_TOPIC.' },
113
+ });
114
+ }
115
+ try {
116
+ const response = await getNtfyService().publish({ topic, ...input }, { signal: ctx.signal });
117
+ return { id: response.id, /* … */ };
118
+ } catch (err) {
119
+ if (isAuthCode(getCode(err))) {
120
+ throw ctx.fail('forbidden_topic', getMessage(err) || `Forbidden for topic ${topic}`);
121
+ }
122
+ throw err; // Let framework auto-classify the rest
123
+ }
124
+ },
125
+ });
126
+ ```
127
+
128
+ ### Resource
129
+
130
+ `ntfy://{topic}` — snapshot resource that delegates to the same service the polling tool uses:
131
+
132
+ ```ts
133
+ import { resource, z } from '@cyanheads/mcp-ts-core';
134
+ import { forbidden } from '@cyanheads/mcp-ts-core/errors';
135
+ import { getNtfyService } from '@/services/ntfy/ntfy-service.js';
136
+
137
+ export const ntfyTopicResource = resource('ntfy://{topic}', {
138
+ name: 'ntfy-topic-snapshot',
139
+ description: "Snapshot of a topic's last 20 messages from the past hour…",
140
+ mimeType: 'application/json',
141
+ params: z.object({
142
+ topic: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).describe('Topic name.'),
143
+ }),
144
+
145
+ async handler(params, ctx) {
146
+ try {
147
+ const raw = await getNtfyService().fetch(
148
+ { topic: params.topic, since: '1h' },
149
+ { signal: ctx.signal },
150
+ );
151
+ return { topic: params.topic, messages: raw, /* … */ };
152
+ } catch (err) {
153
+ if (isAuthCode(getCode(err))) {
154
+ throw forbidden(getMessage(err) || `Forbidden for topic ${params.topic}`, {
155
+ recovery: { hint: 'Try an unprotected topic.' },
156
+ }, { cause: err });
157
+ }
158
+ throw err;
159
+ }
160
+ },
161
+ });
162
+ ```
163
+
164
+ ### Server config
165
+
166
+ ```ts
167
+ // src/config/server-config.ts — lazy-parsed, separate from framework config
168
+ import { z } from '@cyanheads/mcp-ts-core';
169
+ import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
170
+
171
+ const ServerConfigSchema = z
172
+ .object({
173
+ baseUrl: z.string().url().default('https://ntfy.sh').transform((u) => u.replace(/\/+$/, '')),
174
+ defaultTopic: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional(),
175
+ authToken: z.string().min(1).optional(),
176
+ authUsername: z.string().min(1).optional(),
177
+ authPassword: z.string().min(1).optional(),
178
+ requestTimeoutMs: z.coerce.number().int().positive().default(15_000),
179
+ maxRetries: z.coerce.number().int().min(0).default(3),
180
+ })
181
+ .superRefine((cfg, ctx) => {
182
+ // token vs username/password are mutually exclusive; basic-auth pair must be set together.
183
+ });
184
+
185
+ let _config: z.infer<typeof ServerConfigSchema> | undefined;
186
+ export function getServerConfig() {
187
+ _config ??= parseEnvConfig(ServerConfigSchema, {
188
+ baseUrl: 'NTFY_BASE_URL',
189
+ defaultTopic: 'NTFY_DEFAULT_TOPIC',
190
+ authToken: 'NTFY_AUTH_TOKEN',
191
+ /* … */
192
+ });
193
+ return _config;
194
+ }
195
+ ```
196
+
197
+ `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.
198
+
199
+ ---
200
+
201
+ ## Context
202
+
203
+ 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`):
204
+
205
+ | Property | Description |
206
+ |:---------|:------------|
207
+ | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
208
+ | `ctx.signal` | `AbortSignal` forwarded into `NtfyService` calls so client cancellations propagate to upstream HTTP. |
209
+ | `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. |
210
+ | `ctx.requestId` | Unique request ID. Surfaces in logs and error payloads. |
211
+
212
+ ---
213
+
214
+ ## Errors
215
+
216
+ Handlers throw — the framework catches, classifies, and formats.
217
+
218
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` 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 descriptive metadata for the agent's next move (≥ 5 words, lint-validated); for the wire payload's `data.recovery.hint` (which the framework mirrors into `content[]` text), pass it explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
219
+
220
+ ```ts
221
+ errors: [
222
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
223
+ when: 'No item matched the query',
224
+ recovery: 'Broaden the query or check the spelling and try again.' },
225
+ ],
226
+ async handler(input, ctx) {
227
+ const item = await db.find(input.id);
228
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
229
+ return item;
230
+ }
231
+ ```
232
+
233
+ **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.
234
+
235
+ **Fallback (no contract entry fits):** throw via factories or plain `Error`.
236
+
237
+ ```ts
238
+ // Error factories — explicit code
239
+ import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
240
+ throw notFound('Item not found', { itemId });
241
+ throw serviceUnavailable('API unavailable', { url }, { cause: err });
242
+
243
+ // Plain Error — framework auto-classifies from message patterns
244
+ throw new Error('Item not found'); // → NotFound
245
+ throw new Error('Invalid query format'); // → ValidationError
246
+
247
+ // McpError — when no factory exists for the code
248
+ import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
249
+ throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
250
+ ```
251
+
252
+ See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
253
+
254
+ ---
255
+
256
+ ## Structure
257
+
258
+ ```text
259
+ src/
260
+ index.ts # createApp() entry point
261
+ config/
262
+ server-config.ts # NTFY_* env vars (Zod schema, lazy-parsed)
263
+ services/
264
+ ntfy/
265
+ ntfy-service.ts # HTTP client (publish, manage, fetch)
266
+ error-classifier.ts # Map upstream errors → contract reasons
267
+ types.ts # Domain types (NtfyMessage, NtfyAction, …)
268
+ emoji-tags/
269
+ emoji-tag-service.ts # In-memory tag → emoji lookup
270
+ data.generated.ts # Generated from docs/ntfy/emojis.md
271
+ mcp-server/
272
+ tools/definitions/
273
+ ntfy-publish-message.tool.ts # Send/update a notification
274
+ ntfy-manage-message.tool.ts # Clear/delete by sequence_id
275
+ ntfy-fetch-messages.tool.ts # Poll cached messages with filters
276
+ ntfy-search-emoji-tags.tool.ts # Look up emoji short codes
277
+ resources/definitions/
278
+ ntfy-topic.resource.ts # ntfy://{topic} snapshot
279
+ ```
280
+
281
+ ---
282
+
283
+ ## Naming
284
+
285
+ | What | Convention | Example |
286
+ |:-----|:-----------|:--------|
287
+ | Files | kebab-case with suffix | `search-docs.tool.ts` |
288
+ | Tool/resource/prompt names | snake_case | `search_docs` |
289
+ | Directories | kebab-case | `src/services/doc-search/` |
290
+ | Descriptions | Single string or template literal, no `+` concatenation | `'Search items by query and filter.'` |
291
+
292
+ ---
293
+
294
+ ## Skills
295
+
296
+ Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool.
297
+
298
+ **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). This makes skills available as context without needing to reference `skills/` paths manually. After framework updates, run the `maintenance` skill — it re-syncs the agent directory automatically (Phase B).
299
+
300
+ Available skills:
301
+
302
+ | Skill | Purpose |
303
+ |:------|:--------|
304
+ | `setup` | Post-init project orientation |
305
+ | `design-mcp-server` | Design tool surface, resources, and services for a new server |
306
+ | `add-tool` | Scaffold a new tool definition |
307
+ | `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
308
+ | `add-resource` | Scaffold a new resource definition |
309
+ | `add-prompt` | Scaffold a new prompt definition |
310
+ | `add-service` | Scaffold a new service integration |
311
+ | `add-test` | Scaffold test file for a tool, resource, or service |
312
+ | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
313
+ | `tool-defs-analysis` | Read-only audit of tool/resource/prompt definition language across the surface |
314
+ | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
315
+ | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
316
+ | `release-and-publish` | Run final verification, push commits/tags, publish to npm/MCP Registry/GHCR |
317
+ | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
318
+ | `migrate-mcp-ts-template` | Migrate a legacy `mcp-ts-template` fork to `@cyanheads/mcp-ts-core` (one-shot) |
319
+ | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
320
+ | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
321
+ | `api-auth` | Auth modes, scopes, JWT/OAuth |
322
+ | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
323
+ | `api-config` | AppConfig, parseConfig, env vars |
324
+ | `api-context` | Context interface, logger, state, progress |
325
+ | `api-errors` | McpError, JsonRpcErrorCode, error patterns |
326
+ | `api-linter` | MCP definition linter rules reference |
327
+ | `api-services` | LLM, Speech, Graph services |
328
+ | `api-testing` | createMockContext, test patterns |
329
+ | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers |
330
+ | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
331
+ | `api-workers` | Cloudflare Workers runtime |
332
+
333
+ When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
334
+
335
+ ---
336
+
337
+ ## Commands
338
+
339
+ **Runtime:** Scripts shell out to `bun`. `npm run <cmd>` works too, but the scripts assume Bun is on the PATH.
340
+
341
+ | Command | Purpose |
342
+ |:--------|:--------|
343
+ | `bun run build` | Compile TypeScript via `scripts/build.ts` |
344
+ | `bun run rebuild` | Clean + build |
345
+ | `bun run clean` | Remove build artifacts |
346
+ | `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
347
+ | `bun run tree` | Regenerate `docs/tree.md` |
348
+ | `bun run format` | Auto-fix formatting (Biome) |
349
+ | `bun run lint:mcp` | Validate MCP definitions against the spec |
350
+ | `bun run test` | Run the Vitest test suite |
351
+ | `bun run start:stdio` | Production mode (stdio) |
352
+ | `bun run start:http` | Production mode (HTTP) |
353
+ | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/<minor>.x/*.md` |
354
+ | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
355
+ | `bun run scripts/build-emoji-tags.ts` | Regenerate `src/services/emoji-tags/data.generated.ts` from `docs/ntfy/emojis.md` |
356
+
357
+ ---
358
+
359
+ ## Changelog
360
+
361
+ Directory-based, grouped by minor series using the `.x` semver-wildcard convention. Source of truth is `changelog/<major.minor>.x/<version>.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`.
362
+
363
+ Each per-version file opens with YAML frontmatter:
364
+
365
+ ```markdown
366
+ ---
367
+ summary: "One-line headline, ≤250 chars" # required — powers the rollup index
368
+ breaking: false # optional — true flags breaking changes
369
+ security: false # optional — true flags security fixes
370
+ ---
371
+
372
+ # 0.1.0 — YYYY-MM-DD
373
+ ...
374
+ ```
375
+
376
+ `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. When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`.
377
+
378
+ **Section order** (Keep a Changelog): Added, Changed, Deprecated, Removed, Fixed, Security. Include only sections with entries — don't ship empty headers.
379
+
380
+ ---
381
+
382
+ ## Imports
383
+
384
+ ```ts
385
+ // Framework — z is re-exported, no separate zod import needed
386
+ import { tool, z } from '@cyanheads/mcp-ts-core';
387
+ import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
388
+
389
+ // Server's own code — via path alias
390
+ import { getMyService } from '@/services/my-domain/my-service.js';
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Checklist
396
+
397
+ - [ ] 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()`)
398
+ - [ ] 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`.
399
+ - [ ] JSDoc `@fileoverview` + `@module` on every file
400
+ - [ ] `ctx.log` for logging, `ctx.signal` forwarded to upstream HTTP calls
401
+ - [ ] 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)
402
+ - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data
403
+ - [ ] ntfy-specific: raw/domain/output schemas reviewed against real upstream sparsity/nullability before finalizing required vs optional fields
404
+ - [ ] ntfy-specific: normalization and `format()` preserve uncertainty; do not fabricate facts from missing upstream data
405
+ - [ ] ntfy-specific: tests include at least one sparse payload case with omitted upstream fields
406
+ - [ ] 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
407
+ - [ ] Registered in `createApp()` arrays in `src/index.ts`
408
+ - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
409
+ - [ ] `bun run devcheck` passes
package/Dockerfile ADDED
@@ -0,0 +1,98 @@
1
+ # ==============================================================================
2
+ # Build Stage
3
+ #
4
+ # This stage installs all dependencies (including dev), builds the TypeScript
5
+ # source code into JavaScript, and prepares the production assets.
6
+ # ==============================================================================
7
+ FROM oven/bun:1.3 AS build
8
+
9
+ WORKDIR /usr/src/app
10
+
11
+ # Copy dependency manifests for optimized layer caching
12
+ COPY package.json bun.lock ./
13
+
14
+ # Install all dependencies (including dev dependencies for building)
15
+ RUN bun install --frozen-lockfile
16
+
17
+ # Copy the rest of the source code
18
+ COPY . .
19
+
20
+ # Build the application
21
+ RUN bun run build
22
+
23
+
24
+ # ==============================================================================
25
+ # Production Stage
26
+ #
27
+ # This stage creates a minimal, optimized, and secure image for running the
28
+ # application. It uses a slim base image and only includes production
29
+ # dependencies and build artifacts.
30
+ # ==============================================================================
31
+ FROM oven/bun:1.3-slim AS production
32
+
33
+ WORKDIR /usr/src/app
34
+
35
+ # Set the environment to production for performance and to ensure only
36
+ # production dependencies are installed.
37
+ ENV NODE_ENV=production
38
+
39
+ # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
40
+ LABEL org.opencontainers.image.title="ntfy-mcp-server"
41
+ LABEL org.opencontainers.image.description="Send, manage, and replay ntfy push notifications via MCP."
42
+ LABEL org.opencontainers.image.source="https://github.com/cyanheads/ntfy-mcp-server"
43
+ LABEL org.opencontainers.image.licenses="Apache-2.0"
44
+
45
+ # Copy dependency manifests
46
+ COPY package.json bun.lock ./
47
+
48
+ # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
49
+ # that are not needed in the final production image.
50
+ RUN bun install --production --frozen-lockfile --ignore-scripts
51
+
52
+ # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
53
+ # These are not bundled by default to keep the base image lean. Enable at build time
54
+ # with: docker build --build-arg OTEL_ENABLED=true
55
+ ARG OTEL_ENABLED=true
56
+ RUN if [ "$OTEL_ENABLED" = "true" ]; then \
57
+ bun add @hono/otel \
58
+ @opentelemetry/instrumentation-http \
59
+ @opentelemetry/exporter-metrics-otlp-http \
60
+ @opentelemetry/exporter-trace-otlp-http \
61
+ @opentelemetry/instrumentation-pino \
62
+ @opentelemetry/resources \
63
+ @opentelemetry/sdk-metrics \
64
+ @opentelemetry/sdk-node \
65
+ @opentelemetry/sdk-trace-node \
66
+ @opentelemetry/semantic-conventions; \
67
+ fi
68
+
69
+ # Copy the compiled application code from the build stage
70
+ COPY --from=build /usr/src/app/dist ./dist
71
+
72
+ # The 'oven/bun' image already provides a non-root user named 'bun'.
73
+ # We will use this existing user for enhanced security.
74
+
75
+ # Create and set permissions for the log directory, assigning ownership to the 'bun' user.
76
+ RUN mkdir -p /var/log/ntfy-mcp-server && chown -R bun:bun /var/log/ntfy-mcp-server
77
+
78
+ # Switch to the non-root user
79
+ USER bun
80
+
81
+ # Define an argument for the port, allowing it to be overridden at build time.
82
+ # The `PORT` variable is often injected by cloud environments at runtime.
83
+ ARG PORT
84
+
85
+ # Set runtime environment variables
86
+ # Note: PORT is an automatic variable in many cloud environments (e.g., Cloud Run)
87
+ ENV MCP_HTTP_PORT=${PORT:-3010}
88
+ ENV MCP_HTTP_HOST="0.0.0.0"
89
+ ENV MCP_TRANSPORT_TYPE="http"
90
+ ENV MCP_SESSION_MODE="stateless"
91
+ ENV MCP_LOG_LEVEL="info"
92
+ ENV LOGS_DIR="/var/log/ntfy-mcp-server"
93
+
94
+ # Expose the port the server listens on
95
+ EXPOSE ${MCP_HTTP_PORT}
96
+
97
+ # The command to start the server
98
+ CMD ["bun", "run", "dist/index.js"]