@jgardner04/ghost-mcp-server 1.0.0 → 1.1.1

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.
@@ -1,12 +1,12 @@
1
- import sanitizeHtml from "sanitize-html";
2
- import Joi from "joi";
3
- import { createContextLogger } from "../utils/logger.js";
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import Joi from 'joi';
3
+ import { createContextLogger } from '../utils/logger.js';
4
4
  import {
5
5
  createPost as createGhostPost,
6
6
  getTags as getGhostTags,
7
7
  createTag as createGhostTag,
8
8
  // Import other necessary functions from ghostService later
9
- } from "./ghostService.js"; // Note the relative path
9
+ } from './ghostService.js'; // Note the relative path
10
10
 
11
11
  /**
12
12
  * Helper to generate a simple meta description from HTML content.
@@ -16,21 +16,21 @@ import {
16
16
  * @returns {string} A plain text truncated description.
17
17
  */
18
18
  const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
19
- if (!htmlContent) return "";
20
-
19
+ if (!htmlContent) return '';
20
+
21
21
  // Use sanitize-html to safely remove all HTML tags
22
22
  // This prevents ReDoS attacks and properly handles malformed HTML
23
23
  const textContent = sanitizeHtml(htmlContent, {
24
24
  allowedTags: [], // Remove all HTML tags
25
25
  allowedAttributes: {},
26
- textFilter: function(text) {
26
+ textFilter: function (text) {
27
27
  return text.replace(/\s\s+/g, ' ').trim();
28
- }
28
+ },
29
29
  });
30
-
30
+
31
31
  // Truncate and add ellipsis if needed
32
32
  return textContent.length > maxLength
33
- ? textContent.substring(0, maxLength - 3) + "..."
33
+ ? textContent.substring(0, maxLength - 3) + '...'
34
34
  : textContent;
35
35
  };
36
36
 
@@ -52,22 +52,22 @@ const postInputSchema = Joi.object({
52
52
  feature_image_alt: Joi.string().max(255).optional(),
53
53
  feature_image_caption: Joi.string().max(500).optional(),
54
54
  meta_title: Joi.string().max(70).optional(),
55
- meta_description: Joi.string().max(160).optional()
55
+ meta_description: Joi.string().max(160).optional(),
56
56
  });
57
57
 
58
58
  const createPostService = async (postInput) => {
59
59
  const logger = createContextLogger('post-service');
60
-
60
+
61
61
  // Validate input to prevent format string vulnerabilities
62
62
  const { error, value: validatedInput } = postInputSchema.validate(postInput);
63
63
  if (error) {
64
64
  logger.error('Post input validation failed', {
65
65
  error: error.details[0].message,
66
- inputKeys: Object.keys(postInput)
66
+ inputKeys: Object.keys(postInput),
67
67
  });
68
68
  throw new Error(`Invalid post input: ${error.details[0].message}`);
69
69
  }
70
-
70
+
71
71
  const {
72
72
  title,
73
73
  html,
@@ -88,7 +88,7 @@ const createPostService = async (postInput) => {
88
88
  logger.info('Resolving provided tag names', { tagCount: tags.length, tags });
89
89
  resolvedTags = await Promise.all(
90
90
  tags.map(async (tagName) => {
91
- if (typeof tagName !== "string" || !tagName.trim()) {
91
+ if (typeof tagName !== 'string' || !tagName.trim()) {
92
92
  logger.warn('Skipping invalid tag name', { tagName, type: typeof tagName });
93
93
  return null; // Skip invalid entries
94
94
  }
@@ -100,7 +100,7 @@ const createPostService = async (postInput) => {
100
100
  if (existingTags && existingTags.length > 0) {
101
101
  logger.debug('Found existing tag', {
102
102
  tagName,
103
- tagId: existingTags[0].id
103
+ tagId: existingTags[0].id,
104
104
  });
105
105
  // Use the existing tag (Ghost usually accepts name, slug, or id)
106
106
  return { name: tagName }; // Or { id: existingTags[0].id } or { slug: existingTags[0].slug }
@@ -110,7 +110,7 @@ const createPostService = async (postInput) => {
110
110
  const newTag = await createGhostTag({ name: tagName });
111
111
  logger.info('Created new tag successfully', {
112
112
  tagName,
113
- tagId: newTag.id
113
+ tagId: newTag.id,
114
114
  });
115
115
  // Use the new tag
116
116
  return { name: tagName }; // Or { id: newTag.id }
@@ -118,7 +118,7 @@ const createPostService = async (postInput) => {
118
118
  } catch (tagError) {
119
119
  logger.error('Error processing tag', {
120
120
  tagName,
121
- error: tagError.message
121
+ error: tagError.message,
122
122
  });
123
123
  return null; // Skip tags that cause errors during processing
124
124
  }
@@ -128,7 +128,7 @@ const createPostService = async (postInput) => {
128
128
  resolvedTags = resolvedTags.filter((tag) => tag !== null);
129
129
  logger.debug('Resolved tags for API', {
130
130
  resolvedTagCount: resolvedTags.length,
131
- resolvedTags
131
+ resolvedTags,
132
132
  });
133
133
  }
134
134
  // --- End Tag Resolution ---
@@ -140,7 +140,7 @@ const createPostService = async (postInput) => {
140
140
  // Ensure description does not exceed limit even after defaulting
141
141
  const truncatedMetaDescription =
142
142
  finalMetaDescription.length > 500
143
- ? finalMetaDescription.substring(0, 497) + "..."
143
+ ? finalMetaDescription.substring(0, 497) + '...'
144
144
  : finalMetaDescription;
145
145
  // --- End Metadata Defaults ---
146
146
 
@@ -149,7 +149,7 @@ const createPostService = async (postInput) => {
149
149
  title,
150
150
  html,
151
151
  custom_excerpt,
152
- status: status || "draft", // Default to draft
152
+ status: status || 'draft', // Default to draft
153
153
  published_at,
154
154
  tags: resolvedTags,
155
155
  feature_image,
@@ -164,7 +164,7 @@ const createPostService = async (postInput) => {
164
164
  title: postDataForApi.title,
165
165
  status: postDataForApi.status,
166
166
  tagCount: postDataForApi.tags?.length || 0,
167
- hasFeatureImage: !!postDataForApi.feature_image
167
+ hasFeatureImage: !!postDataForApi.feature_image,
168
168
  });
169
169
  // Call the lower-level ghostService function
170
170
  const newPost = await createGhostPost(postDataForApi);
@@ -12,7 +12,7 @@ const isDevelopment = process.env.NODE_ENV === 'development';
12
12
  // Define custom log format
13
13
  const logFormat = winston.format.combine(
14
14
  winston.format.timestamp({
15
- format: 'YYYY-MM-DD HH:mm:ss'
15
+ format: 'YYYY-MM-DD HH:mm:ss',
16
16
  }),
17
17
  winston.format.errors({ stack: true }),
18
18
  winston.format.json(),
@@ -22,7 +22,7 @@ const logFormat = winston.format.combine(
22
22
  // Development format (more readable)
23
23
  const devFormat = winston.format.combine(
24
24
  winston.format.timestamp({
25
- format: 'HH:mm:ss'
25
+ format: 'HH:mm:ss',
26
26
  }),
27
27
  winston.format.colorize(),
28
28
  winston.format.printf(({ level, message, timestamp, metadata }) => {
@@ -38,52 +38,52 @@ const devFormat = winston.format.combine(
38
38
  const logger = winston.createLogger({
39
39
  level: logLevel,
40
40
  format: isDevelopment ? devFormat : logFormat,
41
- defaultMeta: {
41
+ defaultMeta: {
42
42
  service: 'ghost-mcp-server',
43
- pid: process.pid
43
+ pid: process.pid,
44
44
  },
45
45
  transports: [
46
46
  // Console output
47
47
  new winston.transports.Console({
48
- level: isDevelopment ? 'debug' : 'info'
49
- })
48
+ level: isDevelopment ? 'debug' : 'info',
49
+ }),
50
50
  ],
51
51
  // Handle uncaught exceptions
52
- exceptionHandlers: [
53
- new winston.transports.Console()
54
- ],
52
+ exceptionHandlers: [new winston.transports.Console()],
55
53
  // Handle unhandled promise rejections
56
- rejectionHandlers: [
57
- new winston.transports.Console()
58
- ]
54
+ rejectionHandlers: [new winston.transports.Console()],
59
55
  });
60
56
 
61
57
  // Add file logging in production
62
58
  if (!isDevelopment) {
63
59
  const logDir = path.join(__dirname, '../../logs');
64
-
60
+
65
61
  // Create logs directory if it doesn't exist
66
- import('fs').then(fs => {
62
+ import('fs').then((fs) => {
67
63
  if (!fs.existsSync(logDir)) {
68
64
  fs.mkdirSync(logDir, { recursive: true });
69
65
  }
70
66
  });
71
-
67
+
72
68
  // Add file transports for production
73
- logger.add(new winston.transports.File({
74
- filename: path.join(logDir, 'error.log'),
75
- level: 'error',
76
- maxsize: 10 * 1024 * 1024, // 10MB
77
- maxFiles: 5,
78
- tailable: true
79
- }));
80
-
81
- logger.add(new winston.transports.File({
82
- filename: path.join(logDir, 'combined.log'),
83
- maxsize: 10 * 1024 * 1024, // 10MB
84
- maxFiles: 10,
85
- tailable: true
86
- }));
69
+ logger.add(
70
+ new winston.transports.File({
71
+ filename: path.join(logDir, 'error.log'),
72
+ level: 'error',
73
+ maxsize: 10 * 1024 * 1024, // 10MB
74
+ maxFiles: 5,
75
+ tailable: true,
76
+ })
77
+ );
78
+
79
+ logger.add(
80
+ new winston.transports.File({
81
+ filename: path.join(logDir, 'combined.log'),
82
+ maxsize: 10 * 1024 * 1024, // 10MB
83
+ maxFiles: 10,
84
+ tailable: true,
85
+ })
86
+ );
87
87
  }
88
88
 
89
89
  // Create helper functions for common logging patterns
@@ -93,41 +93,41 @@ const createContextLogger = (context) => {
93
93
  info: (message, meta = {}) => logger.info(message, { ...meta, context }),
94
94
  warn: (message, meta = {}) => logger.warn(message, { ...meta, context }),
95
95
  error: (message, meta = {}) => logger.error(message, { ...meta, context }),
96
-
96
+
97
97
  // Convenience methods for common patterns
98
- apiRequest: (method, url, meta = {}) =>
98
+ apiRequest: (method, url, meta = {}) =>
99
99
  logger.info(`${method} ${url}`, { ...meta, context, type: 'api_request' }),
100
-
100
+
101
101
  apiResponse: (method, url, status, meta = {}) =>
102
102
  logger.info(`${method} ${url} -> ${status}`, { ...meta, context, type: 'api_response' }),
103
-
103
+
104
104
  apiError: (method, url, error, meta = {}) =>
105
- logger.error(`${method} ${url} failed`, {
106
- ...meta,
107
- context,
105
+ logger.error(`${method} ${url} failed`, {
106
+ ...meta,
107
+ context,
108
108
  type: 'api_error',
109
109
  error: error.message,
110
- stack: error.stack
110
+ stack: error.stack,
111
111
  }),
112
-
112
+
113
113
  toolExecution: (toolName, input, meta = {}) =>
114
- logger.info(`Executing tool: ${toolName}`, {
115
- ...meta,
116
- context,
114
+ logger.info(`Executing tool: ${toolName}`, {
115
+ ...meta,
116
+ context,
117
117
  type: 'tool_execution',
118
118
  tool: toolName,
119
- inputKeys: Object.keys(input || {})
119
+ inputKeys: Object.keys(input || {}),
120
120
  }),
121
-
121
+
122
122
  toolSuccess: (toolName, result, meta = {}) =>
123
123
  logger.info(`Tool ${toolName} completed successfully`, {
124
124
  ...meta,
125
125
  context,
126
126
  type: 'tool_success',
127
127
  tool: toolName,
128
- resultType: typeof result
128
+ resultType: typeof result,
129
129
  }),
130
-
130
+
131
131
  toolError: (toolName, error, meta = {}) =>
132
132
  logger.error(`Tool ${toolName} failed`, {
133
133
  ...meta,
@@ -135,19 +135,19 @@ const createContextLogger = (context) => {
135
135
  type: 'tool_error',
136
136
  tool: toolName,
137
137
  error: error.message,
138
- stack: error.stack
138
+ stack: error.stack,
139
139
  }),
140
-
140
+
141
141
  fileOperation: (operation, filePath, meta = {}) =>
142
142
  logger.debug(`File operation: ${operation}`, {
143
143
  ...meta,
144
144
  context,
145
145
  type: 'file_operation',
146
146
  operation,
147
- file: path.basename(filePath)
148
- })
147
+ file: path.basename(filePath),
148
+ }),
149
149
  };
150
150
  };
151
151
 
152
152
  export default logger;
153
- export { createContextLogger };
153
+ export { createContextLogger };
@@ -26,7 +26,7 @@ const ALLOWED_DOMAINS = [
26
26
  'pexels.com',
27
27
  'images.pexels.com',
28
28
  'pixabay.com',
29
- 'cdn.pixabay.com'
29
+ 'cdn.pixabay.com',
30
30
  ];
31
31
 
32
32
  // Private/internal IP ranges to block
@@ -44,7 +44,7 @@ const BLOCKED_IP_PATTERNS = [
44
44
  /^::/, // IPv6 unspecified
45
45
  /^fc00:/, // IPv6 unique local
46
46
  /^fe80:/, // IPv6 link local
47
- /^ff00:/ // IPv6 multicast
47
+ /^ff00:/, // IPv6 multicast
48
48
  ];
49
49
 
50
50
  /**
@@ -56,16 +56,16 @@ const isSafeHost = (hostname) => {
56
56
  // Check if hostname is an IP address
57
57
  const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
58
58
  const ipv6Pattern = /^[0-9a-f:]+$/i;
59
-
59
+
60
60
  if (ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname)) {
61
61
  // Check against blocked IP patterns
62
- return !BLOCKED_IP_PATTERNS.some(pattern => pattern.test(hostname));
62
+ return !BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(hostname));
63
63
  }
64
-
64
+
65
65
  // For domain names, check against allowlist
66
66
  const normalizedHost = hostname.toLowerCase();
67
- return ALLOWED_DOMAINS.some(allowed =>
68
- normalizedHost === allowed || normalizedHost.endsWith('.' + allowed)
67
+ return ALLOWED_DOMAINS.some(
68
+ (allowed) => normalizedHost === allowed || normalizedHost.endsWith('.' + allowed)
69
69
  );
70
70
  };
71
71
 
@@ -77,68 +77,71 @@ const isSafeHost = (hostname) => {
77
77
  const validateImageUrl = (url) => {
78
78
  try {
79
79
  // Basic URL validation with Joi
80
- const urlSchema = Joi.string().uri({
81
- scheme: ['http', 'https'],
82
- allowRelative: false
83
- }).required();
84
-
80
+ const urlSchema = Joi.string()
81
+ .uri({
82
+ scheme: ['http', 'https'],
83
+ allowRelative: false,
84
+ })
85
+ .required();
86
+
85
87
  const validation = urlSchema.validate(url);
86
88
  if (validation.error) {
87
89
  return {
88
90
  isValid: false,
89
- error: `Invalid URL format: ${validation.error.details[0].message}`
91
+ error: `Invalid URL format: ${validation.error.details[0].message}`,
90
92
  };
91
93
  }
92
-
94
+
93
95
  // Parse URL for additional security checks
94
96
  const parsedUrl = new URL(url);
95
-
97
+
96
98
  // Only allow HTTP/HTTPS
97
99
  if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
98
100
  return {
99
101
  isValid: false,
100
- error: 'Only HTTP and HTTPS protocols are allowed'
102
+ error: 'Only HTTP and HTTPS protocols are allowed',
101
103
  };
102
104
  }
103
-
105
+
104
106
  // Check if host is safe (not internal/private)
105
107
  if (!isSafeHost(parsedUrl.hostname)) {
106
108
  return {
107
109
  isValid: false,
108
- error: `Requests to ${parsedUrl.hostname} are not allowed for security reasons`
110
+ error: `Requests to ${parsedUrl.hostname} are not allowed for security reasons`,
109
111
  };
110
112
  }
111
-
113
+
112
114
  // Prevent requests to non-standard ports (common in SSRF attacks)
113
115
  const port = parsedUrl.port;
114
116
  if (port && !['80', '443', '8080', '8443'].includes(port)) {
115
117
  return {
116
118
  isValid: false,
117
- error: `Requests to non-standard port ${port} are not allowed`
119
+ error: `Requests to non-standard port ${port} are not allowed`,
118
120
  };
119
121
  }
120
-
122
+
121
123
  // Additional checks for suspicious patterns
122
- if (parsedUrl.hostname.includes('localhost') ||
123
- parsedUrl.hostname === '0.0.0.0' ||
124
- parsedUrl.hostname.startsWith('192.168.') ||
125
- parsedUrl.hostname.startsWith('10.') ||
126
- parsedUrl.hostname.includes('.local')) {
124
+ if (
125
+ parsedUrl.hostname.includes('localhost') ||
126
+ parsedUrl.hostname === '0.0.0.0' ||
127
+ parsedUrl.hostname.startsWith('192.168.') ||
128
+ parsedUrl.hostname.startsWith('10.') ||
129
+ parsedUrl.hostname.includes('.local')
130
+ ) {
127
131
  return {
128
132
  isValid: false,
129
- error: 'Requests to local/private addresses are not allowed'
133
+ error: 'Requests to local/private addresses are not allowed',
130
134
  };
131
135
  }
132
-
136
+
133
137
  return {
134
138
  isValid: true,
135
- sanitizedUrl: parsedUrl.href
139
+ sanitizedUrl: parsedUrl.href,
136
140
  };
137
-
138
141
  } catch (error) {
139
142
  return {
140
143
  isValid: false,
141
- error: `URL parsing failed: ${error.message}`
144
+ error: `URL parsing failed: ${error.message}`,
142
145
  };
143
146
  }
144
147
  };
@@ -157,13 +160,9 @@ const createSecureAxiosConfig = (url) => {
157
160
  maxContentLength: 50 * 1024 * 1024, // 50MB max response
158
161
  validateStatus: (status) => status >= 200 && status < 300, // Only accept 2xx
159
162
  headers: {
160
- 'User-Agent': 'Ghost-MCP-Server/1.0'
161
- }
163
+ 'User-Agent': 'Ghost-MCP-Server/1.0',
164
+ },
162
165
  };
163
166
  };
164
167
 
165
- export {
166
- validateImageUrl,
167
- createSecureAxiosConfig,
168
- ALLOWED_DOMAINS
169
- };
168
+ export { validateImageUrl, createSecureAxiosConfig, ALLOWED_DOMAINS };