@jgardner04/ghost-mcp-server 1.13.2 → 1.13.3

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.
Files changed (33) hide show
  1. package/package.json +5 -13
  2. package/src/__tests__/mcp_server.test.js +204 -117
  3. package/src/__tests__/mcp_server_pages.test.js +32 -18
  4. package/src/config/mcp-config.js +1 -1
  5. package/src/controllers/__tests__/tagController.test.js +12 -8
  6. package/src/controllers/tagController.js +2 -2
  7. package/src/errors/__tests__/index.test.js +3 -3
  8. package/src/errors/index.js +1 -1
  9. package/src/index.js +1 -1
  10. package/src/mcp_server.js +35 -31
  11. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  12. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  13. package/src/schemas/common.js +2 -2
  14. package/src/schemas/memberSchemas.js +20 -8
  15. package/src/schemas/newsletterSchemas.js +10 -10
  16. package/src/schemas/pageSchemas.js +16 -11
  17. package/src/schemas/postSchemas.js +22 -15
  18. package/src/schemas/tagSchemas.js +12 -7
  19. package/src/schemas/tierSchemas.js +17 -8
  20. package/src/services/__tests__/ghostServiceImproved.members.test.js +7 -2
  21. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +10 -6
  22. package/src/services/__tests__/ghostServiceImproved.pages.test.js +4 -4
  23. package/src/services/__tests__/ghostServiceImproved.posts.test.js +4 -4
  24. package/src/services/__tests__/ghostServiceImproved.tags.test.js +2 -2
  25. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +9 -14
  26. package/src/services/__tests__/memberService.test.js +0 -28
  27. package/src/services/__tests__/tierService.test.js +0 -28
  28. package/src/services/ghostServiceImproved.js +69 -217
  29. package/src/services/imageProcessingService.js +1 -1
  30. package/src/services/memberService.js +0 -13
  31. package/src/services/tierService.js +0 -13
  32. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  33. package/src/utils/nqlSanitizer.js +11 -0
@@ -175,7 +175,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
175
175
  const tool = mockTools.get('ghost_get_pages');
176
176
  const result = await tool.handler({});
177
177
 
178
- expect(mockGetPages).toHaveBeenCalledWith({});
178
+ expect(mockGetPages).toHaveBeenCalledWith(expect.objectContaining({}));
179
179
  expect(result.content[0].text).toContain('About Us');
180
180
  expect(result.content[0].text).toContain('Contact');
181
181
  });
@@ -187,7 +187,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
187
187
  const tool = mockTools.get('ghost_get_pages');
188
188
  await tool.handler({ limit: 10, page: 2 });
189
189
 
190
- expect(mockGetPages).toHaveBeenCalledWith({ limit: 10, page: 2 });
190
+ expect(mockGetPages).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, page: 2 }));
191
191
  });
192
192
 
193
193
  it('should validate limit is between 1 and 100', () => {
@@ -207,7 +207,9 @@ describe('mcp_server - ghost_get_pages tool', () => {
207
207
  const tool = mockTools.get('ghost_get_pages');
208
208
  await tool.handler({ filter: 'status:published' });
209
209
 
210
- expect(mockGetPages).toHaveBeenCalledWith({ filter: 'status:published' });
210
+ expect(mockGetPages).toHaveBeenCalledWith(
211
+ expect.objectContaining({ filter: 'status:published' })
212
+ );
211
213
  });
212
214
 
213
215
  it('should handle errors gracefully', async () => {
@@ -236,8 +238,8 @@ describe('mcp_server - ghost_get_page tool', () => {
236
238
  it('should have correct schema with id and slug options', () => {
237
239
  const tool = mockTools.get('ghost_get_page');
238
240
  expect(tool).toBeDefined();
239
- // ghost_get_page uses a refined schema, access via _def.schema.shape
240
- const shape = tool.schema._def.schema.shape;
241
+ // In Zod v4, refined schemas expose .shape directly
242
+ const shape = tool.schema.shape;
241
243
  expect(shape.id).toBeDefined();
242
244
  expect(shape.slug).toBeDefined();
243
245
  expect(shape.include).toBeDefined();
@@ -250,7 +252,10 @@ describe('mcp_server - ghost_get_page tool', () => {
250
252
  const tool = mockTools.get('ghost_get_page');
251
253
  const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
252
254
 
253
- expect(mockGetPage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {});
255
+ expect(mockGetPage).toHaveBeenCalledWith(
256
+ '507f1f77bcf86cd799439011',
257
+ expect.objectContaining({})
258
+ );
254
259
  expect(result.content[0].text).toContain('About Us');
255
260
  });
256
261
 
@@ -261,7 +266,7 @@ describe('mcp_server - ghost_get_page tool', () => {
261
266
  const tool = mockTools.get('ghost_get_page');
262
267
  const result = await tool.handler({ slug: 'about-us' });
263
268
 
264
- expect(mockGetPage).toHaveBeenCalledWith('slug/about-us', {});
269
+ expect(mockGetPage).toHaveBeenCalledWith('slug/about-us', expect.objectContaining({}));
265
270
  expect(result.content[0].text).toContain('About Us');
266
271
  });
267
272
 
@@ -389,9 +394,12 @@ describe('mcp_server - ghost_update_page tool', () => {
389
394
  const tool = mockTools.get('ghost_update_page');
390
395
  const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated Title' });
391
396
 
392
- expect(mockUpdatePage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
393
- title: 'Updated Title',
394
- });
397
+ expect(mockUpdatePage).toHaveBeenCalledWith(
398
+ '507f1f77bcf86cd799439011',
399
+ expect.objectContaining({
400
+ title: 'Updated Title',
401
+ })
402
+ );
395
403
  expect(result.content[0].text).toContain('Updated Title');
396
404
  });
397
405
 
@@ -407,11 +415,14 @@ describe('mcp_server - ghost_update_page tool', () => {
407
415
  html: '<p>Updated content</p>',
408
416
  });
409
417
 
410
- expect(mockUpdatePage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
411
- title: 'New Title',
412
- status: 'published',
413
- html: '<p>Updated content</p>',
414
- });
418
+ expect(mockUpdatePage).toHaveBeenCalledWith(
419
+ '507f1f77bcf86cd799439011',
420
+ expect.objectContaining({
421
+ title: 'New Title',
422
+ status: 'published',
423
+ html: '<p>Updated content</p>',
424
+ })
425
+ );
415
426
  expect(result.content[0].text).toContain('New Title');
416
427
  });
417
428
 
@@ -493,7 +504,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
493
504
  const tool = mockTools.get('ghost_search_pages');
494
505
  const result = await tool.handler({ query: 'about' });
495
506
 
496
- expect(mockSearchPages).toHaveBeenCalledWith('about', {});
507
+ expect(mockSearchPages).toHaveBeenCalledWith('about', expect.objectContaining({}));
497
508
  expect(result.content[0].text).toContain('About Us');
498
509
  });
499
510
 
@@ -504,7 +515,10 @@ describe('mcp_server - ghost_search_pages tool', () => {
504
515
  const tool = mockTools.get('ghost_search_pages');
505
516
  await tool.handler({ query: 'test', status: 'published' });
506
517
 
507
- expect(mockSearchPages).toHaveBeenCalledWith('test', { status: 'published' });
518
+ expect(mockSearchPages).toHaveBeenCalledWith(
519
+ 'test',
520
+ expect.objectContaining({ status: 'published' })
521
+ );
508
522
  });
509
523
 
510
524
  it('should pass limit option', async () => {
@@ -514,7 +528,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
514
528
  const tool = mockTools.get('ghost_search_pages');
515
529
  await tool.handler({ query: 'test', limit: 5 });
516
530
 
517
- expect(mockSearchPages).toHaveBeenCalledWith('test', { limit: 5 });
531
+ expect(mockSearchPages).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 5 }));
518
532
  });
519
533
 
520
534
  it('should validate limit is between 1 and 50', () => {
@@ -1,6 +1,6 @@
1
1
  import dotenv from 'dotenv';
2
2
 
3
- dotenv.config();
3
+ dotenv.config({ quiet: true });
4
4
 
5
5
  /**
6
6
  * MCP Server Configuration
@@ -44,7 +44,7 @@ describe('tagController', () => {
44
44
 
45
45
  await getTags(req, res, next);
46
46
 
47
- expect(ghostService.getTags).toHaveBeenCalledWith({});
47
+ expect(ghostService.getTags).toHaveBeenCalledWith(expect.objectContaining({}));
48
48
  expect(res.status).toHaveBeenCalledWith(200);
49
49
  expect(res.json).toHaveBeenCalledWith(mockTags);
50
50
  expect(next).not.toHaveBeenCalled();
@@ -60,7 +60,9 @@ describe('tagController', () => {
60
60
 
61
61
  await getTags(req, res, next);
62
62
 
63
- expect(ghostService.getTags).toHaveBeenCalledWith({ filter: "name:'Technology'" });
63
+ expect(ghostService.getTags).toHaveBeenCalledWith(
64
+ expect.objectContaining({ filter: "name:'Technology'" })
65
+ );
64
66
  expect(res.status).toHaveBeenCalledWith(200);
65
67
  expect(res.json).toHaveBeenCalledWith(mockTags);
66
68
  expect(next).not.toHaveBeenCalled();
@@ -76,7 +78,7 @@ describe('tagController', () => {
76
78
 
77
79
  await getTags(req, res, next);
78
80
 
79
- expect(ghostService.getTags).toHaveBeenCalledWith({});
81
+ expect(ghostService.getTags).toHaveBeenCalledWith(expect.objectContaining({}));
80
82
  expect(res.status).not.toHaveBeenCalled();
81
83
  expect(res.json).not.toHaveBeenCalled();
82
84
  expect(next).toHaveBeenCalledWith(mockError);
@@ -121,11 +123,13 @@ describe('tagController', () => {
121
123
 
122
124
  await getTags(req, res, next);
123
125
 
124
- expect(ghostService.getTags).toHaveBeenCalledWith({
125
- limit: 10,
126
- order: 'name asc',
127
- include: 'count.posts',
128
- });
126
+ expect(ghostService.getTags).toHaveBeenCalledWith(
127
+ expect.objectContaining({
128
+ limit: 10,
129
+ order: 'name asc',
130
+ include: 'count.posts',
131
+ })
132
+ );
129
133
  expect(res.status).toHaveBeenCalledWith(200);
130
134
  });
131
135
 
@@ -43,10 +43,10 @@ const getTags = async (req, res, next) => {
43
43
  res.status(200).json(tags);
44
44
  } catch (error) {
45
45
  if (error instanceof ZodError) {
46
- logger.warn('Invalid query parameters', { errors: error.errors });
46
+ logger.warn('Invalid query parameters', { errors: error.issues });
47
47
  return res.status(400).json({
48
48
  message: 'Invalid query parameters',
49
- errors: error.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
49
+ errors: error.issues.map((e) => ({ path: e.path.join('.'), message: e.message })),
50
50
  });
51
51
  }
52
52
 
@@ -125,7 +125,7 @@ describe('Error Handling System', () => {
125
125
 
126
126
  it('should create validation error from Zod error', () => {
127
127
  const zodError = {
128
- errors: [
128
+ issues: [
129
129
  {
130
130
  path: ['user', 'email'],
131
131
  message: 'Invalid email',
@@ -157,7 +157,7 @@ describe('Error Handling System', () => {
157
157
 
158
158
  it('should create validation error from Zod error with context', () => {
159
159
  const zodError = {
160
- errors: [
160
+ issues: [
161
161
  {
162
162
  path: ['name'],
163
163
  message: 'String must contain at least 1 character(s)',
@@ -179,7 +179,7 @@ describe('Error Handling System', () => {
179
179
 
180
180
  it('should create validation error from Zod error with empty path', () => {
181
181
  const zodError = {
182
- errors: [
182
+ issues: [
183
183
  {
184
184
  path: [],
185
185
  message: 'Invalid input',
@@ -57,7 +57,7 @@ export class ValidationError extends BaseError {
57
57
  }
58
58
 
59
59
  static fromZod(zodError, context = '') {
60
- const errors = zodError.errors.map((err) => ({
60
+ const errors = zodError.issues.map((err) => ({
61
61
  field: err.path.join('.'),
62
62
  message: err.message,
63
63
  type: err.code,
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import tagRoutes from './routes/tagRoutes.js'; // Import tag routes
8
8
  import { createContextLogger } from './utils/logger.js';
9
9
 
10
10
  // Load environment variables from .env file
11
- dotenv.config();
11
+ dotenv.config({ quiet: true });
12
12
 
13
13
  // Initialize logger for main server
14
14
  const logger = createContextLogger('main');
package/src/mcp_server.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  } from './schemas/index.js';
36
36
 
37
37
  // Load environment variables
38
- dotenv.config();
38
+ dotenv.config({ quiet: true });
39
39
 
40
40
  // Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
41
41
  let ghostService = null;
@@ -95,12 +95,12 @@ const server = new McpServer({
95
95
  const getTagsSchema = tagQueryBaseSchema.partial();
96
96
  const getTagSchema = z
97
97
  .object({
98
- id: ghostIdSchema.optional().describe('The ID of the tag to retrieve.'),
99
- slug: z.string().optional().describe('The slug of the tag to retrieve.'),
98
+ id: ghostIdSchema.optional().meta({ description: 'The ID of the tag to retrieve.' }),
99
+ slug: z.string().optional().meta({ description: 'The slug of the tag to retrieve.' }),
100
100
  include: z
101
101
  .string()
102
102
  .optional()
103
- .describe('Additional resources to include (e.g., "count.posts").'),
103
+ .meta({ description: 'Additional resources to include (e.g., "count.posts").' }),
104
104
  })
105
105
  .refine((data) => data.id || data.slug, {
106
106
  message: 'Either id or slug is required to retrieve a tag',
@@ -350,11 +350,11 @@ server.registerTool(
350
350
 
351
351
  // --- Image Schema ---
352
352
  const uploadImageSchema = z.object({
353
- imageUrl: z.string().describe('The publicly accessible URL of the image to upload.'),
354
- alt: z
355
- .string()
356
- .optional()
357
- .describe('Alt text for the image. If omitted, a default will be generated from the filename.'),
353
+ imageUrl: z.string().meta({ description: 'The publicly accessible URL of the image to upload.' }),
354
+ alt: z.string().optional().meta({
355
+ description:
356
+ 'Alt text for the image. If omitted, a default will be generated from the filename.',
357
+ }),
358
358
  });
359
359
 
360
360
  // Upload Image Tool
@@ -449,33 +449,32 @@ const getPostsSchema = postQuerySchema.extend({
449
449
  status: z
450
450
  .enum(['published', 'draft', 'scheduled', 'all'])
451
451
  .optional()
452
- .describe('Filter posts by status. Options: published, draft, scheduled, all.'),
452
+ .meta({ description: 'Filter posts by status. Options: published, draft, scheduled, all.' }),
453
453
  });
454
454
  const getPostSchema = z
455
455
  .object({
456
- id: ghostIdSchema.optional().describe('The ID of the post to retrieve.'),
457
- slug: z.string().optional().describe('The slug of the post to retrieve.'),
458
- include: z
459
- .string()
460
- .optional()
461
- .describe('Comma-separated list of relations to include (e.g., "tags,authors").'),
456
+ id: ghostIdSchema.optional().meta({ description: 'The ID of the post to retrieve.' }),
457
+ slug: z.string().optional().meta({ description: 'The slug of the post to retrieve.' }),
458
+ include: z.string().optional().meta({
459
+ description: 'Comma-separated list of relations to include (e.g., "tags,authors").',
460
+ }),
462
461
  })
463
462
  .refine((data) => data.id || data.slug, {
464
463
  message: 'Either id or slug is required to retrieve a post',
465
464
  });
466
465
  const searchPostsSchema = z.object({
467
- query: z.string().min(1).describe('Search query to find in post titles.'),
466
+ query: z.string().min(1).meta({ description: 'Search query to find in post titles.' }),
468
467
  status: z
469
468
  .enum(['published', 'draft', 'scheduled', 'all'])
470
469
  .optional()
471
- .describe('Filter by post status. Default searches all statuses.'),
470
+ .meta({ description: 'Filter by post status. Default searches all statuses.' }),
472
471
  limit: z
473
472
  .number()
474
473
  .int()
475
474
  .min(1)
476
475
  .max(50)
477
476
  .optional()
478
- .describe('Maximum number of results (1-50). Default is 15.'),
477
+ .meta({ description: 'Maximum number of results (1-50). Default is 15.' }),
479
478
  });
480
479
  const updatePostInputSchema = updatePostSchema.extend({ id: ghostIdSchema });
481
480
  const deletePostSchema = z.object({ id: ghostIdSchema });
@@ -762,29 +761,31 @@ server.registerTool(
762
761
  // --- Page Schema Definitions ---
763
762
  const getPageSchema = z
764
763
  .object({
765
- id: ghostIdSchema.optional().describe('The ID of the page to retrieve.'),
766
- slug: z.string().optional().describe('The slug of the page to retrieve.'),
764
+ id: ghostIdSchema.optional().meta({ description: 'The ID of the page to retrieve.' }),
765
+ slug: z.string().optional().meta({ description: 'The slug of the page to retrieve.' }),
767
766
  include: z
768
767
  .string()
769
768
  .optional()
770
- .describe('Comma-separated list of relations to include (e.g., "authors").'),
769
+ .meta({ description: 'Comma-separated list of relations to include (e.g., "authors").' }),
771
770
  })
772
771
  .refine((data) => data.id || data.slug, {
773
772
  message: 'Either id or slug is required to retrieve a page',
774
773
  });
775
774
  const updatePageInputSchema = z
776
- .object({ id: ghostIdSchema.describe('The ID of the page to update.') })
775
+ .object({ id: ghostIdSchema.meta({ description: 'The ID of the page to update.' }) })
777
776
  .merge(updatePageSchema);
778
- const deletePageSchema = z.object({ id: ghostIdSchema.describe('The ID of the page to delete.') });
777
+ const deletePageSchema = z.object({
778
+ id: ghostIdSchema.meta({ description: 'The ID of the page to delete.' }),
779
+ });
779
780
  const searchPagesSchema = z.object({
780
781
  query: z
781
782
  .string()
782
783
  .min(1, 'Search query cannot be empty')
783
- .describe('Search query to find in page titles.'),
784
+ .meta({ description: 'Search query to find in page titles.' }),
784
785
  status: z
785
786
  .enum(['published', 'draft', 'scheduled', 'all'])
786
787
  .optional()
787
- .describe('Filter by page status. Default searches all statuses.'),
788
+ .meta({ description: 'Filter by page status. Default searches all statuses.' }),
788
789
  limit: z
789
790
  .number()
790
791
  .int()
@@ -792,7 +793,7 @@ const searchPagesSchema = z.object({
792
793
  .max(50)
793
794
  .default(15)
794
795
  .optional()
795
- .describe('Maximum number of results (1-50). Default is 15.'),
796
+ .meta({ description: 'Maximum number of results (1-50). Default is 15.' }),
796
797
  });
797
798
 
798
799
  // Get Pages Tool
@@ -1076,21 +1077,24 @@ const deleteMemberSchema = z.object({ id: ghostIdSchema });
1076
1077
  const getMembersSchema = memberQuerySchema.omit({ search: true });
1077
1078
  const getMemberSchema = z
1078
1079
  .object({
1079
- id: ghostIdSchema.optional().describe('The ID of the member to retrieve.'),
1080
- email: emailSchema.optional().describe('The email of the member to retrieve.'),
1080
+ id: ghostIdSchema.optional().meta({ description: 'The ID of the member to retrieve.' }),
1081
+ email: emailSchema.optional().meta({ description: 'The email of the member to retrieve.' }),
1081
1082
  })
1082
1083
  .refine((data) => data.id || data.email, {
1083
1084
  message: 'Either id or email must be provided',
1084
1085
  });
1085
1086
  const searchMembersSchema = z.object({
1086
- query: z.string().min(1).describe('Search query to match against member name or email.'),
1087
+ query: z
1088
+ .string()
1089
+ .min(1)
1090
+ .meta({ description: 'Search query to match against member name or email.' }),
1087
1091
  limit: z
1088
1092
  .number()
1089
1093
  .int()
1090
1094
  .min(1)
1091
1095
  .max(50)
1092
1096
  .optional()
1093
- .describe('Maximum number of results to return (1-50). Default is 15.'),
1097
+ .meta({ description: 'Maximum number of results to return (1-50). Default is 15.' }),
1094
1098
  });
1095
1099
 
1096
1100
  // Create Member Tool
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { z } from 'zod';
2
3
  import {
3
4
  createPostSchema,
4
5
  updatePostSchema,
@@ -191,4 +192,22 @@ describe('Post Schemas', () => {
191
192
  expect(() => postOutputSchema.parse(apiPost)).not.toThrow();
192
193
  });
193
194
  });
195
+
196
+ describe('schema metadata', () => {
197
+ it('should expose descriptions via .meta()', () => {
198
+ expect(createPostSchema.shape.html.meta()?.description).toBe('HTML content of the post');
199
+ expect(createPostSchema.shape.tags.meta()?.description).toContain(
200
+ 'Array of tag names or IDs'
201
+ );
202
+ expect(createPostSchema.shape.email_only.meta()?.description).toBe(
203
+ 'Whether post is email-only'
204
+ );
205
+ });
206
+
207
+ it('should include descriptions in JSON Schema output', () => {
208
+ const jsonSchema = z.toJSONSchema(createPostSchema, { unrepresentable: 'any' });
209
+ expect(jsonSchema.properties.html.description).toBe('HTML content of the post');
210
+ expect(jsonSchema.properties.email_only.description).toBe('Whether post is email-only');
211
+ });
212
+ });
194
213
  });
@@ -245,7 +245,7 @@ describe('Tag Schemas', () => {
245
245
  try {
246
246
  tagQuerySchema.parse(query);
247
247
  } catch (error) {
248
- expect(error.errors[0].message).toContain('Cannot specify both "name" and "filter"');
248
+ expect(error.issues[0].message).toContain('Cannot specify both "name" and "filter"');
249
249
  }
250
250
  });
251
251
 
@@ -135,7 +135,7 @@ export const paginationSchema = z.object({
135
135
  * Valid values: draft, published, scheduled
136
136
  */
137
137
  export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
138
- errorMap: () => ({ message: 'Status must be draft, published, or scheduled' }),
138
+ error: () => ({ message: 'Status must be draft, published, or scheduled' }),
139
139
  });
140
140
 
141
141
  /**
@@ -143,7 +143,7 @@ export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
143
143
  * Controls content visibility (public, members, paid, tiers)
144
144
  */
145
145
  export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
146
- errorMap: () => ({ message: 'Visibility must be public, members, paid, or tiers' }),
146
+ error: () => ({ message: 'Visibility must be public, members, paid, or tiers' }),
147
147
  });
148
148
 
149
149
  // ----- Common Field Validators -----
@@ -17,13 +17,22 @@ export const createMemberSchema = z.object({
17
17
  email: emailSchema,
18
18
  name: z.string().max(191, 'Name cannot exceed 191 characters').optional(),
19
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'),
20
+ subscribed: z
21
+ .boolean()
22
+ .default(true)
23
+ .meta({ description: 'Whether member is subscribed to newsletter' }),
24
+ comped: z
25
+ .boolean()
26
+ .default(false)
27
+ .meta({ description: 'Whether member has complimentary subscription' }),
28
+ labels: z
29
+ .array(z.string())
30
+ .optional()
31
+ .meta({ description: 'Array of label names to associate with member' }),
23
32
  newsletters: z
24
33
  .array(ghostIdSchema)
25
34
  .optional()
26
- .describe('Array of newsletter IDs to subscribe member to'),
35
+ .meta({ description: 'Array of newsletter IDs to subscribe member to' }),
27
36
  });
28
37
 
29
38
  /**
@@ -42,13 +51,16 @@ export const memberQuerySchema = z.object({
42
51
  .string()
43
52
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
44
53
  .optional()
45
- .describe('NQL filter string (e.g., "status:paid+subscribed:true")'),
54
+ .meta({ description: 'NQL filter string (e.g., "status:paid+subscribed:true")' }),
46
55
  include: z
47
56
  .string()
48
57
  .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'),
58
+ .meta({ description: 'Comma-separated list of relations (e.g., "labels,newsletters")' }),
59
+ order: z
60
+ .string()
61
+ .optional()
62
+ .meta({ description: 'Order results (e.g., "created_at DESC", "name ASC")' }),
63
+ search: z.string().optional().meta({ description: 'Search members by name or email' }),
52
64
  });
53
65
 
54
66
  /**
@@ -21,46 +21,46 @@ export const createNewsletterSchema = z.object({
21
21
  sender_email: emailSchema.optional(),
22
22
  sender_reply_to: z
23
23
  .enum(['newsletter', 'support'], {
24
- errorMap: () => ({ message: 'Sender reply-to must be newsletter or support' }),
24
+ error: () => ({ message: 'Sender reply-to must be newsletter or support' }),
25
25
  })
26
26
  .default('newsletter'),
27
27
  status: z
28
28
  .enum(['active', 'archived'], {
29
- errorMap: () => ({ message: 'Status must be active or archived' }),
29
+ error: () => ({ message: 'Status must be active or archived' }),
30
30
  })
31
31
  .default('active'),
32
32
  visibility: z
33
33
  .enum(['members', 'paid'], {
34
- errorMap: () => ({ message: 'Visibility must be members or paid' }),
34
+ error: () => ({ message: 'Visibility must be members or paid' }),
35
35
  })
36
36
  .default('members'),
37
37
  subscribe_on_signup: z
38
38
  .boolean()
39
39
  .default(true)
40
- .describe('Whether new members are automatically subscribed'),
40
+ .meta({ description: 'Whether new members are automatically subscribed' }),
41
41
  sort_order: z
42
42
  .number()
43
43
  .int()
44
44
  .min(0, 'Sort order must be non-negative')
45
45
  .optional()
46
- .describe('Display order for newsletters'),
46
+ .meta({ description: 'Display order for newsletters' }),
47
47
  header_image: z.string().url('Invalid header image URL').optional(),
48
48
  show_header_icon: z.boolean().default(true),
49
49
  show_header_title: z.boolean().default(true),
50
50
  title_font_category: z
51
51
  .enum(['serif', 'sans-serif'], {
52
- errorMap: () => ({ message: 'Title font category must be serif or sans-serif' }),
52
+ error: () => ({ message: 'Title font category must be serif or sans-serif' }),
53
53
  })
54
54
  .default('sans-serif'),
55
55
  title_alignment: z
56
56
  .enum(['left', 'center'], {
57
- errorMap: () => ({ message: 'Title alignment must be left or center' }),
57
+ error: () => ({ message: 'Title alignment must be left or center' }),
58
58
  })
59
59
  .default('center'),
60
60
  show_feature_image: z.boolean().default(true),
61
61
  body_font_category: z
62
62
  .enum(['serif', 'sans-serif'], {
63
- errorMap: () => ({ message: 'Body font category must be serif or sans-serif' }),
63
+ error: () => ({ message: 'Body font category must be serif or sans-serif' }),
64
64
  })
65
65
  .default('sans-serif'),
66
66
  footer_content: z.string().optional(),
@@ -85,11 +85,11 @@ export const newsletterQuerySchema = z.object({
85
85
  .string()
86
86
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
87
87
  .optional()
88
- .describe('NQL filter string (e.g., "status:active")'),
88
+ .meta({ description: 'NQL filter string (e.g., "status:active")' }),
89
89
  order: z
90
90
  .string()
91
91
  .optional()
92
- .describe('Order results (e.g., "sort_order ASC", "created_at DESC")'),
92
+ .meta({ description: 'Order results (e.g., "sort_order ASC", "created_at DESC")' }),
93
93
  });
94
94
 
95
95
  /**
@@ -37,7 +37,7 @@ import {
37
37
  */
38
38
  export const createPageSchema = z.object({
39
39
  title: titleSchema,
40
- html: htmlContentSchema.describe('HTML content of the page'),
40
+ html: htmlContentSchema.meta({ description: 'HTML content of the page' }),
41
41
  slug: slugSchema.optional(),
42
42
  status: postStatusSchema.default('draft'),
43
43
  visibility: visibilitySchema.default('public'),
@@ -59,16 +59,18 @@ export const createPageSchema = z.object({
59
59
  .max(500, 'Twitter description cannot exceed 500 characters')
60
60
  .optional(),
61
61
  canonical_url: canonicalUrlSchema,
62
- tags: tagsSchema.describe('Array of tag names or IDs (rarely used for pages)'),
63
- authors: authorsSchema.describe('Array of author IDs or emails'),
64
- published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
62
+ tags: tagsSchema.meta({ description: 'Array of tag names or IDs (rarely used for pages)' }),
63
+ authors: authorsSchema.meta({ description: 'Array of author IDs or emails' }),
64
+ published_at: isoDateSchema
65
+ .optional()
66
+ .meta({ description: 'Scheduled publish time (ISO 8601 format)' }),
65
67
  codeinjection_head: z.string().optional(),
66
68
  codeinjection_foot: z.string().optional(),
67
- custom_template: z.string().optional().describe('Custom template filename'),
69
+ custom_template: z.string().optional().meta({ description: 'Custom template filename' }),
68
70
  show_title_and_feature_image: z
69
71
  .boolean()
70
72
  .default(true)
71
- .describe('Whether to show title and feature image on page'),
73
+ .meta({ description: 'Whether to show title and feature image on page' }),
72
74
  });
73
75
 
74
76
  /**
@@ -87,17 +89,20 @@ export const pageQuerySchema = z.object({
87
89
  .string()
88
90
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
89
91
  .optional()
90
- .describe('NQL filter string (e.g., "status:published+featured:true")'),
92
+ .meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
91
93
  include: z
92
94
  .string()
93
95
  .optional()
94
- .describe('Comma-separated list of relations (e.g., "tags,authors")'),
95
- fields: z.string().optional().describe('Comma-separated list of fields to return'),
96
+ .meta({ description: 'Comma-separated list of relations (e.g., "tags,authors")' }),
97
+ fields: z.string().optional().meta({ description: 'Comma-separated list of fields to return' }),
96
98
  formats: z
97
99
  .string()
98
100
  .optional()
99
- .describe('Comma-separated list of formats (html, plaintext, mobiledoc)'),
100
- order: z.string().optional().describe('Order results (e.g., "published_at DESC", "title ASC")'),
101
+ .meta({ description: 'Comma-separated list of formats (html, plaintext, mobiledoc)' }),
102
+ order: z
103
+ .string()
104
+ .optional()
105
+ .meta({ description: 'Order results (e.g., "published_at DESC", "title ASC")' }),
101
106
  });
102
107
 
103
108
  /**