@jgardner04/ghost-mcp-server 1.14.0 → 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.0",
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
@@ -8,10 +8,12 @@ import fs from 'fs';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import crypto from 'crypto';
11
- import { ValidationError } from './errors/index.js';
12
11
  import { validateToolInput } from './utils/validation.js';
12
+ import { formatErrorResponse } from './utils/formatErrorResponse.js';
13
+ import { createContextLogger } from './utils/logger.js';
13
14
  import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
14
15
  import { resolveLocalImagePath, decodeBase64ToTempFile } from './utils/imageInputResolver.js';
16
+ import { sanitizeNqlValue } from './utils/nqlSanitizer.js';
15
17
  import {
16
18
  createTagSchema,
17
19
  updateTagSchema,
@@ -38,6 +40,24 @@ import {
38
40
  // Load environment variables
39
41
  dotenv.config({ quiet: true });
40
42
 
43
+ const mcpLogger = createContextLogger('mcp-server');
44
+
45
+ /**
46
+ * Emit structured log fields for a caught error. Never passes the raw error
47
+ * object to the logger — Ghost SDK errors carry request headers/URLs that
48
+ * include credentials.
49
+ */
50
+ const logToolError = (toolName, error, extra = {}) => {
51
+ mcpLogger.error(`Tool ${toolName} failed`, {
52
+ tool: toolName,
53
+ errorName: error?.name,
54
+ errorMessage: error?.message,
55
+ errorCode: error?.code,
56
+ ghostStatusCode: error?.ghostStatusCode,
57
+ ...extra,
58
+ });
59
+ };
60
+
41
61
  // Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
42
62
  let ghostService = null;
43
63
  let postService = null;
@@ -57,9 +77,6 @@ const loadServices = async () => {
57
77
  }
58
78
  };
59
79
 
60
- // Generate UUID without external dependency
61
- const generateUuid = () => crypto.randomUUID();
62
-
63
80
  // Helper function for default alt text
64
81
  const getDefaultAltText = (filePath) => {
65
82
  try {
@@ -73,17 +90,6 @@ const getDefaultAltText = (filePath) => {
73
90
  }
74
91
  };
75
92
 
76
- /**
77
- * Escapes single quotes in NQL filter values by doubling them.
78
- * This prevents filter injection attacks when building NQL query strings.
79
- * Example: "O'Reilly" becomes "O''Reilly" for use in name:'O''Reilly'
80
- * @param {string} value - The value to escape
81
- * @returns {string} The escaped value safe for NQL filter strings
82
- */
83
- const escapeNqlValue = (value) => {
84
- return value.replace(/'/g, "''");
85
- };
86
-
87
93
  /**
88
94
  * Higher-order function that wraps a tool handler with standardized
89
95
  * input validation, service loading, and error handling.
@@ -99,9 +105,7 @@ const escapeNqlValue = (value) => {
99
105
  * @returns {Function} Wrapped async handler for server.registerTool
100
106
  */
101
107
  const withErrorHandling = (toolName, schema, handler) => {
102
- const zodContext = toolName.replace('ghost_', '').replace(/_/g, ' ');
103
108
  return async (rawInput) => {
104
- console.error(`Executing tool: ${toolName}`);
105
109
  const validation = validateToolInput(schema, rawInput, toolName);
106
110
  if (!validation.success) {
107
111
  return validation.errorResponse;
@@ -111,18 +115,8 @@ const withErrorHandling = (toolName, schema, handler) => {
111
115
  await loadServices();
112
116
  return await handler(validation.data);
113
117
  } catch (error) {
114
- console.error(`Error in ${toolName}:`, error);
115
- if (error.name === 'ZodError') {
116
- const validationError = ValidationError.fromZod(error, zodContext);
117
- return {
118
- content: [{ type: 'text', text: JSON.stringify(validationError.toJSON(), null, 2) }],
119
- isError: true,
120
- };
121
- }
122
- return {
123
- content: [{ type: 'text', text: `Error in ${toolName}: ${error.message}` }],
124
- isError: true,
125
- };
118
+ logToolError(toolName, error);
119
+ return formatErrorResponse(error, toolName);
126
120
  }
127
121
  };
128
122
  };
@@ -169,10 +163,13 @@ server.registerTool(
169
163
  if (input.include !== undefined) options.include = input.include;
170
164
 
171
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.
172
169
  const filters = [];
173
- if (input.name) filters.push(`name:'${escapeNqlValue(input.name)}'`);
174
- if (input.slug) filters.push(`slug:'${escapeNqlValue(input.slug)}'`);
175
- 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}'`);
176
173
  if (input.filter) filters.push(input.filter);
177
174
 
178
175
  if (filters.length > 0) {
@@ -180,7 +177,7 @@ server.registerTool(
180
177
  }
181
178
 
182
179
  const tags = await ghostService.getTags(options);
183
- console.error(`Retrieved ${tags.length} tags from Ghost.`);
180
+ mcpLogger.info(`Retrieved ${tags.length} tags from Ghost.`);
184
181
 
185
182
  return {
186
183
  content: [{ type: 'text', text: JSON.stringify(tags, null, 2) }],
@@ -197,7 +194,7 @@ server.registerTool(
197
194
  },
198
195
  withErrorHandling('ghost_create_tag', createTagSchema, async (input) => {
199
196
  const createdTag = await ghostService.createTag(input);
200
- console.error(`Tag created successfully. Tag ID: ${createdTag.id}`);
197
+ mcpLogger.info(`Tag created successfully. Tag ID: ${createdTag.id}`);
201
198
 
202
199
  return {
203
200
  content: [{ type: 'text', text: JSON.stringify(createdTag, null, 2) }],
@@ -216,13 +213,10 @@ server.registerTool(
216
213
  const options = {};
217
214
  if (input.include !== undefined) options.include = input.include;
218
215
 
219
- if (!input.id && !input.slug) {
220
- throw new Error('Either id or slug is required');
221
- }
222
216
  const identifier = input.id || `slug/${input.slug}`;
223
217
 
224
218
  const tag = await ghostService.getTag(identifier, options);
225
- console.error(`Retrieved tag: ${tag.name} (ID: ${tag.id})`);
219
+ mcpLogger.info(`Retrieved tag: ${tag.name} (ID: ${tag.id})`);
226
220
 
227
221
  return {
228
222
  content: [{ type: 'text', text: JSON.stringify(tag, null, 2) }],
@@ -240,7 +234,7 @@ server.registerTool(
240
234
  withErrorHandling('ghost_update_tag', updateTagInputSchema, async (input) => {
241
235
  const { id, ...updateData } = input;
242
236
  const updatedTag = await ghostService.updateTag(id, updateData);
243
- console.error(`Tag updated successfully. Tag ID: ${updatedTag.id}`);
237
+ mcpLogger.info(`Tag updated successfully. Tag ID: ${updatedTag.id}`);
244
238
 
245
239
  return {
246
240
  content: [{ type: 'text', text: JSON.stringify(updatedTag, null, 2) }],
@@ -259,7 +253,7 @@ server.registerTool(
259
253
  withErrorHandling('ghost_delete_tag', deleteTagSchema, async (input) => {
260
254
  const { id } = input;
261
255
  await ghostService.deleteTag(id);
262
- console.error(`Tag deleted successfully. Tag ID: ${id}`);
256
+ mcpLogger.info(`Tag deleted successfully. Tag ID: ${id}`);
263
257
 
264
258
  return {
265
259
  content: [{ type: 'text', text: `Tag ${id} has been successfully deleted.` }],
@@ -338,8 +332,8 @@ async function acquireImageForUpload({ imageUrl, imagePath: localPath, imageBase
338
332
 
339
333
  const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
340
334
  const filenameHint =
341
- path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
342
- 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}`);
343
337
 
344
338
  let bytes = 0;
345
339
  response.data.on('data', (chunk) => {
@@ -428,7 +422,7 @@ function validateAndXorImageInput(schema, rawInput, toolName) {
428
422
  if (xorError) {
429
423
  return {
430
424
  success: false,
431
- errorResponse: { content: [{ type: 'text', text: xorError }], isError: true },
425
+ errorResponse: formatErrorResponse(new Error(xorError), toolName),
432
426
  };
433
427
  }
434
428
  return validation;
@@ -452,11 +446,8 @@ server.registerTool(
452
446
  if (uploadResult.ref) result.ref = uploadResult.ref;
453
447
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
454
448
  } catch (error) {
455
- console.error(`Error in ghost_upload_image:`, error);
456
- return {
457
- content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
458
- isError: true,
459
- };
449
+ logToolError('ghost_upload_image', error);
450
+ return formatErrorResponse(error, 'ghost_upload_image');
460
451
  }
461
452
  }
462
453
  );
@@ -502,11 +493,8 @@ server.registerTool(
502
493
  uploadedRef = uploadResult.ref;
503
494
  altText = finalAltText;
504
495
  } catch (error) {
505
- console.error(`ghost_set_feature_image: upload failed`, error);
506
- return {
507
- content: [{ type: 'text', text: `Upload failed: ${error.message}` }],
508
- isError: true,
509
- };
496
+ logToolError('ghost_set_feature_image', error, { phase: 'upload' });
497
+ return formatErrorResponse(error, 'ghost_set_feature_image');
510
498
  }
511
499
 
512
500
  const updatePayload = {
@@ -520,7 +508,7 @@ server.registerTool(
520
508
  type === 'post'
521
509
  ? await ghostService.updatePost(id, updatePayload)
522
510
  : await ghostService.updatePage(id, updatePayload);
523
- console.error(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
511
+ mcpLogger.info(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
524
512
  return {
525
513
  content: [
526
514
  {
@@ -534,24 +522,14 @@ server.registerTool(
534
522
  ],
535
523
  };
536
524
  } catch (error) {
537
- console.error(`ghost_set_feature_image: update failed (orphaned ${uploadedUrl})`, error);
538
- return {
539
- content: [
540
- {
541
- type: 'text',
542
- text: JSON.stringify(
543
- {
544
- error: `Upload succeeded but ${type} update failed: ${error.message}`,
545
- orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
546
- hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
547
- },
548
- null,
549
- 2
550
- ),
551
- },
552
- ],
553
- isError: true,
554
- };
525
+ logToolError('ghost_set_feature_image', error, {
526
+ phase: 'update',
527
+ orphanedUrl: uploadedUrl,
528
+ });
529
+ return formatErrorResponse(error, 'ghost_set_feature_image', {
530
+ orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
531
+ hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
532
+ });
555
533
  }
556
534
  }
557
535
  );
@@ -600,7 +578,7 @@ server.registerTool(
600
578
  },
601
579
  withErrorHandling('ghost_create_post', createPostSchema, async (input) => {
602
580
  const createdPost = await postService.createPostService(input);
603
- console.error(`Post created successfully. Post ID: ${createdPost.id}`);
581
+ mcpLogger.info(`Post created successfully. Post ID: ${createdPost.id}`);
604
582
 
605
583
  return {
606
584
  content: [{ type: 'text', text: JSON.stringify(createdPost, null, 2) }],
@@ -629,7 +607,7 @@ server.registerTool(
629
607
  if (input.formats !== undefined) options.formats = input.formats;
630
608
 
631
609
  const posts = await ghostService.getPosts(options);
632
- console.error(`Retrieved ${posts.length} posts from Ghost.`);
610
+ mcpLogger.info(`Retrieved ${posts.length} posts from Ghost.`);
633
611
 
634
612
  return {
635
613
  content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
@@ -649,14 +627,11 @@ server.registerTool(
649
627
  const options = {};
650
628
  if (input.include !== undefined) options.include = input.include;
651
629
 
652
- // Determine identifier (prefer ID over slug)
653
- if (!input.id && !input.slug) {
654
- throw new Error('Either id or slug is required');
655
- }
630
+ // Prefer ID over slug. The schema's .refine() guarantees one is present.
656
631
  const identifier = input.id || `slug/${input.slug}`;
657
632
 
658
633
  const post = await ghostService.getPost(identifier, options);
659
- console.error(`Retrieved post: ${post.title} (ID: ${post.id})`);
634
+ mcpLogger.info(`Retrieved post: ${post.title} (ID: ${post.id})`);
660
635
 
661
636
  return {
662
637
  content: [{ type: 'text', text: JSON.stringify(post, null, 2) }],
@@ -678,7 +653,7 @@ server.registerTool(
678
653
  if (input.limit !== undefined) options.limit = input.limit;
679
654
 
680
655
  const posts = await ghostService.searchPosts(input.query, options);
681
- console.error(`Found ${posts.length} posts matching "${input.query}".`);
656
+ mcpLogger.info(`Found ${posts.length} posts matching "${input.query}".`);
682
657
 
683
658
  return {
684
659
  content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
@@ -699,7 +674,7 @@ server.registerTool(
699
674
  const { id, ...updateData } = input;
700
675
 
701
676
  const updatedPost = await ghostService.updatePost(id, updateData);
702
- console.error(`Post updated successfully. Post ID: ${updatedPost.id}`);
677
+ mcpLogger.info(`Post updated successfully. Post ID: ${updatedPost.id}`);
703
678
 
704
679
  return {
705
680
  content: [{ type: 'text', text: JSON.stringify(updatedPost, null, 2) }],
@@ -718,7 +693,7 @@ server.registerTool(
718
693
  withErrorHandling('ghost_delete_post', deletePostSchema, async (input) => {
719
694
  const { id } = input;
720
695
  await ghostService.deletePost(id);
721
- console.error(`Post deleted successfully. Post ID: ${id}`);
696
+ mcpLogger.info(`Post deleted successfully. Post ID: ${id}`);
722
697
 
723
698
  return {
724
699
  content: [{ type: 'text', text: `Post ${id} has been successfully deleted.` }],
@@ -788,7 +763,7 @@ server.registerTool(
788
763
  if (input.order !== undefined) options.order = input.order;
789
764
 
790
765
  const pages = await ghostService.getPages(options);
791
- console.error(`Retrieved ${pages.length} pages from Ghost.`);
766
+ mcpLogger.info(`Retrieved ${pages.length} pages from Ghost.`);
792
767
 
793
768
  return {
794
769
  content: [{ type: 'text', text: JSON.stringify(pages, null, 2) }],
@@ -807,13 +782,10 @@ server.registerTool(
807
782
  const options = {};
808
783
  if (input.include !== undefined) options.include = input.include;
809
784
 
810
- if (!input.id && !input.slug) {
811
- throw new Error('Either id or slug is required');
812
- }
813
785
  const identifier = input.id || `slug/${input.slug}`;
814
786
 
815
787
  const page = await ghostService.getPage(identifier, options);
816
- console.error(`Retrieved page: ${page.title} (ID: ${page.id})`);
788
+ mcpLogger.info(`Retrieved page: ${page.title} (ID: ${page.id})`);
817
789
 
818
790
  return {
819
791
  content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
@@ -831,7 +803,7 @@ server.registerTool(
831
803
  },
832
804
  withErrorHandling('ghost_create_page', createPageSchema, async (input) => {
833
805
  const createdPage = await pageService.createPageService(input);
834
- console.error(`Page created successfully. Page ID: ${createdPage.id}`);
806
+ mcpLogger.info(`Page created successfully. Page ID: ${createdPage.id}`);
835
807
 
836
808
  return {
837
809
  content: [{ type: 'text', text: JSON.stringify(createdPage, null, 2) }],
@@ -851,7 +823,7 @@ server.registerTool(
851
823
  const { id, ...updateData } = input;
852
824
 
853
825
  const updatedPage = await ghostService.updatePage(id, updateData);
854
- console.error(`Page updated successfully. Page ID: ${updatedPage.id}`);
826
+ mcpLogger.info(`Page updated successfully. Page ID: ${updatedPage.id}`);
855
827
 
856
828
  return {
857
829
  content: [{ type: 'text', text: JSON.stringify(updatedPage, null, 2) }],
@@ -870,7 +842,7 @@ server.registerTool(
870
842
  withErrorHandling('ghost_delete_page', deletePageSchema, async (input) => {
871
843
  const { id } = input;
872
844
  await ghostService.deletePage(id);
873
- console.error(`Page deleted successfully. Page ID: ${id}`);
845
+ mcpLogger.info(`Page deleted successfully. Page ID: ${id}`);
874
846
 
875
847
  return {
876
848
  content: [{ type: 'text', text: `Page ${id} has been successfully deleted.` }],
@@ -891,7 +863,7 @@ server.registerTool(
891
863
  if (input.limit !== undefined) options.limit = input.limit;
892
864
 
893
865
  const pages = await ghostService.searchPages(input.query, options);
894
- console.error(`Found ${pages.length} pages matching "${input.query}".`);
866
+ mcpLogger.info(`Found ${pages.length} pages matching "${input.query}".`);
895
867
 
896
868
  return {
897
869
  content: [{ type: 'text', text: JSON.stringify(pages, null, 2) }],
@@ -939,7 +911,7 @@ server.registerTool(
939
911
  },
940
912
  withErrorHandling('ghost_create_member', createMemberSchema, async (input) => {
941
913
  const createdMember = await ghostService.createMember(input);
942
- console.error(`Member created successfully. Member ID: ${createdMember.id}`);
914
+ mcpLogger.info(`Member created successfully. Member ID: ${createdMember.id}`);
943
915
 
944
916
  return {
945
917
  content: [{ type: 'text', text: JSON.stringify(createdMember, null, 2) }],
@@ -958,7 +930,7 @@ server.registerTool(
958
930
  const { id, ...updateData } = input;
959
931
 
960
932
  const updatedMember = await ghostService.updateMember(id, updateData);
961
- console.error(`Member updated successfully. Member ID: ${updatedMember.id}`);
933
+ mcpLogger.info(`Member updated successfully. Member ID: ${updatedMember.id}`);
962
934
 
963
935
  return {
964
936
  content: [{ type: 'text', text: JSON.stringify(updatedMember, null, 2) }],
@@ -977,7 +949,7 @@ server.registerTool(
977
949
  withErrorHandling('ghost_delete_member', deleteMemberSchema, async (input) => {
978
950
  const { id } = input;
979
951
  await ghostService.deleteMember(id);
980
- console.error(`Member deleted successfully. Member ID: ${id}`);
952
+ mcpLogger.info(`Member deleted successfully. Member ID: ${id}`);
981
953
 
982
954
  return {
983
955
  content: [{ type: 'text', text: `Member ${id} has been successfully deleted.` }],
@@ -1002,7 +974,7 @@ server.registerTool(
1002
974
  if (input.include !== undefined) options.include = input.include;
1003
975
 
1004
976
  const members = await ghostService.getMembers(options);
1005
- console.error(`Retrieved ${members.length} members from Ghost.`);
977
+ mcpLogger.info(`Retrieved ${members.length} members from Ghost.`);
1006
978
 
1007
979
  return {
1008
980
  content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
@@ -1021,7 +993,7 @@ server.registerTool(
1021
993
  withErrorHandling('ghost_get_member', getMemberSchema, async (input) => {
1022
994
  const { id, email } = input;
1023
995
  const member = await ghostService.getMember({ id, email });
1024
- console.error(`Retrieved member: ${member.email} (ID: ${member.id})`);
996
+ mcpLogger.info(`Retrieved member: ${member.email} (ID: ${member.id})`);
1025
997
 
1026
998
  return {
1027
999
  content: [{ type: 'text', text: JSON.stringify(member, null, 2) }],
@@ -1042,7 +1014,7 @@ server.registerTool(
1042
1014
  if (limit !== undefined) options.limit = limit;
1043
1015
 
1044
1016
  const members = await ghostService.searchMembers(query, options);
1045
- console.error(`Found ${members.length} members matching "${query}".`);
1017
+ mcpLogger.info(`Found ${members.length} members matching "${query}".`);
1046
1018
 
1047
1019
  return {
1048
1020
  content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
@@ -1074,7 +1046,7 @@ server.registerTool(
1074
1046
  if (input.order !== undefined) options.order = input.order;
1075
1047
 
1076
1048
  const newsletters = await ghostService.getNewsletters(options);
1077
- console.error(`Retrieved ${newsletters.length} newsletters from Ghost.`);
1049
+ mcpLogger.info(`Retrieved ${newsletters.length} newsletters from Ghost.`);
1078
1050
 
1079
1051
  return {
1080
1052
  content: [{ type: 'text', text: JSON.stringify(newsletters, null, 2) }],
@@ -1092,7 +1064,7 @@ server.registerTool(
1092
1064
  withErrorHandling('ghost_get_newsletter', getNewsletterSchema, async (input) => {
1093
1065
  const { id } = input;
1094
1066
  const newsletter = await ghostService.getNewsletter(id);
1095
- console.error(`Retrieved newsletter: ${newsletter.name} (ID: ${newsletter.id})`);
1067
+ mcpLogger.info(`Retrieved newsletter: ${newsletter.name} (ID: ${newsletter.id})`);
1096
1068
 
1097
1069
  return {
1098
1070
  content: [{ type: 'text', text: JSON.stringify(newsletter, null, 2) }],
@@ -1110,7 +1082,7 @@ server.registerTool(
1110
1082
  },
1111
1083
  withErrorHandling('ghost_create_newsletter', createNewsletterSchema, async (input) => {
1112
1084
  const createdNewsletter = await newsletterService.createNewsletterService(input);
1113
- console.error(`Newsletter created successfully. Newsletter ID: ${createdNewsletter.id}`);
1085
+ mcpLogger.info(`Newsletter created successfully. Newsletter ID: ${createdNewsletter.id}`);
1114
1086
 
1115
1087
  return {
1116
1088
  content: [{ type: 'text', text: JSON.stringify(createdNewsletter, null, 2) }],
@@ -1130,7 +1102,7 @@ server.registerTool(
1130
1102
  const { id, ...updateData } = input;
1131
1103
 
1132
1104
  const updatedNewsletter = await ghostService.updateNewsletter(id, updateData);
1133
- console.error(`Newsletter updated successfully. Newsletter ID: ${updatedNewsletter.id}`);
1105
+ mcpLogger.info(`Newsletter updated successfully. Newsletter ID: ${updatedNewsletter.id}`);
1134
1106
 
1135
1107
  return {
1136
1108
  content: [{ type: 'text', text: JSON.stringify(updatedNewsletter, null, 2) }],
@@ -1149,7 +1121,7 @@ server.registerTool(
1149
1121
  withErrorHandling('ghost_delete_newsletter', deleteNewsletterSchema, async (input) => {
1150
1122
  const { id } = input;
1151
1123
  await ghostService.deleteNewsletter(id);
1152
- console.error(`Newsletter deleted successfully. Newsletter ID: ${id}`);
1124
+ mcpLogger.info(`Newsletter deleted successfully. Newsletter ID: ${id}`);
1153
1125
 
1154
1126
  return {
1155
1127
  content: [{ type: 'text', text: `Newsletter ${id} has been successfully deleted.` }],
@@ -1174,7 +1146,7 @@ server.registerTool(
1174
1146
  },
1175
1147
  withErrorHandling('ghost_get_tiers', tierQuerySchema, async (input) => {
1176
1148
  const tiers = await ghostService.getTiers(input);
1177
- console.error(`Retrieved ${tiers.length} tiers`);
1149
+ mcpLogger.info(`Retrieved ${tiers.length} tiers`);
1178
1150
 
1179
1151
  return {
1180
1152
  content: [{ type: 'text', text: JSON.stringify(tiers, null, 2) }],
@@ -1192,7 +1164,7 @@ server.registerTool(
1192
1164
  withErrorHandling('ghost_get_tier', getTierSchema, async (input) => {
1193
1165
  const { id } = input;
1194
1166
  const tier = await ghostService.getTier(id);
1195
- console.error(`Tier retrieved successfully. Tier ID: ${tier.id}`);
1167
+ mcpLogger.info(`Tier retrieved successfully. Tier ID: ${tier.id}`);
1196
1168
 
1197
1169
  return {
1198
1170
  content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
@@ -1209,7 +1181,7 @@ server.registerTool(
1209
1181
  },
1210
1182
  withErrorHandling('ghost_create_tier', createTierSchema, async (input) => {
1211
1183
  const tier = await ghostService.createTier(input);
1212
- console.error(`Tier created successfully. Tier ID: ${tier.id}`);
1184
+ mcpLogger.info(`Tier created successfully. Tier ID: ${tier.id}`);
1213
1185
 
1214
1186
  return {
1215
1187
  content: [{ type: 'text', text: JSON.stringify(tier, null, 2) }],
@@ -1229,7 +1201,7 @@ server.registerTool(
1229
1201
  const { id, ...updateData } = input;
1230
1202
 
1231
1203
  const updatedTier = await ghostService.updateTier(id, updateData);
1232
- console.error(`Tier updated successfully. Tier ID: ${updatedTier.id}`);
1204
+ mcpLogger.info(`Tier updated successfully. Tier ID: ${updatedTier.id}`);
1233
1205
 
1234
1206
  return {
1235
1207
  content: [{ type: 'text', text: JSON.stringify(updatedTier, null, 2) }],
@@ -1248,7 +1220,7 @@ server.registerTool(
1248
1220
  withErrorHandling('ghost_delete_tier', deleteTierSchema, async (input) => {
1249
1221
  const { id } = input;
1250
1222
  await ghostService.deleteTier(id);
1251
- console.error(`Tier deleted successfully. Tier ID: ${id}`);
1223
+ mcpLogger.info(`Tier deleted successfully. Tier ID: ${id}`);
1252
1224
 
1253
1225
  return {
1254
1226
  content: [{ type: 'text', text: `Tier ${id} has been successfully deleted.` }],
@@ -1266,7 +1238,7 @@ async function main() {
1266
1238
 
1267
1239
  console.error('Ghost MCP Server running on stdio transport');
1268
1240
  console.error(
1269
- '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, ' +
1270
1242
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
1271
1243
  'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
1272
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
  /**
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { formatErrorResponse } from '../formatErrorResponse.js';
3
+ import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
4
+
5
+ function parseJsonBlock(text) {
6
+ const match = text.match(/```json\n([\s\S]+?)\n```/);
7
+ expect(match, `no JSON block in: ${text}`).toBeTruthy();
8
+ return JSON.parse(match[1]);
9
+ }
10
+
11
+ describe('formatErrorResponse', () => {
12
+ const originalEnv = process.env.GHOST_ADMIN_API_KEY;
13
+
14
+ beforeEach(() => {
15
+ process.env.GHOST_ADMIN_API_KEY = '';
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (originalEnv === undefined) delete process.env.GHOST_ADMIN_API_KEY;
20
+ else process.env.GHOST_ADMIN_API_KEY = originalEnv;
21
+ });
22
+
23
+ it('returns consistent envelope with error key for generic Error (no ghost key)', () => {
24
+ const response = formatErrorResponse(new Error('boom'), 'ghost_get_posts');
25
+ expect(response.isError).toBe(true);
26
+ expect(response.content[0].type).toBe('text');
27
+ const envelope = parseJsonBlock(response.content[0].text);
28
+ expect(envelope.error).toBeDefined();
29
+ expect(envelope.error.message).toBe('boom');
30
+ expect(envelope).not.toHaveProperty('ghost');
31
+ expect(response.content[0].text).toContain('Error in ghost_get_posts: boom');
32
+ });
33
+
34
+ it('includes gated ghost sub-object for GhostAPIError', () => {
35
+ const err = new GhostAPIError('posts.edit', 'Title is required', 422);
36
+ const response = formatErrorResponse(err, 'ghost_update_post');
37
+ const envelope = parseJsonBlock(response.content[0].text);
38
+ expect(envelope.error.name).toBe('GhostAPIError');
39
+ expect(envelope.error.code).toBe('GHOST_VALIDATION_ERROR');
40
+ expect(envelope.ghost).toBeDefined();
41
+ expect(envelope.ghost.operation).toBe('posts.edit');
42
+ expect(envelope.ghost.statusCode).toBe(422);
43
+ expect(envelope.ghost.originalMessage).toBe('Title is required');
44
+ expect(response.content[0].text).toContain('422');
45
+ expect(response.content[0].text).toContain('posts.edit');
46
+ expect(response.content[0].text).toContain('Title is required');
47
+ });
48
+
49
+ it('uses raw ghostStatusCode (not remapped statusCode) in ghost envelope', () => {
50
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
51
+ // GhostAPIError remaps 422 -> 400 for .statusCode; ghost.statusCode must be 422
52
+ expect(err.statusCode).toBe(400);
53
+ expect(err.ghostStatusCode).toBe(422);
54
+ const envelope = parseJsonBlock(formatErrorResponse(err, 'ghost_update_post').content[0].text);
55
+ expect(envelope.ghost.statusCode).toBe(422);
56
+ expect(envelope.error.statusCode).toBe(400);
57
+ });
58
+
59
+ it('does not leak GHOST_ADMIN_API_KEY in surfaced response', () => {
60
+ process.env.GHOST_ADMIN_API_KEY = 'plaintext-admin-key-xyz';
61
+ const err = new GhostAPIError(
62
+ 'posts.edit',
63
+ 'Ghost complained: token plaintext-admin-key-xyz invalid',
64
+ 401
65
+ );
66
+ const response = formatErrorResponse(err, 'ghost_update_post');
67
+ expect(response.content[0].text).not.toContain('plaintext-admin-key-xyz');
68
+ expect(response.content[0].text).toContain('[REDACTED]');
69
+ });
70
+
71
+ it('does not leak Ghost-shaped admin key pattern in originalMessage', () => {
72
+ const fakeKey = `${'1'.repeat(24)}:${'2'.repeat(64)}`;
73
+ const err = new GhostAPIError('posts.edit', `failed with ${fakeKey}`, 401);
74
+ const response = formatErrorResponse(err, 'ghost_update_post');
75
+ expect(response.content[0].text).not.toContain(fakeKey);
76
+ });
77
+
78
+ it('produces envelope for ValidationError (no ghost key)', () => {
79
+ const err = new ValidationError('Validation failed', [
80
+ { field: 'title', message: 'required', type: 'invalid_type' },
81
+ ]);
82
+ const response = formatErrorResponse(err, 'ghost_update_post');
83
+ const envelope = parseJsonBlock(response.content[0].text);
84
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
85
+ expect(envelope).not.toHaveProperty('ghost');
86
+ });
87
+
88
+ it('produces envelope for NotFoundError (no ghost key)', () => {
89
+ const err = new NotFoundError('Post', 'abc');
90
+ const response = formatErrorResponse(err, 'ghost_get_post');
91
+ const envelope = parseJsonBlock(response.content[0].text);
92
+ expect(envelope.error.code).toBe('NOT_FOUND');
93
+ expect(envelope).not.toHaveProperty('ghost');
94
+ });
95
+
96
+ describe('extra context', () => {
97
+ it('includes and sanitizes extra context when provided', () => {
98
+ const extra = {
99
+ orphanedImage: { url: 'https://cdn.example/img.jpg?key=LEAK_ME_XYZ', ref: 'r1' },
100
+ };
101
+ const response = formatErrorResponse(new Error('boom'), 'ghost_set_feature_image', extra);
102
+ const envelope = parseJsonBlock(response.content[0].text);
103
+ expect(envelope.extra).toBeDefined();
104
+ expect(envelope.extra.orphanedImage.url).toContain('key=[REDACTED]');
105
+ expect(response.content[0].text).not.toContain('LEAK_ME_XYZ');
106
+ });
107
+
108
+ it('omits extra key entirely when arg is not provided', () => {
109
+ const envelope = parseJsonBlock(
110
+ formatErrorResponse(new Error('boom'), 'ghost_update_post').content[0].text
111
+ );
112
+ expect(envelope).not.toHaveProperty('extra');
113
+ });
114
+
115
+ it('omits extra key when arg is empty object (no empty-object leak)', () => {
116
+ const envelope = parseJsonBlock(
117
+ formatErrorResponse(new Error('boom'), 'ghost_update_post', {}).content[0].text
118
+ );
119
+ expect(envelope).not.toHaveProperty('extra');
120
+ });
121
+
122
+ it('combines error, ghost, and extra when all apply', () => {
123
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
124
+ const envelope = parseJsonBlock(
125
+ formatErrorResponse(err, 'ghost_set_feature_image', { orphanedImage: { url: 'x' } })
126
+ .content[0].text
127
+ );
128
+ expect(envelope.error).toBeDefined();
129
+ expect(envelope.ghost).toBeDefined();
130
+ expect(envelope.extra).toBeDefined();
131
+ });
132
+ });
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
+
154
+ describe('ZodError coercion', () => {
155
+ it('coerces ZodError-shaped input to VALIDATION_ERROR / 400 envelope', () => {
156
+ const zodLike = Object.assign(new Error('zod'), {
157
+ name: 'ZodError',
158
+ issues: [{ path: ['purpose'], message: 'Invalid enum value', code: 'invalid_enum_value' }],
159
+ });
160
+ const envelope = parseJsonBlock(
161
+ formatErrorResponse(zodLike, 'ghost_upload_image').content[0].text
162
+ );
163
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
164
+ expect(envelope.error.statusCode).toBe(400);
165
+ expect(envelope.error.errors).toEqual([
166
+ { field: 'purpose', message: 'Invalid enum value', type: 'invalid_enum_value' },
167
+ ]);
168
+ });
169
+
170
+ it('does not coerce a non-ZodError error that happens to have an issues field', () => {
171
+ const notZod = Object.assign(new Error('unrelated'), { issues: [] });
172
+ const envelope = parseJsonBlock(
173
+ formatErrorResponse(notZod, 'ghost_update_post').content[0].text
174
+ );
175
+ expect(envelope.error.code).toBe('UNKNOWN');
176
+ });
177
+ });
178
+ });
@@ -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;
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { sanitizeErrorPayload } from '../sanitizeErrorPayload.js';
3
+
4
+ describe('sanitizeErrorPayload', () => {
5
+ const originalEnv = process.env.GHOST_ADMIN_API_KEY;
6
+
7
+ beforeEach(() => {
8
+ process.env.GHOST_ADMIN_API_KEY = '';
9
+ });
10
+
11
+ afterEach(() => {
12
+ if (originalEnv === undefined) {
13
+ delete process.env.GHOST_ADMIN_API_KEY;
14
+ } else {
15
+ process.env.GHOST_ADMIN_API_KEY = originalEnv;
16
+ }
17
+ });
18
+
19
+ it('redacts GHOST_ADMIN_API_KEY when it appears verbatim in a nested string', () => {
20
+ process.env.GHOST_ADMIN_API_KEY = 'super-secret-env-key-value';
21
+ const input = {
22
+ error: { message: 'Leaked super-secret-env-key-value in text' },
23
+ ghost: { originalMessage: 'also super-secret-env-key-value here' },
24
+ };
25
+ const out = sanitizeErrorPayload(input);
26
+ expect(out.error.message).not.toContain('super-secret-env-key-value');
27
+ expect(out.error.message).toContain('[REDACTED]');
28
+ expect(out.ghost.originalMessage).not.toContain('super-secret-env-key-value');
29
+ });
30
+
31
+ it('redacts Ghost-shaped admin key literal embedded in text', () => {
32
+ const key = `${'a'.repeat(24)}:${'b'.repeat(64)}`;
33
+ const out = sanitizeErrorPayload({
34
+ error: { message: `Bad auth with ${key} failed` },
35
+ });
36
+ expect(out.error.message).not.toContain(key);
37
+ expect(out.error.message).toContain('[REDACTED]');
38
+ });
39
+
40
+ it('redacts key= token= and access_token= query params on URLs', () => {
41
+ const out = sanitizeErrorPayload({
42
+ error: { message: 'GET https://ghost.example/admin?key=abc123&other=fine' },
43
+ ghost: {
44
+ originalMessage: 'https://x/y?token=xyz and https://x/y?access_token=qqq',
45
+ },
46
+ });
47
+ expect(out.error.message).toContain('key=[REDACTED]');
48
+ expect(out.error.message).toContain('other=fine');
49
+ expect(out.ghost.originalMessage).toContain('token=[REDACTED]');
50
+ expect(out.ghost.originalMessage).toContain('access_token=[REDACTED]');
51
+ });
52
+
53
+ it('redacts Authorization / Cookie header-style substrings', () => {
54
+ const out = sanitizeErrorPayload({
55
+ error: { message: 'Authorization: Bearer abc.def.ghi and Cookie: sess=xyz' },
56
+ });
57
+ expect(out.error.message).not.toContain('abc.def.ghi');
58
+ expect(out.error.message).not.toContain('sess=xyz');
59
+ expect(out.error.message).toContain('Authorization');
60
+ expect(out.error.message).toContain('[REDACTED]');
61
+ });
62
+
63
+ it('redacts every value in a multi-value Cookie header', () => {
64
+ const out = sanitizeErrorPayload({
65
+ error: { message: 'Cookie: a=first; b=SECRET_SESSION; c=third' },
66
+ });
67
+ expect(out.error.message).not.toContain('SECRET_SESSION');
68
+ expect(out.error.message).not.toContain('a=first');
69
+ expect(out.error.message).not.toContain('c=third');
70
+ expect(out.error.message).toContain('Cookie: [REDACTED]');
71
+ });
72
+
73
+ it('redacts Set-Cookie value but preserves attributes (HttpOnly, Secure, Path)', () => {
74
+ const out = sanitizeErrorPayload({
75
+ error: { message: 'Set-Cookie: sess=TOKEN_XYZ; HttpOnly; Secure; Path=/' },
76
+ });
77
+ expect(out.error.message).not.toContain('TOKEN_XYZ');
78
+ expect(out.error.message).toContain('Set-Cookie: [REDACTED]');
79
+ expect(out.error.message).toContain('HttpOnly');
80
+ expect(out.error.message).toContain('Secure');
81
+ });
82
+
83
+ it('redacts secrets inside string elements of arrays (truncation flag no longer needed)', () => {
84
+ const out = sanitizeErrorPayload({
85
+ ghost: {
86
+ originalMessage: ['https://x/y?key=LEAKED_1', 'https://x/y?token=LEAKED_2'],
87
+ },
88
+ });
89
+ expect(JSON.stringify(out)).not.toContain('LEAKED_1');
90
+ expect(JSON.stringify(out)).not.toContain('LEAKED_2');
91
+ expect(out.ghost.originalMessage[0]).toContain('key=[REDACTED]');
92
+ expect(out.ghost.originalMessage[1]).toContain('token=[REDACTED]');
93
+ });
94
+
95
+ it('leaves benign content untouched', () => {
96
+ const input = {
97
+ error: { message: 'Title is required', code: 'GHOST_VALIDATION_ERROR', statusCode: 400 },
98
+ ghost: {
99
+ operation: 'posts.edit',
100
+ statusCode: 422,
101
+ originalMessage: 'Post title cannot be blank',
102
+ },
103
+ };
104
+ const out = sanitizeErrorPayload(input);
105
+ expect(out).toEqual(input);
106
+ });
107
+
108
+ it('truncates long originalMessage to 2KB cap', () => {
109
+ const longMsg = 'x'.repeat(5000);
110
+ const out = sanitizeErrorPayload({
111
+ ghost: { originalMessage: longMsg },
112
+ });
113
+ // 2048-byte cap + ellipsis suffix: result stays under 2100 characters.
114
+ expect(out.ghost.originalMessage.length).toBeLessThan(2100);
115
+ expect(out.ghost.originalMessage).toContain('[truncated]');
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
+
148
+ it('does not redact env key when env var is empty', () => {
149
+ process.env.GHOST_ADMIN_API_KEY = '';
150
+ const out = sanitizeErrorPayload({ error: { message: 'harmless text' } });
151
+ expect(out.error.message).toBe('harmless text');
152
+ });
153
+
154
+ it('does not mutate the input object', () => {
155
+ process.env.GHOST_ADMIN_API_KEY = 'SECRET';
156
+ const input = { error: { message: 'contains SECRET' } };
157
+ const snapshot = JSON.parse(JSON.stringify(input));
158
+ sanitizeErrorPayload(input);
159
+ expect(input).toEqual(snapshot);
160
+ });
161
+ });
@@ -54,11 +54,14 @@ describe('validateToolInput', () => {
54
54
  const result = validateToolInput(testSchema, { name: '' }, 'test_tool');
55
55
 
56
56
  expect(result.success).toBe(false);
57
- const errorObj = JSON.parse(result.errorResponse.content[0].text);
58
- expect(errorObj.name).toBe('ValidationError');
59
- expect(errorObj.code).toBe('VALIDATION_ERROR');
60
- expect(errorObj.statusCode).toBe(400);
61
- expect(errorObj.message).toContain('Validation failed');
57
+ const text = result.errorResponse.content[0].text;
58
+ const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
59
+ expect(jsonMatch).toBeTruthy();
60
+ const envelope = JSON.parse(jsonMatch[1]);
61
+ expect(envelope.error.name).toBe('ValidationError');
62
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
63
+ expect(envelope.error.statusCode).toBe(400);
64
+ expect(envelope.error.message).toContain('Validation failed');
62
65
  });
63
66
  });
64
67
 
@@ -141,8 +144,11 @@ describe('validateToolInput', () => {
141
144
 
142
145
  expect(result.success).toBe(false);
143
146
  expect(result.errorResponse.isError).toBe(true);
144
- const errorObj = JSON.parse(result.errorResponse.content[0].text);
145
- expect(errorObj.code).toBe('VALIDATION_ERROR');
147
+ const text = result.errorResponse.content[0].text;
148
+ const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
149
+ expect(jsonMatch).toBeTruthy();
150
+ const envelope = JSON.parse(jsonMatch[1]);
151
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
146
152
  });
147
153
 
148
154
  it('should pass validation when refinement is satisfied', () => {
@@ -0,0 +1,62 @@
1
+ import { BaseError, GhostAPIError, ValidationError } from '../errors/index.js';
2
+ import { sanitizeErrorPayload } from './sanitizeErrorPayload.js';
3
+
4
+ /**
5
+ * Builds an MCP tool error response with a consistent envelope shape.
6
+ * All errors produce `{ error: {...} }`; GhostAPIError additionally includes
7
+ * a gated `ghost` sub-object with Ghost-specific diagnostic fields. Callers
8
+ * may pass an optional `extra` object whose contents will be merged into the
9
+ * envelope under an `extra` key and sanitized alongside the rest.
10
+ *
11
+ * @param {Error} error - The caught error.
12
+ * @param {string} toolName - MCP tool name for the human-readable summary line.
13
+ * @param {object} [extra] - Optional caller-supplied context; sanitized with the envelope.
14
+ * @returns {{content: {type: string, text: string}[], isError: true}}
15
+ */
16
+ export function formatErrorResponse(error, toolName, extra) {
17
+ // Duck-type ZodError so we don't couple this module to the zod runtime.
18
+ const normalized =
19
+ error?.name === 'ZodError' && Array.isArray(error.issues)
20
+ ? ValidationError.fromZod(error, toolName)
21
+ : error;
22
+
23
+ const base =
24
+ normalized instanceof BaseError
25
+ ? normalized.toJSON()
26
+ : {
27
+ name: normalized?.name || 'Error',
28
+ message: normalized?.message || String(normalized),
29
+ code: 'UNKNOWN',
30
+ statusCode: 500,
31
+ };
32
+
33
+ const envelope = { error: base };
34
+
35
+ if (normalized instanceof GhostAPIError) {
36
+ envelope.ghost = {
37
+ operation: normalized.operation,
38
+ statusCode: normalized.ghostStatusCode,
39
+ // ExternalServiceError's constructor coerces originalError to a string;
40
+ // String() handles any surprise non-string value without branching.
41
+ originalMessage: String(normalized.originalError ?? ''),
42
+ };
43
+ }
44
+
45
+ // Guard against non-object `extra`: Object.keys(string) returns per-char
46
+ // indices and would silently set envelope.extra to that string.
47
+ if (extra && typeof extra === 'object' && Object.keys(extra).length > 0) {
48
+ envelope.extra = extra;
49
+ }
50
+
51
+ const sanitized = sanitizeErrorPayload(envelope);
52
+ const summary = sanitized.ghost
53
+ ? `Error in ${toolName}: ${sanitized.error.name} [${sanitized.ghost.statusCode ?? '?'} ${sanitized.error.code}] ${sanitized.ghost.operation ?? '?'}: ${sanitized.ghost.originalMessage ?? sanitized.error.message}`
54
+ : `Error in ${toolName}: ${sanitized.error.message}`;
55
+
56
+ const body = `${summary}\n\n\`\`\`json\n${JSON.stringify(sanitized, null, 2)}\n\`\`\``;
57
+
58
+ return {
59
+ content: [{ type: 'text', text: body }],
60
+ isError: true,
61
+ };
62
+ }
@@ -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 };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Sole chokepoint for sanitizing error payloads surfaced to MCP clients.
3
+ * Walks the envelope and replaces values that could leak credentials.
4
+ */
5
+
6
+ const GHOST_KEY_PATTERN = /[0-9a-f]{24}:[0-9a-f]{64}/gi;
7
+ const URL_SECRET_QS_PATTERN = /([?&](?:key|token|access_token)=)[^&\s"']+/gi;
8
+ // Authorization / Set-Cookie values stop at ';' (Set-Cookie attributes like
9
+ // HttpOnly/Secure are not secrets). A bare `Cookie` header can contain multiple
10
+ // `name=value` pairs separated by ';' — all of which may be session tokens — so
11
+ // it gets a separate, greedier pattern that stops only at end-of-line.
12
+ const AUTH_HEADER_PATTERN = /(Authorization|Set-Cookie)\s*[:=]\s*[^\r\n,;]+/gi;
13
+ // Negative look-behind so this pattern matches a bare `Cookie:` header but not
14
+ // the `Cookie` substring inside `Set-Cookie:` (handled by AUTH_HEADER_PATTERN).
15
+ const COOKIE_HEADER_PATTERN = /(?<!Set-)(Cookie)\s*[:=]\s*[^\r\n]+/gi;
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.
21
+ const ORIGINAL_MESSAGE_MAX_BYTES = 2048;
22
+
23
+ function redactString(value, envKey) {
24
+ if (typeof value !== 'string' || value.length === 0) return value;
25
+ let out = value;
26
+ if (envKey) {
27
+ out = out.replaceAll(envKey, REDACTED);
28
+ }
29
+ out = out.replace(GHOST_KEY_PATTERN, REDACTED);
30
+ out = out.replace(URL_SECRET_QS_PATTERN, `$1${REDACTED}`);
31
+ out = out.replace(AUTH_HEADER_PATTERN, `$1: ${REDACTED}`);
32
+ out = out.replace(COOKIE_HEADER_PATTERN, `$1: ${REDACTED}`);
33
+ return out;
34
+ }
35
+
36
+ function truncate(value, maxBytes) {
37
+ if (typeof value !== 'string') return value;
38
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
39
+ // Slice by bytes (not chars) to honour the byte limit even for multibyte content.
40
+ return `${Buffer.from(value, 'utf8').subarray(0, maxBytes).toString('utf8')}…[truncated]`;
41
+ }
42
+
43
+ function walk(node, envKey) {
44
+ if (node === null || node === undefined) return node;
45
+ if (typeof node === 'string') return truncate(redactString(node, envKey), GENERIC_MAX_BYTES);
46
+ if (Array.isArray(node)) return node.map((item) => walk(item, envKey));
47
+ if (typeof node === 'object') {
48
+ const result = {};
49
+ for (const [k, v] of Object.entries(node)) {
50
+ result[k] = walk(v, envKey);
51
+ }
52
+ return result;
53
+ }
54
+ return node;
55
+ }
56
+
57
+ /**
58
+ * Deep-walks an error envelope and redacts known secret patterns.
59
+ * Non-destructive: returns a new object; does not mutate input.
60
+ *
61
+ * @param {object} envelope - Error envelope object.
62
+ * @returns {object} Sanitized envelope.
63
+ */
64
+ export function sanitizeErrorPayload(envelope) {
65
+ const envKey = process.env.GHOST_ADMIN_API_KEY || '';
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;
77
+ }
@@ -2,7 +2,7 @@
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';
5
+ import { formatErrorResponse } from './formatErrorResponse.js';
6
6
 
7
7
  /**
8
8
  * Validates tool input against a Zod schema and returns a structured result.
@@ -15,13 +15,11 @@ import { ValidationError } from '../errors/index.js';
15
15
  export const validateToolInput = (schema, input, toolName) => {
16
16
  const result = schema.safeParse(input);
17
17
  if (!result.success) {
18
- 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.
19
20
  return {
20
21
  success: false,
21
- errorResponse: {
22
- content: [{ type: 'text', text: JSON.stringify(error.toJSON(), null, 2) }],
23
- isError: true,
24
- },
22
+ errorResponse: formatErrorResponse(result.error, toolName),
25
23
  };
26
24
  }
27
25
  return { success: true, data: result.data };