@jgardner04/ghost-mcp-server 1.7.0 → 1.9.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.
@@ -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
+ });
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with members support
6
+ vi.mock('@tryghost/admin-api', () => ({
7
+ default: vi.fn(function () {
8
+ return {
9
+ posts: {
10
+ add: vi.fn(),
11
+ browse: vi.fn(),
12
+ read: vi.fn(),
13
+ edit: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ pages: {
17
+ add: vi.fn(),
18
+ browse: vi.fn(),
19
+ read: vi.fn(),
20
+ edit: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ tags: {
24
+ add: vi.fn(),
25
+ browse: vi.fn(),
26
+ read: vi.fn(),
27
+ edit: vi.fn(),
28
+ delete: vi.fn(),
29
+ },
30
+ members: {
31
+ add: vi.fn(),
32
+ browse: vi.fn(),
33
+ read: vi.fn(),
34
+ edit: vi.fn(),
35
+ delete: vi.fn(),
36
+ },
37
+ site: {
38
+ read: vi.fn(),
39
+ },
40
+ images: {
41
+ upload: vi.fn(),
42
+ },
43
+ };
44
+ }),
45
+ }));
46
+
47
+ // Mock dotenv
48
+ vi.mock('dotenv', () => mockDotenv());
49
+
50
+ // Mock logger
51
+ vi.mock('../../utils/logger.js', () => ({
52
+ createContextLogger: createMockContextLogger(),
53
+ }));
54
+
55
+ // Mock fs for validateImagePath
56
+ vi.mock('fs/promises', () => ({
57
+ default: {
58
+ access: vi.fn(),
59
+ },
60
+ }));
61
+
62
+ // Import after setting up mocks
63
+ import { createMember, updateMember, deleteMember, api } from '../ghostServiceImproved.js';
64
+
65
+ describe('ghostServiceImproved - Members', () => {
66
+ beforeEach(() => {
67
+ // Reset all mocks before each test
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ describe('createMember', () => {
72
+ it('should create a member with required email', async () => {
73
+ const memberData = {
74
+ email: 'test@example.com',
75
+ };
76
+
77
+ const mockCreatedMember = {
78
+ id: 'member-1',
79
+ email: 'test@example.com',
80
+ status: 'free',
81
+ };
82
+
83
+ api.members.add.mockResolvedValue(mockCreatedMember);
84
+
85
+ const result = await createMember(memberData);
86
+
87
+ expect(api.members.add).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ email: 'test@example.com',
90
+ }),
91
+ expect.any(Object)
92
+ );
93
+ expect(result).toEqual(mockCreatedMember);
94
+ });
95
+
96
+ it('should create a member with optional fields', async () => {
97
+ const memberData = {
98
+ email: 'test@example.com',
99
+ name: 'John Doe',
100
+ note: 'Test member',
101
+ labels: ['premium', 'newsletter'],
102
+ newsletters: [{ id: 'newsletter-1' }],
103
+ subscribed: true,
104
+ };
105
+
106
+ const mockCreatedMember = {
107
+ id: 'member-1',
108
+ ...memberData,
109
+ status: 'free',
110
+ };
111
+
112
+ api.members.add.mockResolvedValue(mockCreatedMember);
113
+
114
+ const result = await createMember(memberData);
115
+
116
+ expect(api.members.add).toHaveBeenCalledWith(
117
+ expect.objectContaining(memberData),
118
+ expect.any(Object)
119
+ );
120
+ expect(result).toEqual(mockCreatedMember);
121
+ });
122
+
123
+ it('should throw validation error for missing email', async () => {
124
+ await expect(createMember({})).rejects.toThrow('Member validation failed');
125
+ });
126
+
127
+ it('should throw validation error for invalid email', async () => {
128
+ await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
129
+ 'Member validation failed'
130
+ );
131
+ });
132
+
133
+ it('should throw validation error for invalid labels type', async () => {
134
+ await expect(
135
+ createMember({
136
+ email: 'test@example.com',
137
+ labels: 'premium',
138
+ })
139
+ ).rejects.toThrow('Member validation failed');
140
+ });
141
+
142
+ it('should handle Ghost API errors', async () => {
143
+ const memberData = {
144
+ email: 'test@example.com',
145
+ };
146
+
147
+ api.members.add.mockRejectedValue(new Error('Ghost API Error'));
148
+
149
+ await expect(createMember(memberData)).rejects.toThrow();
150
+ });
151
+ });
152
+
153
+ describe('updateMember', () => {
154
+ it('should update a member with valid ID and data', async () => {
155
+ const memberId = 'member-1';
156
+ const updateData = {
157
+ name: 'Jane Doe',
158
+ note: 'Updated note',
159
+ };
160
+
161
+ const mockExistingMember = {
162
+ id: memberId,
163
+ email: 'test@example.com',
164
+ name: 'John Doe',
165
+ updated_at: '2023-01-01T00:00:00.000Z',
166
+ };
167
+
168
+ const mockUpdatedMember = {
169
+ ...mockExistingMember,
170
+ ...updateData,
171
+ };
172
+
173
+ api.members.read.mockResolvedValue(mockExistingMember);
174
+ api.members.edit.mockResolvedValue(mockUpdatedMember);
175
+
176
+ const result = await updateMember(memberId, updateData);
177
+
178
+ expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
179
+ expect(api.members.edit).toHaveBeenCalledWith(
180
+ expect.objectContaining({
181
+ ...mockExistingMember,
182
+ ...updateData,
183
+ }),
184
+ expect.objectContaining({ id: memberId })
185
+ );
186
+ expect(result).toEqual(mockUpdatedMember);
187
+ });
188
+
189
+ it('should update member email if provided', async () => {
190
+ const memberId = 'member-1';
191
+ const updateData = {
192
+ email: 'newemail@example.com',
193
+ };
194
+
195
+ const mockExistingMember = {
196
+ id: memberId,
197
+ email: 'test@example.com',
198
+ updated_at: '2023-01-01T00:00:00.000Z',
199
+ };
200
+
201
+ const mockUpdatedMember = {
202
+ ...mockExistingMember,
203
+ email: 'newemail@example.com',
204
+ };
205
+
206
+ api.members.read.mockResolvedValue(mockExistingMember);
207
+ api.members.edit.mockResolvedValue(mockUpdatedMember);
208
+
209
+ const result = await updateMember(memberId, updateData);
210
+
211
+ expect(result.email).toBe('newemail@example.com');
212
+ });
213
+
214
+ it('should throw validation error for missing member ID', async () => {
215
+ await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
216
+ 'Member ID is required for update'
217
+ );
218
+ });
219
+
220
+ it('should throw validation error for invalid email in update', async () => {
221
+ await expect(updateMember('member-1', { email: 'invalid-email' })).rejects.toThrow(
222
+ 'Member validation failed'
223
+ );
224
+ });
225
+
226
+ it('should throw not found error if member does not exist', async () => {
227
+ api.members.read.mockRejectedValue({
228
+ response: { status: 404 },
229
+ message: 'Member not found',
230
+ });
231
+
232
+ await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
233
+ });
234
+ });
235
+
236
+ describe('deleteMember', () => {
237
+ it('should delete a member with valid ID', async () => {
238
+ const memberId = 'member-1';
239
+
240
+ api.members.delete.mockResolvedValue({ deleted: true });
241
+
242
+ const result = await deleteMember(memberId);
243
+
244
+ expect(api.members.delete).toHaveBeenCalledWith(memberId, expect.any(Object));
245
+ expect(result).toEqual({ deleted: true });
246
+ });
247
+
248
+ it('should throw validation error for missing member ID', async () => {
249
+ await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
250
+ });
251
+
252
+ it('should throw not found error if member does not exist', async () => {
253
+ api.members.delete.mockRejectedValue({
254
+ response: { status: 404 },
255
+ message: 'Member not found',
256
+ });
257
+
258
+ await expect(deleteMember('non-existent')).rejects.toThrow();
259
+ });
260
+ });
261
+ });