@jgardner04/ghost-mcp-server 1.6.0 → 1.8.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_improved.test.js +285 -0
- package/src/mcp_server_improved.js +124 -1
- package/src/schemas/__tests__/common.test.js +275 -0
- package/src/schemas/__tests__/postSchemas.test.js +194 -0
- package/src/schemas/__tests__/tagSchemas.test.js +262 -0
- package/src/schemas/common.js +227 -0
- package/src/schemas/index.js +39 -0
- package/src/schemas/memberSchemas.js +188 -0
- package/src/schemas/newsletterSchemas.js +168 -0
- package/src/schemas/pageSchemas.js +215 -0
- package/src/schemas/postSchemas.js +210 -0
- package/src/schemas/tagSchemas.js +136 -0
- package/src/schemas/tierSchemas.js +206 -0
- package/src/services/ghostServiceImproved.js +24 -3
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createTagSchema,
|
|
4
|
+
updateTagSchema,
|
|
5
|
+
tagQuerySchema,
|
|
6
|
+
tagIdSchema,
|
|
7
|
+
tagOutputSchema,
|
|
8
|
+
} from '../tagSchemas.js';
|
|
9
|
+
|
|
10
|
+
describe('Tag Schemas', () => {
|
|
11
|
+
describe('createTagSchema', () => {
|
|
12
|
+
it('should accept valid tag creation data', () => {
|
|
13
|
+
const validTag = {
|
|
14
|
+
name: 'Technology',
|
|
15
|
+
slug: 'technology',
|
|
16
|
+
description: 'Posts about technology',
|
|
17
|
+
visibility: 'public',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
expect(() => createTagSchema.parse(validTag)).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should accept minimal tag creation data (name only)', () => {
|
|
24
|
+
const minimalTag = {
|
|
25
|
+
name: 'News',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const result = createTagSchema.parse(minimalTag);
|
|
29
|
+
expect(result.name).toBe('News');
|
|
30
|
+
expect(result.visibility).toBe('public'); // default value
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should accept tag with all optional fields', () => {
|
|
34
|
+
const fullTag = {
|
|
35
|
+
name: 'Technology',
|
|
36
|
+
slug: 'tech',
|
|
37
|
+
description: 'Tech posts',
|
|
38
|
+
feature_image: 'https://example.com/image.jpg',
|
|
39
|
+
visibility: 'public',
|
|
40
|
+
meta_title: 'Technology Posts',
|
|
41
|
+
meta_description: 'All about tech',
|
|
42
|
+
og_image: 'https://example.com/og.jpg',
|
|
43
|
+
og_title: 'Tech on Our Blog',
|
|
44
|
+
og_description: 'Technology articles',
|
|
45
|
+
twitter_image: 'https://example.com/twitter.jpg',
|
|
46
|
+
twitter_title: 'Tech Posts',
|
|
47
|
+
twitter_description: 'Latest tech news',
|
|
48
|
+
codeinjection_head: '<script>console.log("head")</script>',
|
|
49
|
+
codeinjection_foot: '<script>console.log("foot")</script>',
|
|
50
|
+
canonical_url: 'https://example.com/tech',
|
|
51
|
+
accent_color: '#FF5733',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
expect(() => createTagSchema.parse(fullTag)).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should reject tag without name', () => {
|
|
58
|
+
const invalidTag = {
|
|
59
|
+
slug: 'tech',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(() => createTagSchema.parse(invalidTag)).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should reject tag with invalid slug', () => {
|
|
66
|
+
const invalidTag = {
|
|
67
|
+
name: 'Technology',
|
|
68
|
+
slug: 'Tech_Posts', // uppercase and underscore not allowed
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
expect(() => createTagSchema.parse(invalidTag)).toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should reject tag with invalid accent color', () => {
|
|
75
|
+
const invalidTag = {
|
|
76
|
+
name: 'Technology',
|
|
77
|
+
accent_color: 'red', // must be hex format
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(() => createTagSchema.parse(invalidTag)).toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should reject tag with too long description', () => {
|
|
84
|
+
const invalidTag = {
|
|
85
|
+
name: 'Technology',
|
|
86
|
+
description: 'A'.repeat(501),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
expect(() => createTagSchema.parse(invalidTag)).toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should reject tag with invalid visibility', () => {
|
|
93
|
+
const invalidTag = {
|
|
94
|
+
name: 'Technology',
|
|
95
|
+
visibility: 'private', // not a valid value
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
expect(() => createTagSchema.parse(invalidTag)).toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('updateTagSchema', () => {
|
|
103
|
+
it('should accept partial tag updates', () => {
|
|
104
|
+
const update = {
|
|
105
|
+
description: 'Updated description',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
expect(() => updateTagSchema.parse(update)).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should accept empty update object', () => {
|
|
112
|
+
expect(() => updateTagSchema.parse({})).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should accept full tag update', () => {
|
|
116
|
+
const update = {
|
|
117
|
+
name: 'Updated Name',
|
|
118
|
+
slug: 'updated-slug',
|
|
119
|
+
description: 'Updated description',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
expect(() => updateTagSchema.parse(update)).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('tagQuerySchema', () => {
|
|
127
|
+
it('should accept valid query parameters', () => {
|
|
128
|
+
const query = {
|
|
129
|
+
name: 'Technology',
|
|
130
|
+
limit: 20,
|
|
131
|
+
page: 2,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should accept query with NQL filter', () => {
|
|
138
|
+
const query = {
|
|
139
|
+
filter: 'visibility:public+featured:true',
|
|
140
|
+
limit: 10,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should accept query with include parameter', () => {
|
|
147
|
+
const query = {
|
|
148
|
+
include: 'count.posts',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should accept query with order parameter', () => {
|
|
155
|
+
const query = {
|
|
156
|
+
order: 'name ASC',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
expect(() => tagQuerySchema.parse(query)).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should reject query with invalid filter characters', () => {
|
|
163
|
+
const query = {
|
|
164
|
+
filter: 'status;DROP TABLE',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
expect(() => tagQuerySchema.parse(query)).toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should accept empty query object', () => {
|
|
171
|
+
const result = tagQuerySchema.parse({});
|
|
172
|
+
expect(result).toBeDefined();
|
|
173
|
+
// Note: optional fields with defaults don't apply when field is omitted
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('tagIdSchema', () => {
|
|
178
|
+
it('should accept valid Ghost ID', () => {
|
|
179
|
+
const validId = {
|
|
180
|
+
id: '507f1f77bcf86cd799439011',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
expect(() => tagIdSchema.parse(validId)).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should reject invalid Ghost ID', () => {
|
|
187
|
+
const invalidId = {
|
|
188
|
+
id: 'invalid-id',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
expect(() => tagIdSchema.parse(invalidId)).toThrow();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('tagOutputSchema', () => {
|
|
196
|
+
it('should accept valid tag output from Ghost API', () => {
|
|
197
|
+
const apiTag = {
|
|
198
|
+
id: '507f1f77bcf86cd799439011',
|
|
199
|
+
name: 'Technology',
|
|
200
|
+
slug: 'technology',
|
|
201
|
+
description: 'Tech posts',
|
|
202
|
+
feature_image: 'https://example.com/image.jpg',
|
|
203
|
+
visibility: 'public',
|
|
204
|
+
meta_title: 'Tech Posts',
|
|
205
|
+
meta_description: 'Technology articles',
|
|
206
|
+
og_image: 'https://example.com/og.jpg',
|
|
207
|
+
og_title: 'Tech',
|
|
208
|
+
og_description: 'Tech posts',
|
|
209
|
+
twitter_image: 'https://example.com/twitter.jpg',
|
|
210
|
+
twitter_title: 'Tech',
|
|
211
|
+
twitter_description: 'Tech posts',
|
|
212
|
+
codeinjection_head: null,
|
|
213
|
+
codeinjection_foot: null,
|
|
214
|
+
canonical_url: 'https://example.com/tech',
|
|
215
|
+
accent_color: '#FF5733',
|
|
216
|
+
created_at: '2024-01-15T10:30:00.000Z',
|
|
217
|
+
updated_at: '2024-01-15T10:30:00.000Z',
|
|
218
|
+
url: 'https://example.com/tag/technology',
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should accept tag with null optional fields', () => {
|
|
225
|
+
const apiTag = {
|
|
226
|
+
id: '507f1f77bcf86cd799439011',
|
|
227
|
+
name: 'Technology',
|
|
228
|
+
slug: 'technology',
|
|
229
|
+
description: null,
|
|
230
|
+
feature_image: null,
|
|
231
|
+
visibility: 'public',
|
|
232
|
+
meta_title: null,
|
|
233
|
+
meta_description: null,
|
|
234
|
+
og_image: null,
|
|
235
|
+
og_title: null,
|
|
236
|
+
og_description: null,
|
|
237
|
+
twitter_image: null,
|
|
238
|
+
twitter_title: null,
|
|
239
|
+
twitter_description: null,
|
|
240
|
+
codeinjection_head: null,
|
|
241
|
+
codeinjection_foot: null,
|
|
242
|
+
canonical_url: null,
|
|
243
|
+
accent_color: null,
|
|
244
|
+
created_at: '2024-01-15T10:30:00.000Z',
|
|
245
|
+
updated_at: '2024-01-15T10:30:00.000Z',
|
|
246
|
+
url: 'https://example.com/tag/technology',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should reject tag output without required fields', () => {
|
|
253
|
+
const invalidTag = {
|
|
254
|
+
name: 'Technology',
|
|
255
|
+
slug: 'technology',
|
|
256
|
+
// missing id, created_at, updated_at, url, visibility
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
expect(() => tagOutputSchema.parse(invalidTag)).toThrow();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common Zod schemas for validation across all Ghost MCP resources.
|
|
5
|
+
* These validators provide consistent validation and security controls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ----- Basic Type Validators -----
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Email validation schema
|
|
12
|
+
* Validates proper email format
|
|
13
|
+
*/
|
|
14
|
+
export const emailSchema = z.string().email('Invalid email format');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* URL validation schema
|
|
18
|
+
* Validates proper URL format (http/https)
|
|
19
|
+
*/
|
|
20
|
+
export const urlSchema = z.string().url('Invalid URL format');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ISO 8601 datetime validation schema
|
|
24
|
+
* Validates ISO datetime strings
|
|
25
|
+
*/
|
|
26
|
+
export const isoDateSchema = z.string().datetime('Invalid ISO 8601 datetime format');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Slug validation schema
|
|
30
|
+
* Validates URL-friendly slugs (lowercase alphanumeric with hyphens)
|
|
31
|
+
*/
|
|
32
|
+
export const slugSchema = z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ghost ID validation schema
|
|
38
|
+
* Validates 24-character hexadecimal Ghost object IDs
|
|
39
|
+
*/
|
|
40
|
+
export const ghostIdSchema = z
|
|
41
|
+
.string()
|
|
42
|
+
.regex(/^[a-f0-9]{24}$/, 'Invalid Ghost ID format (must be 24 hex characters)');
|
|
43
|
+
|
|
44
|
+
// ----- Security Validators -----
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* NQL (Ghost Query Language) filter validation schema
|
|
48
|
+
* Prevents injection attacks by restricting allowed characters
|
|
49
|
+
* Allows: alphanumeric, underscores, hyphens, colons, dots, quotes, spaces, commas, brackets, comparison operators
|
|
50
|
+
*/
|
|
51
|
+
export const nqlFilterSchema = z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid NQL filter: contains disallowed characters')
|
|
54
|
+
.optional();
|
|
55
|
+
|
|
56
|
+
// ----- Pagination Validators -----
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Limit validation schema for pagination
|
|
60
|
+
* Restricts result count between 1 and 100
|
|
61
|
+
*/
|
|
62
|
+
export const limitSchema = z
|
|
63
|
+
.number()
|
|
64
|
+
.int('Limit must be an integer')
|
|
65
|
+
.min(1, 'Limit must be at least 1')
|
|
66
|
+
.max(100, 'Limit cannot exceed 100')
|
|
67
|
+
.default(15);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Page number validation schema for pagination
|
|
71
|
+
* Must be a positive integer starting from 1
|
|
72
|
+
*/
|
|
73
|
+
export const pageSchema = z
|
|
74
|
+
.number()
|
|
75
|
+
.int('Page must be an integer')
|
|
76
|
+
.min(1, 'Page must be at least 1')
|
|
77
|
+
.default(1);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Complete pagination options schema
|
|
81
|
+
*/
|
|
82
|
+
export const paginationSchema = z.object({
|
|
83
|
+
limit: limitSchema,
|
|
84
|
+
page: pageSchema,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ----- Status Enum Validators -----
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Post/Page status validation schema
|
|
91
|
+
* Valid values: draft, published, scheduled
|
|
92
|
+
*/
|
|
93
|
+
export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
|
|
94
|
+
errorMap: () => ({ message: 'Status must be draft, published, or scheduled' }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Visibility validation schema
|
|
99
|
+
* Controls content visibility (public, members, paid, tiers)
|
|
100
|
+
*/
|
|
101
|
+
export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
|
|
102
|
+
errorMap: () => ({ message: 'Visibility must be public, members, paid, or tiers' }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ----- Common Field Validators -----
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* HTML content validation schema
|
|
109
|
+
* Validates that content is a non-empty string
|
|
110
|
+
* Note: HTML sanitization should be performed separately
|
|
111
|
+
*/
|
|
112
|
+
export const htmlContentSchema = z.string().min(1, 'HTML content cannot be empty');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Title validation schema
|
|
116
|
+
* Validates post/page titles (1-255 characters)
|
|
117
|
+
*/
|
|
118
|
+
export const titleSchema = z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1, 'Title cannot be empty')
|
|
121
|
+
.max(255, 'Title cannot exceed 255 characters');
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Excerpt/description validation schema
|
|
125
|
+
* Optional text snippet (max 500 characters)
|
|
126
|
+
*/
|
|
127
|
+
export const excerptSchema = z.string().max(500, 'Excerpt cannot exceed 500 characters').optional();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Meta title validation schema (SEO)
|
|
131
|
+
* Optional SEO title (max 300 characters)
|
|
132
|
+
*/
|
|
133
|
+
export const metaTitleSchema = z
|
|
134
|
+
.string()
|
|
135
|
+
.max(300, 'Meta title cannot exceed 300 characters')
|
|
136
|
+
.optional();
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Meta description validation schema (SEO)
|
|
140
|
+
* Optional SEO description (max 500 characters)
|
|
141
|
+
*/
|
|
142
|
+
export const metaDescriptionSchema = z
|
|
143
|
+
.string()
|
|
144
|
+
.max(500, 'Meta description cannot exceed 500 characters')
|
|
145
|
+
.optional();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Featured flag validation schema
|
|
149
|
+
* Boolean indicating if content is featured
|
|
150
|
+
*/
|
|
151
|
+
export const featuredSchema = z.boolean().default(false);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Feature image URL validation schema
|
|
155
|
+
* Optional URL for featured image
|
|
156
|
+
*/
|
|
157
|
+
export const featureImageSchema = z.string().url('Invalid feature image URL').optional();
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Feature image alt text validation schema
|
|
161
|
+
* Optional alt text for accessibility
|
|
162
|
+
*/
|
|
163
|
+
export const featureImageAltSchema = z
|
|
164
|
+
.string()
|
|
165
|
+
.max(125, 'Feature image alt text cannot exceed 125 characters')
|
|
166
|
+
.optional();
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Tag name validation schema
|
|
170
|
+
* Tag names (1-191 characters)
|
|
171
|
+
*/
|
|
172
|
+
export const tagNameSchema = z
|
|
173
|
+
.string()
|
|
174
|
+
.min(1, 'Tag name cannot be empty')
|
|
175
|
+
.max(191, 'Tag name cannot exceed 191 characters');
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Authors array validation schema
|
|
179
|
+
* Array of Ghost author IDs or email addresses
|
|
180
|
+
*/
|
|
181
|
+
export const authorsSchema = z.array(z.string()).optional();
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Tags array validation schema
|
|
185
|
+
* Array of tag names or Ghost tag IDs
|
|
186
|
+
*/
|
|
187
|
+
export const tagsSchema = z.array(z.string()).optional();
|
|
188
|
+
|
|
189
|
+
// ----- Utility Validators -----
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Canonical URL validation schema
|
|
193
|
+
* Optional canonical URL for SEO
|
|
194
|
+
*/
|
|
195
|
+
export const canonicalUrlSchema = z.string().url('Invalid canonical URL').optional();
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Code injection validation schema
|
|
199
|
+
* Optional HTML/JS code injection (use with caution)
|
|
200
|
+
*/
|
|
201
|
+
export const codeInjectionSchema = z
|
|
202
|
+
.object({
|
|
203
|
+
head: z.string().optional(),
|
|
204
|
+
foot: z.string().optional(),
|
|
205
|
+
})
|
|
206
|
+
.optional();
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* OG (Open Graph) image validation schema
|
|
210
|
+
* Optional social media image URL
|
|
211
|
+
*/
|
|
212
|
+
export const ogImageSchema = z.string().url('Invalid OG image URL').optional();
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Twitter image validation schema
|
|
216
|
+
* Optional Twitter card image URL
|
|
217
|
+
*/
|
|
218
|
+
export const twitterImageSchema = z.string().url('Invalid Twitter image URL').optional();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Custom excerpt validation schema for social media
|
|
222
|
+
* Optional custom excerpt for social sharing
|
|
223
|
+
*/
|
|
224
|
+
export const customExcerptSchema = z
|
|
225
|
+
.string()
|
|
226
|
+
.max(300, 'Custom excerpt cannot exceed 300 characters')
|
|
227
|
+
.optional();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized Zod Schema Library for Ghost MCP Server
|
|
3
|
+
*
|
|
4
|
+
* This module exports all validation schemas for Ghost CMS resources.
|
|
5
|
+
* Use these schemas for consistent input/output validation across all MCP tools.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { createPostSchema, tagQuerySchema } from './schemas/index.js';
|
|
9
|
+
*
|
|
10
|
+
* // Validate input
|
|
11
|
+
* const validatedPost = createPostSchema.parse(inputData);
|
|
12
|
+
*
|
|
13
|
+
* // Safe parse with error handling
|
|
14
|
+
* const result = tagQuerySchema.safeParse(queryParams);
|
|
15
|
+
* if (!result.success) {
|
|
16
|
+
* console.error(result.error);
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Common validators and utilities
|
|
21
|
+
export * from './common.js';
|
|
22
|
+
|
|
23
|
+
// Post schemas
|
|
24
|
+
export * from './postSchemas.js';
|
|
25
|
+
|
|
26
|
+
// Page schemas
|
|
27
|
+
export * from './pageSchemas.js';
|
|
28
|
+
|
|
29
|
+
// Tag schemas
|
|
30
|
+
export * from './tagSchemas.js';
|
|
31
|
+
|
|
32
|
+
// Member schemas
|
|
33
|
+
export * from './memberSchemas.js';
|
|
34
|
+
|
|
35
|
+
// Newsletter schemas
|
|
36
|
+
export * from './newsletterSchemas.js';
|
|
37
|
+
|
|
38
|
+
// Tier (membership/product) schemas
|
|
39
|
+
export * from './tierSchemas.js';
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ghostIdSchema, emailSchema } from './common.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Member Schemas for Ghost CMS
|
|
6
|
+
* Provides input/output validation for member operations
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ----- Input Schemas -----
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Schema for creating a new member
|
|
13
|
+
* Required: email
|
|
14
|
+
* Optional: name, note, subscribed, labels, etc.
|
|
15
|
+
*/
|
|
16
|
+
export const createMemberSchema = z.object({
|
|
17
|
+
email: emailSchema,
|
|
18
|
+
name: z.string().max(191, 'Name cannot exceed 191 characters').optional(),
|
|
19
|
+
note: z.string().max(2000, 'Note cannot exceed 2000 characters').optional(),
|
|
20
|
+
subscribed: z.boolean().default(true).describe('Whether member is subscribed to newsletter'),
|
|
21
|
+
comped: z.boolean().default(false).describe('Whether member has complimentary subscription'),
|
|
22
|
+
labels: z.array(z.string()).optional().describe('Array of label names to associate with member'),
|
|
23
|
+
newsletters: z
|
|
24
|
+
.array(ghostIdSchema)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Array of newsletter IDs to subscribe member to'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Schema for updating an existing member
|
|
31
|
+
* All fields optional
|
|
32
|
+
*/
|
|
33
|
+
export const updateMemberSchema = createMemberSchema.partial();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Schema for member query/filter parameters
|
|
37
|
+
*/
|
|
38
|
+
export const memberQuerySchema = z.object({
|
|
39
|
+
limit: z.number().int().min(1).max(100).default(15).optional(),
|
|
40
|
+
page: z.number().int().min(1).default(1).optional(),
|
|
41
|
+
filter: z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('NQL filter string (e.g., "status:paid+subscribed:true")'),
|
|
46
|
+
include: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe('Comma-separated list of relations (e.g., "labels,newsletters")'),
|
|
50
|
+
order: z.string().optional().describe('Order results (e.g., "created_at DESC", "name ASC")'),
|
|
51
|
+
search: z.string().optional().describe('Search members by name or email'),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Schema for member ID parameter
|
|
56
|
+
*/
|
|
57
|
+
export const memberIdSchema = z.object({
|
|
58
|
+
id: ghostIdSchema,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Schema for member email parameter
|
|
63
|
+
*/
|
|
64
|
+
export const memberEmailSchema = z.object({
|
|
65
|
+
email: emailSchema,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ----- Output Schemas -----
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Schema for a label object (nested in member)
|
|
72
|
+
*/
|
|
73
|
+
export const labelOutputSchema = z.object({
|
|
74
|
+
id: ghostIdSchema,
|
|
75
|
+
name: z.string(),
|
|
76
|
+
slug: z.string(),
|
|
77
|
+
created_at: z.string().datetime(),
|
|
78
|
+
updated_at: z.string().datetime(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Schema for a newsletter object (nested in member)
|
|
83
|
+
*/
|
|
84
|
+
export const newsletterOutputSchema = z.object({
|
|
85
|
+
id: ghostIdSchema,
|
|
86
|
+
uuid: z.string().uuid(),
|
|
87
|
+
name: z.string(),
|
|
88
|
+
description: z.string().nullable().optional(),
|
|
89
|
+
slug: z.string(),
|
|
90
|
+
sender_name: z.string().nullable().optional(),
|
|
91
|
+
sender_email: z.string().email().nullable().optional(),
|
|
92
|
+
sender_reply_to: z.enum(['newsletter', 'support']).optional(),
|
|
93
|
+
status: z.enum(['active', 'archived']),
|
|
94
|
+
visibility: z.enum(['members', 'paid']),
|
|
95
|
+
subscribe_on_signup: z.boolean(),
|
|
96
|
+
sort_order: z.number(),
|
|
97
|
+
header_image: z.string().url().nullable().optional(),
|
|
98
|
+
show_header_icon: z.boolean(),
|
|
99
|
+
show_header_title: z.boolean(),
|
|
100
|
+
title_font_category: z.string(),
|
|
101
|
+
title_alignment: z.enum(['left', 'center']),
|
|
102
|
+
show_feature_image: z.boolean(),
|
|
103
|
+
body_font_category: z.string(),
|
|
104
|
+
footer_content: z.string().nullable().optional(),
|
|
105
|
+
show_badge: z.boolean(),
|
|
106
|
+
created_at: z.string().datetime(),
|
|
107
|
+
updated_at: z.string().datetime(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Schema for member subscription (tier/product)
|
|
112
|
+
*/
|
|
113
|
+
export const memberSubscriptionSchema = z.object({
|
|
114
|
+
id: z.string(),
|
|
115
|
+
customer: z.object({
|
|
116
|
+
id: z.string(),
|
|
117
|
+
name: z.string().nullable().optional(),
|
|
118
|
+
email: z.string().email(),
|
|
119
|
+
}),
|
|
120
|
+
plan: z.object({
|
|
121
|
+
id: z.string(),
|
|
122
|
+
nickname: z.string(),
|
|
123
|
+
amount: z.number(),
|
|
124
|
+
interval: z.enum(['month', 'year']),
|
|
125
|
+
currency: z.string(),
|
|
126
|
+
}),
|
|
127
|
+
status: z.enum(['active', 'trialing', 'past_due', 'canceled', 'unpaid']),
|
|
128
|
+
start_date: z.string().datetime(),
|
|
129
|
+
current_period_end: z.string().datetime(),
|
|
130
|
+
cancel_at_period_end: z.boolean(),
|
|
131
|
+
cancellation_reason: z.string().nullable().optional(),
|
|
132
|
+
trial_start_date: z.string().datetime().nullable().optional(),
|
|
133
|
+
trial_end_date: z.string().datetime().nullable().optional(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Schema for a Ghost member object (as returned by the API)
|
|
138
|
+
*/
|
|
139
|
+
export const memberOutputSchema = z.object({
|
|
140
|
+
id: ghostIdSchema,
|
|
141
|
+
uuid: z.string().uuid(),
|
|
142
|
+
email: z.string().email(),
|
|
143
|
+
name: z.string().nullable().optional(),
|
|
144
|
+
note: z.string().nullable().optional(),
|
|
145
|
+
geolocation: z.string().nullable().optional(),
|
|
146
|
+
enable_comment_notifications: z.boolean(),
|
|
147
|
+
email_count: z.number(),
|
|
148
|
+
email_opened_count: z.number(),
|
|
149
|
+
email_open_rate: z.number().nullable().optional(),
|
|
150
|
+
status: z.enum(['free', 'paid', 'comped']),
|
|
151
|
+
created_at: z.string().datetime(),
|
|
152
|
+
updated_at: z.string().datetime(),
|
|
153
|
+
subscribed: z.boolean(),
|
|
154
|
+
comped: z.boolean(),
|
|
155
|
+
email_suppression: z
|
|
156
|
+
.object({
|
|
157
|
+
suppressed: z.boolean(),
|
|
158
|
+
info: z.string().nullable().optional(),
|
|
159
|
+
})
|
|
160
|
+
.nullable()
|
|
161
|
+
.optional(),
|
|
162
|
+
labels: z.array(labelOutputSchema).optional(),
|
|
163
|
+
subscriptions: z.array(memberSubscriptionSchema).optional(),
|
|
164
|
+
newsletters: z.array(newsletterOutputSchema).optional(),
|
|
165
|
+
avatar_image: z.string().url().nullable().optional(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Schema for array of members
|
|
170
|
+
*/
|
|
171
|
+
export const membersArraySchema = z.array(memberOutputSchema);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Schema for paginated member response
|
|
175
|
+
*/
|
|
176
|
+
export const membersPaginatedSchema = z.object({
|
|
177
|
+
members: membersArraySchema,
|
|
178
|
+
meta: z.object({
|
|
179
|
+
pagination: z.object({
|
|
180
|
+
page: z.number(),
|
|
181
|
+
limit: z.number(),
|
|
182
|
+
pages: z.number(),
|
|
183
|
+
total: z.number(),
|
|
184
|
+
next: z.number().nullable(),
|
|
185
|
+
prev: z.number().nullable(),
|
|
186
|
+
}),
|
|
187
|
+
}),
|
|
188
|
+
});
|