@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 +1 -1
- package/src/__tests__/mcp_server.test.js +25 -4
- package/src/mcp_server.js +46 -66
- package/src/services/ghostService.js +1 -9
- package/src/utils/__tests__/formatErrorResponse.test.js +20 -0
- package/src/utils/__tests__/logger.test.js +90 -1
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +33 -2
- package/src/utils/formatErrorResponse.js +5 -6
- package/src/utils/logger.js +16 -11
- package/src/utils/sanitizeErrorPayload.js +17 -7
- package/src/utils/validation.js +3 -3
package/package.json
CHANGED
|
@@ -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'
|
|
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'
|
|
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:'${
|
|
182
|
-
if (input.slug) filters.push(`slug:'${
|
|
183
|
-
if (input.visibility) filters.push(`visibility:'${input.visibility}'`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
350
|
-
const downloadedPath = path.join(tempDir, `mcp-download-${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
}
|
package/src/utils/logger.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/validation.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|