@jgardner04/ghost-mcp-server 1.1.0 → 1.1.2

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
@@ -25,7 +25,6 @@ _(Refer to `src/mcp_server.js` for full resource schemas.)_
25
25
  Below is a guide for using the available MCP tools:
26
26
 
27
27
  1. **`ghost_create_tag`**
28
-
29
28
  - **Purpose**: Creates a new tag.
30
29
  - **Inputs**:
31
30
  - `name` (string, required): The name for the new tag.
@@ -34,14 +33,12 @@ Below is a guide for using the available MCP tools:
34
33
  - **Output**: The created `ghost/tag` resource.
35
34
 
36
35
  2. **`ghost_get_tags`**
37
-
38
36
  - **Purpose**: Retrieves existing tags. Can be used to find a tag ID or check if a tag exists before creation.
39
37
  - **Inputs**:
40
38
  - `name` (string, optional): Filter tags by exact name.
41
39
  - **Output**: An array of `ghost/tag` resources matching the filter (or all tags if no name is provided).
42
40
 
43
41
  3. **`ghost_upload_image`**
44
-
45
42
  - **Purpose**: Uploads an image to Ghost for use, typically as a post's featured image.
46
43
  - **Inputs**:
47
44
  - `imageUrl` (string URL, required): A publicly accessible URL of the image to upload.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -42,7 +42,12 @@
42
42
  "start:mcp:websocket": "MCP_TRANSPORT=websocket node src/mcp_server_improved.js",
43
43
  "test": "vitest run",
44
44
  "test:watch": "vitest watch",
45
- "test:coverage": "vitest run --coverage"
45
+ "test:coverage": "vitest run --coverage",
46
+ "lint": "eslint .",
47
+ "lint:fix": "eslint . --fix",
48
+ "format": "prettier --write \"**/*.{js,json,md}\"",
49
+ "format:check": "prettier --check \"**/*.{js,json,md}\"",
50
+ "prepare": "husky"
46
51
  },
47
52
  "dependencies": {
48
53
  "@anthropic-ai/sdk": "^0.39.0",
@@ -85,8 +90,21 @@
85
90
  "access": "public"
86
91
  },
87
92
  "license": "MIT",
93
+ "lint-staged": {
94
+ "*.js": [
95
+ "eslint --fix",
96
+ "prettier --write"
97
+ ]
98
+ },
88
99
  "devDependencies": {
100
+ "@eslint/js": "^9.39.1",
89
101
  "@vitest/coverage-v8": "^4.0.15",
102
+ "eslint": "^9.39.1",
103
+ "eslint-config-prettier": "^10.1.8",
104
+ "eslint-plugin-prettier": "^5.5.4",
105
+ "husky": "^9.1.7",
106
+ "lint-staged": "^16.2.7",
107
+ "prettier": "^3.7.4",
90
108
  "semantic-release": "^25.0.2",
91
109
  "vitest": "^4.0.15"
92
110
  }
@@ -4,7 +4,7 @@ dotenv.config();
4
4
 
5
5
  /**
6
6
  * MCP Server Configuration
7
- *
7
+ *
8
8
  * Transport Options:
9
9
  * - 'stdio': Best for CLI tools and direct process communication
10
10
  * - 'http'/'sse': Good for web clients, supports CORS
@@ -15,42 +15,44 @@ export const mcpConfig = {
15
15
  transport: {
16
16
  type: process.env.MCP_TRANSPORT || 'http', // 'stdio', 'http', 'sse', 'websocket'
17
17
  port: parseInt(process.env.MCP_PORT || '3001'),
18
-
18
+
19
19
  // HTTP/SSE specific options
20
20
  cors: process.env.MCP_CORS || '*',
21
21
  sseEndpoint: process.env.MCP_SSE_ENDPOINT || '/mcp/sse',
22
-
22
+
23
23
  // WebSocket specific options
24
24
  wsPath: process.env.MCP_WS_PATH || '/',
25
25
  wsHeartbeatInterval: parseInt(process.env.MCP_WS_HEARTBEAT || '30000'),
26
26
  },
27
-
27
+
28
28
  // Server metadata
29
29
  metadata: {
30
30
  name: process.env.MCP_SERVER_NAME || 'Ghost CMS Manager',
31
- description: process.env.MCP_SERVER_DESC || 'MCP Server to manage a Ghost CMS instance using the Admin API.',
31
+ description:
32
+ process.env.MCP_SERVER_DESC ||
33
+ 'MCP Server to manage a Ghost CMS instance using the Admin API.',
32
34
  version: process.env.MCP_SERVER_VERSION || '1.0.0',
33
35
  },
34
-
36
+
35
37
  // Error handling
36
38
  errorHandling: {
37
39
  includeStackTrace: process.env.NODE_ENV === 'development',
38
40
  maxRetries: parseInt(process.env.MCP_MAX_RETRIES || '3'),
39
41
  retryDelay: parseInt(process.env.MCP_RETRY_DELAY || '1000'),
40
42
  },
41
-
43
+
42
44
  // Logging
43
45
  logging: {
44
46
  level: process.env.MCP_LOG_LEVEL || 'info', // 'debug', 'info', 'warn', 'error'
45
47
  format: process.env.MCP_LOG_FORMAT || 'json', // 'json', 'text'
46
48
  },
47
-
49
+
48
50
  // Security
49
51
  security: {
50
52
  // Add API key authentication if needed
51
53
  apiKey: process.env.MCP_API_KEY,
52
54
  allowedOrigins: process.env.MCP_ALLOWED_ORIGINS?.split(',') || ['*'],
53
- }
55
+ },
54
56
  };
55
57
 
56
58
  /**
@@ -58,14 +60,14 @@ export const mcpConfig = {
58
60
  */
59
61
  export function getTransportConfig() {
60
62
  const { transport } = mcpConfig;
61
-
63
+
62
64
  switch (transport.type) {
63
65
  case 'stdio':
64
66
  return {
65
67
  type: 'stdio',
66
68
  // No additional config needed for stdio
67
69
  };
68
-
70
+
69
71
  case 'http':
70
72
  case 'sse':
71
73
  return {
@@ -74,7 +76,7 @@ export function getTransportConfig() {
74
76
  cors: transport.cors,
75
77
  endpoint: transport.sseEndpoint,
76
78
  };
77
-
79
+
78
80
  case 'websocket':
79
81
  return {
80
82
  type: 'websocket',
@@ -82,7 +84,7 @@ export function getTransportConfig() {
82
84
  path: transport.wsPath,
83
85
  heartbeatInterval: transport.wsHeartbeatInterval,
84
86
  };
85
-
87
+
86
88
  default:
87
89
  throw new Error(`Unknown transport type: ${transport.type}`);
88
90
  }
@@ -93,7 +95,7 @@ export function getTransportConfig() {
93
95
  */
94
96
  export function validateConfig() {
95
97
  const errors = [];
96
-
98
+
97
99
  // Check if transport configuration exists
98
100
  if (!mcpConfig.transport) {
99
101
  errors.push('Missing transport configuration');
@@ -103,29 +105,36 @@ export function validateConfig() {
103
105
  if (!mcpConfig.transport.type || !validTransports.includes(mcpConfig.transport.type)) {
104
106
  errors.push(`Invalid transport type: ${mcpConfig.transport.type}`);
105
107
  }
106
-
108
+
107
109
  // Check port for network transports
108
- if (mcpConfig.transport.type && ['http', 'sse', 'websocket'].includes(mcpConfig.transport.type)) {
109
- if (!mcpConfig.transport.port || mcpConfig.transport.port < 1 || mcpConfig.transport.port > 65535) {
110
+ if (
111
+ mcpConfig.transport.type &&
112
+ ['http', 'sse', 'websocket'].includes(mcpConfig.transport.type)
113
+ ) {
114
+ if (
115
+ !mcpConfig.transport.port ||
116
+ mcpConfig.transport.port < 1 ||
117
+ mcpConfig.transport.port > 65535
118
+ ) {
110
119
  errors.push(`Invalid port: ${mcpConfig.transport.port}`);
111
120
  }
112
121
  }
113
122
  }
114
-
123
+
115
124
  // Check Ghost configuration
116
125
  if (!process.env.GHOST_ADMIN_API_URL) {
117
126
  errors.push('Missing GHOST_ADMIN_API_URL environment variable');
118
127
  }
119
-
128
+
120
129
  if (!process.env.GHOST_ADMIN_API_KEY) {
121
130
  errors.push('Missing GHOST_ADMIN_API_KEY environment variable');
122
131
  }
123
-
132
+
124
133
  if (errors.length > 0) {
125
134
  throw new Error(`Configuration errors:\n${errors.join('\n')}`);
126
135
  }
127
-
136
+
128
137
  return true;
129
138
  }
130
139
 
131
- export default mcpConfig;
140
+ export default mcpConfig;
@@ -1,12 +1,12 @@
1
- import multer from "multer";
2
- import path from "path";
3
- import fs from "fs";
4
- import os from "os"; // Import the os module
5
- import Joi from "joi";
6
- import crypto from "crypto";
7
- import { createContextLogger } from "../utils/logger.js";
8
- import { uploadImage as uploadGhostImage } from "../services/ghostService.js"; // Assuming uploadImage is in ghostService
9
- import { processImage } from "../services/imageProcessingService.js"; // Import the processing service
1
+ import multer from 'multer';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import os from 'os'; // Import the os module
5
+ import Joi from 'joi';
6
+ import crypto from 'crypto';
7
+ import { createContextLogger } from '../utils/logger.js';
8
+ import { uploadImage as uploadGhostImage } from '../services/ghostService.js'; // Assuming uploadImage is in ghostService
9
+ import { processImage } from '../services/imageProcessingService.js'; // Import the processing service
10
10
 
11
11
  // --- Use OS temporary directory for uploads ---
12
12
  const uploadDir = os.tmpdir(); // Use the OS default temp directory
@@ -18,15 +18,21 @@ const uploadDir = os.tmpdir(); // Use the OS default temp directory
18
18
  // Validation schema for uploaded files (excluding size - validated by multer limits)
19
19
  const fileValidationSchema = Joi.object({
20
20
  originalname: Joi.string().max(255).required(),
21
- mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required()
21
+ mimetype: Joi.string()
22
+ .pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i)
23
+ .required(),
22
24
  });
23
25
 
24
26
  // Post-upload validation schema (when file.size is available)
25
27
  const uploadedFileValidationSchema = Joi.object({
26
28
  originalname: Joi.string().max(255).required(),
27
- mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required(),
28
- size: Joi.number().max(10 * 1024 * 1024).required(), // 10MB max
29
- path: Joi.string().required()
29
+ mimetype: Joi.string()
30
+ .pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i)
31
+ .required(),
32
+ size: Joi.number()
33
+ .max(10 * 1024 * 1024)
34
+ .required(), // 10MB max
35
+ path: Joi.string().required(),
30
36
  });
31
37
 
32
38
  // Safe filename generation
@@ -35,11 +41,11 @@ const generateSafeFilename = (originalName) => {
35
41
  // Validate extension against whitelist
36
42
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
37
43
  const normalizedExt = ext.toLowerCase();
38
-
44
+
39
45
  if (!allowedExtensions.includes(normalizedExt)) {
40
46
  throw new Error('Invalid file extension');
41
47
  }
42
-
48
+
43
49
  // Generate cryptographically secure random filename
44
50
  const randomBytes = crypto.randomBytes(16).toString('hex');
45
51
  const timestamp = Date.now();
@@ -67,36 +73,36 @@ const imageFileFilter = (req, file, cb) => {
67
73
  // Validate file properties (excluding size - not available at this stage)
68
74
  const validation = fileValidationSchema.validate({
69
75
  originalname: file.originalname,
70
- mimetype: file.mimetype
76
+ mimetype: file.mimetype,
71
77
  });
72
-
78
+
73
79
  if (validation.error) {
74
80
  return cb(new Error(`File validation failed: ${validation.error.details[0].message}`), false);
75
81
  }
76
-
82
+
77
83
  // Additional security checks
78
84
  const filename = file.originalname;
79
-
85
+
80
86
  // Check for path traversal attempts
81
87
  if (filename.includes('../') || filename.includes('..\\') || path.isAbsolute(filename)) {
82
88
  return cb(new Error('Invalid filename: Path traversal detected'), false);
83
89
  }
84
-
90
+
85
91
  // Check for null bytes
86
92
  if (filename.includes('\0')) {
87
93
  return cb(new Error('Invalid filename: Null byte detected'), false);
88
94
  }
89
-
95
+
90
96
  cb(null, true);
91
97
  };
92
98
 
93
- const upload = multer({
94
- storage: storage,
99
+ const upload = multer({
100
+ storage: storage,
95
101
  fileFilter: imageFileFilter,
96
102
  limits: {
97
103
  fileSize: 10 * 1024 * 1024, // 10MB
98
- files: 1 // Only allow 1 file per request
99
- }
104
+ files: 1, // Only allow 1 file per request
105
+ },
100
106
  });
101
107
 
102
108
  /**
@@ -110,25 +116,19 @@ const getDefaultAltText = (originalName) => {
110
116
  // Use the original filename directly instead of a file path to avoid path traversal
111
117
  // Validate the input is a string and not a path
112
118
  if (!originalName || typeof originalName !== 'string') {
113
- return "Uploaded image";
119
+ return 'Uploaded image';
114
120
  }
115
-
121
+
116
122
  // Ensure no path separators are present (defense in depth)
117
123
  const sanitizedName = originalName.replace(/[/\\:]/g, '');
118
-
119
- const originalFilename = sanitizedName
120
- .split(".")
121
- .slice(0, -1)
122
- .join(".");
123
-
124
+
125
+ const originalFilename = sanitizedName.split('.').slice(0, -1).join('.');
126
+
124
127
  // Attempt to remove common prefixes/suffixes added during upload/processing
125
- const nameWithoutIds = originalFilename.replace(
126
- /^(processed-|mcp-upload-)\d+-\d+-?/,
127
- ""
128
- );
129
- return nameWithoutIds.replace(/[-_]/g, " ") || "Uploaded image";
130
- } catch (e) {
131
- return "Uploaded image"; // Fallback
128
+ const nameWithoutIds = originalFilename.replace(/^(processed-|mcp-upload-)\d+-\d+-?/, '');
129
+ return nameWithoutIds.replace(/[-_]/g, ' ') || 'Uploaded image';
130
+ } catch (_e) {
131
+ return 'Uploaded image'; // Fallback
132
132
  }
133
133
  };
134
134
 
@@ -143,51 +143,51 @@ const handleImageUpload = async (req, res, next) => {
143
143
 
144
144
  try {
145
145
  if (!req.file) {
146
- return res.status(400).json({ message: "No image file uploaded." });
146
+ return res.status(400).json({ message: 'No image file uploaded.' });
147
147
  }
148
-
148
+
149
149
  // Post-upload validation with complete file information
150
150
  const fileValidation = uploadedFileValidationSchema.validate({
151
151
  originalname: req.file.originalname,
152
152
  mimetype: req.file.mimetype,
153
153
  size: req.file.size,
154
- path: req.file.path
154
+ path: req.file.path,
155
155
  });
156
-
156
+
157
157
  if (fileValidation.error) {
158
158
  // Delete the uploaded file since validation failed
159
159
  // Validate file path is within upload directory before deletion
160
160
  const filePath = req.file.path;
161
161
  const resolvedFilePath = path.resolve(filePath);
162
162
  const resolvedUploadDir = path.resolve(uploadDir);
163
-
163
+
164
164
  if (resolvedFilePath.startsWith(resolvedUploadDir)) {
165
165
  fs.unlink(filePath, () => {});
166
166
  }
167
-
168
- return res.status(400).json({
169
- message: `File validation failed: ${fileValidation.error.details[0].message}`
167
+
168
+ return res.status(400).json({
169
+ message: `File validation failed: ${fileValidation.error.details[0].message}`,
170
170
  });
171
171
  }
172
-
172
+
173
173
  // Validate the file path is within our temp directory (defense in depth)
174
174
  originalPath = req.file.path;
175
175
  const resolvedPath = path.resolve(originalPath);
176
176
  const resolvedUploadDir = path.resolve(uploadDir);
177
-
177
+
178
178
  if (!resolvedPath.startsWith(resolvedUploadDir)) {
179
179
  logger.error('Security violation: File path outside upload directory', {
180
180
  filePath: path.basename(originalPath),
181
- uploadDir: path.basename(uploadDir)
181
+ uploadDir: path.basename(uploadDir),
182
182
  });
183
183
  throw new Error('Security violation: File path outside of upload directory');
184
184
  }
185
-
185
+
186
186
  logger.info('Image received for processing', {
187
187
  originalName: req.file.originalname,
188
188
  size: req.file.size,
189
189
  mimetype: req.file.mimetype,
190
- tempFile: path.basename(originalPath)
190
+ tempFile: path.basename(originalPath),
191
191
  });
192
192
 
193
193
  // Process Image (output directory is still the temp dir)
@@ -197,11 +197,11 @@ const handleImageUpload = async (req, res, next) => {
197
197
  // Validate and sanitize alt text from the request body
198
198
  const altSchema = Joi.string().max(500).allow('').optional();
199
199
  const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
200
-
200
+
201
201
  if (error) {
202
202
  return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
203
203
  }
204
-
204
+
205
205
  const providedAlt = sanitizedAlt;
206
206
  // Generate a default alt text from the original filename if none provided
207
207
  const defaultAlt = getDefaultAltText(req.file.originalname);
@@ -209,7 +209,7 @@ const handleImageUpload = async (req, res, next) => {
209
209
  logger.debug('Alt text determined', {
210
210
  provided: !!providedAlt,
211
211
  generated: !providedAlt,
212
- altText
212
+ altText,
213
213
  });
214
214
  // --- End Alt Text Handling ---
215
215
 
@@ -217,7 +217,7 @@ const handleImageUpload = async (req, res, next) => {
217
217
  const uploadResult = await uploadGhostImage(processedPath);
218
218
  logger.info('Image uploaded to Ghost successfully', {
219
219
  ghostUrl: uploadResult.url,
220
- processedFile: path.basename(processedPath)
220
+ processedFile: path.basename(processedPath),
221
221
  });
222
222
 
223
223
  // Respond with the URL and the determined alt text
@@ -227,7 +227,7 @@ const handleImageUpload = async (req, res, next) => {
227
227
  error: error.message,
228
228
  stack: error.stack,
229
229
  originalFile: originalPath ? path.basename(originalPath) : null,
230
- processedFile: processedPath ? path.basename(processedPath) : null
230
+ processedFile: processedPath ? path.basename(processedPath) : null,
231
231
  });
232
232
  // If it's a multer error (e.g., file filter), it might need specific handling
233
233
  if (error instanceof multer.MulterError) {
@@ -240,13 +240,13 @@ const handleImageUpload = async (req, res, next) => {
240
240
  if (originalPath) {
241
241
  const resolvedOriginalPath = path.resolve(originalPath);
242
242
  const resolvedUploadDir = path.resolve(uploadDir);
243
-
243
+
244
244
  if (resolvedOriginalPath.startsWith(resolvedUploadDir)) {
245
245
  fs.unlink(originalPath, (err) => {
246
246
  if (err)
247
247
  logger.warn('Failed to delete original temp file', {
248
248
  file: path.basename(originalPath),
249
- error: err.message
249
+ error: err.message,
250
250
  });
251
251
  });
252
252
  }
@@ -254,13 +254,13 @@ const handleImageUpload = async (req, res, next) => {
254
254
  if (processedPath && processedPath !== originalPath) {
255
255
  const resolvedProcessedPath = path.resolve(processedPath);
256
256
  const resolvedUploadDir = path.resolve(uploadDir);
257
-
257
+
258
258
  if (resolvedProcessedPath.startsWith(resolvedUploadDir)) {
259
259
  fs.unlink(processedPath, (err) => {
260
260
  if (err)
261
261
  logger.warn('Failed to delete processed temp file', {
262
262
  file: path.basename(processedPath),
263
- error: err.message
263
+ error: err.message,
264
264
  });
265
265
  });
266
266
  }
@@ -1,5 +1,5 @@
1
- import { createPostService } from "../services/postService.js";
2
- import { createContextLogger } from "../utils/logger.js";
1
+ import { createPostService } from '../services/postService.js';
2
+ import { createContextLogger } from '../utils/logger.js';
3
3
 
4
4
  /**
5
5
  * Controller to handle creating a new post.
@@ -9,7 +9,7 @@ import { createContextLogger } from "../utils/logger.js";
9
9
  */
10
10
  const createPost = async (req, res, next) => {
11
11
  const logger = createContextLogger('post-controller');
12
-
12
+
13
13
  try {
14
14
  // Input is already validated by express-validator middleware
15
15
  // The body now includes potential feature_image and metadata fields
@@ -19,16 +19,16 @@ const createPost = async (req, res, next) => {
19
19
  title: postInput.title,
20
20
  status: postInput.status,
21
21
  hasFeatureImage: !!postInput.feature_image,
22
- tagCount: postInput.tags?.length || 0
22
+ tagCount: postInput.tags?.length || 0,
23
23
  });
24
-
24
+
25
25
  // Call the service layer function
26
26
  const newPost = await createPostService(postInput);
27
-
27
+
28
28
  logger.info('Post created successfully', {
29
29
  postId: newPost.id,
30
30
  title: newPost.title,
31
- status: newPost.status
31
+ status: newPost.status,
32
32
  });
33
33
 
34
34
  res.status(201).json(newPost);
@@ -36,7 +36,7 @@ const createPost = async (req, res, next) => {
36
36
  logger.error('Post creation failed', {
37
37
  error: error.message,
38
38
  stack: error.stack,
39
- title: req.body?.title
39
+ title: req.body?.title,
40
40
  });
41
41
  // Pass error to the Express error handler
42
42
  next(error);
@@ -1,8 +1,5 @@
1
- import {
2
- getTags as getGhostTags,
3
- createTag as createGhostTag,
4
- } from "../services/ghostService.js";
5
- import { createContextLogger } from "../utils/logger.js";
1
+ import { getTags as getGhostTags, createTag as createGhostTag } from '../services/ghostService.js';
2
+ import { createContextLogger } from '../utils/logger.js';
6
3
 
7
4
  /**
8
5
  * Controller to handle fetching tags.
@@ -10,26 +7,26 @@ import { createContextLogger } from "../utils/logger.js";
10
7
  */
11
8
  const getTags = async (req, res, next) => {
12
9
  const logger = createContextLogger('tag-controller');
13
-
10
+
14
11
  try {
15
12
  const { name } = req.query; // Get name from query params like /api/tags?name=some-tag
16
13
  logger.info('Fetching tags', {
17
14
  filtered: !!name,
18
- filterName: name
15
+ filterName: name,
19
16
  });
20
-
17
+
21
18
  const tags = await getGhostTags(name);
22
-
19
+
23
20
  logger.info('Tags retrieved successfully', {
24
21
  count: tags.length,
25
- filtered: !!name
22
+ filtered: !!name,
26
23
  });
27
-
24
+
28
25
  res.status(200).json(tags);
29
26
  } catch (error) {
30
27
  logger.error('Get tags failed', {
31
28
  error: error.message,
32
- filterName: req.query?.name
29
+ filterName: req.query?.name,
33
30
  });
34
31
  next(error);
35
32
  }
@@ -40,35 +37,35 @@ const getTags = async (req, res, next) => {
40
37
  */
41
38
  const createTag = async (req, res, next) => {
42
39
  const logger = createContextLogger('tag-controller');
43
-
40
+
44
41
  try {
45
42
  // Basic validation (more could be added via express-validator)
46
43
  const { name, description, slug, ...otherData } = req.body;
47
44
  if (!name) {
48
45
  logger.warn('Tag creation attempted without name');
49
- return res.status(400).json({ message: "Tag name is required." });
46
+ return res.status(400).json({ message: 'Tag name is required.' });
50
47
  }
51
48
  const tagData = { name, description, slug, ...otherData };
52
49
 
53
50
  logger.info('Creating tag', {
54
51
  name,
55
52
  hasDescription: !!description,
56
- hasSlug: !!slug
53
+ hasSlug: !!slug,
57
54
  });
58
-
55
+
59
56
  const newTag = await createGhostTag(tagData);
60
-
57
+
61
58
  logger.info('Tag created successfully', {
62
59
  tagId: newTag.id,
63
60
  name: newTag.name,
64
- slug: newTag.slug
61
+ slug: newTag.slug,
65
62
  });
66
-
63
+
67
64
  res.status(201).json(newTag);
68
65
  } catch (error) {
69
66
  logger.error('Tag creation failed', {
70
67
  error: error.message,
71
- tagName: req.body?.name
68
+ tagName: req.body?.name,
72
69
  });
73
70
  next(error);
74
71
  }