@jgardner04/ghost-mcp-server 1.14.1 → 1.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.14.1",
3
+ "version": "1.14.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",
@@ -568,7 +568,7 @@ describe('mcp_server - ghost_get_tags tool', () => {
568
568
  );
569
569
  });
570
570
 
571
- it('should escape single quotes in name parameter', async () => {
571
+ it('should backslash-escape single quotes in name parameter', async () => {
572
572
  const mockTags = [{ id: '1', name: "O'Reilly", slug: 'oreilly' }];
573
573
  mockGetTags.mockResolvedValue(mockTags);
574
574
 
@@ -577,12 +577,12 @@ describe('mcp_server - ghost_get_tags tool', () => {
577
577
 
578
578
  expect(mockGetTags).toHaveBeenCalledWith(
579
579
  expect.objectContaining({
580
- filter: "name:'O''Reilly'",
580
+ filter: "name:'O\\'Reilly'",
581
581
  })
582
582
  );
583
583
  });
584
584
 
585
- it('should escape single quotes in slug parameter', async () => {
585
+ it('should backslash-escape single quotes in slug parameter', async () => {
586
586
  const mockTags = [{ id: '1', name: 'Test', slug: "test'slug" }];
587
587
  mockGetTags.mockResolvedValue(mockTags);
588
588
 
@@ -591,7 +591,28 @@ describe('mcp_server - ghost_get_tags tool', () => {
591
591
 
592
592
  expect(mockGetTags).toHaveBeenCalledWith(
593
593
  expect.objectContaining({
594
- filter: "slug:'test''slug'",
594
+ filter: "slug:'test\\'slug'",
595
+ })
596
+ );
597
+ });
598
+
599
+ // `name` has a schema regex allowlist (letters/digits/space/-/_/') so `\` and `"` are
600
+ // rejected at validation. `slug` has no such restriction, so it's the actual surface
601
+ // where backslash/double-quote can flow into the NQL filter — exercise it here.
602
+ it('should backslash-escape single quotes, double quotes, and backslashes in slug parameter', async () => {
603
+ // Input bytes: a ' b \ " c (6 chars)
604
+ // After sanitize: a \' b \\ \" c (9 chars)
605
+ // Filter string: slug:'a\'b\\\"c'
606
+ const slug = 'a\'b\\"c';
607
+ // Mock return value is irrelevant — this test only asserts what mockGetTags was called with.
608
+ mockGetTags.mockResolvedValue([{ id: '1', name: 'Test', slug: 'test' }]);
609
+
610
+ const tool = mockTools.get('ghost_get_tags');
611
+ await tool.handler({ slug });
612
+
613
+ expect(mockGetTags).toHaveBeenCalledWith(
614
+ expect.objectContaining({
615
+ filter: "slug:'a\\'b\\\\\\\"c'",
595
616
  })
596
617
  );
597
618
  });
package/src/mcp_server.js CHANGED
@@ -13,6 +13,7 @@ import { formatErrorResponse } from './utils/formatErrorResponse.js';
13
13
  import { createContextLogger } from './utils/logger.js';
14
14
  import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
15
15
  import { resolveLocalImagePath, decodeBase64ToTempFile } from './utils/imageInputResolver.js';
16
+ import { sanitizeNqlValue } from './utils/nqlSanitizer.js';
16
17
  import {
17
18
  createTagSchema,
18
19
  updateTagSchema,
@@ -76,9 +77,6 @@ const loadServices = async () => {
76
77
  }
77
78
  };
78
79
 
79
- // Generate UUID without external dependency
80
- const generateUuid = () => crypto.randomUUID();
81
-
82
80
  // Helper function for default alt text
83
81
  const getDefaultAltText = (filePath) => {
84
82
  try {
@@ -92,17 +90,6 @@ const getDefaultAltText = (filePath) => {
92
90
  }
93
91
  };
94
92
 
95
- /**
96
- * Escapes single quotes in NQL filter values by doubling them.
97
- * This prevents filter injection attacks when building NQL query strings.
98
- * Example: "O'Reilly" becomes "O''Reilly" for use in name:'O''Reilly'
99
- * @param {string} value - The value to escape
100
- * @returns {string} The escaped value safe for NQL filter strings
101
- */
102
- const escapeNqlValue = (value) => {
103
- return value.replace(/'/g, "''");
104
- };
105
-
106
93
  /**
107
94
  * Higher-order function that wraps a tool handler with standardized
108
95
  * input validation, service loading, and error handling.
@@ -119,7 +106,6 @@ const escapeNqlValue = (value) => {
119
106
  */
120
107
  const withErrorHandling = (toolName, schema, handler) => {
121
108
  return async (rawInput) => {
122
- console.error(`Executing tool: ${toolName}`);
123
109
  const validation = validateToolInput(schema, rawInput, toolName);
124
110
  if (!validation.success) {
125
111
  return validation.errorResponse;
@@ -177,10 +163,13 @@ server.registerTool(
177
163
  if (input.include !== undefined) options.include = input.include;
178
164
 
179
165
  // Build filter string from individual filter parameters
166
+ // Note: `slug` has no schema regex (see src/schemas/tagSchemas.js), so it is the
167
+ // primary NQL injection surface here. `name` has an allowlist that pre-rejects
168
+ // `\` and `"`. `visibility` is an enum, no escaping needed.
180
169
  const filters = [];
181
- if (input.name) filters.push(`name:'${escapeNqlValue(input.name)}'`);
182
- if (input.slug) filters.push(`slug:'${escapeNqlValue(input.slug)}'`);
183
- if (input.visibility) filters.push(`visibility:'${input.visibility}'`); // visibility is enum-validated, no escaping needed
170
+ if (input.name) filters.push(`name:'${sanitizeNqlValue(input.name)}'`);
171
+ if (input.slug) filters.push(`slug:'${sanitizeNqlValue(input.slug)}'`);
172
+ if (input.visibility) filters.push(`visibility:'${input.visibility}'`);
184
173
  if (input.filter) filters.push(input.filter);
185
174
 
186
175
  if (filters.length > 0) {
@@ -188,7 +177,7 @@ server.registerTool(
188
177
  }
189
178
 
190
179
  const tags = await ghostService.getTags(options);
191
- console.error(`Retrieved ${tags.length} tags from Ghost.`);
180
+ mcpLogger.info(`Retrieved ${tags.length} tags from Ghost.`);
192
181
 
193
182
  return {
194
183
  content: [{ type: 'text', text: JSON.stringify(tags, null, 2) }],
@@ -205,7 +194,7 @@ server.registerTool(
205
194
  },
206
195
  withErrorHandling('ghost_create_tag', createTagSchema, async (input) => {
207
196
  const createdTag = await ghostService.createTag(input);
208
- console.error(`Tag created successfully. Tag ID: ${createdTag.id}`);
197
+ mcpLogger.info(`Tag created successfully. Tag ID: ${createdTag.id}`);
209
198
 
210
199
  return {
211
200
  content: [{ type: 'text', text: JSON.stringify(createdTag, null, 2) }],
@@ -224,13 +213,10 @@ server.registerTool(
224
213
  const options = {};
225
214
  if (input.include !== undefined) options.include = input.include;
226
215
 
227
- if (!input.id && !input.slug) {
228
- throw new Error('Either id or slug is required');
229
- }
230
216
  const identifier = input.id || `slug/${input.slug}`;
231
217
 
232
218
  const tag = await ghostService.getTag(identifier, options);
233
- console.error(`Retrieved tag: ${tag.name} (ID: ${tag.id})`);
219
+ mcpLogger.info(`Retrieved tag: ${tag.name} (ID: ${tag.id})`);
234
220
 
235
221
  return {
236
222
  content: [{ type: 'text', text: JSON.stringify(tag, null, 2) }],
@@ -248,7 +234,7 @@ server.registerTool(
248
234
  withErrorHandling('ghost_update_tag', updateTagInputSchema, async (input) => {
249
235
  const { id, ...updateData } = input;
250
236
  const updatedTag = await ghostService.updateTag(id, updateData);
251
- console.error(`Tag updated successfully. Tag ID: ${updatedTag.id}`);
237
+ mcpLogger.info(`Tag updated successfully. Tag ID: ${updatedTag.id}`);
252
238
 
253
239
  return {
254
240
  content: [{ type: 'text', text: JSON.stringify(updatedTag, null, 2) }],
@@ -267,7 +253,7 @@ server.registerTool(
267
253
  withErrorHandling('ghost_delete_tag', deleteTagSchema, async (input) => {
268
254
  const { id } = input;
269
255
  await ghostService.deleteTag(id);
270
- console.error(`Tag deleted successfully. Tag ID: ${id}`);
256
+ mcpLogger.info(`Tag deleted successfully. Tag ID: ${id}`);
271
257
 
272
258
  return {
273
259
  content: [{ type: 'text', text: `Tag ${id} has been successfully deleted.` }],
@@ -346,8 +332,8 @@ async function acquireImageForUpload({ imageUrl, imagePath: localPath, imageBase
346
332
 
347
333
  const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
348
334
  const filenameHint =
349
- path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
350
- const downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
335
+ path.basename(imageUrl.split('?')[0]) || `image-${crypto.randomUUID()}${extension}`;
336
+ const downloadedPath = path.join(tempDir, `mcp-download-${crypto.randomUUID()}${extension}`);
351
337
 
352
338
  let bytes = 0;
353
339
  response.data.on('data', (chunk) => {
@@ -436,7 +422,7 @@ function validateAndXorImageInput(schema, rawInput, toolName) {
436
422
  if (xorError) {
437
423
  return {
438
424
  success: false,
439
- errorResponse: { content: [{ type: 'text', text: xorError }], isError: true },
425
+ errorResponse: formatErrorResponse(new Error(xorError), toolName),
440
426
  };
441
427
  }
442
428
  return validation;
@@ -522,7 +508,7 @@ server.registerTool(
522
508
  type === 'post'
523
509
  ? await ghostService.updatePost(id, updatePayload)
524
510
  : await ghostService.updatePage(id, updatePayload);
525
- console.error(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
511
+ mcpLogger.info(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
526
512
  return {
527
513
  content: [
528
514
  {
@@ -592,7 +578,7 @@ server.registerTool(
592
578
  },
593
579
  withErrorHandling('ghost_create_post', createPostSchema, async (input) => {
594
580
  const createdPost = await postService.createPostService(input);
595
- console.error(`Post created successfully. Post ID: ${createdPost.id}`);
581
+ mcpLogger.info(`Post created successfully. Post ID: ${createdPost.id}`);
596
582
 
597
583
  return {
598
584
  content: [{ type: 'text', text: JSON.stringify(createdPost, null, 2) }],
@@ -621,7 +607,7 @@ server.registerTool(
621
607
  if (input.formats !== undefined) options.formats = input.formats;
622
608
 
623
609
  const posts = await ghostService.getPosts(options);
624
- console.error(`Retrieved ${posts.length} posts from Ghost.`);
610
+ mcpLogger.info(`Retrieved ${posts.length} posts from Ghost.`);
625
611
 
626
612
  return {
627
613
  content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
@@ -641,14 +627,11 @@ server.registerTool(
641
627
  const options = {};
642
628
  if (input.include !== undefined) options.include = input.include;
643
629
 
644
- // Determine identifier (prefer ID over slug)
645
- if (!input.id && !input.slug) {
646
- throw new Error('Either id or slug is required');
647
- }
630
+ // Prefer ID over slug. The schema's .refine() guarantees one is present.
648
631
  const identifier = input.id || `slug/${input.slug}`;
649
632
 
650
633
  const post = await ghostService.getPost(identifier, options);
651
- console.error(`Retrieved post: ${post.title} (ID: ${post.id})`);
634
+ mcpLogger.info(`Retrieved post: ${post.title} (ID: ${post.id})`);
652
635
 
653
636
  return {
654
637
  content: [{ type: 'text', text: JSON.stringify(post, null, 2) }],
@@ -670,7 +653,7 @@ server.registerTool(
670
653
  if (input.limit !== undefined) options.limit = input.limit;
671
654
 
672
655
  const posts = await ghostService.searchPosts(input.query, options);
673
- console.error(`Found ${posts.length} posts matching "${input.query}".`);
656
+ mcpLogger.info(`Found ${posts.length} posts matching "${input.query}".`);
674
657
 
675
658
  return {
676
659
  content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
@@ -691,7 +674,7 @@ server.registerTool(
691
674
  const { id, ...updateData } = input;
692
675
 
693
676
  const updatedPost = await ghostService.updatePost(id, updateData);
694
- console.error(`Post updated successfully. Post ID: ${updatedPost.id}`);
677
+ mcpLogger.info(`Post updated successfully. Post ID: ${updatedPost.id}`);
695
678
 
696
679
  return {
697
680
  content: [{ type: 'text', text: JSON.stringify(updatedPost, null, 2) }],
@@ -710,7 +693,7 @@ server.registerTool(
710
693
  withErrorHandling('ghost_delete_post', deletePostSchema, async (input) => {
711
694
  const { id } = input;
712
695
  await ghostService.deletePost(id);
713
- console.error(`Post deleted successfully. Post ID: ${id}`);
696
+ mcpLogger.info(`Post deleted successfully. Post ID: ${id}`);
714
697
 
715
698
  return {
716
699
  content: [{ type: 'text', text: `Post ${id} has been successfully deleted.` }],
@@ -780,7 +763,7 @@ server.registerTool(
780
763
  if (input.order !== undefined) options.order = input.order;
781
764
 
782
765
  const pages = await ghostService.getPages(options);
783
- console.error(`Retrieved ${pages.length} pages from Ghost.`);
766
+ mcpLogger.info(`Retrieved ${pages.length} pages from Ghost.`);
784
767
 
785
768
  return {
786
769
  content: [{ type: 'text', text: JSON.stringify(pages, null, 2) }],
@@ -799,13 +782,10 @@ server.registerTool(
799
782
  const options = {};
800
783
  if (input.include !== undefined) options.include = input.include;
801
784
 
802
- if (!input.id && !input.slug) {
803
- throw new Error('Either id or slug is required');
804
- }
805
785
  const identifier = input.id || `slug/${input.slug}`;
806
786
 
807
787
  const page = await ghostService.getPage(identifier, options);
808
- console.error(`Retrieved page: ${page.title} (ID: ${page.id})`);
788
+ mcpLogger.info(`Retrieved page: ${page.title} (ID: ${page.id})`);
809
789
 
810
790
  return {
811
791
  content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
@@ -823,7 +803,7 @@ server.registerTool(
823
803
  },
824
804
  withErrorHandling('ghost_create_page', createPageSchema, async (input) => {
825
805
  const createdPage = await pageService.createPageService(input);
826
- console.error(`Page created successfully. Page ID: ${createdPage.id}`);
806
+ mcpLogger.info(`Page created successfully. Page ID: ${createdPage.id}`);
827
807
 
828
808
  return {
829
809
  content: [{ type: 'text', text: JSON.stringify(createdPage, null, 2) }],
@@ -843,7 +823,7 @@ server.registerTool(
843
823
  const { id, ...updateData } = input;
844
824
 
845
825
  const updatedPage = await ghostService.updatePage(id, updateData);
846
- console.error(`Page updated successfully. Page ID: ${updatedPage.id}`);
826
+ mcpLogger.info(`Page updated successfully. Page ID: ${updatedPage.id}`);
847
827
 
848
828
  return {
849
829
  content: [{ type: 'text', text: JSON.stringify(updatedPage, null, 2) }],
@@ -862,7 +842,7 @@ server.registerTool(
862
842
  withErrorHandling('ghost_delete_page', deletePageSchema, async (input) => {
863
843
  const { id } = input;
864
844
  await ghostService.deletePage(id);
865
- console.error(`Page deleted successfully. Page ID: ${id}`);
845
+ mcpLogger.info(`Page deleted successfully. Page ID: ${id}`);
866
846
 
867
847
  return {
868
848
  content: [{ type: 'text', text: `Page ${id} has been successfully deleted.` }],
@@ -883,7 +863,7 @@ server.registerTool(
883
863
  if (input.limit !== undefined) options.limit = input.limit;
884
864
 
885
865
  const pages = await ghostService.searchPages(input.query, options);
886
- console.error(`Found ${pages.length} pages matching "${input.query}".`);
866
+ mcpLogger.info(`Found ${pages.length} pages matching "${input.query}".`);
887
867
 
888
868
  return {
889
869
  content: [{ type: 'text', text: JSON.stringify(pages, null, 2) }],
@@ -931,7 +911,7 @@ server.registerTool(
931
911
  },
932
912
  withErrorHandling('ghost_create_member', createMemberSchema, async (input) => {
933
913
  const createdMember = await ghostService.createMember(input);
934
- console.error(`Member created successfully. Member ID: ${createdMember.id}`);
914
+ mcpLogger.info(`Member created successfully. Member ID: ${createdMember.id}`);
935
915
 
936
916
  return {
937
917
  content: [{ type: 'text', text: JSON.stringify(createdMember, null, 2) }],
@@ -950,7 +930,7 @@ server.registerTool(
950
930
  const { id, ...updateData } = input;
951
931
 
952
932
  const updatedMember = await ghostService.updateMember(id, updateData);
953
- console.error(`Member updated successfully. Member ID: ${updatedMember.id}`);
933
+ mcpLogger.info(`Member updated successfully. Member ID: ${updatedMember.id}`);
954
934
 
955
935
  return {
956
936
  content: [{ type: 'text', text: JSON.stringify(updatedMember, null, 2) }],
@@ -969,7 +949,7 @@ server.registerTool(
969
949
  withErrorHandling('ghost_delete_member', deleteMemberSchema, async (input) => {
970
950
  const { id } = input;
971
951
  await ghostService.deleteMember(id);
972
- console.error(`Member deleted successfully. Member ID: ${id}`);
952
+ mcpLogger.info(`Member deleted successfully. Member ID: ${id}`);
973
953
 
974
954
  return {
975
955
  content: [{ type: 'text', text: `Member ${id} has been successfully deleted.` }],
@@ -994,7 +974,7 @@ server.registerTool(
994
974
  if (input.include !== undefined) options.include = input.include;
995
975
 
996
976
  const members = await ghostService.getMembers(options);
997
- console.error(`Retrieved ${members.length} members from Ghost.`);
977
+ mcpLogger.info(`Retrieved ${members.length} members from Ghost.`);
998
978
 
999
979
  return {
1000
980
  content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
@@ -1013,7 +993,7 @@ server.registerTool(
1013
993
  withErrorHandling('ghost_get_member', getMemberSchema, async (input) => {
1014
994
  const { id, email } = input;
1015
995
  const member = await ghostService.getMember({ id, email });
1016
- console.error(`Retrieved member: ${member.email} (ID: ${member.id})`);
996
+ mcpLogger.info(`Retrieved member: ${member.email} (ID: ${member.id})`);
1017
997
 
1018
998
  return {
1019
999
  content: [{ type: 'text', text: JSON.stringify(member, null, 2) }],
@@ -1034,7 +1014,7 @@ server.registerTool(
1034
1014
  if (limit !== undefined) options.limit = limit;
1035
1015
 
1036
1016
  const members = await ghostService.searchMembers(query, options);
1037
- console.error(`Found ${members.length} members matching "${query}".`);
1017
+ mcpLogger.info(`Found ${members.length} members matching "${query}".`);
1038
1018
 
1039
1019
  return {
1040
1020
  content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
@@ -1066,7 +1046,7 @@ server.registerTool(
1066
1046
  if (input.order !== undefined) options.order = input.order;
1067
1047
 
1068
1048
  const newsletters = await ghostService.getNewsletters(options);
1069
- console.error(`Retrieved ${newsletters.length} newsletters from Ghost.`);
1049
+ mcpLogger.info(`Retrieved ${newsletters.length} newsletters from Ghost.`);
1070
1050
 
1071
1051
  return {
1072
1052
  content: [{ type: 'text', text: JSON.stringify(newsletters, null, 2) }],
@@ -1084,7 +1064,7 @@ server.registerTool(
1084
1064
  withErrorHandling('ghost_get_newsletter', getNewsletterSchema, async (input) => {
1085
1065
  const { id } = input;
1086
1066
  const newsletter = await ghostService.getNewsletter(id);
1087
- console.error(`Retrieved newsletter: ${newsletter.name} (ID: ${newsletter.id})`);
1067
+ mcpLogger.info(`Retrieved newsletter: ${newsletter.name} (ID: ${newsletter.id})`);
1088
1068
 
1089
1069
  return {
1090
1070
  content: [{ type: 'text', text: JSON.stringify(newsletter, null, 2) }],
@@ -1102,7 +1082,7 @@ server.registerTool(
1102
1082
  },
1103
1083
  withErrorHandling('ghost_create_newsletter', createNewsletterSchema, async (input) => {
1104
1084
  const createdNewsletter = await newsletterService.createNewsletterService(input);
1105
- console.error(`Newsletter created successfully. Newsletter ID: ${createdNewsletter.id}`);
1085
+ mcpLogger.info(`Newsletter created successfully. Newsletter ID: ${createdNewsletter.id}`);
1106
1086
 
1107
1087
  return {
1108
1088
  content: [{ type: 'text', text: JSON.stringify(createdNewsletter, null, 2) }],
@@ -1122,7 +1102,7 @@ server.registerTool(
1122
1102
  const { id, ...updateData } = input;
1123
1103
 
1124
1104
  const updatedNewsletter = await ghostService.updateNewsletter(id, updateData);
1125
- console.error(`Newsletter updated successfully. Newsletter ID: ${updatedNewsletter.id}`);
1105
+ mcpLogger.info(`Newsletter updated successfully. Newsletter ID: ${updatedNewsletter.id}`);
1126
1106
 
1127
1107
  return {
1128
1108
  content: [{ type: 'text', text: JSON.stringify(updatedNewsletter, null, 2) }],
@@ -1141,7 +1121,7 @@ server.registerTool(
1141
1121
  withErrorHandling('ghost_delete_newsletter', deleteNewsletterSchema, async (input) => {
1142
1122
  const { id } = input;
1143
1123
  await ghostService.deleteNewsletter(id);
1144
- console.error(`Newsletter deleted successfully. Newsletter ID: ${id}`);
1124
+ mcpLogger.info(`Newsletter deleted successfully. Newsletter ID: ${id}`);
1145
1125
 
1146
1126
  return {
1147
1127
  content: [{ type: 'text', text: `Newsletter ${id} has been successfully deleted.` }],
@@ -1166,7 +1146,7 @@ server.registerTool(
1166
1146
  },
1167
1147
  withErrorHandling('ghost_get_tiers', tierQuerySchema, async (input) => {
1168
1148
  const tiers = await ghostService.getTiers(input);
1169
- console.error(`Retrieved ${tiers.length} tiers`);
1149
+ mcpLogger.info(`Retrieved ${tiers.length} tiers`);
1170
1150
 
1171
1151
  return {
1172
1152
  content: [{ type: 'text', text: JSON.stringify(tiers, null, 2) }],
@@ -1184,7 +1164,7 @@ server.registerTool(
1184
1164
  withErrorHandling('ghost_get_tier', getTierSchema, async (input) => {
1185
1165
  const { id } = input;
1186
1166
  const tier = await ghostService.getTier(id);
1187
- console.error(`Tier retrieved successfully. Tier ID: ${tier.id}`);
1167
+ mcpLogger.info(`Tier retrieved successfully. Tier ID: ${tier.id}`);
1188
1168
 
1189
1169
  return {
1190
1170
  content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
@@ -1201,7 +1181,7 @@ server.registerTool(
1201
1181
  },
1202
1182
  withErrorHandling('ghost_create_tier', createTierSchema, async (input) => {
1203
1183
  const tier = await ghostService.createTier(input);
1204
- console.error(`Tier created successfully. Tier ID: ${tier.id}`);
1184
+ mcpLogger.info(`Tier created successfully. Tier ID: ${tier.id}`);
1205
1185
 
1206
1186
  return {
1207
1187
  content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
@@ -1221,7 +1201,7 @@ server.registerTool(
1221
1201
  const { id, ...updateData } = input;
1222
1202
 
1223
1203
  const updatedTier = await ghostService.updateTier(id, updateData);
1224
- console.error(`Tier updated successfully. Tier ID: ${updatedTier.id}`);
1204
+ mcpLogger.info(`Tier updated successfully. Tier ID: ${updatedTier.id}`);
1225
1205
 
1226
1206
  return {
1227
1207
  content: [{ type: 'text', text: JSON.stringify(updatedTier, null, 2) }],
@@ -1240,7 +1220,7 @@ server.registerTool(
1240
1220
  withErrorHandling('ghost_delete_tier', deleteTierSchema, async (input) => {
1241
1221
  const { id } = input;
1242
1222
  await ghostService.deleteTier(id);
1243
- console.error(`Tier deleted successfully. Tier ID: ${id}`);
1223
+ mcpLogger.info(`Tier deleted successfully. Tier ID: ${id}`);
1244
1224
 
1245
1225
  return {
1246
1226
  content: [{ type: 'text', text: `Tier ${id} has been successfully deleted.` }],
@@ -1258,7 +1238,7 @@ async function main() {
1258
1238
 
1259
1239
  console.error('Ghost MCP Server running on stdio transport');
1260
1240
  console.error(
1261
- 'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
1241
+ 'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ghost_set_feature_image, ' +
1262
1242
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
1263
1243
  'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
1264
1244
  'ghost_create_member, ghost_update_member, ghost_delete_member, ghost_get_members, ghost_get_member, ghost_search_members, ' +
@@ -33,7 +33,7 @@ const api = new GhostAdminAPI({
33
33
  const handleApiRequest = async (resource, action, data = {}, options = {}, retries = 3) => {
34
34
  if (!api[resource] || typeof api[resource][action] !== 'function') {
35
35
  const errorMsg = `Invalid Ghost API resource or action: ${resource}.${action}`;
36
- console.error(errorMsg);
36
+ logger.error(errorMsg);
37
37
  throw new Error(errorMsg);
38
38
  }
39
39
 
@@ -116,14 +116,6 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, retri
116
116
  // Example function (will be expanded later)
117
117
  const getSiteInfo = async () => {
118
118
  return handleApiRequest('site', 'read');
119
- // try {
120
- // const site = await api.site.read();
121
- // console.log("Connected to Ghost site:", site.title);
122
- // return site;
123
- // } catch (error) {
124
- // console.error("Error connecting to Ghost Admin API:", error);
125
- // throw error; // Re-throw the error for handling upstream
126
- // }
127
119
  };
128
120
 
129
121
  /**
@@ -131,6 +131,26 @@ describe('formatErrorResponse', () => {
131
131
  });
132
132
  });
133
133
 
134
+ describe('XOR image-input error pass-through', () => {
135
+ it('renders the XOR error through the sanitized envelope shape', () => {
136
+ // Mirror the call shape `validateAndXorImageInput` uses when the caller
137
+ // supplied more than one of imageUrl/imagePath/imageBase64.
138
+ const xorError = new Error('Provide exactly one of imageUrl, imagePath, or imageBase64.');
139
+ const response = formatErrorResponse(xorError, 'ghost_upload_image');
140
+
141
+ expect(response.isError).toBe(true);
142
+ expect(response.content[0].type).toBe('text');
143
+ expect(response.content[0].text).toContain('Error in ghost_upload_image:');
144
+
145
+ const envelope = parseJsonBlock(response.content[0].text);
146
+ expect(envelope.error).toBeDefined();
147
+ expect(envelope.error.message).toBe(
148
+ 'Provide exactly one of imageUrl, imagePath, or imageBase64.'
149
+ );
150
+ expect(envelope).not.toHaveProperty('ghost');
151
+ });
152
+ });
153
+
134
154
  describe('ZodError coercion', () => {
135
155
  it('coerces ZodError-shaped input to VALIDATION_ERROR / 400 envelope', () => {
136
156
  const zodLike = Object.assign(new Error('zod'), {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { createContextLogger } from '../logger.js';
2
+ import { LEVEL, MESSAGE } from 'triple-beam';
3
+ import { createContextLogger, createSafeConsoleTransport } from '../logger.js';
3
4
  import logger from '../logger.js';
4
5
 
5
6
  describe('logger', () => {
@@ -21,6 +22,94 @@ describe('logger', () => {
21
22
  });
22
23
  });
23
24
 
25
+ describe('transport routing', () => {
26
+ // stdio MCP transport uses stdout for JSON-RPC frames; all log output must
27
+ // land on stderr so a log line cannot corrupt the protocol channel.
28
+ let stdoutSpy;
29
+ let stderrSpy;
30
+
31
+ beforeEach(() => {
32
+ // Winston writes to console._stdout/_stderr (Node aliases for
33
+ // process.stdout/stderr, captured at logger construction). Spy on the
34
+ // same references winston uses so the hook lines up with the write.
35
+ // These are Node internals — fail loudly if they ever disappear so the
36
+ // test cannot silently turn into a no-op when the assumption breaks.
37
+ if (!console._stdout || !console._stderr) {
38
+ throw new Error(
39
+ 'console._stdout/_stderr unavailable; logger transport-routing test ' +
40
+ 'assumptions broken — winston uses these refs for its Console transport.'
41
+ );
42
+ }
43
+ stdoutSpy = vi.spyOn(console._stdout, 'write').mockImplementation(() => true);
44
+ stderrSpy = vi.spyOn(console._stderr, 'write').mockImplementation(() => true);
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ // The stderrLevels contract is pinned by the createSafeConsoleTransport
52
+ // suite below. Routing tests below verify the runtime side: every level's
53
+ // bytes actually land on stderr, not stdout.
54
+
55
+ // Drive the Console transport's log() directly. Winston's writable-stream
56
+ // pipeline can defer writes across ticks, so routing the message through
57
+ // logger.info() makes the test flaky. The transport's routing logic is
58
+ // synchronous, so calling log() with a winston-shaped info object covers it.
59
+ const makeInfo = (level, message) => ({
60
+ level,
61
+ message,
62
+ [LEVEL]: level,
63
+ [MESSAGE]: `formatted: ${message}`,
64
+ });
65
+
66
+ it.each([['error'], ['warn'], ['info'], ['debug']])('routes %s level to stderr', (level) => {
67
+ const transport = logger.transports.find((t) => t.name === 'console');
68
+ transport.log(makeInfo(level, `${level}-test`), () => {});
69
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`${level}-test`));
70
+ expect(stdoutSpy).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+
74
+ describe('createSafeConsoleTransport', () => {
75
+ // Factory exists so the stderrLevels invariant cannot drift away from any
76
+ // future Console transport. Bare `new winston.transports.Console(...)` is
77
+ // forbidden by ESLint outside this module; this test pins the contract.
78
+
79
+ it('returns a winston Console transport', () => {
80
+ const t = createSafeConsoleTransport();
81
+ expect(t).toBeDefined();
82
+ expect(t.name).toBe('console');
83
+ });
84
+
85
+ it('always sets stderrLevels for every level', () => {
86
+ const t = createSafeConsoleTransport();
87
+ expect(t.stderrLevels).toMatchObject({
88
+ error: true,
89
+ warn: true,
90
+ info: true,
91
+ debug: true,
92
+ });
93
+ });
94
+
95
+ it('preserves caller-supplied options like level', () => {
96
+ const t = createSafeConsoleTransport({ level: 'warn' });
97
+ expect(t.level).toBe('warn');
98
+ });
99
+
100
+ it('forces stderrLevels even if caller tries to override it', () => {
101
+ // Defense in depth: a caller cannot accidentally narrow stderrLevels
102
+ // by passing their own array. This is the whole point of the factory.
103
+ const t = createSafeConsoleTransport({ stderrLevels: ['error'] });
104
+ expect(t.stderrLevels).toMatchObject({
105
+ error: true,
106
+ warn: true,
107
+ info: true,
108
+ debug: true,
109
+ });
110
+ });
111
+ });
112
+
24
113
  describe('createContextLogger', () => {
25
114
  let contextLogger;
26
115
  let logSpy;
@@ -105,15 +105,46 @@ describe('sanitizeErrorPayload', () => {
105
105
  expect(out).toEqual(input);
106
106
  });
107
107
 
108
- it('truncates long originalMessage', () => {
108
+ it('truncates long originalMessage to 2KB cap', () => {
109
109
  const longMsg = 'x'.repeat(5000);
110
110
  const out = sanitizeErrorPayload({
111
111
  ghost: { originalMessage: longMsg },
112
112
  });
113
- expect(out.ghost.originalMessage.length).toBeLessThan(longMsg.length);
113
+ // 2048-byte cap + ellipsis suffix: result stays under 2100 characters.
114
+ expect(out.ghost.originalMessage.length).toBeLessThan(2100);
114
115
  expect(out.ghost.originalMessage).toContain('[truncated]');
115
116
  });
116
117
 
118
+ it('caps generic strings at 4KB even outside ghost.originalMessage', () => {
119
+ const longMsg = 'y'.repeat(10000);
120
+ const out = sanitizeErrorPayload({
121
+ error: { message: longMsg },
122
+ });
123
+ expect(out.error.message.length).toBeLessThan(4200);
124
+ expect(out.error.message).toContain('[truncated]');
125
+ });
126
+
127
+ it('redacts BEFORE truncating so secrets spanning the cap boundary cannot leak a prefix', () => {
128
+ // Place a 89-byte Ghost-shaped admin key so it STRADDLES the 4096-byte
129
+ // generic cap (starts at 4050, ends at 4139). The ordering matters only
130
+ // for boundary-spanning values:
131
+ // redact-first → full pattern matches, entire secret becomes [REDACTED]
132
+ // before truncation; no prefix of the secret remains in the output.
133
+ // truncate-first → first 4096 bytes keep a 46-byte PREFIX of the secret;
134
+ // GHOST_KEY_PATTERN requires the full 89 chars to match, so the
135
+ // partial prefix slips past redaction and leaks.
136
+ const secret = `${'a'.repeat(24)}:${'b'.repeat(64)}`; // 89 bytes
137
+ const msg = 'x'.repeat(4050) + secret; // 4139 bytes — boundary at 4096 cuts the secret
138
+ const out = sanitizeErrorPayload({ error: { message: msg } });
139
+ expect(out.error.message).not.toContain(secret);
140
+ // The partial prefix that truncate-first would leave behind. If this
141
+ // appears in the output, redaction ran AFTER truncation on a string the
142
+ // pattern no longer matches.
143
+ const leakedPrefix = `${'a'.repeat(24)}:${'b'.repeat(21)}`;
144
+ expect(out.error.message).not.toContain(leakedPrefix);
145
+ expect(out.error.message).toContain('[REDACTED]');
146
+ });
147
+
117
148
  it('does not redact env key when env var is empty', () => {
118
149
  process.env.GHOST_ADMIN_API_KEY = '';
119
150
  const out = sanitizeErrorPayload({ error: { message: 'harmless text' } });
@@ -36,15 +36,14 @@ export function formatErrorResponse(error, toolName, extra) {
36
36
  envelope.ghost = {
37
37
  operation: normalized.operation,
38
38
  statusCode: normalized.ghostStatusCode,
39
- // normalized.originalError is already a string (coerced by ExternalServiceError constructor);
40
- // coerce again defensively so the sanitizer always receives a string, not an Error object.
41
- originalMessage:
42
- typeof normalized.originalError === 'string'
43
- ? normalized.originalError
44
- : (normalized.originalError?.message ?? String(normalized.originalError)),
39
+ // ExternalServiceError's constructor coerces originalError to a string;
40
+ // String() handles any surprise non-string value without branching.
41
+ originalMessage: String(normalized.originalError ?? ''),
45
42
  };
46
43
  }
47
44
 
45
+ // Guard against non-object `extra`: Object.keys(string) returns per-char
46
+ // indices and would silently set envelope.extra to that string.
48
47
  if (extra && typeof extra === 'object' && Object.keys(extra).length > 0) {
49
48
  envelope.extra = extra;
50
49
  }
@@ -9,6 +9,18 @@ const __dirname = path.dirname(__filename);
9
9
  const logLevel = process.env.LOG_LEVEL || 'info';
10
10
  const isDevelopment = process.env.NODE_ENV === 'development';
11
11
 
12
+ // Every level winston emits must route to stderr — the stdio MCP transport
13
+ // reserves stdout for JSON-RPC frames. Single source of truth so adding a new
14
+ // level later can't silently leave one transport routing to stdout.
15
+ const ALL_LEVELS_TO_STDERR = ['error', 'warn', 'info', 'debug'];
16
+
17
+ // Factory for Console transports. Always forces stderrLevels for all npm
18
+ // levels — caller cannot narrow it. Bare `new winston.transports.Console(...)`
19
+ // is forbidden by ESLint outside this module so this factory is the only way
20
+ // to construct a Console transport in the project.
21
+ const createSafeConsoleTransport = (options = {}) =>
22
+ new winston.transports.Console({ ...options, stderrLevels: ALL_LEVELS_TO_STDERR });
23
+
12
24
  // Define custom log format
13
25
  const logFormat = winston.format.combine(
14
26
  winston.format.timestamp({
@@ -42,16 +54,9 @@ const logger = winston.createLogger({
42
54
  service: 'ghost-mcp-server',
43
55
  pid: process.pid,
44
56
  },
45
- transports: [
46
- // Console output
47
- new winston.transports.Console({
48
- level: isDevelopment ? 'debug' : 'info',
49
- }),
50
- ],
51
- // Handle uncaught exceptions
52
- exceptionHandlers: [new winston.transports.Console()],
53
- // Handle unhandled promise rejections
54
- rejectionHandlers: [new winston.transports.Console()],
57
+ transports: [createSafeConsoleTransport({ level: isDevelopment ? 'debug' : 'info' })],
58
+ exceptionHandlers: [createSafeConsoleTransport()],
59
+ rejectionHandlers: [createSafeConsoleTransport()],
55
60
  });
56
61
 
57
62
  // Add file logging in production
@@ -150,4 +155,4 @@ const createContextLogger = (context) => {
150
155
  };
151
156
 
152
157
  export default logger;
153
- export { createContextLogger };
158
+ export { createContextLogger, createSafeConsoleTransport };
@@ -14,6 +14,10 @@ const AUTH_HEADER_PATTERN = /(Authorization|Set-Cookie)\s*[:=]\s*[^\r\n,;]+/gi;
14
14
  // the `Cookie` substring inside `Set-Cookie:` (handled by AUTH_HEADER_PATTERN).
15
15
  const COOKIE_HEADER_PATTERN = /(?<!Set-)(Cookie)\s*[:=]\s*[^\r\n]+/gi;
16
16
  const REDACTED = '[REDACTED]';
17
+ // Upper bound on any string value in the envelope (guards against pathological
18
+ // Ghost responses bloating the MCP reply).
19
+ const GENERIC_MAX_BYTES = 4096;
20
+ // Tighter cap on `ghost.originalMessage` specifically.
17
21
  const ORIGINAL_MESSAGE_MAX_BYTES = 2048;
18
22
 
19
23
  function redactString(value, envKey) {
@@ -38,16 +42,12 @@ function truncate(value, maxBytes) {
38
42
 
39
43
  function walk(node, envKey) {
40
44
  if (node === null || node === undefined) return node;
41
- if (typeof node === 'string') return redactString(node, envKey);
45
+ if (typeof node === 'string') return truncate(redactString(node, envKey), GENERIC_MAX_BYTES);
42
46
  if (Array.isArray(node)) return node.map((item) => walk(item, envKey));
43
47
  if (typeof node === 'object') {
44
48
  const result = {};
45
49
  for (const [k, v] of Object.entries(node)) {
46
- const walked = walk(v, envKey);
47
- result[k] =
48
- k === 'originalMessage' && typeof walked === 'string'
49
- ? truncate(walked, ORIGINAL_MESSAGE_MAX_BYTES)
50
- : walked;
50
+ result[k] = walk(v, envKey);
51
51
  }
52
52
  return result;
53
53
  }
@@ -63,5 +63,15 @@ function walk(node, envKey) {
63
63
  */
64
64
  export function sanitizeErrorPayload(envelope) {
65
65
  const envKey = process.env.GHOST_ADMIN_API_KEY || '';
66
- return walk(envelope, envKey);
66
+ const sanitized = walk(envelope, envKey);
67
+ // Tighter cap for ghost.originalMessage specifically. walk() already applied
68
+ // the 4 KB generic cap; this narrows it further on the field most likely to
69
+ // receive a verbose Ghost response body.
70
+ if (sanitized?.ghost && typeof sanitized.ghost.originalMessage === 'string') {
71
+ sanitized.ghost.originalMessage = truncate(
72
+ sanitized.ghost.originalMessage,
73
+ ORIGINAL_MESSAGE_MAX_BYTES
74
+ );
75
+ }
76
+ return sanitized;
67
77
  }
@@ -2,7 +2,6 @@
2
2
  * Validation utilities for MCP tool handlers
3
3
  * Provides explicit Zod validation to ensure input is validated at handler entry points.
4
4
  */
5
- import { ValidationError } from '../errors/index.js';
6
5
  import { formatErrorResponse } from './formatErrorResponse.js';
7
6
 
8
7
  /**
@@ -16,10 +15,11 @@ import { formatErrorResponse } from './formatErrorResponse.js';
16
15
  export const validateToolInput = (schema, input, toolName) => {
17
16
  const result = schema.safeParse(input);
18
17
  if (!result.success) {
19
- const error = ValidationError.fromZod(result.error, toolName);
18
+ // formatErrorResponse duck-types ZodError and coerces it to ValidationError,
19
+ // so pass the raw ZodError through — it owns the single coercion path.
20
20
  return {
21
21
  success: false,
22
- errorResponse: formatErrorResponse(error, toolName),
22
+ errorResponse: formatErrorResponse(result.error, toolName),
23
23
  };
24
24
  }
25
25
  return { success: true, data: result.data };