@jgardner04/ghost-mcp-server 1.11.0 → 1.12.0
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 +10 -4
- package/src/__tests__/mcp_server_improved.test.js +192 -149
- package/src/__tests__/mcp_server_pages.test.js +72 -68
- package/src/errors/__tests__/index.test.js +70 -0
- package/src/errors/index.js +10 -0
- package/src/mcp_server.js +9 -19
- package/src/mcp_server_improved.js +684 -472
- package/src/schemas/__tests__/common.test.js +84 -0
- package/src/schemas/common.js +50 -3
- package/src/services/__tests__/ghostServiceImproved.members.test.js +12 -61
- package/src/services/__tests__/postService.test.js +7 -99
- package/src/services/ghostServiceImproved.js +10 -21
- package/src/services/postService.js +4 -30
- package/src/utils/__tests__/tempFileManager.test.js +316 -0
- package/src/utils/__tests__/validation.test.js +163 -0
- package/src/utils/tempFileManager.js +113 -0
- package/src/utils/validation.js +28 -0
|
@@ -177,6 +177,90 @@ describe('Common Schemas', () => {
|
|
|
177
177
|
it('should reject empty strings', () => {
|
|
178
178
|
expect(() => htmlContentSchema.parse('')).toThrow();
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// XSS Prevention Tests
|
|
182
|
+
describe('XSS sanitization', () => {
|
|
183
|
+
it('should strip script tags', () => {
|
|
184
|
+
const result = htmlContentSchema.parse('<p>Safe</p><script>alert("xss")</script>');
|
|
185
|
+
expect(result).not.toContain('<script>');
|
|
186
|
+
expect(result).not.toContain('alert');
|
|
187
|
+
expect(result).toContain('<p>Safe</p>');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should strip onclick and other event handlers', () => {
|
|
191
|
+
const result = htmlContentSchema.parse('<p onclick="alert(1)">Click me</p>');
|
|
192
|
+
expect(result).not.toContain('onclick');
|
|
193
|
+
expect(result).toContain('<p>Click me</p>');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should strip javascript: URLs', () => {
|
|
197
|
+
const result = htmlContentSchema.parse('<a href="javascript:alert(1)">Link</a>');
|
|
198
|
+
expect(result).not.toContain('javascript:');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should strip onerror handlers on images', () => {
|
|
202
|
+
const result = htmlContentSchema.parse('<img src="x" onerror="alert(1)">');
|
|
203
|
+
expect(result).not.toContain('onerror');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should allow safe tags', () => {
|
|
207
|
+
const safeHtml =
|
|
208
|
+
'<h1>Title</h1><p>Paragraph</p><a href="https://example.com">Link</a><ul><li>Item</li></ul>';
|
|
209
|
+
const result = htmlContentSchema.parse(safeHtml);
|
|
210
|
+
expect(result).toContain('<h1>');
|
|
211
|
+
expect(result).toContain('<p>');
|
|
212
|
+
expect(result).toContain('<a ');
|
|
213
|
+
expect(result).toContain('<ul>');
|
|
214
|
+
expect(result).toContain('<li>');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should allow safe attributes on links', () => {
|
|
218
|
+
const result = htmlContentSchema.parse(
|
|
219
|
+
'<a href="https://example.com" title="Example">Link</a>'
|
|
220
|
+
);
|
|
221
|
+
expect(result).toContain('href="https://example.com"');
|
|
222
|
+
expect(result).toContain('title="Example"');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should allow safe attributes on images', () => {
|
|
226
|
+
const result = htmlContentSchema.parse(
|
|
227
|
+
'<img src="https://example.com/img.jpg" alt="Description" title="Title" width="100" height="100">'
|
|
228
|
+
);
|
|
229
|
+
expect(result).toContain('src="https://example.com/img.jpg"');
|
|
230
|
+
expect(result).toContain('alt="Description"');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should strip style attributes by default', () => {
|
|
234
|
+
const result = htmlContentSchema.parse('<p style="color: red">Styled</p>');
|
|
235
|
+
expect(result).not.toContain('style=');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should strip iframe tags', () => {
|
|
239
|
+
const result = htmlContentSchema.parse(
|
|
240
|
+
'<iframe src="https://evil.com"></iframe><p>Safe</p>'
|
|
241
|
+
);
|
|
242
|
+
expect(result).not.toContain('<iframe');
|
|
243
|
+
expect(result).toContain('<p>Safe</p>');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should strip data: URLs on images', () => {
|
|
247
|
+
// data: URLs can be used for XSS in some contexts
|
|
248
|
+
const result = htmlContentSchema.parse(
|
|
249
|
+
'<img src="data:text/html,<script>alert(1)</script>">'
|
|
250
|
+
);
|
|
251
|
+
// The src should either be removed or the tag stripped
|
|
252
|
+
expect(result).not.toContain('<script>');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should preserve text content while stripping dangerous elements', () => {
|
|
256
|
+
const result = htmlContentSchema.parse(
|
|
257
|
+
'<div>Safe text<script>evil()</script> more text</div>'
|
|
258
|
+
);
|
|
259
|
+
expect(result).toContain('Safe text');
|
|
260
|
+
expect(result).toContain('more text');
|
|
261
|
+
expect(result).not.toContain('evil');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
180
264
|
});
|
|
181
265
|
|
|
182
266
|
describe('titleSchema', () => {
|
package/src/schemas/common.js
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import sanitizeHtml from 'sanitize-html';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Common Zod schemas for validation across all Ghost MCP resources.
|
|
5
6
|
* These validators provide consistent validation and security controls.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* HTML sanitization configuration
|
|
11
|
+
* Prevents XSS attacks by allowing only safe HTML tags and attributes
|
|
12
|
+
*/
|
|
13
|
+
const htmlSanitizeConfig = {
|
|
14
|
+
allowedTags: [
|
|
15
|
+
'h1',
|
|
16
|
+
'h2',
|
|
17
|
+
'h3',
|
|
18
|
+
'h4',
|
|
19
|
+
'h5',
|
|
20
|
+
'h6',
|
|
21
|
+
'blockquote',
|
|
22
|
+
'p',
|
|
23
|
+
'a',
|
|
24
|
+
'ul',
|
|
25
|
+
'ol',
|
|
26
|
+
'nl',
|
|
27
|
+
'li',
|
|
28
|
+
'b',
|
|
29
|
+
'i',
|
|
30
|
+
'strong',
|
|
31
|
+
'em',
|
|
32
|
+
'strike',
|
|
33
|
+
'code',
|
|
34
|
+
'hr',
|
|
35
|
+
'br',
|
|
36
|
+
'div',
|
|
37
|
+
'span',
|
|
38
|
+
'img',
|
|
39
|
+
'pre',
|
|
40
|
+
'figure',
|
|
41
|
+
'figcaption',
|
|
42
|
+
],
|
|
43
|
+
allowedAttributes: {
|
|
44
|
+
a: ['href', 'name', 'target', 'rel', 'title'],
|
|
45
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
46
|
+
'*': ['class', 'id'],
|
|
47
|
+
},
|
|
48
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
49
|
+
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
|
50
|
+
};
|
|
51
|
+
|
|
8
52
|
// ----- Basic Type Validators -----
|
|
9
53
|
|
|
10
54
|
/**
|
|
@@ -106,10 +150,13 @@ export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
|
|
|
106
150
|
|
|
107
151
|
/**
|
|
108
152
|
* HTML content validation schema
|
|
109
|
-
* Validates that content is a non-empty string
|
|
110
|
-
*
|
|
153
|
+
* Validates that content is a non-empty string and sanitizes HTML to prevent XSS
|
|
154
|
+
* Uses transform to sanitize HTML at schema level (defense-in-depth)
|
|
111
155
|
*/
|
|
112
|
-
export const htmlContentSchema = z
|
|
156
|
+
export const htmlContentSchema = z
|
|
157
|
+
.string()
|
|
158
|
+
.min(1, 'HTML content cannot be empty')
|
|
159
|
+
.transform((html) => sanitizeHtml(html, htmlSanitizeConfig));
|
|
113
160
|
|
|
114
161
|
/**
|
|
115
162
|
* Title validation schema
|
|
@@ -128,24 +128,9 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
128
128
|
expect(result).toEqual(mockCreatedMember);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
it('should throw validation error for invalid email', async () => {
|
|
136
|
-
await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
137
|
-
'Member validation failed'
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should throw validation error for invalid labels type', async () => {
|
|
142
|
-
await expect(
|
|
143
|
-
createMember({
|
|
144
|
-
email: 'test@example.com',
|
|
145
|
-
labels: 'premium',
|
|
146
|
-
})
|
|
147
|
-
).rejects.toThrow('Member validation failed');
|
|
148
|
-
});
|
|
131
|
+
// NOTE: Input validation tests (missing email, invalid email, invalid labels)
|
|
132
|
+
// have been moved to MCP layer tests. The service layer now relies on
|
|
133
|
+
// Zod schema validation at the MCP tool layer.
|
|
149
134
|
|
|
150
135
|
it('should handle Ghost API errors', async () => {
|
|
151
136
|
const memberData = {
|
|
@@ -225,11 +210,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
225
210
|
);
|
|
226
211
|
});
|
|
227
212
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'Member validation failed'
|
|
231
|
-
);
|
|
232
|
-
});
|
|
213
|
+
// NOTE: Input validation tests (invalid email in update) have been moved to
|
|
214
|
+
// MCP layer tests. The service layer now relies on Zod schema validation.
|
|
233
215
|
|
|
234
216
|
it('should throw not found error if member does not exist', async () => {
|
|
235
217
|
api.members.read.mockRejectedValue({
|
|
@@ -345,14 +327,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
345
327
|
);
|
|
346
328
|
});
|
|
347
329
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it('should throw validation error for invalid page', async () => {
|
|
354
|
-
await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
|
|
355
|
-
});
|
|
330
|
+
// NOTE: Input validation tests (invalid limit, invalid page) have been moved to
|
|
331
|
+
// MCP layer tests. The service layer now relies on Zod schema validation.
|
|
356
332
|
|
|
357
333
|
it('should return empty array when no members found', async () => {
|
|
358
334
|
api.members.browse.mockResolvedValue([]);
|
|
@@ -407,15 +383,8 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
407
383
|
expect(result).toEqual(mockMember);
|
|
408
384
|
});
|
|
409
385
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it('should throw validation error for invalid email format', async () => {
|
|
415
|
-
await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
416
|
-
'Member lookup validation failed'
|
|
417
|
-
);
|
|
418
|
-
});
|
|
386
|
+
// NOTE: Input validation tests (missing id/email, invalid email format) have been
|
|
387
|
+
// moved to MCP layer tests. The service layer now relies on Zod schema validation.
|
|
419
388
|
|
|
420
389
|
it('should throw not found error when member not found by ID', async () => {
|
|
421
390
|
api.members.read.mockRejectedValue({
|
|
@@ -493,27 +462,9 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
493
462
|
);
|
|
494
463
|
});
|
|
495
464
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
it('should throw validation error for non-string query', async () => {
|
|
502
|
-
await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
|
|
503
|
-
await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it('should throw validation error for invalid limit', async () => {
|
|
507
|
-
await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
|
|
508
|
-
'Search options validation failed'
|
|
509
|
-
);
|
|
510
|
-
await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
|
|
511
|
-
'Search options validation failed'
|
|
512
|
-
);
|
|
513
|
-
await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
|
|
514
|
-
'Search options validation failed'
|
|
515
|
-
);
|
|
516
|
-
});
|
|
465
|
+
// NOTE: Input validation tests (empty query, non-string query, invalid limit)
|
|
466
|
+
// have been moved to MCP layer tests. The service layer now relies on
|
|
467
|
+
// Zod schema validation at the MCP tool layer.
|
|
517
468
|
|
|
518
469
|
it('should sanitize query to prevent NQL injection', async () => {
|
|
519
470
|
const mockMembers = [];
|
|
@@ -26,7 +26,11 @@ describe('postService', () => {
|
|
|
26
26
|
vi.clearAllMocks();
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// NOTE: Input validation tests have been moved to MCP layer tests.
|
|
30
|
+
// The postService no longer performs Joi validation - input is validated
|
|
31
|
+
// by Zod schemas at the MCP tool layer (see mcp_server_improved.js).
|
|
32
|
+
|
|
33
|
+
describe('createPostService - basic functionality', () => {
|
|
30
34
|
it('should accept valid input and create a post', async () => {
|
|
31
35
|
const validInput = {
|
|
32
36
|
title: 'Test Post',
|
|
@@ -47,41 +51,6 @@ describe('postService', () => {
|
|
|
47
51
|
);
|
|
48
52
|
});
|
|
49
53
|
|
|
50
|
-
it('should reject input with missing title', async () => {
|
|
51
|
-
const invalidInput = {
|
|
52
|
-
html: '<p>Test content</p>',
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
56
|
-
'Invalid post input: "title" is required'
|
|
57
|
-
);
|
|
58
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should reject input with missing html', async () => {
|
|
62
|
-
const invalidInput = {
|
|
63
|
-
title: 'Test Post',
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
67
|
-
'Invalid post input: "html" is required'
|
|
68
|
-
);
|
|
69
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should reject input with invalid status', async () => {
|
|
73
|
-
const invalidInput = {
|
|
74
|
-
title: 'Test Post',
|
|
75
|
-
html: '<p>Content</p>',
|
|
76
|
-
status: 'invalid-status',
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
80
|
-
'Invalid post input: "status" must be one of [draft, published, scheduled]'
|
|
81
|
-
);
|
|
82
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
54
|
it('should accept valid status values', async () => {
|
|
86
55
|
const statuses = ['draft', 'published', 'scheduled'];
|
|
87
56
|
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
@@ -100,39 +69,6 @@ describe('postService', () => {
|
|
|
100
69
|
}
|
|
101
70
|
});
|
|
102
71
|
|
|
103
|
-
it('should validate tags array with maximum length', async () => {
|
|
104
|
-
const invalidInput = {
|
|
105
|
-
title: 'Test Post',
|
|
106
|
-
html: '<p>Content</p>',
|
|
107
|
-
tags: Array(11).fill('tag'), // 11 tags exceeds max of 10
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
111
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('should validate tag string max length', async () => {
|
|
115
|
-
const invalidInput = {
|
|
116
|
-
title: 'Test Post',
|
|
117
|
-
html: '<p>Content</p>',
|
|
118
|
-
tags: ['a'.repeat(51)], // 51 chars exceeds max of 50
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
122
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('should validate feature_image is a valid URI', async () => {
|
|
126
|
-
const invalidInput = {
|
|
127
|
-
title: 'Test Post',
|
|
128
|
-
html: '<p>Content</p>',
|
|
129
|
-
feature_image: 'not-a-valid-url',
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
133
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
72
|
it('should accept valid feature_image URI', async () => {
|
|
137
73
|
const validInput = {
|
|
138
74
|
title: 'Test Post',
|
|
@@ -217,27 +153,7 @@ describe('postService', () => {
|
|
|
217
153
|
);
|
|
218
154
|
});
|
|
219
155
|
|
|
220
|
-
|
|
221
|
-
const input = {
|
|
222
|
-
title: 'Test Post',
|
|
223
|
-
html: '<p>Content</p>',
|
|
224
|
-
tags: [null, 'valid-tag'],
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
228
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('should reject tags array with empty strings', async () => {
|
|
232
|
-
const input = {
|
|
233
|
-
title: 'Test Post',
|
|
234
|
-
html: '<p>Content</p>',
|
|
235
|
-
tags: ['', 'valid-tag'],
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
239
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
240
|
-
});
|
|
156
|
+
// NOTE: Tag validation tests (non-string values, empty strings) moved to MCP layer
|
|
241
157
|
|
|
242
158
|
it('should trim whitespace from tag names', async () => {
|
|
243
159
|
const input = {
|
|
@@ -383,15 +299,7 @@ describe('postService', () => {
|
|
|
383
299
|
expect(calledDescription).toBe('a'.repeat(497) + '...');
|
|
384
300
|
});
|
|
385
301
|
|
|
386
|
-
|
|
387
|
-
const input = {
|
|
388
|
-
title: 'Test Post',
|
|
389
|
-
html: '',
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
393
|
-
expect(createPost).not.toHaveBeenCalled();
|
|
394
|
-
});
|
|
302
|
+
// NOTE: Empty HTML validation test moved to MCP layer
|
|
395
303
|
|
|
396
304
|
it('should strip HTML tags and truncate when generating meta_description', async () => {
|
|
397
305
|
const longHtml = '<p>' + 'word '.repeat(200) + '</p>';
|
|
@@ -792,10 +792,7 @@ export async function deleteTag(tagId) {
|
|
|
792
792
|
* @throws {GhostAPIError} If the API request fails
|
|
793
793
|
*/
|
|
794
794
|
export async function createMember(memberData, options = {}) {
|
|
795
|
-
//
|
|
796
|
-
const { validateMemberData } = await import('./memberService.js');
|
|
797
|
-
validateMemberData(memberData);
|
|
798
|
-
|
|
795
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
799
796
|
try {
|
|
800
797
|
return await handleApiRequest('members', 'add', memberData, options);
|
|
801
798
|
} catch (error) {
|
|
@@ -825,14 +822,11 @@ export async function createMember(memberData, options = {}) {
|
|
|
825
822
|
* @throws {GhostAPIError} If the API request fails
|
|
826
823
|
*/
|
|
827
824
|
export async function updateMember(memberId, updateData, options = {}) {
|
|
825
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
828
826
|
if (!memberId) {
|
|
829
827
|
throw new ValidationError('Member ID is required for update');
|
|
830
828
|
}
|
|
831
829
|
|
|
832
|
-
// Import and validate update data
|
|
833
|
-
const { validateMemberUpdateData } = await import('./memberService.js');
|
|
834
|
-
validateMemberUpdateData(updateData);
|
|
835
|
-
|
|
836
830
|
try {
|
|
837
831
|
// Get existing member to retrieve updated_at for conflict resolution
|
|
838
832
|
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
@@ -889,10 +883,7 @@ export async function deleteMember(memberId) {
|
|
|
889
883
|
* @throws {GhostAPIError} If the API request fails
|
|
890
884
|
*/
|
|
891
885
|
export async function getMembers(options = {}) {
|
|
892
|
-
//
|
|
893
|
-
const { validateMemberQueryOptions } = await import('./memberService.js');
|
|
894
|
-
validateMemberQueryOptions(options);
|
|
895
|
-
|
|
886
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
896
887
|
const defaultOptions = {
|
|
897
888
|
limit: 15,
|
|
898
889
|
...options,
|
|
@@ -918,12 +909,12 @@ export async function getMembers(options = {}) {
|
|
|
918
909
|
* @throws {GhostAPIError} If the API request fails
|
|
919
910
|
*/
|
|
920
911
|
export async function getMember(params) {
|
|
921
|
-
//
|
|
922
|
-
const {
|
|
923
|
-
const {
|
|
912
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
913
|
+
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
914
|
+
const { id, email } = params;
|
|
924
915
|
|
|
925
916
|
try {
|
|
926
|
-
if (
|
|
917
|
+
if (id) {
|
|
927
918
|
// Lookup by ID using read endpoint
|
|
928
919
|
return await handleApiRequest('members', 'read', { id }, { id });
|
|
929
920
|
} else {
|
|
@@ -960,11 +951,9 @@ export async function getMember(params) {
|
|
|
960
951
|
* @throws {GhostAPIError} If the API request fails
|
|
961
952
|
*/
|
|
962
953
|
export async function searchMembers(query, options = {}) {
|
|
963
|
-
//
|
|
964
|
-
const {
|
|
965
|
-
|
|
966
|
-
validateSearchOptions(options);
|
|
967
|
-
const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
|
|
954
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
955
|
+
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
956
|
+
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
968
957
|
|
|
969
958
|
const limit = options.limit || 15;
|
|
970
959
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import sanitizeHtml from 'sanitize-html';
|
|
2
|
-
import Joi from 'joi';
|
|
3
2
|
import { createContextLogger } from '../utils/logger.js';
|
|
4
3
|
import {
|
|
5
4
|
createPost as createGhostPost,
|
|
6
5
|
getTags as getGhostTags,
|
|
7
6
|
createTag as createGhostTag,
|
|
8
|
-
|
|
9
|
-
} from './ghostService.js'; // Note the relative path
|
|
7
|
+
} from './ghostService.js';
|
|
10
8
|
|
|
11
9
|
/**
|
|
12
10
|
* Helper to generate a simple meta description from HTML content.
|
|
@@ -37,37 +35,13 @@ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
|
|
|
37
35
|
/**
|
|
38
36
|
* Service layer function to handle the business logic of creating a post.
|
|
39
37
|
* Transforms input data, handles/resolves tags, includes feature image and metadata.
|
|
40
|
-
*
|
|
38
|
+
* Input validation is handled at the MCP layer using Zod schemas.
|
|
39
|
+
* @param {object} postInput - Validated data received from the MCP tool.
|
|
41
40
|
* @returns {Promise<object>} The created post object from the Ghost API.
|
|
42
41
|
*/
|
|
43
|
-
// Validation schema for post input
|
|
44
|
-
const postInputSchema = Joi.object({
|
|
45
|
-
title: Joi.string().max(255).required(),
|
|
46
|
-
html: Joi.string().required(),
|
|
47
|
-
custom_excerpt: Joi.string().max(500).optional(),
|
|
48
|
-
status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
|
|
49
|
-
published_at: Joi.string().isoDate().optional(),
|
|
50
|
-
tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
|
|
51
|
-
feature_image: Joi.string().uri().optional(),
|
|
52
|
-
feature_image_alt: Joi.string().max(255).optional(),
|
|
53
|
-
feature_image_caption: Joi.string().max(500).optional(),
|
|
54
|
-
meta_title: Joi.string().max(70).optional(),
|
|
55
|
-
meta_description: Joi.string().max(160).optional(),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
42
|
const createPostService = async (postInput) => {
|
|
59
43
|
const logger = createContextLogger('post-service');
|
|
60
44
|
|
|
61
|
-
// Validate input to prevent format string vulnerabilities
|
|
62
|
-
const { error, value: validatedInput } = postInputSchema.validate(postInput);
|
|
63
|
-
if (error) {
|
|
64
|
-
logger.error('Post input validation failed', {
|
|
65
|
-
error: error.details[0].message,
|
|
66
|
-
inputKeys: Object.keys(postInput),
|
|
67
|
-
});
|
|
68
|
-
throw new Error(`Invalid post input: ${error.details[0].message}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
45
|
const {
|
|
72
46
|
title,
|
|
73
47
|
html,
|
|
@@ -80,7 +54,7 @@ const createPostService = async (postInput) => {
|
|
|
80
54
|
feature_image_caption,
|
|
81
55
|
meta_title,
|
|
82
56
|
meta_description,
|
|
83
|
-
} =
|
|
57
|
+
} = postInput;
|
|
84
58
|
|
|
85
59
|
// --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
|
|
86
60
|
let resolvedTags = [];
|