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.
- package/CLAUDE.md +409 -0
- package/Dockerfile +98 -0
- package/README.md +167 -393
- package/changelog/1.0.x/1.0.0.md +13 -0
- package/changelog/1.0.x/1.0.1.md +11 -0
- package/changelog/1.0.x/1.0.2.md +12 -0
- package/changelog/1.0.x/1.0.3.md +17 -0
- package/changelog/1.0.x/1.0.4.md +12 -0
- package/changelog/1.0.x/1.0.6.md +22 -0
- package/changelog/2.0.x/2.0.0.md +57 -0
- package/changelog/template.md +93 -0
- package/dist/config/server-config.d.ts +41 -0
- package/dist/config/server-config.d.ts.map +1 -0
- package/dist/config/server-config.js +189 -0
- package/dist/config/server-config.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -104
- package/dist/index.js.map +1 -0
- package/dist/mcp-server/resources/definitions/ntfy-topic.resource.d.ts +12 -0
- package/dist/mcp-server/resources/definitions/ntfy-topic.resource.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/ntfy-topic.resource.js +58 -0
- package/dist/mcp-server/resources/definitions/ntfy-topic.resource.js.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.d.ts +101 -0
- package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.js +408 -0
- package/dist/mcp-server/tools/definitions/ntfy-fetch-messages.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.d.ts +44 -0
- package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.js +133 -0
- package/dist/mcp-server/tools/definitions/ntfy-manage-message.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.d.ts +125 -0
- package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.js +411 -0
- package/dist/mcp-server/tools/definitions/ntfy-publish-message.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.d.ts +20 -0
- package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.js +69 -0
- package/dist/mcp-server/tools/definitions/ntfy-search-emoji-tags.tool.js.map +1 -0
- package/dist/services/emoji-tags/data.generated.d.ts +8 -0
- package/dist/services/emoji-tags/data.generated.d.ts.map +1 -0
- package/dist/services/emoji-tags/data.generated.js +1821 -0
- package/dist/services/emoji-tags/data.generated.js.map +1 -0
- package/dist/services/emoji-tags/emoji-tag-service.d.ts +28 -0
- package/dist/services/emoji-tags/emoji-tag-service.d.ts.map +1 -0
- package/dist/services/emoji-tags/emoji-tag-service.js +50 -0
- package/dist/services/emoji-tags/emoji-tag-service.js.map +1 -0
- package/dist/services/ntfy/error-classifier.d.ts +31 -0
- package/dist/services/ntfy/error-classifier.d.ts.map +1 -0
- package/dist/services/ntfy/error-classifier.js +65 -0
- package/dist/services/ntfy/error-classifier.js.map +1 -0
- package/dist/services/ntfy/ntfy-service.d.ts +42 -0
- package/dist/services/ntfy/ntfy-service.d.ts.map +1 -0
- package/dist/services/ntfy/ntfy-service.js +213 -0
- package/dist/services/ntfy/ntfy-service.js.map +1 -0
- package/dist/services/ntfy/types.d.ts +108 -138
- package/dist/services/ntfy/types.d.ts.map +1 -0
- package/dist/services/ntfy/types.js +6 -1
- package/dist/services/ntfy/types.js.map +1 -0
- package/package.json +76 -38
- package/server.json +224 -0
- package/dist/config/index.d.ts +0 -23
- package/dist/config/index.js +0 -111
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +0 -2
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +0 -133
- package/dist/mcp-server/resources/ntfyResource/index.d.ts +0 -12
- package/dist/mcp-server/resources/ntfyResource/index.js +0 -77
- package/dist/mcp-server/resources/ntfyResource/types.d.ts +0 -59
- package/dist/mcp-server/resources/ntfyResource/types.js +0 -8
- package/dist/mcp-server/server.d.ts +0 -40
- package/dist/mcp-server/server.js +0 -299
- package/dist/mcp-server/tools/ntfyTool/index.d.ts +0 -11
- package/dist/mcp-server/tools/ntfyTool/index.js +0 -124
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +0 -9
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +0 -307
- package/dist/mcp-server/tools/ntfyTool/types.d.ts +0 -252
- package/dist/mcp-server/tools/ntfyTool/types.js +0 -144
- package/dist/mcp-server/utils/registrationHelper.d.ts +0 -48
- package/dist/mcp-server/utils/registrationHelper.js +0 -63
- package/dist/services/ntfy/constants.d.ts +0 -37
- package/dist/services/ntfy/constants.js +0 -37
- package/dist/services/ntfy/errors.d.ts +0 -79
- package/dist/services/ntfy/errors.js +0 -134
- package/dist/services/ntfy/index.d.ts +0 -33
- package/dist/services/ntfy/index.js +0 -56
- package/dist/services/ntfy/publisher.d.ts +0 -66
- package/dist/services/ntfy/publisher.js +0 -229
- package/dist/services/ntfy/subscriber.d.ts +0 -81
- package/dist/services/ntfy/subscriber.js +0 -502
- package/dist/services/ntfy/utils.d.ts +0 -85
- package/dist/services/ntfy/utils.js +0 -410
- package/dist/types-global/errors.d.ts +0 -35
- package/dist/types-global/errors.js +0 -39
- package/dist/types-global/mcp.d.ts +0 -30
- package/dist/types-global/mcp.js +0 -25
- package/dist/types-global/tool.d.ts +0 -44
- package/dist/types-global/tool.js +0 -30
- package/dist/utils/errorHandler.d.ts +0 -98
- package/dist/utils/errorHandler.js +0 -271
- package/dist/utils/idGenerator.d.ts +0 -94
- package/dist/utils/idGenerator.js +0 -149
- package/dist/utils/index.d.ts +0 -13
- package/dist/utils/index.js +0 -16
- package/dist/utils/logger.d.ts +0 -38
- package/dist/utils/logger.js +0 -141
- package/dist/utils/rateLimiter.d.ts +0 -115
- package/dist/utils/rateLimiter.js +0 -180
- package/dist/utils/requestContext.d.ts +0 -68
- package/dist/utils/requestContext.js +0 -91
- package/dist/utils/sanitization.d.ts +0 -224
- package/dist/utils/sanitization.js +0 -367
- package/dist/utils/security.d.ts +0 -26
- 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"]
|