@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 +1 -1
- package/src/__tests__/mcp_server.test.js +25 -4
- package/src/mcp_server.js +80 -108
- package/src/services/ghostService.js +1 -9
- package/src/utils/__tests__/formatErrorResponse.test.js +178 -0
- package/src/utils/__tests__/logger.test.js +90 -1
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +161 -0
- package/src/utils/__tests__/validation.test.js +13 -7
- package/src/utils/formatErrorResponse.js +62 -0
- package/src/utils/logger.js +16 -11
- package/src/utils/sanitizeErrorPayload.js +77 -0
- package/src/utils/validation.js +4 -6
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
|
@@ -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
|
-
|
|
115
|
-
|
|
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:'${
|
|
174
|
-
if (input.slug) filters.push(`slug:'${
|
|
175
|
-
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}'`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
342
|
-
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}`);
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
58
|
-
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
expect(
|
|
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
|
|
145
|
-
|
|
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
|
+
}
|
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 };
|
|
@@ -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
|
+
}
|
package/src/utils/validation.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 };
|