ntfy-mcp-server 1.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/LICENSE +201 -0
- package/README.md +423 -0
- package/dist/config/index.d.ts +23 -0
- package/dist/config/index.js +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +108 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +2 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +111 -0
- package/dist/mcp-server/resources/ntfyResource/index.d.ts +12 -0
- package/dist/mcp-server/resources/ntfyResource/index.js +72 -0
- package/dist/mcp-server/resources/ntfyResource/types.d.ts +27 -0
- package/dist/mcp-server/resources/ntfyResource/types.js +8 -0
- package/dist/mcp-server/server.d.ts +40 -0
- package/dist/mcp-server/server.js +245 -0
- package/dist/mcp-server/tools/ntfyTool/index.d.ts +11 -0
- package/dist/mcp-server/tools/ntfyTool/index.js +110 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +9 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +289 -0
- package/dist/mcp-server/tools/ntfyTool/types.d.ts +252 -0
- package/dist/mcp-server/tools/ntfyTool/types.js +144 -0
- package/dist/mcp-server/utils/registrationHelper.d.ts +48 -0
- package/dist/mcp-server/utils/registrationHelper.js +63 -0
- package/dist/services/ntfy/constants.d.ts +37 -0
- package/dist/services/ntfy/constants.js +37 -0
- package/dist/services/ntfy/errors.d.ts +79 -0
- package/dist/services/ntfy/errors.js +134 -0
- package/dist/services/ntfy/index.d.ts +33 -0
- package/dist/services/ntfy/index.js +56 -0
- package/dist/services/ntfy/publisher.d.ts +66 -0
- package/dist/services/ntfy/publisher.js +229 -0
- package/dist/services/ntfy/subscriber.d.ts +81 -0
- package/dist/services/ntfy/subscriber.js +502 -0
- package/dist/services/ntfy/types.d.ts +161 -0
- package/dist/services/ntfy/types.js +4 -0
- package/dist/services/ntfy/utils.d.ts +85 -0
- package/dist/services/ntfy/utils.js +410 -0
- package/dist/types-global/errors.d.ts +35 -0
- package/dist/types-global/errors.js +39 -0
- package/dist/types-global/mcp.d.ts +30 -0
- package/dist/types-global/mcp.js +25 -0
- package/dist/types-global/tool.d.ts +61 -0
- package/dist/types-global/tool.js +99 -0
- package/dist/utils/errorHandler.d.ts +98 -0
- package/dist/utils/errorHandler.js +271 -0
- package/dist/utils/idGenerator.d.ts +94 -0
- package/dist/utils/idGenerator.js +149 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +16 -0
- package/dist/utils/logger.d.ts +36 -0
- package/dist/utils/logger.js +92 -0
- package/dist/utils/rateLimiter.d.ts +115 -0
- package/dist/utils/rateLimiter.js +180 -0
- package/dist/utils/requestContext.d.ts +68 -0
- package/dist/utils/requestContext.js +91 -0
- package/dist/utils/sanitization.d.ts +224 -0
- package/dist/utils/sanitization.js +367 -0
- package/dist/utils/security.d.ts +26 -0
- package/dist/utils/security.js +27 -0
- package/package.json +47 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { NtfyMessage, NtfySubscriptionOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates a topic name
|
|
4
|
+
* @param topic Topic name to validate
|
|
5
|
+
* @returns True if the topic name is valid, false otherwise
|
|
6
|
+
*/
|
|
7
|
+
export declare function isValidTopic(topic: string): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Validate a topic name synchronously
|
|
10
|
+
* This is a synchronous version for performance and cases where async isn't possible
|
|
11
|
+
* @param topic Topic to validate
|
|
12
|
+
* @returns True if topic is valid
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateTopicSync(topic: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Builds a ntfy subscription URL
|
|
17
|
+
* @param topic Topic to subscribe to (can be comma-separated for multiple topics)
|
|
18
|
+
* @param format Subscription format (json, sse, raw, ws)
|
|
19
|
+
* @param options Subscription options
|
|
20
|
+
* @returns Complete URL for the subscription
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildSubscriptionUrl(topic: string, format: string, options: NtfySubscriptionOptions): Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Builds a subscription URL synchronously
|
|
25
|
+
* @param topic Topic to subscribe to
|
|
26
|
+
* @param format Subscription format
|
|
27
|
+
* @param options Subscription options
|
|
28
|
+
* @returns Complete URL
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildSubscriptionUrlSync(topic: string, format: string, options: NtfySubscriptionOptions): string;
|
|
31
|
+
/**
|
|
32
|
+
* Creates authorization header for basic auth
|
|
33
|
+
* @param username Username
|
|
34
|
+
* @param password Password
|
|
35
|
+
* @returns Basic auth header value
|
|
36
|
+
*/
|
|
37
|
+
export declare function createBasicAuthHeader(username: string, password: string): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Creates basic auth header synchronously
|
|
40
|
+
* @param username Username
|
|
41
|
+
* @param password Password
|
|
42
|
+
* @returns Basic auth header value
|
|
43
|
+
*/
|
|
44
|
+
export declare function createBasicAuthHeaderSync(username: string, password: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Parses a JSON message from ntfy
|
|
47
|
+
* @param data JSON string to parse
|
|
48
|
+
* @returns Parsed ntfy message
|
|
49
|
+
* @throws NtfyParseError if the message cannot be parsed
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseJsonMessage(data: string): Promise<NtfyMessage>;
|
|
52
|
+
/**
|
|
53
|
+
* Parse JSON message synchronously
|
|
54
|
+
* @param data JSON string to parse
|
|
55
|
+
* @returns Parsed ntfy message
|
|
56
|
+
* @throws NtfyParseError if parsing fails
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseJsonMessageSync(data: string): NtfyMessage;
|
|
59
|
+
/**
|
|
60
|
+
* Creates request headers for ntfy API calls
|
|
61
|
+
* @param options Subscription options
|
|
62
|
+
* @returns Headers object for fetch
|
|
63
|
+
*/
|
|
64
|
+
export declare function createRequestHeaders(options: NtfySubscriptionOptions): Promise<HeadersInit>;
|
|
65
|
+
/**
|
|
66
|
+
* Create request headers synchronously
|
|
67
|
+
* @param options Subscription options
|
|
68
|
+
* @returns Headers object
|
|
69
|
+
*/
|
|
70
|
+
export declare function createRequestHeadersSync(options: NtfySubscriptionOptions): HeadersInit;
|
|
71
|
+
/**
|
|
72
|
+
* Generates a timeout promise that rejects after the specified time
|
|
73
|
+
* @param ms Timeout in milliseconds
|
|
74
|
+
* @returns Promise that rejects after the specified time
|
|
75
|
+
*/
|
|
76
|
+
export declare function createTimeout(ms: number): Promise<never>;
|
|
77
|
+
/**
|
|
78
|
+
* Creates an AbortController with a timeout
|
|
79
|
+
* @param timeoutMs Timeout in milliseconds
|
|
80
|
+
* @returns AbortController and a cleanup function
|
|
81
|
+
*/
|
|
82
|
+
export declare function createAbortControllerWithTimeout(timeoutMs: number): {
|
|
83
|
+
controller: AbortController;
|
|
84
|
+
cleanup: () => void;
|
|
85
|
+
};
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for the ntfy service
|
|
3
|
+
*/
|
|
4
|
+
import { BaseErrorCode } from '../../types-global/errors.js';
|
|
5
|
+
import { ErrorHandler } from '../../utils/errorHandler.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { sanitizeInput } from '../../utils/sanitization.js';
|
|
8
|
+
import { createRequestContext } from '../../utils/requestContext.js';
|
|
9
|
+
import { idGenerator } from '../../utils/idGenerator.js';
|
|
10
|
+
import { DEFAULT_NTFY_BASE_URL } from './constants.js';
|
|
11
|
+
import { NtfyParseError, ntfyErrorMapper } from './errors.js';
|
|
12
|
+
// Create a module-specific logger
|
|
13
|
+
const moduleLogger = logger.createChildLogger({
|
|
14
|
+
module: 'NtfyUtils',
|
|
15
|
+
serviceId: idGenerator.generateRandomString(8)
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Validates a topic name
|
|
19
|
+
* @param topic Topic name to validate
|
|
20
|
+
* @returns True if the topic name is valid, false otherwise
|
|
21
|
+
*/
|
|
22
|
+
export async function isValidTopic(topic) {
|
|
23
|
+
return ErrorHandler.tryCatch(async () => {
|
|
24
|
+
// Topic names are validated on the server side, but we can do basic validation here
|
|
25
|
+
if (!topic)
|
|
26
|
+
return false;
|
|
27
|
+
const sanitizedTopic = sanitizeInput.string(topic);
|
|
28
|
+
return sanitizedTopic.trim().length > 0 &&
|
|
29
|
+
!sanitizedTopic.includes('\n') &&
|
|
30
|
+
!sanitizedTopic.includes('\r');
|
|
31
|
+
}, {
|
|
32
|
+
operation: 'validateNtfyTopic',
|
|
33
|
+
context: { topic },
|
|
34
|
+
errorCode: BaseErrorCode.VALIDATION_ERROR,
|
|
35
|
+
rethrow: false,
|
|
36
|
+
// Return false on error rather than throwing
|
|
37
|
+
errorMapper: () => false
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validate a topic name synchronously
|
|
42
|
+
* This is a synchronous version for performance and cases where async isn't possible
|
|
43
|
+
* @param topic Topic to validate
|
|
44
|
+
* @returns True if topic is valid
|
|
45
|
+
*/
|
|
46
|
+
export function validateTopicSync(topic) {
|
|
47
|
+
try {
|
|
48
|
+
if (!topic)
|
|
49
|
+
return false;
|
|
50
|
+
const sanitizedTopic = sanitizeInput.string(topic);
|
|
51
|
+
return sanitizedTopic.trim().length > 0 &&
|
|
52
|
+
!sanitizedTopic.includes('\n') &&
|
|
53
|
+
!sanitizedTopic.includes('\r');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
moduleLogger.warn('Error validating topic', { topic, error });
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds a ntfy subscription URL
|
|
62
|
+
* @param topic Topic to subscribe to (can be comma-separated for multiple topics)
|
|
63
|
+
* @param format Subscription format (json, sse, raw, ws)
|
|
64
|
+
* @param options Subscription options
|
|
65
|
+
* @returns Complete URL for the subscription
|
|
66
|
+
*/
|
|
67
|
+
export async function buildSubscriptionUrl(topic, format, options) {
|
|
68
|
+
return ErrorHandler.tryCatch(async () => {
|
|
69
|
+
const requestCtx = createRequestContext({
|
|
70
|
+
operation: 'buildSubscriptionUrl',
|
|
71
|
+
topic,
|
|
72
|
+
format
|
|
73
|
+
});
|
|
74
|
+
// Sanitize inputs
|
|
75
|
+
const sanitizedTopic = sanitizeInput.string(topic);
|
|
76
|
+
const sanitizedFormat = sanitizeInput.string(format);
|
|
77
|
+
moduleLogger.debug('Building subscription URL', {
|
|
78
|
+
topic: sanitizedTopic,
|
|
79
|
+
format: sanitizedFormat,
|
|
80
|
+
requestId: requestCtx.requestId
|
|
81
|
+
});
|
|
82
|
+
const baseUrl = sanitizeInput.url(options.baseUrl || DEFAULT_NTFY_BASE_URL);
|
|
83
|
+
const endpoint = `/${sanitizedTopic}/${sanitizedFormat}`;
|
|
84
|
+
// Build query parameters
|
|
85
|
+
const params = new URLSearchParams();
|
|
86
|
+
if (options.poll) {
|
|
87
|
+
params.append('poll', '1');
|
|
88
|
+
}
|
|
89
|
+
if (options.since) {
|
|
90
|
+
params.append('since', options.since.toString());
|
|
91
|
+
}
|
|
92
|
+
if (options.scheduled) {
|
|
93
|
+
params.append('scheduled', '1');
|
|
94
|
+
}
|
|
95
|
+
if (options.id) {
|
|
96
|
+
params.append('id', sanitizeInput.string(options.id));
|
|
97
|
+
}
|
|
98
|
+
if (options.message) {
|
|
99
|
+
params.append('message', sanitizeInput.string(options.message));
|
|
100
|
+
}
|
|
101
|
+
if (options.title) {
|
|
102
|
+
params.append('title', sanitizeInput.string(options.title));
|
|
103
|
+
}
|
|
104
|
+
if (options.priority) {
|
|
105
|
+
params.append('priority', sanitizeInput.string(options.priority.toString()));
|
|
106
|
+
}
|
|
107
|
+
if (options.tags) {
|
|
108
|
+
params.append('tags', sanitizeInput.string(options.tags));
|
|
109
|
+
}
|
|
110
|
+
if (options.auth) {
|
|
111
|
+
params.append('auth', sanitizeInput.string(options.auth));
|
|
112
|
+
}
|
|
113
|
+
const queryString = params.toString();
|
|
114
|
+
const fullUrl = `${baseUrl}${endpoint}${queryString ? `?${queryString}` : ''}`;
|
|
115
|
+
moduleLogger.debug('Built subscription URL', {
|
|
116
|
+
url: fullUrl,
|
|
117
|
+
requestId: requestCtx.requestId
|
|
118
|
+
});
|
|
119
|
+
return fullUrl;
|
|
120
|
+
}, {
|
|
121
|
+
operation: 'buildSubscriptionUrl',
|
|
122
|
+
context: { topic, format },
|
|
123
|
+
input: options,
|
|
124
|
+
errorCode: BaseErrorCode.VALIDATION_ERROR,
|
|
125
|
+
errorMapper: ntfyErrorMapper,
|
|
126
|
+
rethrow: true
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Builds a subscription URL synchronously
|
|
131
|
+
* @param topic Topic to subscribe to
|
|
132
|
+
* @param format Subscription format
|
|
133
|
+
* @param options Subscription options
|
|
134
|
+
* @returns Complete URL
|
|
135
|
+
*/
|
|
136
|
+
export function buildSubscriptionUrlSync(topic, format, options) {
|
|
137
|
+
try {
|
|
138
|
+
// Sanitize inputs
|
|
139
|
+
const sanitizedTopic = sanitizeInput.string(topic);
|
|
140
|
+
const sanitizedFormat = sanitizeInput.string(format);
|
|
141
|
+
const baseUrl = sanitizeInput.url(options.baseUrl || DEFAULT_NTFY_BASE_URL);
|
|
142
|
+
const endpoint = `/${sanitizedTopic}/${sanitizedFormat}`;
|
|
143
|
+
// Build query parameters
|
|
144
|
+
const params = new URLSearchParams();
|
|
145
|
+
if (options.poll) {
|
|
146
|
+
params.append('poll', '1');
|
|
147
|
+
}
|
|
148
|
+
if (options.since) {
|
|
149
|
+
params.append('since', options.since.toString());
|
|
150
|
+
}
|
|
151
|
+
if (options.scheduled) {
|
|
152
|
+
params.append('scheduled', '1');
|
|
153
|
+
}
|
|
154
|
+
if (options.id) {
|
|
155
|
+
params.append('id', sanitizeInput.string(options.id));
|
|
156
|
+
}
|
|
157
|
+
if (options.message) {
|
|
158
|
+
params.append('message', sanitizeInput.string(options.message));
|
|
159
|
+
}
|
|
160
|
+
if (options.title) {
|
|
161
|
+
params.append('title', sanitizeInput.string(options.title));
|
|
162
|
+
}
|
|
163
|
+
if (options.priority) {
|
|
164
|
+
params.append('priority', sanitizeInput.string(options.priority.toString()));
|
|
165
|
+
}
|
|
166
|
+
if (options.tags) {
|
|
167
|
+
params.append('tags', sanitizeInput.string(options.tags));
|
|
168
|
+
}
|
|
169
|
+
if (options.auth) {
|
|
170
|
+
params.append('auth', sanitizeInput.string(options.auth));
|
|
171
|
+
}
|
|
172
|
+
const queryString = params.toString();
|
|
173
|
+
return `${baseUrl}${endpoint}${queryString ? `?${queryString}` : ''}`;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
moduleLogger.error('Error building subscription URL', { topic, format, error });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Creates authorization header for basic auth
|
|
182
|
+
* @param username Username
|
|
183
|
+
* @param password Password
|
|
184
|
+
* @returns Basic auth header value
|
|
185
|
+
*/
|
|
186
|
+
export async function createBasicAuthHeader(username, password) {
|
|
187
|
+
return ErrorHandler.tryCatch(async () => {
|
|
188
|
+
const requestCtx = createRequestContext({
|
|
189
|
+
operation: 'createBasicAuthHeader',
|
|
190
|
+
hasCredentials: !!username && !!password
|
|
191
|
+
});
|
|
192
|
+
if (!username || !password) {
|
|
193
|
+
moduleLogger.warn('Missing username or password for basic auth', {
|
|
194
|
+
requestId: requestCtx.requestId,
|
|
195
|
+
hasUsername: !!username
|
|
196
|
+
});
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
// Sanitize credentials
|
|
200
|
+
const sanitizedUsername = sanitizeInput.string(username);
|
|
201
|
+
// Don't log or sanitize password directly to avoid potential leaks
|
|
202
|
+
// Use btoa for base64 encoding (available in Node.js and browsers)
|
|
203
|
+
return `Basic ${btoa(`${sanitizedUsername}:${password}`)}`;
|
|
204
|
+
}, {
|
|
205
|
+
operation: 'createBasicAuthHeader',
|
|
206
|
+
errorCode: BaseErrorCode.VALIDATION_ERROR,
|
|
207
|
+
// Don't include username/password in logs
|
|
208
|
+
rethrow: false,
|
|
209
|
+
// Return empty string on error rather than throwing
|
|
210
|
+
errorMapper: () => ''
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Creates basic auth header synchronously
|
|
215
|
+
* @param username Username
|
|
216
|
+
* @param password Password
|
|
217
|
+
* @returns Basic auth header value
|
|
218
|
+
*/
|
|
219
|
+
export function createBasicAuthHeaderSync(username, password) {
|
|
220
|
+
try {
|
|
221
|
+
if (!username || !password) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
// Sanitize credentials
|
|
225
|
+
const sanitizedUsername = sanitizeInput.string(username);
|
|
226
|
+
// Use btoa for base64 encoding
|
|
227
|
+
return `Basic ${btoa(`${sanitizedUsername}:${password}`)}`;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
moduleLogger.warn('Error creating basic auth header', { error });
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Parses a JSON message from ntfy
|
|
236
|
+
* @param data JSON string to parse
|
|
237
|
+
* @returns Parsed ntfy message
|
|
238
|
+
* @throws NtfyParseError if the message cannot be parsed
|
|
239
|
+
*/
|
|
240
|
+
export async function parseJsonMessage(data) {
|
|
241
|
+
return ErrorHandler.tryCatch(async () => {
|
|
242
|
+
if (!data || typeof data !== 'string') {
|
|
243
|
+
throw new Error('Invalid input: data must be a non-empty string');
|
|
244
|
+
}
|
|
245
|
+
const message = JSON.parse(data);
|
|
246
|
+
// Basic validation to ensure it's a proper ntfy message
|
|
247
|
+
if (!message.id || !message.time || !message.event || !message.topic) {
|
|
248
|
+
throw new Error('Invalid message format');
|
|
249
|
+
}
|
|
250
|
+
return message;
|
|
251
|
+
}, {
|
|
252
|
+
operation: 'parseJsonMessage',
|
|
253
|
+
context: { dataLength: data?.length ?? 0 },
|
|
254
|
+
input: { data: data?.length > 100 ? `${data.substring(0, 100)}...` : data },
|
|
255
|
+
errorCode: BaseErrorCode.VALIDATION_ERROR,
|
|
256
|
+
errorMapper: (error) => {
|
|
257
|
+
// Transform the error to our NtfyParseError
|
|
258
|
+
return new NtfyParseError(`Failed to parse message: ${error instanceof Error ? error.message : 'Unknown error'}`, data);
|
|
259
|
+
},
|
|
260
|
+
rethrow: true
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Parse JSON message synchronously
|
|
265
|
+
* @param data JSON string to parse
|
|
266
|
+
* @returns Parsed ntfy message
|
|
267
|
+
* @throws NtfyParseError if parsing fails
|
|
268
|
+
*/
|
|
269
|
+
export function parseJsonMessageSync(data) {
|
|
270
|
+
try {
|
|
271
|
+
if (!data || typeof data !== 'string') {
|
|
272
|
+
throw new Error('Invalid input: data must be a non-empty string');
|
|
273
|
+
}
|
|
274
|
+
const message = JSON.parse(data);
|
|
275
|
+
// Basic validation to ensure it's a proper ntfy message
|
|
276
|
+
if (!message.id || !message.time || !message.event || !message.topic) {
|
|
277
|
+
throw new Error('Invalid message format');
|
|
278
|
+
}
|
|
279
|
+
return message;
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
throw new NtfyParseError(`Failed to parse message: ${error instanceof Error ? error.message : 'Unknown error'}`, data);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Creates request headers for ntfy API calls
|
|
287
|
+
* @param options Subscription options
|
|
288
|
+
* @returns Headers object for fetch
|
|
289
|
+
*/
|
|
290
|
+
export async function createRequestHeaders(options) {
|
|
291
|
+
return ErrorHandler.tryCatch(async () => {
|
|
292
|
+
const requestCtx = createRequestContext({
|
|
293
|
+
operation: 'createRequestHeaders'
|
|
294
|
+
});
|
|
295
|
+
moduleLogger.debug('Creating request headers', {
|
|
296
|
+
requestId: requestCtx.requestId,
|
|
297
|
+
hasAuth: !!options.auth || !!(options.username && options.password),
|
|
298
|
+
hasCustomHeaders: !!options.headers && Object.keys(options.headers).length > 0
|
|
299
|
+
});
|
|
300
|
+
const headers = {
|
|
301
|
+
'Accept': 'application/json',
|
|
302
|
+
'User-Agent': 'ntfy-mcp-server/1.0.0',
|
|
303
|
+
};
|
|
304
|
+
// Add custom headers if provided (after sanitization)
|
|
305
|
+
if (options.headers) {
|
|
306
|
+
Object.entries(options.headers).forEach(([key, value]) => {
|
|
307
|
+
headers[sanitizeInput.string(key)] = sanitizeInput.string(value);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// Add authorization header if credentials are provided
|
|
311
|
+
if (options.username && options.password) {
|
|
312
|
+
const authHeader = await createBasicAuthHeader(options.username, options.password);
|
|
313
|
+
headers['Authorization'] = authHeader;
|
|
314
|
+
}
|
|
315
|
+
else if (options.auth && !options.auth.includes('=')) {
|
|
316
|
+
// Check if the auth token is an ntfy API key (starts with tk_)
|
|
317
|
+
if (options.auth.startsWith('tk_')) {
|
|
318
|
+
// Format as Bearer token for ntfy API key
|
|
319
|
+
headers['Authorization'] = `Bearer ${sanitizeInput.string(options.auth)}`;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
headers['Authorization'] = sanitizeInput.string(options.auth);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return headers;
|
|
326
|
+
}, {
|
|
327
|
+
operation: 'createRequestHeaders',
|
|
328
|
+
rethrow: false,
|
|
329
|
+
// Return minimal headers on error rather than breaking calls
|
|
330
|
+
errorMapper: () => ({
|
|
331
|
+
'Accept': 'application/json',
|
|
332
|
+
'User-Agent': 'ntfy-mcp-server/1.0.0',
|
|
333
|
+
})
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Create request headers synchronously
|
|
338
|
+
* @param options Subscription options
|
|
339
|
+
* @returns Headers object
|
|
340
|
+
*/
|
|
341
|
+
export function createRequestHeadersSync(options) {
|
|
342
|
+
try {
|
|
343
|
+
const headers = {
|
|
344
|
+
'Accept': 'application/json',
|
|
345
|
+
'User-Agent': 'ntfy-mcp-server/1.0.0',
|
|
346
|
+
};
|
|
347
|
+
// Add custom headers if provided (after sanitization)
|
|
348
|
+
if (options.headers) {
|
|
349
|
+
Object.entries(options.headers).forEach(([key, value]) => {
|
|
350
|
+
headers[sanitizeInput.string(key)] = sanitizeInput.string(value);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
// Add authorization header if credentials are provided
|
|
354
|
+
if (options.username && options.password) {
|
|
355
|
+
headers['Authorization'] = createBasicAuthHeaderSync(options.username, options.password);
|
|
356
|
+
}
|
|
357
|
+
else if (options.auth && !options.auth.includes('=')) {
|
|
358
|
+
// Check if the auth token is an ntfy API key (starts with tk_)
|
|
359
|
+
if (options.auth.startsWith('tk_')) {
|
|
360
|
+
// Format as Bearer token for ntfy API key
|
|
361
|
+
headers['Authorization'] = `Bearer ${sanitizeInput.string(options.auth)}`;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
headers['Authorization'] = sanitizeInput.string(options.auth);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return headers;
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
moduleLogger.error('Error creating request headers', { error });
|
|
371
|
+
// Return minimal headers on error
|
|
372
|
+
return {
|
|
373
|
+
'Accept': 'application/json',
|
|
374
|
+
'User-Agent': 'ntfy-mcp-server/1.0.0',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Generates a timeout promise that rejects after the specified time
|
|
380
|
+
* @param ms Timeout in milliseconds
|
|
381
|
+
* @returns Promise that rejects after the specified time
|
|
382
|
+
*/
|
|
383
|
+
export function createTimeout(ms) {
|
|
384
|
+
const timeoutId = createRequestContext({ operation: 'createTimeout', timeoutMs: ms }).requestId;
|
|
385
|
+
moduleLogger.debug('Creating timeout promise', { timeoutMs: ms, timeoutId });
|
|
386
|
+
return new Promise((_, reject) => {
|
|
387
|
+
setTimeout(() => {
|
|
388
|
+
moduleLogger.debug('Timeout reached', { timeoutMs: ms, timeoutId });
|
|
389
|
+
reject(new Error(`Operation timed out after ${ms}ms`));
|
|
390
|
+
}, ms);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Creates an AbortController with a timeout
|
|
395
|
+
* @param timeoutMs Timeout in milliseconds
|
|
396
|
+
* @returns AbortController and a cleanup function
|
|
397
|
+
*/
|
|
398
|
+
export function createAbortControllerWithTimeout(timeoutMs) {
|
|
399
|
+
const controlId = createRequestContext({ operation: 'createAbortController', timeoutMs }).requestId;
|
|
400
|
+
moduleLogger.debug('Creating AbortController with timeout', { timeoutMs, controlId });
|
|
401
|
+
const controller = new AbortController();
|
|
402
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
403
|
+
return {
|
|
404
|
+
controller,
|
|
405
|
+
cleanup: () => {
|
|
406
|
+
clearTimeout(timeoutId);
|
|
407
|
+
moduleLogger.debug('Cleaned up AbortController timeout', { controlId });
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { McpToolResponse } from "./mcp.js";
|
|
3
|
+
export declare const BaseErrorCode: {
|
|
4
|
+
readonly UNAUTHORIZED: "UNAUTHORIZED";
|
|
5
|
+
readonly FORBIDDEN: "FORBIDDEN";
|
|
6
|
+
readonly NOT_FOUND: "NOT_FOUND";
|
|
7
|
+
readonly CONFLICT: "CONFLICT";
|
|
8
|
+
readonly VALIDATION_ERROR: "VALIDATION_ERROR";
|
|
9
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
10
|
+
readonly TIMEOUT: "TIMEOUT";
|
|
11
|
+
readonly SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
|
|
12
|
+
readonly INTERNAL_ERROR: "INTERNAL_ERROR";
|
|
13
|
+
readonly UNKNOWN_ERROR: "UNKNOWN_ERROR";
|
|
14
|
+
};
|
|
15
|
+
export type BaseErrorCode = typeof BaseErrorCode[keyof typeof BaseErrorCode];
|
|
16
|
+
export declare class McpError extends Error {
|
|
17
|
+
code: BaseErrorCode;
|
|
18
|
+
details?: Record<string, unknown> | undefined;
|
|
19
|
+
constructor(code: BaseErrorCode, message: string, details?: Record<string, unknown> | undefined);
|
|
20
|
+
toResponse(): McpToolResponse;
|
|
21
|
+
}
|
|
22
|
+
export declare const ErrorSchema: z.ZodObject<{
|
|
23
|
+
code: z.ZodString;
|
|
24
|
+
message: z.ZodString;
|
|
25
|
+
details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
message: string;
|
|
28
|
+
code: string;
|
|
29
|
+
details?: Record<string, unknown> | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
message: string;
|
|
32
|
+
code: string;
|
|
33
|
+
details?: Record<string, unknown> | undefined;
|
|
34
|
+
}>;
|
|
35
|
+
export type ErrorResponse = z.infer<typeof ErrorSchema>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// Base error codes that all tools can use
|
|
3
|
+
export const BaseErrorCode = {
|
|
4
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
5
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
6
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
7
|
+
CONFLICT: 'CONFLICT',
|
|
8
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
9
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
10
|
+
TIMEOUT: 'TIMEOUT',
|
|
11
|
+
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
|
12
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
13
|
+
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
|
|
14
|
+
};
|
|
15
|
+
// Base MCP error class
|
|
16
|
+
export class McpError extends Error {
|
|
17
|
+
constructor(code, message, details) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.details = details;
|
|
21
|
+
this.name = 'McpError';
|
|
22
|
+
}
|
|
23
|
+
toResponse() {
|
|
24
|
+
const content = {
|
|
25
|
+
type: "text",
|
|
26
|
+
text: `Error [${this.code}]: ${this.message}${this.details ? `\nDetails: ${JSON.stringify(this.details, null, 2)}` : ''}`
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
content: [content],
|
|
30
|
+
isError: true
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Error schema for validation
|
|
35
|
+
export const ErrorSchema = z.object({
|
|
36
|
+
code: z.string(),
|
|
37
|
+
message: z.string(),
|
|
38
|
+
details: z.record(z.unknown()).optional()
|
|
39
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface McpContent {
|
|
2
|
+
type: "text";
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface McpToolResponse {
|
|
6
|
+
content: McpContent[];
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ResourceContent {
|
|
10
|
+
uri: string;
|
|
11
|
+
text: string;
|
|
12
|
+
mimeType?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ResourceResponse {
|
|
15
|
+
contents: ResourceContent[];
|
|
16
|
+
}
|
|
17
|
+
export interface PromptMessageContent {
|
|
18
|
+
type: "text";
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PromptMessage {
|
|
22
|
+
role: "user" | "assistant";
|
|
23
|
+
content: PromptMessageContent;
|
|
24
|
+
}
|
|
25
|
+
export interface PromptResponse {
|
|
26
|
+
messages: PromptMessage[];
|
|
27
|
+
}
|
|
28
|
+
export declare const createToolResponse: (text: string, isError?: boolean) => McpToolResponse;
|
|
29
|
+
export declare const createResourceResponse: (uri: string, text: string, mimeType?: string) => ResourceResponse;
|
|
30
|
+
export declare const createPromptResponse: (text: string, role?: "user" | "assistant") => PromptResponse;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Type definitions for the MCP (Message Control Protocol) protocol
|
|
2
|
+
// Helper functions
|
|
3
|
+
export const createToolResponse = (text, isError) => ({
|
|
4
|
+
content: [{
|
|
5
|
+
type: "text",
|
|
6
|
+
text
|
|
7
|
+
}],
|
|
8
|
+
isError
|
|
9
|
+
});
|
|
10
|
+
export const createResourceResponse = (uri, text, mimeType) => ({
|
|
11
|
+
contents: [{
|
|
12
|
+
uri,
|
|
13
|
+
text,
|
|
14
|
+
mimeType
|
|
15
|
+
}]
|
|
16
|
+
});
|
|
17
|
+
export const createPromptResponse = (text, role = "assistant") => ({
|
|
18
|
+
messages: [{
|
|
19
|
+
role,
|
|
20
|
+
content: {
|
|
21
|
+
type: "text",
|
|
22
|
+
text
|
|
23
|
+
}
|
|
24
|
+
}]
|
|
25
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { RateLimitConfig } from "../utils/rateLimiter.js";
|
|
3
|
+
import { OperationContext } from "../utils/security.js";
|
|
4
|
+
/**
|
|
5
|
+
* Metadata for a tool example
|
|
6
|
+
*/
|
|
7
|
+
export interface ToolExample {
|
|
8
|
+
/** Example input parameters */
|
|
9
|
+
input: Record<string, unknown>;
|
|
10
|
+
/** Expected output string */
|
|
11
|
+
output: string;
|
|
12
|
+
/** Description of the example */
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for a tool
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolMetadata {
|
|
19
|
+
/** Examples showing how to use the tool */
|
|
20
|
+
examples: ToolExample[];
|
|
21
|
+
/** Optional permission required for this tool */
|
|
22
|
+
requiredPermission?: string;
|
|
23
|
+
/** Optional schema for the return value */
|
|
24
|
+
returnSchema?: z.ZodType<unknown>;
|
|
25
|
+
/** Rate limit configuration for the tool */
|
|
26
|
+
rateLimit?: RateLimitConfig;
|
|
27
|
+
/** Whether this tool can be used without authentication */
|
|
28
|
+
allowUnauthenticated?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a tool example
|
|
32
|
+
*
|
|
33
|
+
* @param input Example input parameters
|
|
34
|
+
* @param output Expected output (as a formatted string)
|
|
35
|
+
* @param description Description of what the example demonstrates
|
|
36
|
+
* @returns A tool example object
|
|
37
|
+
*/
|
|
38
|
+
export declare function createToolExample(input: Record<string, unknown>, output: string, description: string): ToolExample;
|
|
39
|
+
/**
|
|
40
|
+
* Create tool metadata
|
|
41
|
+
*
|
|
42
|
+
* @param metadata Tool metadata options
|
|
43
|
+
* @returns Tool metadata configuration
|
|
44
|
+
*/
|
|
45
|
+
export declare function createToolMetadata(metadata: ToolMetadata): ToolMetadata;
|
|
46
|
+
/**
|
|
47
|
+
* Register a tool with the MCP server
|
|
48
|
+
*
|
|
49
|
+
* This is a compatibility wrapper for the McpServer.tool() method.
|
|
50
|
+
* In the current implementation, the tool registration is handled by the McpServer class,
|
|
51
|
+
* so this function primarily exists to provide a consistent API.
|
|
52
|
+
*
|
|
53
|
+
* @param server MCP server instance
|
|
54
|
+
* @param name Tool name
|
|
55
|
+
* @param description Tool description
|
|
56
|
+
* @param inputSchema Schema for validating input
|
|
57
|
+
* @param handler Handler function for the tool
|
|
58
|
+
* @param metadata Optional tool metadata
|
|
59
|
+
*/
|
|
60
|
+
export declare function registerTool(server: any, // Using any to avoid type conflicts
|
|
61
|
+
name: string, description: string, inputSchema: Record<string, z.ZodType<any>>, handler: (input: unknown, context: OperationContext) => Promise<unknown>, metadata?: ToolMetadata): Promise<void>;
|