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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Ntfy MCP Server
2
2
 
3
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.8.2-blue.svg)](https://www.typescriptlang.org/)
4
- [![Model Context Protocol](https://img.shields.io/badge/MCP-1.8.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-1.0.3-blue.svg)](https://github.com/cyanheads/ntfy-mcp-server/releases)
3
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
+ [![Model Context Protocol](https://img.shields.io/badge/MCP-1.10.2-green.svg)](https://modelcontextprotocol.io/)
5
+ [![Version](https://img.shields.io/badge/Version-1.0.4-blue.svg)](https://github.com/cyanheads/ntfy-mcp-server/releases)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/ntfy-mcp-server)
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/ntfy-mcp-server?style=social)](https://github.com/cyanheads/ntfy-mcp-server)
@@ -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
- export const getNtfyTopic = async (uri) => {
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
- // Extract the topic from the URI pathname
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
- let defaultTopic = ntfyConfig.defaultTopic;
29
- if (!defaultTopic) {
30
- resourceLogger.warn("Default ntfy topic is not configured, using fallback value", {
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
- // Provide a fallback value instead of failing
35
- defaultTopic = "ATLAS";
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
- // Use a different topic for actual fetching based on whether this is default or not
41
- const topicToFetch = topic === "default" ? defaultTopic : topic;
42
- // Attempt to fetch the 10 most recent messages
43
- const response = await fetch(`${config.ntfy.baseUrl || 'https://ntfy.sh'}/${topicToFetch}/json?poll=1&since=30d`, {
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
- recentMessages = lines.map(line => JSON.parse(line))
55
- .filter(msg => msg.event === 'message')
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
- // Handle the "default" topic case specially
92
+ // Prepare response data based on whether 'default' was the requested topic
72
93
  const responseData = topic === "default" ?
73
94
  {
74
- defaultTopic,
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 topic = params.topic;
55
- resourceLogger.info(`Processing ntfy resource request for topic: ${topic}`, {
56
- topic,
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 (!topic) {
61
- resourceLogger.error(`Missing topic in ntfy resource uri: ${uri.href}`, {
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
- protocol: uri.protocol
63
+ params: params,
64
+ topicType: typeof topicParam
64
65
  });
65
- throw new McpError(BaseErrorCode.NOT_FOUND, `Resource not found: ${uri.href}`, { uri: uri.href });
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
- return await getNtfyTopic(uri);
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
- * Data structure for the ntfy response
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 NtfyData {
24
- defaultTopic: string;
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
- // Go up three levels from the compiled file location (e.g., dist/mcp-server/server.js)
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 = 3;
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
- const failedRegistrations = [];
179
+ let hasRequiredFailure = false;
181
180
  registrationResults.forEach(result => {
182
181
  if (result.status === 'rejected') {
183
- failedRegistrations.push({
184
- success: false,
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
- failedRegistrations.push(result.value);
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
- // Process failed registrations
195
- if (failedRegistrations.length > 0) {
196
- serverLogger.warn(`${failedRegistrations.length} registrations failed initially`, {
197
- failedComponents: failedRegistrations.map(f => `${f.type}:${f.name}`)
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 - using type assertion to avoid TS errors
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
- throw error;
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)
@@ -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 {};
@@ -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 }), winston.format.printf(({ timestamp, level, message, context, stack }) => {
20
- const contextStr = context ? `\n Context: ${JSON.stringify(context, null, 2)}` : "";
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, { context });
90
+ this.logger.debug(message, context);
65
91
  }
66
92
  info(message, context) {
67
- this.logger.info(message, { context });
93
+ this.logger.info(message, context);
68
94
  }
69
95
  warn(message, context) {
70
- this.logger.warn(message, { context });
96
+ this.logger.warn(message, context);
71
97
  }
72
98
  error(message, context) {
73
- this.logger.error(message, { context });
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
- this.debug(`[${metadata.module}] ${message}`, context);
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
- this.info(`[${metadata.module}] ${message}`, context);
123
+ const mergedContext = { ...staticMetadata, ...context };
124
+ this.info(message, mergedContext);
82
125
  },
83
126
  warn: (message, context) => {
84
- this.warn(`[${metadata.module}] ${message}`, context);
127
+ const mergedContext = { ...staticMetadata, ...context };
128
+ this.warn(message, mergedContext);
85
129
  },
86
130
  error: (message, context) => {
87
- this.error(`[${metadata.module}] ${message}`, context);
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",
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.8.0",
20
- "@types/node": "^22.13.14",
21
- "@types/sanitize-html": "^2.13.0",
22
- "@types/validator": "^13.12.3",
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.4.7",
24
+ "dotenv": "^16.5.0",
25
25
  "path-normalize": "^6.0.13",
26
- "sanitize-html": "^2.15.0",
26
+ "sanitize-html": "^2.16.0",
27
27
  "ts-node": "^10.9.2",
28
- "typescript": "^5.8.2",
29
- "undici-types": "^7.5.0",
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"