@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,136 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
ghostIdSchema,
|
|
4
|
+
slugSchema,
|
|
5
|
+
tagNameSchema,
|
|
6
|
+
metaTitleSchema,
|
|
7
|
+
metaDescriptionSchema,
|
|
8
|
+
canonicalUrlSchema,
|
|
9
|
+
featureImageSchema,
|
|
10
|
+
visibilitySchema,
|
|
11
|
+
ogImageSchema,
|
|
12
|
+
twitterImageSchema,
|
|
13
|
+
} from './common.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tag Schemas for Ghost CMS
|
|
17
|
+
* Provides input/output validation for tag operations
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ----- Input Schemas -----
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Schema for creating a new tag
|
|
24
|
+
* Required: name
|
|
25
|
+
* Optional: slug, description, feature_image, visibility, meta fields
|
|
26
|
+
*/
|
|
27
|
+
export const createTagSchema = z.object({
|
|
28
|
+
name: tagNameSchema,
|
|
29
|
+
slug: slugSchema.optional(),
|
|
30
|
+
description: z.string().max(500, 'Description cannot exceed 500 characters').optional(),
|
|
31
|
+
feature_image: featureImageSchema,
|
|
32
|
+
visibility: visibilitySchema.default('public'),
|
|
33
|
+
meta_title: metaTitleSchema,
|
|
34
|
+
meta_description: metaDescriptionSchema,
|
|
35
|
+
og_image: ogImageSchema,
|
|
36
|
+
og_title: z.string().max(300, 'OG title cannot exceed 300 characters').optional(),
|
|
37
|
+
og_description: z.string().max(500, 'OG description cannot exceed 500 characters').optional(),
|
|
38
|
+
twitter_image: twitterImageSchema,
|
|
39
|
+
twitter_title: z.string().max(300, 'Twitter title cannot exceed 300 characters').optional(),
|
|
40
|
+
twitter_description: z
|
|
41
|
+
.string()
|
|
42
|
+
.max(500, 'Twitter description cannot exceed 500 characters')
|
|
43
|
+
.optional(),
|
|
44
|
+
codeinjection_head: z.string().optional(),
|
|
45
|
+
codeinjection_foot: z.string().optional(),
|
|
46
|
+
canonical_url: canonicalUrlSchema,
|
|
47
|
+
accent_color: z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^#[0-9A-Fa-f]{6}$/, 'Accent color must be a hex color code (e.g., #FF5733)')
|
|
50
|
+
.optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Schema for updating an existing tag
|
|
55
|
+
* All fields optional except when doing full replacement
|
|
56
|
+
*/
|
|
57
|
+
export const updateTagSchema = createTagSchema.partial();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Schema for tag query/filter parameters
|
|
61
|
+
*/
|
|
62
|
+
export const tagQuerySchema = z.object({
|
|
63
|
+
name: z.string().optional().describe('Filter by exact tag name'),
|
|
64
|
+
slug: z.string().optional().describe('Filter by tag slug'),
|
|
65
|
+
visibility: visibilitySchema.optional().describe('Filter by visibility'),
|
|
66
|
+
limit: z.number().int().min(1).max(100).default(15).optional(),
|
|
67
|
+
page: z.number().int().min(1).default(1).optional(),
|
|
68
|
+
filter: z
|
|
69
|
+
.string()
|
|
70
|
+
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('NQL filter string'),
|
|
73
|
+
include: z
|
|
74
|
+
.string()
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('Comma-separated list of relations to include (e.g., "count.posts")'),
|
|
77
|
+
order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Schema for tag ID parameter
|
|
82
|
+
*/
|
|
83
|
+
export const tagIdSchema = z.object({
|
|
84
|
+
id: ghostIdSchema,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ----- Output Schemas -----
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Schema for a Ghost tag object (as returned by the API)
|
|
91
|
+
*/
|
|
92
|
+
export const tagOutputSchema = z.object({
|
|
93
|
+
id: ghostIdSchema,
|
|
94
|
+
name: z.string(),
|
|
95
|
+
slug: z.string(),
|
|
96
|
+
description: z.string().nullable().optional(),
|
|
97
|
+
feature_image: z.string().url().nullable().optional(),
|
|
98
|
+
visibility: visibilitySchema,
|
|
99
|
+
meta_title: z.string().nullable().optional(),
|
|
100
|
+
meta_description: z.string().nullable().optional(),
|
|
101
|
+
og_image: z.string().url().nullable().optional(),
|
|
102
|
+
og_title: z.string().nullable().optional(),
|
|
103
|
+
og_description: z.string().nullable().optional(),
|
|
104
|
+
twitter_image: z.string().url().nullable().optional(),
|
|
105
|
+
twitter_title: z.string().nullable().optional(),
|
|
106
|
+
twitter_description: z.string().nullable().optional(),
|
|
107
|
+
codeinjection_head: z.string().nullable().optional(),
|
|
108
|
+
codeinjection_foot: z.string().nullable().optional(),
|
|
109
|
+
canonical_url: z.string().url().nullable().optional(),
|
|
110
|
+
accent_color: z.string().nullable().optional(),
|
|
111
|
+
created_at: z.string().datetime(),
|
|
112
|
+
updated_at: z.string().datetime(),
|
|
113
|
+
url: z.string().url(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Schema for array of tags
|
|
118
|
+
*/
|
|
119
|
+
export const tagsArraySchema = z.array(tagOutputSchema);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Schema for paginated tag response
|
|
123
|
+
*/
|
|
124
|
+
export const tagsPaginatedSchema = z.object({
|
|
125
|
+
tags: tagsArraySchema,
|
|
126
|
+
meta: z.object({
|
|
127
|
+
pagination: z.object({
|
|
128
|
+
page: z.number(),
|
|
129
|
+
limit: z.number(),
|
|
130
|
+
pages: z.number(),
|
|
131
|
+
total: z.number(),
|
|
132
|
+
next: z.number().nullable(),
|
|
133
|
+
prev: z.number().nullable(),
|
|
134
|
+
}),
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ghostIdSchema, slugSchema } from './common.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tier (Membership/Product) Schemas for Ghost CMS
|
|
6
|
+
* Tiers represent membership levels and pricing plans
|
|
7
|
+
* Provides input/output validation for tier operations
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ----- Input Schemas -----
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Schema for tier benefits (features)
|
|
14
|
+
*/
|
|
15
|
+
export const tierBenefitSchema = z.object({
|
|
16
|
+
name: z.string().min(1, 'Benefit name cannot be empty').max(191, 'Benefit name too long'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Schema for monthly price configuration
|
|
21
|
+
*/
|
|
22
|
+
export const monthlyPriceSchema = z.object({
|
|
23
|
+
amount: z.number().int().min(0, 'Monthly price must be non-negative'),
|
|
24
|
+
currency: z
|
|
25
|
+
.string()
|
|
26
|
+
.length(3, 'Currency must be 3-letter ISO code (e.g., USD, EUR)')
|
|
27
|
+
.regex(/^[A-Z]{3}$/, 'Currency must be uppercase 3-letter code'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Schema for yearly price configuration
|
|
32
|
+
*/
|
|
33
|
+
export const yearlyPriceSchema = z.object({
|
|
34
|
+
amount: z.number().int().min(0, 'Yearly price must be non-negative'),
|
|
35
|
+
currency: z
|
|
36
|
+
.string()
|
|
37
|
+
.length(3, 'Currency must be 3-letter ISO code (e.g., USD, EUR)')
|
|
38
|
+
.regex(/^[A-Z]{3}$/, 'Currency must be uppercase 3-letter code'),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Schema for creating a new tier
|
|
43
|
+
* Required: name
|
|
44
|
+
* Optional: description, benefits, pricing, visibility
|
|
45
|
+
*/
|
|
46
|
+
export const createTierSchema = z.object({
|
|
47
|
+
name: z.string().min(1, 'Name cannot be empty').max(191, 'Name cannot exceed 191 characters'),
|
|
48
|
+
description: z.string().max(2000, 'Description cannot exceed 2000 characters').optional(),
|
|
49
|
+
slug: slugSchema.optional(),
|
|
50
|
+
active: z.boolean().default(true).describe('Whether tier is currently active/available'),
|
|
51
|
+
type: z
|
|
52
|
+
.enum(['free', 'paid'], {
|
|
53
|
+
errorMap: () => ({ message: 'Type must be free or paid' }),
|
|
54
|
+
})
|
|
55
|
+
.default('paid'),
|
|
56
|
+
welcome_page_url: z.string().url('Invalid welcome page URL').optional(),
|
|
57
|
+
visibility: z
|
|
58
|
+
.enum(['public', 'none'], {
|
|
59
|
+
errorMap: () => ({ message: 'Visibility must be public or none' }),
|
|
60
|
+
})
|
|
61
|
+
.default('public'),
|
|
62
|
+
trial_days: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.min(0, 'Trial days must be non-negative')
|
|
66
|
+
.default(0)
|
|
67
|
+
.describe('Number of trial days for paid tiers'),
|
|
68
|
+
currency: z
|
|
69
|
+
.string()
|
|
70
|
+
.length(3, 'Currency must be 3-letter ISO code')
|
|
71
|
+
.regex(/^[A-Z]{3}$/, 'Currency must be uppercase')
|
|
72
|
+
.optional(),
|
|
73
|
+
monthly_price: z.number().int().min(0, 'Monthly price must be non-negative').optional(),
|
|
74
|
+
yearly_price: z.number().int().min(0, 'Yearly price must be non-negative').optional(),
|
|
75
|
+
benefits: z.array(z.string()).optional().describe('Array of benefit names/descriptions'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Schema for updating an existing tier
|
|
80
|
+
* All fields optional
|
|
81
|
+
*/
|
|
82
|
+
export const updateTierSchema = createTierSchema.partial();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Schema for tier query/filter parameters
|
|
86
|
+
*/
|
|
87
|
+
export const tierQuerySchema = z.object({
|
|
88
|
+
limit: z.number().int().min(1).max(100).default(15).optional(),
|
|
89
|
+
page: z.number().int().min(1).default(1).optional(),
|
|
90
|
+
filter: z
|
|
91
|
+
.string()
|
|
92
|
+
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
93
|
+
.optional()
|
|
94
|
+
.describe('NQL filter string (e.g., "type:paid+active:true")'),
|
|
95
|
+
include: z.string().optional().describe('Comma-separated list of relations to include'),
|
|
96
|
+
order: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Order results (e.g., "monthly_price ASC", "created_at DESC")'),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Schema for tier ID parameter
|
|
104
|
+
*/
|
|
105
|
+
export const tierIdSchema = z.object({
|
|
106
|
+
id: ghostIdSchema,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Schema for tier slug parameter
|
|
111
|
+
*/
|
|
112
|
+
export const tierSlugSchema = z.object({
|
|
113
|
+
slug: slugSchema,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ----- Output Schemas -----
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Schema for a benefit object (as returned by the API)
|
|
120
|
+
*/
|
|
121
|
+
export const benefitOutputSchema = z.object({
|
|
122
|
+
id: ghostIdSchema,
|
|
123
|
+
name: z.string(),
|
|
124
|
+
slug: z.string(),
|
|
125
|
+
created_at: z.string().datetime(),
|
|
126
|
+
updated_at: z.string().datetime(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Schema for monthly price object (as returned by the API)
|
|
131
|
+
*/
|
|
132
|
+
export const monthlyPriceOutputSchema = z.object({
|
|
133
|
+
id: z.string(),
|
|
134
|
+
tier_id: ghostIdSchema,
|
|
135
|
+
nickname: z.string(),
|
|
136
|
+
amount: z.number(),
|
|
137
|
+
interval: z.literal('month'),
|
|
138
|
+
type: z.enum(['recurring', 'one-time']),
|
|
139
|
+
currency: z.string(),
|
|
140
|
+
active: z.boolean(),
|
|
141
|
+
created_at: z.string().datetime(),
|
|
142
|
+
updated_at: z.string().datetime(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Schema for yearly price object (as returned by the API)
|
|
147
|
+
*/
|
|
148
|
+
export const yearlyPriceOutputSchema = z.object({
|
|
149
|
+
id: z.string(),
|
|
150
|
+
tier_id: ghostIdSchema,
|
|
151
|
+
nickname: z.string(),
|
|
152
|
+
amount: z.number(),
|
|
153
|
+
interval: z.literal('year'),
|
|
154
|
+
type: z.enum(['recurring', 'one-time']),
|
|
155
|
+
currency: z.string(),
|
|
156
|
+
active: z.boolean(),
|
|
157
|
+
created_at: z.string().datetime(),
|
|
158
|
+
updated_at: z.string().datetime(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Schema for a Ghost tier object (as returned by the API)
|
|
163
|
+
*/
|
|
164
|
+
export const tierOutputSchema = z.object({
|
|
165
|
+
id: ghostIdSchema,
|
|
166
|
+
name: z.string(),
|
|
167
|
+
slug: z.string(),
|
|
168
|
+
description: z.string().nullable().optional(),
|
|
169
|
+
active: z.boolean(),
|
|
170
|
+
type: z.enum(['free', 'paid']),
|
|
171
|
+
welcome_page_url: z.string().url().nullable().optional(),
|
|
172
|
+
visibility: z.enum(['public', 'none']),
|
|
173
|
+
trial_days: z.number(),
|
|
174
|
+
currency: z.string().nullable().optional(),
|
|
175
|
+
monthly_price: z.number().nullable().optional(),
|
|
176
|
+
yearly_price: z.number().nullable().optional(),
|
|
177
|
+
monthly_price_id: z.string().nullable().optional(),
|
|
178
|
+
yearly_price_id: z.string().nullable().optional(),
|
|
179
|
+
created_at: z.string().datetime(),
|
|
180
|
+
updated_at: z.string().datetime(),
|
|
181
|
+
benefits: z.array(benefitOutputSchema).optional(),
|
|
182
|
+
monthly_price_object: monthlyPriceOutputSchema.nullable().optional(),
|
|
183
|
+
yearly_price_object: yearlyPriceOutputSchema.nullable().optional(),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Schema for array of tiers
|
|
188
|
+
*/
|
|
189
|
+
export const tiersArraySchema = z.array(tierOutputSchema);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Schema for paginated tier response
|
|
193
|
+
*/
|
|
194
|
+
export const tiersPaginatedSchema = z.object({
|
|
195
|
+
tiers: tiersArraySchema,
|
|
196
|
+
meta: z.object({
|
|
197
|
+
pagination: z.object({
|
|
198
|
+
page: z.number(),
|
|
199
|
+
limit: z.number(),
|
|
200
|
+
pages: z.number(),
|
|
201
|
+
total: z.number(),
|
|
202
|
+
next: z.number().nullable(),
|
|
203
|
+
prev: z.number().nullable(),
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
@@ -173,6 +173,27 @@ const validators = {
|
|
|
173
173
|
}
|
|
174
174
|
},
|
|
175
175
|
|
|
176
|
+
validateTagUpdateData(updateData) {
|
|
177
|
+
const errors = [];
|
|
178
|
+
|
|
179
|
+
// Name is optional in updates, but if provided, it cannot be empty
|
|
180
|
+
if (updateData.name !== undefined && updateData.name.trim().length === 0) {
|
|
181
|
+
errors.push({ field: 'name', message: 'Tag name cannot be empty' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate slug format if provided
|
|
185
|
+
if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) {
|
|
186
|
+
errors.push({
|
|
187
|
+
field: 'slug',
|
|
188
|
+
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (errors.length > 0) {
|
|
193
|
+
throw new ValidationError('Tag update validation failed', errors);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
176
197
|
async validateImagePath(imagePath) {
|
|
177
198
|
if (!imagePath || typeof imagePath !== 'string') {
|
|
178
199
|
throw new ValidationError('Image path is required and must be a string');
|
|
@@ -693,13 +714,13 @@ export async function getTags(name) {
|
|
|
693
714
|
}
|
|
694
715
|
}
|
|
695
716
|
|
|
696
|
-
export async function getTag(tagId) {
|
|
717
|
+
export async function getTag(tagId, options = {}) {
|
|
697
718
|
if (!tagId) {
|
|
698
719
|
throw new ValidationError('Tag ID is required');
|
|
699
720
|
}
|
|
700
721
|
|
|
701
722
|
try {
|
|
702
|
-
return await handleApiRequest('tags', 'read', { id: tagId });
|
|
723
|
+
return await handleApiRequest('tags', 'read', { id: tagId }, options);
|
|
703
724
|
} catch (error) {
|
|
704
725
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
705
726
|
throw new NotFoundError('Tag', tagId);
|
|
@@ -713,7 +734,7 @@ export async function updateTag(tagId, updateData) {
|
|
|
713
734
|
throw new ValidationError('Tag ID is required for update');
|
|
714
735
|
}
|
|
715
736
|
|
|
716
|
-
validators.
|
|
737
|
+
validators.validateTagUpdateData(updateData); // Validate update data
|
|
717
738
|
|
|
718
739
|
try {
|
|
719
740
|
const existingTag = await getTag(tagId);
|