ntfy-mcp-server 1.0.3 → 1.0.4
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/README.md +3 -3
- package/dist/config/index.js +1 -1
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +1 -1
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +42 -20
- package/dist/mcp-server/resources/ntfyResource/index.js +15 -10
- package/dist/mcp-server/resources/ntfyResource/types.d.ts +36 -4
- package/dist/mcp-server/server.js +65 -20
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +19 -1
- package/dist/types-global/tool.d.ts +0 -17
- package/dist/types-global/tool.js +1 -70
- package/dist/utils/logger.d.ts +8 -6
- package/dist/utils/logger.js +60 -11
- package/package.json +11 -12
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Ntfy MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://www.typescriptlang.org/)
|
|
4
|
+
[](https://modelcontextprotocol.io/)
|
|
5
|
+
[](https://github.com/cyanheads/ntfy-mcp-server/releases)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/ntfy-mcp-server)
|
|
8
8
|
[](https://github.com/cyanheads/ntfy-mcp-server)
|
package/dist/config/index.js
CHANGED
|
@@ -101,7 +101,7 @@ export const config = {
|
|
|
101
101
|
configLogger.info('Configuration loaded', {
|
|
102
102
|
environment: config.environment,
|
|
103
103
|
logLevel: config.logLevel,
|
|
104
|
-
server: config.server,
|
|
104
|
+
server: { host: config.server.host }, // Log only host, not port
|
|
105
105
|
ntfy: {
|
|
106
106
|
baseUrl: config.ntfy.baseUrl,
|
|
107
107
|
defaultTopic: config.ntfy.defaultTopic || '(not set)',
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { NtfyResourceResponse } from './types.js';
|
|
2
|
-
export declare const getNtfyTopic: (uri: URL) => Promise<NtfyResourceResponse>;
|
|
2
|
+
export declare const getNtfyTopic: (topic: string, uri: URL) => Promise<NtfyResourceResponse>;
|
|
@@ -8,15 +8,16 @@ const resourceLogger = logger.createChildLogger({
|
|
|
8
8
|
module: 'NtfyResource',
|
|
9
9
|
service: 'NtfyResource'
|
|
10
10
|
});
|
|
11
|
-
|
|
11
|
+
// Updated signature to accept topic explicitly
|
|
12
|
+
export const getNtfyTopic = async (topic, uri) => {
|
|
12
13
|
// Create a request context with unique ID
|
|
13
14
|
const requestContext = createRequestContext({
|
|
14
15
|
operation: 'getNtfyTopic',
|
|
15
16
|
uri: uri.toString()
|
|
16
17
|
});
|
|
17
18
|
const requestId = requestContext.requestId;
|
|
18
|
-
//
|
|
19
|
-
const topic = uri.hostname || "";
|
|
19
|
+
// Topic is now passed as an argument, no need to extract from hostname
|
|
20
|
+
// const topic = uri.hostname || ""; // Removed
|
|
20
21
|
resourceLogger.info("Ntfy resource request received", {
|
|
21
22
|
requestId,
|
|
22
23
|
uri: uri.href,
|
|
@@ -25,25 +26,35 @@ export const getNtfyTopic = async (uri) => {
|
|
|
25
26
|
return ErrorHandler.tryCatch(async () => {
|
|
26
27
|
// Get the default topic from configuration
|
|
27
28
|
const ntfyConfig = config.ntfy;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const defaultTopic = ntfyConfig.defaultTopic;
|
|
30
|
+
// Handle case where 'default' is requested but not configured
|
|
31
|
+
if (topic === "default" && !defaultTopic) {
|
|
32
|
+
resourceLogger.error("Requested default ntfy topic, but none is configured.", {
|
|
31
33
|
requestId,
|
|
32
34
|
uri: uri.href
|
|
33
35
|
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, // Corrected error code
|
|
37
|
+
"Default ntfy topic requested via ntfy://default, but no default topic is configured in the environment variables.", { requestId, uri: uri.toString() });
|
|
36
38
|
}
|
|
39
|
+
// Determine the actual topic to fetch messages for
|
|
40
|
+
const topicToFetch = topic === "default" ? defaultTopic : topic;
|
|
37
41
|
// Get recent messages asynchronously for this topic
|
|
38
|
-
let recentMessages = [];
|
|
42
|
+
let recentMessages = []; // Define type for recentMessages
|
|
39
43
|
try {
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// Ensure topicToFetch is valid before fetching
|
|
45
|
+
if (!topicToFetch) {
|
|
46
|
+
// This case should theoretically be caught by the check above, but adding for safety
|
|
47
|
+
throw new Error("Cannot fetch messages for an empty topic.");
|
|
48
|
+
}
|
|
49
|
+
// Attempt to fetch the 10 most recent messages - removed poll=1
|
|
50
|
+
const fetchUrl = `${config.ntfy.baseUrl || 'https://ntfy.sh'}/${topicToFetch}/json?since=30d`;
|
|
51
|
+
resourceLogger.debug("Fetching recent messages", { requestId, url: fetchUrl });
|
|
52
|
+
const response = await fetch(fetchUrl, {
|
|
44
53
|
method: 'GET',
|
|
45
54
|
headers: {
|
|
46
|
-
'Accept': 'application/json'
|
|
55
|
+
'Accept': 'application/json',
|
|
56
|
+
// Add API key header if configured
|
|
57
|
+
...(config.ntfy.apiKey && { 'Authorization': `Bearer ${config.ntfy.apiKey}` })
|
|
47
58
|
}
|
|
48
59
|
});
|
|
49
60
|
if (response.ok) {
|
|
@@ -51,8 +62,18 @@ export const getNtfyTopic = async (uri) => {
|
|
|
51
62
|
const text = await response.text();
|
|
52
63
|
const lines = text.split('\n').filter(line => line.trim());
|
|
53
64
|
// Parse each line as a JSON object and add to recent messages
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
// Ensure messages have an 'id' and 'time' for potential sorting/filtering if needed later
|
|
66
|
+
recentMessages = lines.map(line => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(line);
|
|
69
|
+
}
|
|
70
|
+
catch (parseError) {
|
|
71
|
+
resourceLogger.warn("Failed to parse message line from ntfy stream", { requestId, line, error: parseError instanceof Error ? parseError.message : String(parseError) });
|
|
72
|
+
return null; // Skip invalid lines
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.filter(msg => msg && msg.event === 'message' && msg.id && msg.time) // Ensure it's a valid message event
|
|
76
|
+
.sort((a, b) => b.time - a.time) // Sort by time descending (most recent first)
|
|
56
77
|
.slice(0, 10); // Keep only the 10 most recent
|
|
57
78
|
resourceLogger.info(`Retrieved ${recentMessages.length} recent messages`, {
|
|
58
79
|
topic: topicToFetch,
|
|
@@ -68,17 +89,18 @@ export const getNtfyTopic = async (uri) => {
|
|
|
68
89
|
requestId
|
|
69
90
|
});
|
|
70
91
|
}
|
|
71
|
-
//
|
|
92
|
+
// Prepare response data based on whether 'default' was the requested topic
|
|
72
93
|
const responseData = topic === "default" ?
|
|
73
94
|
{
|
|
74
|
-
|
|
95
|
+
requestedTopic: "default", // Clarify what was requested
|
|
96
|
+
resolvedTopic: defaultTopic, // Show the resolved topic
|
|
75
97
|
timestamp: new Date().toISOString(),
|
|
76
98
|
requestUri: uri.href,
|
|
77
99
|
requestId,
|
|
78
|
-
recentMessages: recentMessages.length > 0 ? recentMessages : undefined
|
|
100
|
+
recentMessages: recentMessages.length > 0 ? recentMessages : undefined // Keep undefined if empty
|
|
79
101
|
} :
|
|
80
102
|
{
|
|
81
|
-
topic,
|
|
103
|
+
topic: topicToFetch, // Use the actual topic fetched
|
|
82
104
|
timestamp: new Date().toISOString(),
|
|
83
105
|
requestUri: uri.href,
|
|
84
106
|
requestId,
|
|
@@ -50,22 +50,27 @@ export const registerNtfyResource = async (server) => {
|
|
|
50
50
|
},
|
|
51
51
|
// Resource handler
|
|
52
52
|
async (uri, params) => {
|
|
53
|
-
// Extract the topic from the URI
|
|
54
|
-
const
|
|
55
|
-
resourceLogger.info(`Processing ntfy resource request for topic: ${
|
|
56
|
-
|
|
53
|
+
// Extract the topic from the URI parameters provided by the SDK
|
|
54
|
+
const topicParam = params.topic;
|
|
55
|
+
resourceLogger.info(`Processing ntfy resource request for topic parameter: ${topicParam}`, {
|
|
56
|
+
topicParam,
|
|
57
57
|
href: uri.href
|
|
58
58
|
});
|
|
59
|
-
// Check if the topic is valid
|
|
60
|
-
if (!
|
|
61
|
-
resourceLogger.error(`
|
|
59
|
+
// Check if the topic parameter is valid and is a string
|
|
60
|
+
if (typeof topicParam !== 'string' || !topicParam) {
|
|
61
|
+
resourceLogger.error(`Invalid or missing topic parameter in ntfy resource uri: ${uri.href}`, {
|
|
62
62
|
href: uri.href,
|
|
63
|
-
|
|
63
|
+
params: params,
|
|
64
|
+
topicType: typeof topicParam
|
|
64
65
|
});
|
|
65
|
-
|
|
66
|
+
// Use VALIDATION_ERROR as it's an issue with the input derived from the URI
|
|
67
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid resource URI: Topic parameter must be a non-empty string in ${uri.href}`, { uri: uri.href, params: params });
|
|
66
68
|
}
|
|
69
|
+
// Now we know topicParam is a string
|
|
70
|
+
const topic = topicParam;
|
|
67
71
|
// Process the request using our dedicated handler
|
|
68
|
-
|
|
72
|
+
// Pass the validated topic string and the original URI to the handler
|
|
73
|
+
return await getNtfyTopic(topic, uri);
|
|
69
74
|
});
|
|
70
75
|
resourceLogger.info("Ntfy resource handler registered");
|
|
71
76
|
});
|
|
@@ -18,10 +18,42 @@ export interface NtfyResourceResponse {
|
|
|
18
18
|
];
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
21
|
+
* Represents a single message retrieved from the ntfy topic history.
|
|
22
|
+
* Based on the structure observed from ntfy.sh/topic/json.
|
|
22
23
|
*/
|
|
23
|
-
export interface
|
|
24
|
-
|
|
24
|
+
export interface NtfyMessage {
|
|
25
|
+
id: string;
|
|
26
|
+
time: number;
|
|
27
|
+
event: 'message';
|
|
28
|
+
topic: string;
|
|
29
|
+
message: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
priority?: number;
|
|
33
|
+
click?: string;
|
|
34
|
+
actions?: any[];
|
|
35
|
+
attachment?: {
|
|
36
|
+
name: string;
|
|
37
|
+
type?: string;
|
|
38
|
+
size?: number;
|
|
39
|
+
expires?: number;
|
|
40
|
+
url: string;
|
|
41
|
+
};
|
|
42
|
+
[key: string]: any;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Data structure for the ntfy resource response content.
|
|
46
|
+
* This reflects the actual JSON structure returned within the 'text' field
|
|
47
|
+
* of the McpContent object.
|
|
48
|
+
*/
|
|
49
|
+
export type NtfyResourceData = {
|
|
25
50
|
timestamp: string;
|
|
26
51
|
requestUri: string;
|
|
27
|
-
|
|
52
|
+
requestId: string;
|
|
53
|
+
recentMessages?: NtfyMessage[];
|
|
54
|
+
} & ({
|
|
55
|
+
requestedTopic: "default";
|
|
56
|
+
resolvedTopic: string;
|
|
57
|
+
} | {
|
|
58
|
+
topic: string;
|
|
59
|
+
});
|
|
@@ -30,8 +30,7 @@ const loadPackageInfo = async (loggerInstance) => {
|
|
|
30
30
|
const pkgLogger = loggerInstance || logger.createChildLogger({ module: 'PackageInfo' });
|
|
31
31
|
return await ErrorHandler.tryCatch(async () => {
|
|
32
32
|
// Use the globally defined __dirname from the top of the file
|
|
33
|
-
|
|
34
|
-
const pkgPath = path.resolve(__dirname, '../../../package.json');
|
|
33
|
+
const pkgPath = path.resolve(__dirname, '../../package.json');
|
|
35
34
|
const safePath = sanitizeInput.path(pkgPath);
|
|
36
35
|
pkgLogger.debug(`Looking for package.json at: ${safePath}`);
|
|
37
36
|
// Get file stats to check size before reading
|
|
@@ -89,8 +88,8 @@ class ServerEvents extends EventEmitter {
|
|
|
89
88
|
export const createMcpServer = async () => {
|
|
90
89
|
// Initialize server variable outside try/catch
|
|
91
90
|
let server;
|
|
92
|
-
// Maximum registration retry attempts
|
|
93
|
-
const MAX_REGISTRATION_RETRIES =
|
|
91
|
+
// Maximum registration retry attempts (currently not implemented, but placeholder)
|
|
92
|
+
const MAX_REGISTRATION_RETRIES = 1;
|
|
94
93
|
// Create a unique server instance ID
|
|
95
94
|
const serverId = idGenerator.generateRandomString(8);
|
|
96
95
|
// Initialize server state for tracking
|
|
@@ -162,11 +161,11 @@ export const createMcpServer = async () => {
|
|
|
162
161
|
serverState.registeredResources.add(name);
|
|
163
162
|
}
|
|
164
163
|
serverLogger.debug(`Successfully registered ${type}: ${name}`);
|
|
165
|
-
return { success: true, type, name };
|
|
164
|
+
return { success: true, type, name }; // No error on success
|
|
166
165
|
}
|
|
167
166
|
catch (error) {
|
|
168
167
|
serverLogger.error(`Failed to register ${type}: ${name}`, { error });
|
|
169
|
-
return { success: false, type, name, error };
|
|
168
|
+
return { success: false, type, name, error }; // Error included on failure
|
|
170
169
|
}
|
|
171
170
|
};
|
|
172
171
|
// Register components with proper error handling
|
|
@@ -177,35 +176,77 @@ export const createMcpServer = async () => {
|
|
|
177
176
|
];
|
|
178
177
|
const registrationResults = await Promise.allSettled(registrationPromises);
|
|
179
178
|
// Process the results to find failed registrations
|
|
180
|
-
|
|
179
|
+
let hasRequiredFailure = false;
|
|
181
180
|
registrationResults.forEach(result => {
|
|
182
181
|
if (result.status === 'rejected') {
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
// This indicates an unexpected error during the registerComponent wrapper itself
|
|
183
|
+
const failure = {
|
|
185
184
|
type: 'unknown',
|
|
186
185
|
name: 'unknown',
|
|
187
|
-
error: result.reason
|
|
188
|
-
|
|
186
|
+
error: result.reason ?? new Error('Unknown registration wrapper error'), // Ensure error exists
|
|
187
|
+
attempts: 1 // Assuming 1 attempt for now
|
|
188
|
+
};
|
|
189
|
+
serverState.failedRegistrations.push(failure);
|
|
190
|
+
serverLogger.error("Unexpected error during component registration wrapper", { failure });
|
|
191
|
+
// Assume any unknown failure could be critical
|
|
192
|
+
hasRequiredFailure = true;
|
|
189
193
|
}
|
|
190
194
|
else if (!result.value.success) {
|
|
191
|
-
|
|
195
|
+
// This indicates a failure within the specific registerFn (result.value.error should exist)
|
|
196
|
+
const failure = {
|
|
197
|
+
type: result.value.type,
|
|
198
|
+
name: result.value.name,
|
|
199
|
+
// Provide a fallback error just in case, though logic implies error exists
|
|
200
|
+
error: result.value.error ?? new Error(`Unknown error registering ${result.value.type} ${result.value.name}`),
|
|
201
|
+
attempts: 1 // Assuming 1 attempt for now
|
|
202
|
+
};
|
|
203
|
+
serverState.failedRegistrations.push(failure);
|
|
204
|
+
serverLogger.warn(`Registration failed for ${failure.type}: ${failure.name}`, { error: failure.error });
|
|
205
|
+
// Check if the failed component was required
|
|
206
|
+
if ((failure.type === 'tool' && serverState.requiredTools.has(failure.name)) ||
|
|
207
|
+
(failure.type === 'resource' && serverState.requiredResources.has(failure.name))) {
|
|
208
|
+
serverLogger.error(`Required ${failure.type} '${failure.name}' failed to register. Server will be degraded.`, { error: failure.error });
|
|
209
|
+
hasRequiredFailure = true;
|
|
210
|
+
}
|
|
192
211
|
}
|
|
193
212
|
});
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
// Update server status based on registration results
|
|
214
|
+
const previousStatus = serverState.status;
|
|
215
|
+
if (hasRequiredFailure) {
|
|
216
|
+
serverState.status = 'degraded';
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
serverState.status = 'running'; // Move to running only if all required components registered
|
|
199
220
|
}
|
|
221
|
+
// Emit state change if status updated
|
|
222
|
+
if (serverState.status !== previousStatus) {
|
|
223
|
+
serverEvents.emitStateChange(previousStatus, serverState.status);
|
|
224
|
+
}
|
|
225
|
+
serverLogger.info(`Component registration complete. Status: ${serverState.status}`, {
|
|
226
|
+
registeredTools: Array.from(serverState.registeredTools),
|
|
227
|
+
registeredResources: Array.from(serverState.registeredResources),
|
|
228
|
+
failedCount: serverState.failedRegistrations.length,
|
|
229
|
+
failedComponents: serverState.failedRegistrations.map(f => `${f.type}:${f.name}`)
|
|
230
|
+
});
|
|
200
231
|
// Add debug logs to diagnose the connection issue
|
|
201
232
|
serverLogger.debug("About to connect to stdio transport");
|
|
202
233
|
try {
|
|
203
234
|
// Connect using stdio transport
|
|
204
235
|
const transport = new StdioServerTransport();
|
|
205
236
|
serverLogger.debug("Created StdioServerTransport instance");
|
|
206
|
-
// Set event handlers
|
|
237
|
+
// Set event handlers
|
|
238
|
+
// Using 'as any' for onerror as the type might not be directly exposed or stable in the SDK.
|
|
239
|
+
// This bypasses TypeScript checks but allows attaching the handler.
|
|
240
|
+
// TODO: Revisit if future SDK versions provide a type-safe way to attach error handlers.
|
|
207
241
|
server.onerror = (err) => {
|
|
208
|
-
serverLogger.error(`Server error: ${err.message}`, { stack: err.stack });
|
|
242
|
+
serverLogger.error(`Server transport error: ${err.message}`, { stack: err.stack });
|
|
243
|
+
// Optionally update server state on transport errors
|
|
244
|
+
if (serverState.status !== 'error' && serverState.status !== 'shutting_down') {
|
|
245
|
+
const oldStatus = serverState.status;
|
|
246
|
+
serverState.status = 'error';
|
|
247
|
+
serverEvents.emitStateChange(oldStatus, 'error');
|
|
248
|
+
}
|
|
249
|
+
serverState.errors.push({ timestamp: new Date(), message: err.message, code: 'TRANSPORT_ERROR' });
|
|
209
250
|
};
|
|
210
251
|
// Skip setting onrequest since we don't have access to the type
|
|
211
252
|
await server.connect(transport);
|
|
@@ -216,7 +257,11 @@ export const createMcpServer = async () => {
|
|
|
216
257
|
error: error instanceof Error ? error.message : String(error),
|
|
217
258
|
stack: error instanceof Error ? error.stack : undefined
|
|
218
259
|
});
|
|
219
|
-
|
|
260
|
+
// Update state on connection failure
|
|
261
|
+
const oldStatus = serverState.status;
|
|
262
|
+
serverState.status = 'error';
|
|
263
|
+
serverEvents.emitStateChange(oldStatus, 'error');
|
|
264
|
+
throw error; // Re-throw connection error
|
|
220
265
|
}
|
|
221
266
|
serverLogger.info("MCP server initialized and connected");
|
|
222
267
|
return server;
|
|
@@ -21,8 +21,9 @@ const globalRateLimiter = new RateLimiter({
|
|
|
21
21
|
});
|
|
22
22
|
// Map to cache per-topic rate limiters
|
|
23
23
|
const topicRateLimiters = new Map();
|
|
24
|
+
const MAX_CACHED_TOPIC_LIMITERS = 1000; // Limit the cache size
|
|
24
25
|
/**
|
|
25
|
-
* Gets or creates a rate limiter for a specific topic
|
|
26
|
+
* Gets or creates a rate limiter for a specific topic, with cache cleanup.
|
|
26
27
|
*
|
|
27
28
|
* @param topic - The ntfy topic
|
|
28
29
|
* @returns Rate limiter instance for the topic
|
|
@@ -30,6 +31,18 @@ const topicRateLimiters = new Map();
|
|
|
30
31
|
function getTopicRateLimiter(topic) {
|
|
31
32
|
const normalizedTopic = topic.toLowerCase().trim();
|
|
32
33
|
if (!topicRateLimiters.has(normalizedTopic)) {
|
|
34
|
+
// Check cache size before adding a new limiter
|
|
35
|
+
if (topicRateLimiters.size >= MAX_CACHED_TOPIC_LIMITERS) {
|
|
36
|
+
// Remove the oldest entry (first key in insertion order)
|
|
37
|
+
const oldestTopic = topicRateLimiters.keys().next().value;
|
|
38
|
+
if (oldestTopic) {
|
|
39
|
+
topicRateLimiters.delete(oldestTopic);
|
|
40
|
+
ntfyToolLogger.debug(`Removed oldest topic rate limiter due to cache size limit: ${oldestTopic}`, {
|
|
41
|
+
cacheSize: topicRateLimiters.size,
|
|
42
|
+
limit: MAX_CACHED_TOPIC_LIMITERS
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
33
46
|
// Make per-topic limit more restrictive than global
|
|
34
47
|
const perTopicLimit = Math.min(50, Math.floor(rateLimit.maxRequests / 2));
|
|
35
48
|
topicRateLimiters.set(normalizedTopic, new RateLimiter({
|
|
@@ -37,6 +50,9 @@ function getTopicRateLimiter(topic) {
|
|
|
37
50
|
maxRequests: perTopicLimit,
|
|
38
51
|
errorMessage: `Rate limit exceeded for topic '${normalizedTopic}'. Please try again in {waitTime} seconds.`,
|
|
39
52
|
}));
|
|
53
|
+
ntfyToolLogger.debug(`Created new rate limiter for topic: ${normalizedTopic}`, {
|
|
54
|
+
cacheSize: topicRateLimiters.size
|
|
55
|
+
});
|
|
40
56
|
}
|
|
41
57
|
return topicRateLimiters.get(normalizedTopic);
|
|
42
58
|
}
|
|
@@ -137,6 +153,8 @@ export const processNtfyMessage = async (params) => {
|
|
|
137
153
|
action: sanitizeInput.string(action.action),
|
|
138
154
|
url: action.url ? sanitizeInput.url(action.url) : undefined,
|
|
139
155
|
method: action.method ? sanitizeInput.string(action.method) : undefined,
|
|
156
|
+
// TODO: Review if action.headers need sanitization/validation based on ntfy processing.
|
|
157
|
+
// Currently passed through as-is.
|
|
140
158
|
headers: action.headers,
|
|
141
159
|
body: action.body ? sanitizeInput.string(action.body) : undefined,
|
|
142
160
|
clear: action.clear
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { RateLimitConfig } from "../utils/rateLimiter.js";
|
|
3
|
-
import { OperationContext } from "../utils/security.js";
|
|
4
3
|
/**
|
|
5
4
|
* Metadata for a tool example
|
|
6
5
|
*/
|
|
@@ -43,19 +42,3 @@ export declare function createToolExample(input: Record<string, unknown>, output
|
|
|
43
42
|
* @returns Tool metadata configuration
|
|
44
43
|
*/
|
|
45
44
|
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>;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ErrorHandler } from "../utils/errorHandler.js";
|
|
2
1
|
import { logger } from "../utils/logger.js";
|
|
3
2
|
// Create a module-level logger
|
|
4
3
|
const toolLogger = logger.createChildLogger({
|
|
@@ -28,72 +27,4 @@ export function createToolExample(input, output, description) {
|
|
|
28
27
|
export function createToolMetadata(metadata) {
|
|
29
28
|
return metadata;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
* Register a tool with the MCP server
|
|
33
|
-
*
|
|
34
|
-
* This is a compatibility wrapper for the McpServer.tool() method.
|
|
35
|
-
* In the current implementation, the tool registration is handled by the McpServer class,
|
|
36
|
-
* so this function primarily exists to provide a consistent API.
|
|
37
|
-
*
|
|
38
|
-
* @param server MCP server instance
|
|
39
|
-
* @param name Tool name
|
|
40
|
-
* @param description Tool description
|
|
41
|
-
* @param inputSchema Schema for validating input
|
|
42
|
-
* @param handler Handler function for the tool
|
|
43
|
-
* @param metadata Optional tool metadata
|
|
44
|
-
*/
|
|
45
|
-
export function registerTool(server, // Using any to avoid type conflicts
|
|
46
|
-
name, description, inputSchema, handler, metadata) {
|
|
47
|
-
return ErrorHandler.tryCatch(async () => {
|
|
48
|
-
// Log the registration attempt
|
|
49
|
-
toolLogger.info(`Registering tool: ${name}`, {
|
|
50
|
-
toolName: name,
|
|
51
|
-
schemaKeys: Object.keys(inputSchema),
|
|
52
|
-
hasMetadata: Boolean(metadata),
|
|
53
|
-
hasExamples: Boolean(metadata?.examples?.length)
|
|
54
|
-
});
|
|
55
|
-
// Some basic validation
|
|
56
|
-
if (!name) {
|
|
57
|
-
throw new Error('Tool name is required');
|
|
58
|
-
}
|
|
59
|
-
if (!inputSchema) {
|
|
60
|
-
throw new Error('Input schema is required');
|
|
61
|
-
}
|
|
62
|
-
if (!handler || typeof handler !== 'function') {
|
|
63
|
-
throw new Error('Handler must be a function');
|
|
64
|
-
}
|
|
65
|
-
// Convert schema to a more standardized format if needed
|
|
66
|
-
const schemaDescription = Object.entries(inputSchema).map(([key, schema]) => {
|
|
67
|
-
const description = schema.description;
|
|
68
|
-
const isRequired = !schema.isOptional?.();
|
|
69
|
-
return `${key}${isRequired ? ' (required)' : ''}: ${description || 'No description'}`;
|
|
70
|
-
}).join('\n');
|
|
71
|
-
toolLogger.debug(`Tool ${name} schema:`, {
|
|
72
|
-
toolName: name,
|
|
73
|
-
schema: schemaDescription
|
|
74
|
-
});
|
|
75
|
-
// Actually register the tool with the server
|
|
76
|
-
// Check if it's an McpServer instance with tool() method
|
|
77
|
-
if (server.tool && typeof server.tool === 'function') {
|
|
78
|
-
// Use the McpServer.tool() method directly
|
|
79
|
-
toolLogger.debug('Using McpServer.tool() method');
|
|
80
|
-
server.tool(name, inputSchema, handler, {
|
|
81
|
-
description,
|
|
82
|
-
examples: metadata?.examples
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
// For other server types or for testing, log a warning
|
|
87
|
-
toolLogger.warn(`Unable to register tool ${name} with server - missing tool() method`, {
|
|
88
|
-
toolName: name
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
// Log successful registration
|
|
92
|
-
toolLogger.info(`Tool ${name} registered successfully`);
|
|
93
|
-
}, {
|
|
94
|
-
context: { toolName: name },
|
|
95
|
-
operation: "registering tool",
|
|
96
|
-
errorMapper: (error) => new Error(`Failed to register tool ${name}: ${error instanceof Error ? error.message : String(error)}`),
|
|
97
|
-
rethrow: true
|
|
98
|
-
});
|
|
99
|
-
}
|
|
30
|
+
// Removed unused registerTool function (registration handled elsewhere)
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ declare class Logger {
|
|
|
13
13
|
info(message: string, context?: Record<string, unknown>): void;
|
|
14
14
|
warn(message: string, context?: Record<string, unknown>): void;
|
|
15
15
|
error(message: string, context?: Record<string, unknown>): void;
|
|
16
|
+
/**
|
|
17
|
+
* Creates a child logger that automatically includes the provided metadata
|
|
18
|
+
* in the context object of every log message.
|
|
19
|
+
*
|
|
20
|
+
* @param metadata - Static metadata to include with every log from this child.
|
|
21
|
+
* @returns A ChildLogger instance.
|
|
22
|
+
*/
|
|
16
23
|
createChildLogger(metadata: {
|
|
17
24
|
module: string;
|
|
18
25
|
service?: string;
|
|
@@ -25,12 +32,7 @@ declare class Logger {
|
|
|
25
32
|
environment?: string;
|
|
26
33
|
serverId?: string;
|
|
27
34
|
[key: string]: any;
|
|
28
|
-
}):
|
|
29
|
-
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
30
|
-
info: (message: string, context?: Record<string, unknown>) => void;
|
|
31
|
-
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
32
|
-
error: (message: string, context?: Record<string, unknown>) => void;
|
|
33
|
-
};
|
|
35
|
+
}): ChildLogger;
|
|
34
36
|
}
|
|
35
37
|
export declare const logger: Logger;
|
|
36
38
|
export {};
|
package/dist/utils/logger.js
CHANGED
|
@@ -16,19 +16,30 @@ class Logger {
|
|
|
16
16
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
17
17
|
}
|
|
18
18
|
// Common format for all transports
|
|
19
|
-
const commonFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }),
|
|
20
|
-
|
|
19
|
+
const commonFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }),
|
|
20
|
+
// The 'context' object passed to logger methods (e.g., logger.info(message, context))
|
|
21
|
+
// will be available here in the 'info' object passed to the printf function.
|
|
22
|
+
// Winston automatically merges the metadata passed during logger creation
|
|
23
|
+
// with the metadata passed at the call site if you use logger.child().
|
|
24
|
+
// However, since we are implementing a custom child logger wrapper,
|
|
25
|
+
// we need to handle the merging manually.
|
|
26
|
+
winston.format.printf(({ timestamp, level, message, context, stack }) => {
|
|
27
|
+
// Ensure context is an object before stringifying
|
|
28
|
+
const contextStr = (context && typeof context === 'object' && Object.keys(context).length > 0)
|
|
29
|
+
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
|
30
|
+
: "";
|
|
21
31
|
const stackStr = stack ? `\n Stack: ${stack}` : "";
|
|
22
32
|
return `[${timestamp}] ${level}: ${message}${contextStr}${stackStr}`;
|
|
23
33
|
}));
|
|
24
34
|
this.logger = winston.createLogger({
|
|
25
35
|
level: logLevel,
|
|
36
|
+
// Use json format for structured logging internally, printf for files
|
|
26
37
|
format: winston.format.json(),
|
|
27
38
|
transports: [
|
|
28
39
|
// Combined log file for all levels
|
|
29
40
|
new winston.transports.File({
|
|
30
41
|
filename: path.join(logsDir, 'combined.log'),
|
|
31
|
-
format: commonFormat
|
|
42
|
+
format: commonFormat // Apply the custom format here
|
|
32
43
|
}),
|
|
33
44
|
// Separate log files for each level
|
|
34
45
|
new winston.transports.File({
|
|
@@ -53,6 +64,20 @@ class Logger {
|
|
|
53
64
|
})
|
|
54
65
|
]
|
|
55
66
|
});
|
|
67
|
+
// Add console transport only if not in production
|
|
68
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
69
|
+
this.logger.add(new winston.transports.Console({
|
|
70
|
+
format: winston.format.combine(winston.format.colorize(), winston.format.simple(), // Use simple format for console readability
|
|
71
|
+
winston.format.printf(({ level, message, timestamp, context, stack }) => {
|
|
72
|
+
const contextStr = (context && typeof context === 'object' && Object.keys(context).length > 0)
|
|
73
|
+
? ` ${JSON.stringify(context)}`
|
|
74
|
+
: "";
|
|
75
|
+
const stackStr = stack ? `\n${stack}` : "";
|
|
76
|
+
// Simple console format
|
|
77
|
+
return `${level}: ${message}${contextStr}${stackStr}`;
|
|
78
|
+
}))
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
56
81
|
}
|
|
57
82
|
static getInstance() {
|
|
58
83
|
if (!Logger.instance) {
|
|
@@ -60,31 +85,55 @@ class Logger {
|
|
|
60
85
|
}
|
|
61
86
|
return Logger.instance;
|
|
62
87
|
}
|
|
88
|
+
// Base log methods now accept the context directly
|
|
63
89
|
debug(message, context) {
|
|
64
|
-
this.logger.debug(message,
|
|
90
|
+
this.logger.debug(message, context);
|
|
65
91
|
}
|
|
66
92
|
info(message, context) {
|
|
67
|
-
this.logger.info(message,
|
|
93
|
+
this.logger.info(message, context);
|
|
68
94
|
}
|
|
69
95
|
warn(message, context) {
|
|
70
|
-
this.logger.warn(message,
|
|
96
|
+
this.logger.warn(message, context);
|
|
71
97
|
}
|
|
72
98
|
error(message, context) {
|
|
73
|
-
|
|
99
|
+
// Ensure error context includes stack if available
|
|
100
|
+
const errorContext = context || {};
|
|
101
|
+
if (errorContext.error instanceof Error && !errorContext.stack) {
|
|
102
|
+
errorContext.stack = errorContext.error.stack;
|
|
103
|
+
}
|
|
104
|
+
this.logger.error(message, errorContext);
|
|
74
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Creates a child logger that automatically includes the provided metadata
|
|
108
|
+
* in the context object of every log message.
|
|
109
|
+
*
|
|
110
|
+
* @param metadata - Static metadata to include with every log from this child.
|
|
111
|
+
* @returns A ChildLogger instance.
|
|
112
|
+
*/
|
|
75
113
|
createChildLogger(metadata) {
|
|
114
|
+
// Filter out undefined values from metadata once
|
|
115
|
+
const staticMetadata = Object.fromEntries(Object.entries(metadata).filter(([_, v]) => v !== undefined));
|
|
76
116
|
return {
|
|
77
117
|
debug: (message, context) => {
|
|
78
|
-
|
|
118
|
+
// Merge static metadata with call-site context
|
|
119
|
+
const mergedContext = { ...staticMetadata, ...context };
|
|
120
|
+
this.debug(message, mergedContext);
|
|
79
121
|
},
|
|
80
122
|
info: (message, context) => {
|
|
81
|
-
|
|
123
|
+
const mergedContext = { ...staticMetadata, ...context };
|
|
124
|
+
this.info(message, mergedContext);
|
|
82
125
|
},
|
|
83
126
|
warn: (message, context) => {
|
|
84
|
-
|
|
127
|
+
const mergedContext = { ...staticMetadata, ...context };
|
|
128
|
+
this.warn(message, mergedContext);
|
|
85
129
|
},
|
|
86
130
|
error: (message, context) => {
|
|
87
|
-
|
|
131
|
+
const mergedContext = { ...staticMetadata, ...context };
|
|
132
|
+
// Ensure error context includes stack if available
|
|
133
|
+
if (mergedContext.error instanceof Error && !mergedContext.stack) {
|
|
134
|
+
mergedContext.stack = mergedContext.error.stack;
|
|
135
|
+
}
|
|
136
|
+
this.error(message, mergedContext);
|
|
88
137
|
}
|
|
89
138
|
};
|
|
90
139
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ntfy-mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "An MCP (Model Context Protocol) server designed to interact with the ntfy push notification service. It enables LLMs and AI agents to send notifications to your devices with extensive customization options.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
"watch": "tail -f logs/combined.log"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
20
|
-
"@types/node": "^22.
|
|
21
|
-
"@types/sanitize-html": "^2.
|
|
22
|
-
"@types/validator": "^13.
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.10.2",
|
|
20
|
+
"@types/node": "^22.14.1",
|
|
21
|
+
"@types/sanitize-html": "^2.15.0",
|
|
22
|
+
"@types/validator": "^13.15.0",
|
|
23
23
|
"@types/xss-filters": "^1.2.0",
|
|
24
|
-
"dotenv": "^16.
|
|
24
|
+
"dotenv": "^16.5.0",
|
|
25
25
|
"path-normalize": "^6.0.13",
|
|
26
|
-
"sanitize-html": "^2.
|
|
26
|
+
"sanitize-html": "^2.16.0",
|
|
27
27
|
"ts-node": "^10.9.2",
|
|
28
|
-
"typescript": "^5.8.
|
|
29
|
-
"undici-types": "^7.
|
|
28
|
+
"typescript": "^5.8.3",
|
|
29
|
+
"undici-types": "^7.8.0",
|
|
30
30
|
"validator": "^13.15.0",
|
|
31
31
|
"winston": "^3.17.0",
|
|
32
32
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -36,15 +36,14 @@
|
|
|
36
36
|
"ntfy",
|
|
37
37
|
"notifications",
|
|
38
38
|
"push-notifications",
|
|
39
|
-
"pub-sub",
|
|
40
39
|
"MCP",
|
|
41
40
|
"model-context-protocol",
|
|
42
|
-
"mcp-server",
|
|
43
41
|
"LLM",
|
|
44
42
|
"AI-integration",
|
|
45
43
|
"server",
|
|
46
44
|
"typescript",
|
|
47
|
-
"claude"
|
|
45
|
+
"claude",
|
|
46
|
+
"messaging"
|
|
48
47
|
],
|
|
49
48
|
"author": "Casey Hand @cyanheads",
|
|
50
49
|
"license": "Apache-2.0"
|