@jgardner04/ghost-mcp-server 1.6.0 → 1.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -41,7 +41,10 @@ vi.mock('crypto', () => ({
41
41
  const mockGetPosts = vi.fn();
42
42
  const mockGetPost = vi.fn();
43
43
  const mockGetTags = vi.fn();
44
+ const mockGetTag = vi.fn();
44
45
  const mockCreateTag = vi.fn();
46
+ const mockUpdateTag = vi.fn();
47
+ const mockDeleteTag = vi.fn();
45
48
  const mockUploadImage = vi.fn();
46
49
  const mockCreatePostService = vi.fn();
47
50
  const mockProcessImage = vi.fn();
@@ -67,6 +70,9 @@ vi.mock('../services/ghostServiceImproved.js', () => ({
67
70
  updatePost: (...args) => mockUpdatePost(...args),
68
71
  deletePost: (...args) => mockDeletePost(...args),
69
72
  searchPosts: (...args) => mockSearchPosts(...args),
73
+ getTag: (...args) => mockGetTag(...args),
74
+ updateTag: (...args) => mockUpdateTag(...args),
75
+ deleteTag: (...args) => mockDeleteTag(...args),
70
76
  }));
71
77
 
72
78
  vi.mock('../services/imageProcessingService.js', () => ({
@@ -907,3 +913,282 @@ describe('mcp_server_improved - ghost_search_posts tool', () => {
907
913
  expect(result.isError).toBeUndefined();
908
914
  });
909
915
  });
916
+
917
+ describe('ghost_get_tag', () => {
918
+ beforeEach(() => {
919
+ vi.clearAllMocks();
920
+ });
921
+
922
+ it('should be registered as a tool', () => {
923
+ expect(mockTools.has('ghost_get_tag')).toBe(true);
924
+ const tool = mockTools.get('ghost_get_tag');
925
+ expect(tool.name).toBe('ghost_get_tag');
926
+ expect(tool.description).toBeDefined();
927
+ expect(tool.schema).toBeDefined();
928
+ expect(tool.handler).toBeDefined();
929
+ });
930
+
931
+ it('should have correct schema with id and slug as optional', () => {
932
+ const tool = mockTools.get('ghost_get_tag');
933
+ expect(tool.schema.id).toBeDefined();
934
+ expect(tool.schema.slug).toBeDefined();
935
+ expect(tool.schema.include).toBeDefined();
936
+ });
937
+
938
+ it('should retrieve tag by ID', async () => {
939
+ const mockTag = {
940
+ id: '123',
941
+ name: 'Test Tag',
942
+ slug: 'test-tag',
943
+ description: 'A test tag',
944
+ };
945
+ mockGetTag.mockResolvedValue(mockTag);
946
+
947
+ const tool = mockTools.get('ghost_get_tag');
948
+ const result = await tool.handler({ id: '123' });
949
+
950
+ expect(mockGetTag).toHaveBeenCalledWith('123', {});
951
+ expect(result.content).toBeDefined();
952
+ expect(result.content[0].type).toBe('text');
953
+ expect(result.content[0].text).toContain('"id": "123"');
954
+ expect(result.content[0].text).toContain('"name": "Test Tag"');
955
+ });
956
+
957
+ it('should retrieve tag by slug', async () => {
958
+ const mockTag = {
959
+ id: '123',
960
+ name: 'Test Tag',
961
+ slug: 'test-tag',
962
+ description: 'A test tag',
963
+ };
964
+ mockGetTag.mockResolvedValue(mockTag);
965
+
966
+ const tool = mockTools.get('ghost_get_tag');
967
+ const result = await tool.handler({ slug: 'test-tag' });
968
+
969
+ expect(mockGetTag).toHaveBeenCalledWith('slug/test-tag', {});
970
+ expect(result.content[0].text).toContain('"slug": "test-tag"');
971
+ });
972
+
973
+ it('should support include parameter for post count', async () => {
974
+ const mockTag = {
975
+ id: '123',
976
+ name: 'Test Tag',
977
+ slug: 'test-tag',
978
+ count: { posts: 5 },
979
+ };
980
+ mockGetTag.mockResolvedValue(mockTag);
981
+
982
+ const tool = mockTools.get('ghost_get_tag');
983
+ const result = await tool.handler({ id: '123', include: 'count.posts' });
984
+
985
+ expect(mockGetTag).toHaveBeenCalledWith('123', { include: 'count.posts' });
986
+ expect(result.content[0].text).toContain('"count"');
987
+ });
988
+
989
+ it('should return error when neither id nor slug provided', async () => {
990
+ const tool = mockTools.get('ghost_get_tag');
991
+ const result = await tool.handler({});
992
+
993
+ expect(result.content[0].type).toBe('text');
994
+ expect(result.content[0].text).toContain('Either id or slug must be provided');
995
+ expect(result.isError).toBe(true);
996
+ });
997
+
998
+ it('should handle not found error', async () => {
999
+ mockGetTag.mockRejectedValue(new Error('Tag not found'));
1000
+
1001
+ const tool = mockTools.get('ghost_get_tag');
1002
+ const result = await tool.handler({ id: '999' });
1003
+
1004
+ expect(result.isError).toBe(true);
1005
+ expect(result.content[0].text).toContain('Tag not found');
1006
+ });
1007
+ });
1008
+
1009
+ describe('ghost_update_tag', () => {
1010
+ beforeEach(() => {
1011
+ vi.clearAllMocks();
1012
+ });
1013
+
1014
+ it('should be registered as a tool', () => {
1015
+ expect(mockTools.has('ghost_update_tag')).toBe(true);
1016
+ const tool = mockTools.get('ghost_update_tag');
1017
+ expect(tool.name).toBe('ghost_update_tag');
1018
+ expect(tool.description).toBeDefined();
1019
+ expect(tool.schema).toBeDefined();
1020
+ expect(tool.handler).toBeDefined();
1021
+ });
1022
+
1023
+ it('should have correct schema with all update fields', () => {
1024
+ const tool = mockTools.get('ghost_update_tag');
1025
+ expect(tool.schema.id).toBeDefined();
1026
+ expect(tool.schema.name).toBeDefined();
1027
+ expect(tool.schema.slug).toBeDefined();
1028
+ expect(tool.schema.description).toBeDefined();
1029
+ expect(tool.schema.feature_image).toBeDefined();
1030
+ expect(tool.schema.meta_title).toBeDefined();
1031
+ expect(tool.schema.meta_description).toBeDefined();
1032
+ });
1033
+
1034
+ it('should update tag name', async () => {
1035
+ const mockUpdatedTag = {
1036
+ id: '123',
1037
+ name: 'Updated Tag',
1038
+ slug: 'updated-tag',
1039
+ };
1040
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1041
+
1042
+ const tool = mockTools.get('ghost_update_tag');
1043
+ const result = await tool.handler({ id: '123', name: 'Updated Tag' });
1044
+
1045
+ expect(mockUpdateTag).toHaveBeenCalledWith('123', { name: 'Updated Tag' });
1046
+ expect(result.content[0].text).toContain('"name": "Updated Tag"');
1047
+ });
1048
+
1049
+ it('should update tag description', async () => {
1050
+ const mockUpdatedTag = {
1051
+ id: '123',
1052
+ name: 'Test Tag',
1053
+ description: 'New description',
1054
+ };
1055
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1056
+
1057
+ const tool = mockTools.get('ghost_update_tag');
1058
+ const result = await tool.handler({ id: '123', description: 'New description' });
1059
+
1060
+ expect(mockUpdateTag).toHaveBeenCalledWith('123', { description: 'New description' });
1061
+ expect(result.content[0].text).toContain('"description": "New description"');
1062
+ });
1063
+
1064
+ it('should update multiple fields at once', async () => {
1065
+ const mockUpdatedTag = {
1066
+ id: '123',
1067
+ name: 'Updated Tag',
1068
+ slug: 'updated-tag',
1069
+ description: 'Updated description',
1070
+ meta_title: 'Updated Meta',
1071
+ };
1072
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1073
+
1074
+ const tool = mockTools.get('ghost_update_tag');
1075
+ await tool.handler({
1076
+ id: '123',
1077
+ name: 'Updated Tag',
1078
+ description: 'Updated description',
1079
+ meta_title: 'Updated Meta',
1080
+ });
1081
+
1082
+ expect(mockUpdateTag).toHaveBeenCalledWith('123', {
1083
+ name: 'Updated Tag',
1084
+ description: 'Updated description',
1085
+ meta_title: 'Updated Meta',
1086
+ });
1087
+ });
1088
+
1089
+ it('should update tag feature image', async () => {
1090
+ const mockUpdatedTag = {
1091
+ id: '123',
1092
+ name: 'Test Tag',
1093
+ feature_image: 'https://example.com/image.jpg',
1094
+ };
1095
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1096
+
1097
+ const tool = mockTools.get('ghost_update_tag');
1098
+ await tool.handler({
1099
+ id: '123',
1100
+ feature_image: 'https://example.com/image.jpg',
1101
+ });
1102
+
1103
+ expect(mockUpdateTag).toHaveBeenCalledWith('123', {
1104
+ feature_image: 'https://example.com/image.jpg',
1105
+ });
1106
+ });
1107
+
1108
+ it('should return error when id is missing', async () => {
1109
+ const tool = mockTools.get('ghost_update_tag');
1110
+ const result = await tool.handler({ name: 'Test' });
1111
+
1112
+ expect(result.isError).toBe(true);
1113
+ expect(result.content[0].text).toContain('Tag ID is required');
1114
+ });
1115
+
1116
+ it('should handle validation error', async () => {
1117
+ mockUpdateTag.mockRejectedValue(new Error('Validation failed'));
1118
+
1119
+ const tool = mockTools.get('ghost_update_tag');
1120
+ const result = await tool.handler({ id: '123', name: '' });
1121
+
1122
+ expect(result.isError).toBe(true);
1123
+ expect(result.content[0].text).toContain('Validation failed');
1124
+ });
1125
+
1126
+ it('should handle not found error', async () => {
1127
+ mockUpdateTag.mockRejectedValue(new Error('Tag not found'));
1128
+
1129
+ const tool = mockTools.get('ghost_update_tag');
1130
+ const result = await tool.handler({ id: '999', name: 'Test' });
1131
+
1132
+ expect(result.isError).toBe(true);
1133
+ expect(result.content[0].text).toContain('Tag not found');
1134
+ });
1135
+ });
1136
+
1137
+ describe('ghost_delete_tag', () => {
1138
+ beforeEach(() => {
1139
+ vi.clearAllMocks();
1140
+ });
1141
+
1142
+ it('should be registered as a tool', () => {
1143
+ expect(mockTools.has('ghost_delete_tag')).toBe(true);
1144
+ const tool = mockTools.get('ghost_delete_tag');
1145
+ expect(tool.name).toBe('ghost_delete_tag');
1146
+ expect(tool.description).toBeDefined();
1147
+ expect(tool.schema).toBeDefined();
1148
+ expect(tool.handler).toBeDefined();
1149
+ });
1150
+
1151
+ it('should have correct schema with id field', () => {
1152
+ const tool = mockTools.get('ghost_delete_tag');
1153
+ expect(tool.schema.id).toBeDefined();
1154
+ });
1155
+
1156
+ it('should delete tag successfully', async () => {
1157
+ mockDeleteTag.mockResolvedValue({ success: true });
1158
+
1159
+ const tool = mockTools.get('ghost_delete_tag');
1160
+ const result = await tool.handler({ id: '123' });
1161
+
1162
+ expect(mockDeleteTag).toHaveBeenCalledWith('123');
1163
+ expect(result.content[0].text).toContain('successfully deleted');
1164
+ expect(result.isError).toBeUndefined();
1165
+ });
1166
+
1167
+ it('should return error when id is missing', async () => {
1168
+ const tool = mockTools.get('ghost_delete_tag');
1169
+ const result = await tool.handler({});
1170
+
1171
+ expect(result.isError).toBe(true);
1172
+ expect(result.content[0].text).toContain('Tag ID is required');
1173
+ });
1174
+
1175
+ it('should handle not found error', async () => {
1176
+ mockDeleteTag.mockRejectedValue(new Error('Tag not found'));
1177
+
1178
+ const tool = mockTools.get('ghost_delete_tag');
1179
+ const result = await tool.handler({ id: '999' });
1180
+
1181
+ expect(result.isError).toBe(true);
1182
+ expect(result.content[0].text).toContain('Tag not found');
1183
+ });
1184
+
1185
+ it('should handle deletion error', async () => {
1186
+ mockDeleteTag.mockRejectedValue(new Error('Failed to delete tag'));
1187
+
1188
+ const tool = mockTools.get('ghost_delete_tag');
1189
+ const result = await tool.handler({ id: '123' });
1190
+
1191
+ expect(result.isError).toBe(true);
1192
+ expect(result.content[0].text).toContain('Failed to delete tag');
1193
+ });
1194
+ });
@@ -122,6 +122,129 @@ server.tool(
122
122
  }
123
123
  );
124
124
 
125
+ // Get Tag Tool
126
+ server.tool(
127
+ 'ghost_get_tag',
128
+ 'Retrieves a single tag from Ghost CMS by ID or slug.',
129
+ {
130
+ id: z.string().optional().describe('The ID of the tag to retrieve.'),
131
+ slug: z.string().optional().describe('The slug of the tag to retrieve.'),
132
+ include: z
133
+ .string()
134
+ .optional()
135
+ .describe('Additional resources to include (e.g., "count.posts").'),
136
+ },
137
+ async ({ id, slug, include }) => {
138
+ console.error(`Executing tool: ghost_get_tag`);
139
+ try {
140
+ if (!id && !slug) {
141
+ throw new Error('Either id or slug must be provided');
142
+ }
143
+
144
+ await loadServices();
145
+
146
+ // If slug is provided, use the slug/slug-name format
147
+ const identifier = slug ? `slug/${slug}` : id;
148
+ const options = include ? { include } : {};
149
+
150
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
151
+ const tag = await ghostServiceImproved.getTag(identifier, options);
152
+ console.error(`Tag retrieved successfully. Tag ID: ${tag.id}`);
153
+
154
+ return {
155
+ content: [{ type: 'text', text: JSON.stringify(tag, null, 2) }],
156
+ };
157
+ } catch (error) {
158
+ console.error(`Error in ghost_get_tag:`, error);
159
+ return {
160
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
161
+ isError: true,
162
+ };
163
+ }
164
+ }
165
+ );
166
+
167
+ // Update Tag Tool
168
+ server.tool(
169
+ 'ghost_update_tag',
170
+ 'Updates an existing tag in Ghost CMS.',
171
+ {
172
+ id: z.string().describe('The ID of the tag to update.'),
173
+ name: z.string().optional().describe('The new name for the tag.'),
174
+ slug: z.string().optional().describe('The new slug for the tag.'),
175
+ description: z.string().optional().describe('The new description for the tag.'),
176
+ feature_image: z.string().url().optional().describe('URL of the feature image for the tag.'),
177
+ meta_title: z.string().optional().describe('SEO meta title for the tag.'),
178
+ meta_description: z.string().optional().describe('SEO meta description for the tag.'),
179
+ },
180
+ async ({ id, name, slug, description, feature_image, meta_title, meta_description }) => {
181
+ console.error(`Executing tool: ghost_update_tag for ID: ${id}`);
182
+ try {
183
+ if (!id) {
184
+ throw new Error('Tag ID is required');
185
+ }
186
+
187
+ await loadServices();
188
+
189
+ // Build update data object with only provided fields
190
+ const updateData = {};
191
+ if (name !== undefined) updateData.name = name;
192
+ if (slug !== undefined) updateData.slug = slug;
193
+ if (description !== undefined) updateData.description = description;
194
+ if (feature_image !== undefined) updateData.feature_image = feature_image;
195
+ if (meta_title !== undefined) updateData.meta_title = meta_title;
196
+ if (meta_description !== undefined) updateData.meta_description = meta_description;
197
+
198
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
199
+ const updatedTag = await ghostServiceImproved.updateTag(id, updateData);
200
+ console.error(`Tag updated successfully. Tag ID: ${updatedTag.id}`);
201
+
202
+ return {
203
+ content: [{ type: 'text', text: JSON.stringify(updatedTag, null, 2) }],
204
+ };
205
+ } catch (error) {
206
+ console.error(`Error in ghost_update_tag:`, error);
207
+ return {
208
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
209
+ isError: true,
210
+ };
211
+ }
212
+ }
213
+ );
214
+
215
+ // Delete Tag Tool
216
+ server.tool(
217
+ 'ghost_delete_tag',
218
+ 'Deletes a tag from Ghost CMS by ID. This operation is permanent.',
219
+ {
220
+ id: z.string().describe('The ID of the tag to delete.'),
221
+ },
222
+ async ({ id }) => {
223
+ console.error(`Executing tool: ghost_delete_tag for ID: ${id}`);
224
+ try {
225
+ if (!id) {
226
+ throw new Error('Tag ID is required');
227
+ }
228
+
229
+ await loadServices();
230
+
231
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
232
+ await ghostServiceImproved.deleteTag(id);
233
+ console.error(`Tag deleted successfully. Tag ID: ${id}`);
234
+
235
+ return {
236
+ content: [{ type: 'text', text: `Tag with ID ${id} has been successfully deleted.` }],
237
+ };
238
+ } catch (error) {
239
+ console.error(`Error in ghost_delete_tag:`, error);
240
+ return {
241
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
242
+ isError: true,
243
+ };
244
+ }
245
+ }
246
+ );
247
+
125
248
  // Upload Image Tool
126
249
  server.tool(
127
250
  'ghost_upload_image',
@@ -982,7 +1105,7 @@ async function main() {
982
1105
 
983
1106
  console.error('Ghost MCP Server running on stdio transport');
984
1107
  console.error(
985
- 'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ' +
1108
+ 'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
986
1109
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
987
1110
  'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
988
1111
  'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
@@ -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.validateTagData({ name: 'dummy', ...updateData }); // Validate update data
737
+ validators.validateTagUpdateData(updateData); // Validate update data
717
738
 
718
739
  try {
719
740
  const existingTag = await getTag(tagId);