ntfy-mcp-server 1.0.2 → 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 +64 -18
- 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 +9 -9
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
|
+
});
|
|
@@ -88,8 +88,8 @@ class ServerEvents extends EventEmitter {
|
|
|
88
88
|
export const createMcpServer = async () => {
|
|
89
89
|
// Initialize server variable outside try/catch
|
|
90
90
|
let server;
|
|
91
|
-
// Maximum registration retry attempts
|
|
92
|
-
const MAX_REGISTRATION_RETRIES =
|
|
91
|
+
// Maximum registration retry attempts (currently not implemented, but placeholder)
|
|
92
|
+
const MAX_REGISTRATION_RETRIES = 1;
|
|
93
93
|
// Create a unique server instance ID
|
|
94
94
|
const serverId = idGenerator.generateRandomString(8);
|
|
95
95
|
// Initialize server state for tracking
|
|
@@ -161,11 +161,11 @@ export const createMcpServer = async () => {
|
|
|
161
161
|
serverState.registeredResources.add(name);
|
|
162
162
|
}
|
|
163
163
|
serverLogger.debug(`Successfully registered ${type}: ${name}`);
|
|
164
|
-
return { success: true, type, name };
|
|
164
|
+
return { success: true, type, name }; // No error on success
|
|
165
165
|
}
|
|
166
166
|
catch (error) {
|
|
167
167
|
serverLogger.error(`Failed to register ${type}: ${name}`, { error });
|
|
168
|
-
return { success: false, type, name, error };
|
|
168
|
+
return { success: false, type, name, error }; // Error included on failure
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
171
|
// Register components with proper error handling
|
|
@@ -176,35 +176,77 @@ export const createMcpServer = async () => {
|
|
|
176
176
|
];
|
|
177
177
|
const registrationResults = await Promise.allSettled(registrationPromises);
|
|
178
178
|
// Process the results to find failed registrations
|
|
179
|
-
|
|
179
|
+
let hasRequiredFailure = false;
|
|
180
180
|
registrationResults.forEach(result => {
|
|
181
181
|
if (result.status === 'rejected') {
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
// This indicates an unexpected error during the registerComponent wrapper itself
|
|
183
|
+
const failure = {
|
|
184
184
|
type: 'unknown',
|
|
185
185
|
name: 'unknown',
|
|
186
|
-
error: result.reason
|
|
187
|
-
|
|
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;
|
|
188
193
|
}
|
|
189
194
|
else if (!result.value.success) {
|
|
190
|
-
|
|
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
|
+
}
|
|
191
211
|
}
|
|
192
212
|
});
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
198
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
|
+
});
|
|
199
231
|
// Add debug logs to diagnose the connection issue
|
|
200
232
|
serverLogger.debug("About to connect to stdio transport");
|
|
201
233
|
try {
|
|
202
234
|
// Connect using stdio transport
|
|
203
235
|
const transport = new StdioServerTransport();
|
|
204
236
|
serverLogger.debug("Created StdioServerTransport instance");
|
|
205
|
-
// 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.
|
|
206
241
|
server.onerror = (err) => {
|
|
207
|
-
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' });
|
|
208
250
|
};
|
|
209
251
|
// Skip setting onrequest since we don't have access to the type
|
|
210
252
|
await server.connect(transport);
|
|
@@ -215,7 +257,11 @@ export const createMcpServer = async () => {
|
|
|
215
257
|
error: error instanceof Error ? error.message : String(error),
|
|
216
258
|
stack: error instanceof Error ? error.stack : undefined
|
|
217
259
|
});
|
|
218
|
-
|
|
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
|
|
219
265
|
}
|
|
220
266
|
serverLogger.info("MCP server initialized and connected");
|
|
221
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",
|