@jgardner04/ghost-mcp-server 1.14.2 → 1.14.4

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.14.2",
3
+ "version": "1.14.4",
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",
@@ -117,3 +117,25 @@ export async function waitFor(condition, timeout = 5000, interval = 100) {
117
117
  export function delay(ms) {
118
118
  return new Promise((resolve) => setTimeout(resolve, ms));
119
119
  }
120
+
121
+ /**
122
+ * Asserts that a promise rejects with a specific error type and message.
123
+ * Combines the common double `.rejects` pattern into a single call.
124
+ *
125
+ * @param {Promise} promise - The promise expected to reject
126
+ * @param {Function} ErrorClass - The expected error constructor (e.g., NotFoundError)
127
+ * @param {string|RegExp} message - The expected error message (string or regex)
128
+ *
129
+ * @example
130
+ * import { expectRejection } from '../helpers/testUtils.js';
131
+ *
132
+ * await expectRejection(
133
+ * updateMember('non-existent', { name: 'Test' }),
134
+ * NotFoundError,
135
+ * 'Member not found'
136
+ * );
137
+ */
138
+ export async function expectRejection(promise, ErrorClass, message) {
139
+ await expect(promise).rejects.toBeInstanceOf(ErrorClass);
140
+ await expect(promise).rejects.toThrow(message);
141
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
+ import { mockDotenv, expectRejection } from '../../__tests__/helpers/testUtils.js';
4
4
  import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
5
5
 
6
6
  // Mock the Ghost Admin API using shared mock factory
@@ -187,9 +187,11 @@ describe('ghostServiceImproved - Members', () => {
187
187
  const error404 = new GhostAPIError('members.read', 'Member not found', 404);
188
188
  api.members.read.mockRejectedValue(error404);
189
189
 
190
- const rejection = updateMember('non-existent', { name: 'Test' });
191
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
192
- await expect(rejection).rejects.toThrow('Member not found');
190
+ await expectRejection(
191
+ updateMember('non-existent', { name: 'Test' }),
192
+ NotFoundError,
193
+ 'Member not found'
194
+ );
193
195
  });
194
196
  });
195
197
 
@@ -213,9 +215,7 @@ describe('ghostServiceImproved - Members', () => {
213
215
  const error404 = new GhostAPIError('members.delete', 'Member not found', 404);
214
216
  api.members.delete.mockRejectedValue(error404);
215
217
 
216
- const rejection = deleteMember('non-existent');
217
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
218
- await expect(rejection).rejects.toThrow('Member not found');
218
+ await expectRejection(deleteMember('non-existent'), NotFoundError, 'Member not found');
219
219
  });
220
220
  });
221
221
 
@@ -360,9 +360,7 @@ describe('ghostServiceImproved - Members', () => {
360
360
  const error404 = new GhostAPIError('members.read', 'Member not found', 404);
361
361
  api.members.read.mockRejectedValue(error404);
362
362
 
363
- const rejection = getMember({ id: 'non-existent' });
364
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
365
- await expect(rejection).rejects.toThrow('Member not found');
363
+ await expectRejection(getMember({ id: 'non-existent' }), NotFoundError, 'Member not found');
366
364
  });
367
365
 
368
366
  it('should throw not found error when member not found by email', async () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
+ import { mockDotenv, expectRejection } from '../../__tests__/helpers/testUtils.js';
4
4
  import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
5
5
 
6
6
  // Mock the Ghost Admin API using shared mock factory
@@ -210,9 +210,11 @@ describe('ghostServiceImproved - Pages', () => {
210
210
  error422.response = { status: 422 };
211
211
  api.pages.add.mockRejectedValue(error422);
212
212
 
213
- const rejection = createPage({ title: 'Test', html: '<p>Content</p>' });
214
- await expect(rejection).rejects.toBeInstanceOf(ValidationError);
215
- await expect(rejection).rejects.toThrow('Page creation failed due to validation errors');
213
+ await expectRejection(
214
+ createPage({ title: 'Test', html: '<p>Content</p>' }),
215
+ ValidationError,
216
+ 'Page creation failed due to validation errors'
217
+ );
216
218
  });
217
219
 
218
220
  it('should NOT include tags in page creation (pages do not support tags)', async () => {
@@ -276,9 +278,11 @@ describe('ghostServiceImproved - Pages', () => {
276
278
  const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
277
279
  api.pages.read.mockRejectedValue(error404);
278
280
 
279
- const rejection = updatePage('nonexistent-id', { title: 'Updated' });
280
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
281
- await expect(rejection).rejects.toThrow('Page not found');
281
+ await expectRejection(
282
+ updatePage('nonexistent-id', { title: 'Updated' }),
283
+ NotFoundError,
284
+ 'Page not found'
285
+ );
282
286
  });
283
287
 
284
288
  it('should preserve updated_at timestamp for conflict resolution', async () => {
@@ -388,9 +392,7 @@ describe('ghostServiceImproved - Pages', () => {
388
392
  const error404 = new GhostAPIError('pages.delete', 'Page not found', 404);
389
393
  api.pages.delete.mockRejectedValue(error404);
390
394
 
391
- const rejection = deletePage('nonexistent-id');
392
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
393
- await expect(rejection).rejects.toThrow('Page not found');
395
+ await expectRejection(deletePage('nonexistent-id'), NotFoundError, 'Page not found');
394
396
  });
395
397
  });
396
398
 
@@ -437,9 +439,7 @@ describe('ghostServiceImproved - Pages', () => {
437
439
  const error404 = new GhostAPIError('pages.read', 'Page not found', 404);
438
440
  api.pages.read.mockRejectedValue(error404);
439
441
 
440
- const rejection = getPage('nonexistent-id');
441
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
442
- await expect(rejection).rejects.toThrow('Page not found');
442
+ await expectRejection(getPage('nonexistent-id'), NotFoundError, 'Page not found');
443
443
  });
444
444
  });
445
445
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
+ import { mockDotenv, expectRejection } from '../../__tests__/helpers/testUtils.js';
4
4
  import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
5
5
 
6
6
  // Mock the Ghost Admin API using shared mock factory
@@ -95,9 +95,11 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
95
95
  const error404 = new GhostAPIError('posts.read', 'Post not found', 404);
96
96
  api.posts.read.mockRejectedValue(error404);
97
97
 
98
- const rejection = updatePost('nonexistent-id', { title: 'Updated' });
99
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
100
- await expect(rejection).rejects.toThrow('Post not found');
98
+ await expectRejection(
99
+ updatePost('nonexistent-id', { title: 'Updated' }),
100
+ NotFoundError,
101
+ 'Post not found'
102
+ );
101
103
  });
102
104
 
103
105
  it('should throw ValidationError when updating to scheduled without published_at', async () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
+ import { mockDotenv, expectRejection } from '../../__tests__/helpers/testUtils.js';
4
4
  import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
5
5
 
6
6
  // Mock the Ghost Admin API using shared mock factory
@@ -254,9 +254,7 @@ describe('ghostServiceImproved - Tags', () => {
254
254
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
255
255
  api.tags.read.mockRejectedValue(error404);
256
256
 
257
- const rejection = getTag('non-existent');
258
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
259
- await expect(rejection).rejects.toThrow('Tag not found');
257
+ await expectRejection(getTag('non-existent'), NotFoundError, 'Tag not found');
260
258
  });
261
259
  });
262
260
 
@@ -412,9 +410,11 @@ describe('ghostServiceImproved - Tags', () => {
412
410
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
413
411
  api.tags.read.mockRejectedValue(error404);
414
412
 
415
- const rejection = updateTag('non-existent', { name: 'Test' });
416
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
417
- await expect(rejection).rejects.toThrow('Tag not found');
413
+ await expectRejection(
414
+ updateTag('non-existent', { name: 'Test' }),
415
+ NotFoundError,
416
+ 'Tag not found'
417
+ );
418
418
  });
419
419
  });
420
420
 
@@ -438,9 +438,7 @@ describe('ghostServiceImproved - Tags', () => {
438
438
  const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
439
439
  api.tags.delete.mockRejectedValue(error404);
440
440
 
441
- const rejection = deleteTag('non-existent');
442
- await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
443
- await expect(rejection).rejects.toThrow('Tag not found');
441
+ await expectRejection(deleteTag('non-existent'), NotFoundError, 'Tag not found');
444
442
  });
445
443
  });
446
444
  });
@@ -377,6 +377,40 @@ describe('urlValidator', () => {
377
377
  expect(result.isValid).toBe(true);
378
378
  });
379
379
  });
380
+
381
+ // Regression tests for JON-151: the previous two-parser sequence
382
+ // (Joi.uri() + new URL()) could disagree on these edge cases, opening a
383
+ // path-around-isSafeHost SSRF window. The single-parser pipeline must
384
+ // either reject these or canonicalize them consistently with new URL().
385
+ describe('parse-disagreement regressions (JON-151)', () => {
386
+ it('should reject credentials-prefixed URL whose true host is private (127.0.0.1)', () => {
387
+ // WHATWG: hostname is 127.0.0.1. A naive parser that split on '@' the
388
+ // wrong way could see "imgur.com" and let this through. isSafeHost
389
+ // must see 127.0.0.1 and reject.
390
+ const result = validateImageUrl('https://imgur.com@127.0.0.1/image.jpg');
391
+ expect(result.isValid).toBe(false);
392
+ });
393
+
394
+ it('should reject Unicode hostname not on the allowlist', () => {
395
+ // new URL('https://☃.com/') canonicalizes the host to xn--n3h.com
396
+ // (Punycode). Either form must be rejected — neither is allowlisted.
397
+ const result = validateImageUrl('https://☃.com/image.jpg');
398
+ expect(result.isValid).toBe(false);
399
+ });
400
+
401
+ it('should reject Punycode hostname not on the allowlist', () => {
402
+ const result = validateImageUrl('https://xn--n3h.com/image.jpg');
403
+ expect(result.isValid).toBe(false);
404
+ });
405
+
406
+ it('should reject IPv6 loopback with zone ID', () => {
407
+ // [::1%25eth0] is the percent-encoded form of [::1%eth0]. The host is
408
+ // still ::1 — the SSRF guard must catch it regardless of whether the
409
+ // parser accepts the zone-ID syntax or rejects the URL outright.
410
+ const result = validateImageUrl('http://[::1%25eth0]/image.jpg');
411
+ expect(result.isValid).toBe(false);
412
+ });
413
+ });
380
414
  });
381
415
 
382
416
  describe('createSecureAxiosConfig', () => {
@@ -1,4 +1,4 @@
1
- import Joi from 'joi';
1
+ import { z } from 'zod';
2
2
  import { URL } from 'url';
3
3
 
4
4
  // Configure allowed domains for image downloads
@@ -69,6 +69,25 @@ const isSafeHost = (hostname) => {
69
69
  );
70
70
  };
71
71
 
72
+ // Single-parser URL schema. z.string().url() uses the WHATWG URL parser
73
+ // (same engine as new URL() below), so the schema check and the subsequent
74
+ // hostname extraction cannot disagree on edge cases like embedded
75
+ // credentials, IDN/Unicode, or IPv6 zone IDs. See JON-151.
76
+ const urlSchema = z
77
+ .string()
78
+ .url()
79
+ .refine(
80
+ (s) => {
81
+ try {
82
+ const proto = new URL(s).protocol;
83
+ return proto === 'http:' || proto === 'https:';
84
+ } catch {
85
+ return false;
86
+ }
87
+ },
88
+ { message: 'must use http or https scheme' }
89
+ );
90
+
72
91
  /**
73
92
  * Validates and sanitizes a URL for safe external requests
74
93
  * @param {string} url - The URL to validate
@@ -76,19 +95,11 @@ const isSafeHost = (hostname) => {
76
95
  */
77
96
  const validateImageUrl = (url) => {
78
97
  try {
79
- // Basic URL validation with Joi
80
- const urlSchema = Joi.string()
81
- .uri({
82
- scheme: ['http', 'https'],
83
- allowRelative: false,
84
- })
85
- .required();
86
-
87
- const validation = urlSchema.validate(url);
88
- if (validation.error) {
98
+ const result = urlSchema.safeParse(url);
99
+ if (!result.success) {
89
100
  return {
90
101
  isValid: false,
91
- error: `Invalid URL format: ${validation.error.details[0].message}`,
102
+ error: `Invalid URL format: ${result.error.issues[0].message}`,
92
103
  };
93
104
  }
94
105