@jgardner04/ghost-mcp-server 1.13.4 → 1.14.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.
Files changed (40) hide show
  1. package/README.md +68 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/helpers/testUtils.js +15 -1
  4. package/src/__tests__/mcp_server.test.js +152 -1
  5. package/src/__tests__/mcp_server_pages.test.js +23 -6
  6. package/src/controllers/__tests__/imageController.test.js +2 -2
  7. package/src/controllers/imageController.js +11 -10
  8. package/src/mcp_server.js +647 -1203
  9. package/src/routes/__tests__/imageRoutes.test.js +2 -2
  10. package/src/schemas/__tests__/common.test.js +3 -3
  11. package/src/schemas/__tests__/pageSchemas.test.js +11 -2
  12. package/src/schemas/common.js +3 -2
  13. package/src/schemas/pageSchemas.js +1 -1
  14. package/src/schemas/postSchemas.js +1 -1
  15. package/src/services/__tests__/createResourceService.test.js +468 -0
  16. package/src/services/__tests__/ghostService.test.js +0 -19
  17. package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
  18. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
  19. package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
  20. package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
  21. package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
  22. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
  23. package/src/services/__tests__/imageProcessingService.test.js +148 -177
  24. package/src/services/__tests__/images.test.js +78 -0
  25. package/src/services/createResourceService.js +138 -0
  26. package/src/services/ghostApiClient.js +240 -0
  27. package/src/services/ghostService.js +1 -19
  28. package/src/services/ghostServiceImproved.js +76 -915
  29. package/src/services/imageProcessingService.js +100 -56
  30. package/src/services/images.js +54 -0
  31. package/src/services/members.js +127 -0
  32. package/src/services/newsletters.js +63 -0
  33. package/src/services/pageService.js +2 -2
  34. package/src/services/pages.js +116 -0
  35. package/src/services/posts.js +116 -0
  36. package/src/services/tags.js +118 -0
  37. package/src/services/tiers.js +72 -0
  38. package/src/services/validators.js +218 -0
  39. package/src/utils/__tests__/imageInputResolver.test.js +134 -0
  40. package/src/utils/imageInputResolver.js +127 -0
package/README.md CHANGED
@@ -278,6 +278,74 @@ For development, the following scripts are available:
278
278
  | `npm run lint` | Check code for linting errors |
279
279
  | `npm run lint:fix` | Auto-fix linting errors |
280
280
 
281
+ ## MCP Client Configuration
282
+
283
+ The Ghost MCP Server works with any MCP-compatible client. Below are quickstart configurations for the most common clients. For a complete guide including WebSocket and HTTP/SSE transports, see [docs/MCP_CLIENT_SETUP.md](docs/MCP_CLIENT_SETUP.md).
284
+
285
+ ### Claude Code (including the Sidedoc project)
286
+
287
+ Create a `.mcp.json` file at the root of your project (e.g., the [Sidedoc repository](https://github.com/jgardner04/sidedoc)):
288
+
289
+ ```json
290
+ {
291
+ "mcpServers": {
292
+ "ghost": {
293
+ "command": "npx",
294
+ "args": ["-y", "@jgardner04/ghost-mcp-server"],
295
+ "env": {
296
+ "GHOST_ADMIN_API_URL": "https://your-ghost-site.com",
297
+ "GHOST_ADMIN_API_KEY": "your_admin_api_key"
298
+ }
299
+ }
300
+ }
301
+ }
302
+ ```
303
+
304
+ Claude Code will automatically detect this file and make all 34 Ghost MCP tools available within that project. You can also register the server globally:
305
+
306
+ ```bash
307
+ claude mcp add ghost \
308
+ --scope user \
309
+ --command npx \
310
+ --args "-y @jgardner04/ghost-mcp-server" \
311
+ --env GHOST_ADMIN_API_URL=https://your-ghost-site.com \
312
+ --env GHOST_ADMIN_API_KEY=your_admin_api_key
313
+ ```
314
+
315
+ > **Tip:** Do not commit `.mcp.json` files that contain real API keys. Add `.mcp.json` to your `.gitignore` or source credentials from a `.env` file.
316
+
317
+ ### Claude Desktop
318
+
319
+ Add the server to your Claude Desktop configuration file:
320
+
321
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
322
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
323
+
324
+ ```json
325
+ {
326
+ "mcpServers": {
327
+ "ghost": {
328
+ "command": "npx",
329
+ "args": ["-y", "@jgardner04/ghost-mcp-server"],
330
+ "env": {
331
+ "GHOST_ADMIN_API_URL": "https://your-ghost-site.com",
332
+ "GHOST_ADMIN_API_KEY": "your_admin_api_key"
333
+ }
334
+ }
335
+ }
336
+ }
337
+ ```
338
+
339
+ Restart Claude Desktop after saving the file.
340
+
341
+ ### Cursor
342
+
343
+ Open **Cursor Settings → Features → MCP Servers**, click **Add Server**, and provide:
344
+
345
+ - **Name:** `ghost`
346
+ - **Command:** `npx -y @jgardner04/ghost-mcp-server`
347
+ - **Environment Variables:** `GHOST_ADMIN_API_URL` and `GHOST_ADMIN_API_KEY`
348
+
281
349
  ## Development Setup
282
350
 
283
351
  For contributors or advanced users who want to modify the source code:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.13.4",
3
+ "version": "1.14.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",
@@ -43,12 +43,13 @@
43
43
  "lint:fix": "eslint . --fix",
44
44
  "format": "prettier --write \"**/*.{js,json,md}\"",
45
45
  "format:check": "prettier --check \"**/*.{js,json,md}\"",
46
- "prepare": "husky"
46
+ "prepare": "husky",
47
+ "prepublishOnly": "chmod +x src/mcp_server.js"
47
48
  },
48
49
  "dependencies": {
49
50
  "@modelcontextprotocol/sdk": "^1.25.2",
50
51
  "@tryghost/admin-api": "^1.13.12",
51
- "axios": "^1.12.1",
52
+ "axios": "^1.15.0",
52
53
  "chalk": "^5.3.0",
53
54
  "cli-table3": "^0.6.3",
54
55
  "dotenv": "^17.0.0",
@@ -86,6 +87,9 @@
86
87
  "prettier --write"
87
88
  ]
88
89
  },
90
+ "overrides": {
91
+ "axios": "^1.15.0"
92
+ },
89
93
  "devDependencies": {
90
94
  "@eslint/js": "^10.0.0",
91
95
  "@vitest/coverage-v8": "^4.0.15",
@@ -1,4 +1,18 @@
1
- import { vi } from 'vitest';
1
+ import { expect, vi } from 'vitest';
2
+
3
+ /**
4
+ * Asserts that a value is a ZodObject exposing a `.shape` property.
5
+ *
6
+ * Uses `expect` so Vitest reports a clean named assertion failure, and
7
+ * `schema?.shape` so a null/undefined `schema` is handled without a secondary
8
+ * TypeError — the exact opaque failure mode this helper eliminates.
9
+ *
10
+ * @param {unknown} schema - Value expected to be a ZodObject
11
+ * @param {string} toolName - Tool name included in the failure message
12
+ */
13
+ export function assertZodShape(schema, toolName) {
14
+ expect(schema?.shape, `${toolName}: schema is not a ZodObject (missing .shape)`).toBeDefined();
15
+ }
2
16
 
3
17
  /**
4
18
  * Creates a mock environment variable configuration.
@@ -1,4 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
2
+ // zod/v4-mini is a subpath export of zod@^4 — used here because the MCP SDK's
3
+ // internal JSON Schema converter (zod-json-schema-compat.js) uses this same module.
4
+ import * as z4mini from 'zod/v4-mini';
5
+ import { assertZodShape } from './helpers/testUtils.js';
2
6
 
3
7
  // Mock the McpServer to capture tool registrations
4
8
  const mockTools = new Map();
@@ -212,6 +216,7 @@ describe('mcp_server - ghost_get_posts tool', () => {
212
216
  expect(tool.description).toContain('posts');
213
217
  expect(tool.schema).toBeDefined();
214
218
  // Zod schemas store field definitions in schema.shape
219
+ assertZodShape(tool.schema, 'ghost_get_posts');
215
220
  expect(tool.schema.shape.limit).toBeDefined();
216
221
  expect(tool.schema.shape.page).toBeDefined();
217
222
  expect(tool.schema.shape.status).toBeDefined();
@@ -250,6 +255,7 @@ describe('mcp_server - ghost_get_posts tool', () => {
250
255
  it('should validate limit is between 1 and 100', () => {
251
256
  const tool = mockTools.get('ghost_get_posts');
252
257
  // Zod schemas store field definitions in schema.shape
258
+ assertZodShape(tool.schema, 'ghost_get_posts');
253
259
  const shape = tool.schema.shape;
254
260
 
255
261
  // Test that limit schema exists and has proper validation
@@ -262,6 +268,7 @@ describe('mcp_server - ghost_get_posts tool', () => {
262
268
  it('should validate page is at least 1', () => {
263
269
  const tool = mockTools.get('ghost_get_posts');
264
270
  // Zod schemas store field definitions in schema.shape
271
+ assertZodShape(tool.schema, 'ghost_get_posts');
265
272
  const shape = tool.schema.shape;
266
273
 
267
274
  expect(shape.page).toBeDefined();
@@ -282,6 +289,7 @@ describe('mcp_server - ghost_get_posts tool', () => {
282
289
  it('should validate status enum values', () => {
283
290
  const tool = mockTools.get('ghost_get_posts');
284
291
  // Zod schemas store field definitions in schema.shape
292
+ assertZodShape(tool.schema, 'ghost_get_posts');
285
293
  const shape = tool.schema.shape;
286
294
 
287
295
  expect(shape.status).toBeDefined();
@@ -449,6 +457,7 @@ describe('mcp_server - ghost_get_tags tool', () => {
449
457
  expect(tool.description).toContain('tags');
450
458
  expect(tool.schema).toBeDefined();
451
459
  // Zod schemas store field definitions in schema.shape
460
+ assertZodShape(tool.schema, 'ghost_get_tags');
452
461
  expect(tool.schema.shape.limit).toBeDefined();
453
462
  expect(tool.schema.shape.page).toBeDefined();
454
463
  expect(tool.schema.shape.order).toBeDefined();
@@ -683,6 +692,7 @@ describe('mcp_server - ghost_get_post tool', () => {
683
692
  expect(tool.description).toContain('post');
684
693
  expect(tool.schema).toBeDefined();
685
694
  // In Zod v4, refined schemas expose .shape directly
695
+ assertZodShape(tool.schema, 'ghost_get_post');
686
696
  const shape = tool.schema.shape;
687
697
  expect(shape.id).toBeDefined();
688
698
  expect(shape.slug).toBeDefined();
@@ -849,6 +859,7 @@ describe('mcp_server - ghost_update_post tool', () => {
849
859
  expect(tool.description).toContain('Updates an existing post');
850
860
  expect(tool.schema).toBeDefined();
851
861
  // Zod schemas store field definitions in schema.shape
862
+ assertZodShape(tool.schema, 'ghost_update_post');
852
863
  expect(tool.schema.shape.id).toBeDefined();
853
864
  expect(tool.schema.shape.title).toBeDefined();
854
865
  expect(tool.schema.shape.html).toBeDefined();
@@ -1120,6 +1131,7 @@ describe('mcp_server - ghost_delete_post tool', () => {
1120
1131
  expect(tool.description).toContain('permanent');
1121
1132
  expect(tool.schema).toBeDefined();
1122
1133
  // Zod schemas store field definitions in schema.shape
1134
+ assertZodShape(tool.schema, 'ghost_delete_post');
1123
1135
  expect(tool.schema.shape.id).toBeDefined();
1124
1136
  });
1125
1137
 
@@ -1199,6 +1211,7 @@ describe('mcp_server - ghost_search_posts tool', () => {
1199
1211
  expect(tool.description).toContain('Search');
1200
1212
  expect(tool.schema).toBeDefined();
1201
1213
  // Zod schemas store field definitions in schema.shape
1214
+ assertZodShape(tool.schema, 'ghost_search_posts');
1202
1215
  expect(tool.schema.shape.query).toBeDefined();
1203
1216
  expect(tool.schema.shape.status).toBeDefined();
1204
1217
  expect(tool.schema.shape.limit).toBeDefined();
@@ -1247,6 +1260,7 @@ describe('mcp_server - ghost_search_posts tool', () => {
1247
1260
  it('should validate limit is between 1 and 50', () => {
1248
1261
  const tool = mockTools.get('ghost_search_posts');
1249
1262
  // Zod schemas store field definitions in schema.shape
1263
+ assertZodShape(tool.schema, 'ghost_search_posts');
1250
1264
  const shape = tool.schema.shape;
1251
1265
 
1252
1266
  expect(shape.limit).toBeDefined();
@@ -1258,6 +1272,7 @@ describe('mcp_server - ghost_search_posts tool', () => {
1258
1272
  it('should validate status enum values', () => {
1259
1273
  const tool = mockTools.get('ghost_search_posts');
1260
1274
  // Zod schemas store field definitions in schema.shape
1275
+ assertZodShape(tool.schema, 'ghost_search_posts');
1261
1276
  const shape = tool.schema.shape;
1262
1277
 
1263
1278
  expect(shape.status).toBeDefined();
@@ -1356,6 +1371,7 @@ describe('ghost_get_tag', () => {
1356
1371
  it('should have correct schema with id and slug as optional', () => {
1357
1372
  const tool = mockTools.get('ghost_get_tag');
1358
1373
  // In Zod v4, refined schemas expose .shape directly
1374
+ assertZodShape(tool.schema, 'ghost_get_tag');
1359
1375
  const shape = tool.schema.shape;
1360
1376
  expect(shape.id).toBeDefined();
1361
1377
  expect(shape.slug).toBeDefined();
@@ -1456,6 +1472,7 @@ describe('ghost_update_tag', () => {
1456
1472
  it('should have correct schema with all update fields', () => {
1457
1473
  const tool = mockTools.get('ghost_update_tag');
1458
1474
  // Zod schemas store field definitions in schema.shape
1475
+ assertZodShape(tool.schema, 'ghost_update_tag');
1459
1476
  expect(tool.schema.shape.id).toBeDefined();
1460
1477
  expect(tool.schema.shape.name).toBeDefined();
1461
1478
  expect(tool.schema.shape.slug).toBeDefined();
@@ -1602,6 +1619,7 @@ describe('ghost_delete_tag', () => {
1602
1619
  it('should have correct schema with id field', () => {
1603
1620
  const tool = mockTools.get('ghost_delete_tag');
1604
1621
  // Zod schemas store field definitions in schema.shape
1622
+ assertZodShape(tool.schema, 'ghost_delete_tag');
1605
1623
  expect(tool.schema.shape.id).toBeDefined();
1606
1624
  });
1607
1625
 
@@ -1644,3 +1662,136 @@ describe('ghost_delete_tag', () => {
1644
1662
  expect(result.content[0].text).toContain('Failed to delete tag');
1645
1663
  });
1646
1664
  });
1665
+
1666
+ // --- JSON Schema regression tests (JON-103) ---
1667
+ // Verifies that every registered MCP tool exposes a non-empty JSON Schema
1668
+ // to clients. Uses zod/v4-mini's toJSONSchema — the same converter the
1669
+ // MCP SDK calls internally (see zod-json-schema-compat.js).
1670
+ describe('tool schema JSON Schema output', () => {
1671
+ // Matches the MCP SDK's internal conversion options (see zod-json-schema-compat.js)
1672
+ const JSON_SCHEMA_OPTS = { target: 'draft-7', io: 'input' };
1673
+
1674
+ beforeAll(async () => {
1675
+ if (mockTools.size === 0) {
1676
+ await import('../mcp_server.js');
1677
+ }
1678
+ });
1679
+
1680
+ it('should produce non-empty properties for every registered tool', () => {
1681
+ expect(mockTools.size).toBeGreaterThan(0);
1682
+
1683
+ for (const [name, tool] of mockTools) {
1684
+ const schema = tool.schema;
1685
+
1686
+ // Schema must be a Zod object with a shape
1687
+ assertZodShape(schema, name);
1688
+ expect(Object.keys(schema.shape).length, `${name}: shape has no keys`).toBeGreaterThan(0);
1689
+
1690
+ // Convert via the same path the MCP SDK uses
1691
+ const jsonSchema = z4mini.toJSONSchema(schema, JSON_SCHEMA_OPTS);
1692
+
1693
+ expect(jsonSchema.type, `${name}: type should be 'object'`).toBe('object');
1694
+ expect(
1695
+ Object.keys(jsonSchema.properties || {}).length,
1696
+ `${name}: JSON Schema properties is empty`
1697
+ ).toBeGreaterThan(0);
1698
+ }
1699
+ });
1700
+
1701
+ it('should declare title and html as required for ghost_create_post and ghost_create_page', () => {
1702
+ for (const toolName of ['ghost_create_post', 'ghost_create_page']) {
1703
+ const tool = mockTools.get(toolName);
1704
+ expect(tool, `${toolName}: tool not found in registry`).toBeDefined();
1705
+ const jsonSchema = z4mini.toJSONSchema(tool.schema, JSON_SCHEMA_OPTS);
1706
+
1707
+ expect(jsonSchema.properties, `${toolName}: properties missing`).toBeDefined();
1708
+ expect(jsonSchema.required, `${toolName}: title not required`).toContain('title');
1709
+ expect(jsonSchema.required, `${toolName}: html not required`).toContain('html');
1710
+ expect(jsonSchema.properties.title.type, `${toolName}: title type`).toBe('string');
1711
+ expect(jsonSchema.properties.html.type, `${toolName}: html type`).toBe('string');
1712
+ }
1713
+ });
1714
+ });
1715
+
1716
+ // Regression tests for PR B — download size cap enforcement in ghost_upload_image.
1717
+ // axios does NOT enforce maxContentLength for responseType: 'stream', so the tool
1718
+ // must cap bytes itself via Content-Length pre-check and mid-stream byte tracking.
1719
+ describe('mcp_server - ghost_upload_image download size cap', () => {
1720
+ const CAP = 50 * 1024 * 1024;
1721
+ let handler;
1722
+
1723
+ beforeAll(async () => {
1724
+ if (mockTools.size === 0) await import('../mcp_server.js');
1725
+ handler = mockTools.get('ghost_upload_image').handler;
1726
+ });
1727
+
1728
+ beforeEach(() => {
1729
+ vi.clearAllMocks();
1730
+ mockValidateImageUrl.mockReturnValue({
1731
+ isValid: true,
1732
+ sanitizedUrl: 'https://imgur.com/x.jpg',
1733
+ });
1734
+ mockCreateSecureAxiosConfig.mockReturnValue({ url: 'https://imgur.com/x.jpg' });
1735
+ });
1736
+
1737
+ it('rejects up front when Content-Length header exceeds the cap', async () => {
1738
+ const destroy = vi.fn();
1739
+ mockAxios.mockResolvedValue({
1740
+ headers: { 'content-length': String(CAP + 1) },
1741
+ data: { destroy, on: vi.fn(), pipe: vi.fn() },
1742
+ });
1743
+
1744
+ const result = await handler({ imageUrl: 'https://imgur.com/huge.jpg' });
1745
+
1746
+ expect(result.isError).toBe(true);
1747
+ expect(result.content[0].text).toMatch(/exceeds .* byte limit/);
1748
+ expect(destroy).toHaveBeenCalled();
1749
+ });
1750
+
1751
+ it('aborts mid-stream when bytes exceed the cap', async () => {
1752
+ // Build a controllable fake stream that the tool's `.on('data', ...)` hook
1753
+ // can drive past the cap without allocating 50MB.
1754
+ const listeners = {};
1755
+ const destroyCalls = [];
1756
+ const dataStream = {
1757
+ on: (event, cb) => {
1758
+ (listeners[event] ||= []).push(cb);
1759
+ return dataStream;
1760
+ },
1761
+ pipe: vi.fn(),
1762
+ destroy: (err) => {
1763
+ destroyCalls.push(err);
1764
+ (listeners.error || []).forEach((cb) => cb(err));
1765
+ },
1766
+ };
1767
+ const writer = {
1768
+ on: (event, cb) => {
1769
+ // Resolve the finish/error promise via the writer path.
1770
+ if (event === 'error' && destroyCalls.length > 0) {
1771
+ cb(destroyCalls[0]);
1772
+ }
1773
+ return writer;
1774
+ },
1775
+ };
1776
+ mockCreateWriteStream.mockReturnValue(writer);
1777
+ mockAxios.mockResolvedValue({
1778
+ headers: {},
1779
+ data: dataStream,
1780
+ });
1781
+
1782
+ // Kick off the handler, then simulate a single >cap chunk landing.
1783
+ const promise = handler({ imageUrl: 'https://imgur.com/x.jpg' });
1784
+ // Let all the handler's awaits resolve (loadServices, axios, Promise
1785
+ // construction with listener attachments) before emitting the chunk.
1786
+ await new Promise((r) => setTimeout(r, 20));
1787
+ const dataCbs = listeners.data || [];
1788
+ expect(dataCbs.length).toBeGreaterThan(0);
1789
+ dataCbs.forEach((cb) => cb({ length: CAP + 1 }));
1790
+
1791
+ const result = await promise;
1792
+ expect(result.isError).toBe(true);
1793
+ expect(result.content[0].text).toMatch(/exceeds .* byte limit|Error uploading image/);
1794
+ expect(destroyCalls.length).toBeGreaterThan(0);
1795
+ expect(destroyCalls[0]).toBeInstanceOf(Error);
1796
+ });
1797
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { assertZodShape } from './helpers/testUtils.js';
2
3
 
3
4
  // Mock the McpServer to capture tool registrations
4
5
  const mockTools = new Map();
@@ -158,6 +159,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
158
159
  expect(tool).toBeDefined();
159
160
  expect(tool.description).toContain('pages');
160
161
  expect(tool.schema).toBeDefined();
162
+ assertZodShape(tool.schema, 'ghost_get_pages');
161
163
  expect(tool.schema.shape.limit).toBeDefined();
162
164
  expect(tool.schema.shape.page).toBeDefined();
163
165
  expect(tool.schema.shape.include).toBeDefined();
@@ -194,6 +196,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
194
196
  const tool = mockTools.get('ghost_get_pages');
195
197
  const schema = tool.schema;
196
198
 
199
+ assertZodShape(schema, 'ghost_get_pages');
197
200
  expect(schema.shape.limit).toBeDefined();
198
201
  expect(() => schema.shape.limit.parse(0)).toThrow();
199
202
  expect(() => schema.shape.limit.parse(101)).toThrow();
@@ -219,7 +222,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
219
222
  const result = await tool.handler({});
220
223
 
221
224
  expect(result.isError).toBe(true);
222
- expect(result.content[0].text).toContain('Error retrieving pages');
225
+ expect(result.content[0].text).toContain('Error in ghost_get_pages');
223
226
  });
224
227
  });
225
228
 
@@ -239,6 +242,7 @@ describe('mcp_server - ghost_get_page tool', () => {
239
242
  const tool = mockTools.get('ghost_get_page');
240
243
  expect(tool).toBeDefined();
241
244
  // In Zod v4, refined schemas expose .shape directly
245
+ assertZodShape(tool.schema, 'ghost_get_page');
242
246
  const shape = tool.schema.shape;
243
247
  expect(shape.id).toBeDefined();
244
248
  expect(shape.slug).toBeDefined();
@@ -279,6 +283,14 @@ describe('mcp_server - ghost_get_page tool', () => {
279
283
  expect(() => tool.schema.parse({ slug: 'about-us' })).not.toThrow();
280
284
  });
281
285
 
286
+ it('should return validation error when neither id nor slug provided', async () => {
287
+ const tool = mockTools.get('ghost_get_page');
288
+ const result = await tool.handler({});
289
+
290
+ expect(result.isError).toBe(true);
291
+ expect(result.content[0].text).toContain('Either id or slug is required');
292
+ });
293
+
282
294
  it('should handle errors gracefully', async () => {
283
295
  mockGetPage.mockRejectedValue(new Error('Page not found'));
284
296
 
@@ -286,7 +298,7 @@ describe('mcp_server - ghost_get_page tool', () => {
286
298
  const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
287
299
 
288
300
  expect(result.isError).toBe(true);
289
- expect(result.content[0].text).toContain('Error retrieving page');
301
+ expect(result.content[0].text).toContain('Error in ghost_get_page');
290
302
  });
291
303
  });
292
304
 
@@ -306,6 +318,7 @@ describe('mcp_server - ghost_create_page tool', () => {
306
318
  const tool = mockTools.get('ghost_create_page');
307
319
  expect(tool).toBeDefined();
308
320
  expect(tool.description).toContain('page');
321
+ assertZodShape(tool.schema, 'ghost_create_page');
309
322
  expect(tool.schema.shape.title).toBeDefined();
310
323
  expect(tool.schema.shape.html).toBeDefined();
311
324
  expect(tool.schema.shape.status).toBeDefined();
@@ -361,7 +374,7 @@ describe('mcp_server - ghost_create_page tool', () => {
361
374
  const result = await tool.handler({ title: 'Test', html: '<p>Content</p>' });
362
375
 
363
376
  expect(result.isError).toBe(true);
364
- expect(result.content[0].text).toContain('Error creating page');
377
+ expect(result.content[0].text).toContain('Error in ghost_create_page');
365
378
  });
366
379
  });
367
380
 
@@ -381,6 +394,7 @@ describe('mcp_server - ghost_update_page tool', () => {
381
394
  const tool = mockTools.get('ghost_update_page');
382
395
  expect(tool).toBeDefined();
383
396
  expect(tool.description).toContain('page');
397
+ assertZodShape(tool.schema, 'ghost_update_page');
384
398
  expect(tool.schema.shape.id).toBeDefined();
385
399
  expect(tool.schema.shape.title).toBeDefined();
386
400
  expect(tool.schema.shape.html).toBeDefined();
@@ -433,7 +447,7 @@ describe('mcp_server - ghost_update_page tool', () => {
433
447
  const result = await tool.handler({ id: '507f1f77bcf86cd799439099', title: 'Test' });
434
448
 
435
449
  expect(result.isError).toBe(true);
436
- expect(result.content[0].text).toContain('Error updating page');
450
+ expect(result.content[0].text).toContain('Error in ghost_update_page');
437
451
  });
438
452
  });
439
453
 
@@ -452,6 +466,7 @@ describe('mcp_server - ghost_delete_page tool', () => {
452
466
  it('should have correct schema with id required', () => {
453
467
  const tool = mockTools.get('ghost_delete_page');
454
468
  expect(tool).toBeDefined();
469
+ assertZodShape(tool.schema, 'ghost_delete_page');
455
470
  expect(tool.schema.shape.id).toBeDefined();
456
471
  expect(tool.description).toContain('permanent');
457
472
  });
@@ -473,7 +488,7 @@ describe('mcp_server - ghost_delete_page tool', () => {
473
488
  const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
474
489
 
475
490
  expect(result.isError).toBe(true);
476
- expect(result.content[0].text).toContain('Error deleting page');
491
+ expect(result.content[0].text).toContain('Error in ghost_delete_page');
477
492
  });
478
493
  });
479
494
 
@@ -492,6 +507,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
492
507
  it('should have correct schema with query required', () => {
493
508
  const tool = mockTools.get('ghost_search_pages');
494
509
  expect(tool).toBeDefined();
510
+ assertZodShape(tool.schema, 'ghost_search_pages');
495
511
  expect(tool.schema.shape.query).toBeDefined();
496
512
  expect(tool.schema.shape.status).toBeDefined();
497
513
  expect(tool.schema.shape.limit).toBeDefined();
@@ -535,6 +551,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
535
551
  const tool = mockTools.get('ghost_search_pages');
536
552
  const schema = tool.schema;
537
553
 
554
+ assertZodShape(schema, 'ghost_search_pages');
538
555
  expect(schema.shape.limit).toBeDefined();
539
556
  expect(() => schema.shape.limit.parse(0)).toThrow();
540
557
  expect(() => schema.shape.limit.parse(51)).toThrow();
@@ -548,6 +565,6 @@ describe('mcp_server - ghost_search_pages tool', () => {
548
565
  const result = await tool.handler({ query: 'test' });
549
566
 
550
567
  expect(result.isError).toBe(true);
551
- expect(result.content[0].text).toContain('Error searching pages');
568
+ expect(result.content[0].text).toContain('Error in ghost_search_pages');
552
569
  });
553
570
  });
@@ -25,7 +25,7 @@ vi.mock('crypto', () => ({
25
25
  const mockUploadGhostImage = vi.fn();
26
26
  const mockProcessImage = vi.fn();
27
27
 
28
- vi.mock('../../services/ghostService.js', () => ({
28
+ vi.mock('../../services/images.js', () => ({
29
29
  uploadImage: (...args) => mockUploadGhostImage(...args),
30
30
  }));
31
31
 
@@ -167,7 +167,7 @@ describe('imageController', () => {
167
167
  path: '/tmp/mcp-upload-123-abc.jpg',
168
168
  },
169
169
  body: {
170
- alt: 'a'.repeat(501), // exceeds 500 char limit
170
+ alt: 'a'.repeat(192), // exceeds 191 char limit (Ghost varchar(191))
171
171
  },
172
172
  });
173
173
  const res = createMockResponse();
@@ -5,8 +5,9 @@ import os from 'os'; // Import the os module
5
5
  import Joi from 'joi';
6
6
  import crypto from 'crypto';
7
7
  import { createContextLogger } from '../utils/logger.js';
8
- import { uploadImage as uploadGhostImage } from '../services/ghostService.js'; // Assuming uploadImage is in ghostService
9
- import { processImage } from '../services/imageProcessingService.js'; // Import the processing service
8
+ import { uploadImage as uploadGhostImage } from '../services/images.js';
9
+ import { processImage } from '../services/imageProcessingService.js';
10
+ import { featureImageAltSchema } from '../schemas/common.js';
10
11
 
11
12
  // --- Use OS temporary directory for uploads ---
12
13
  const uploadDir = os.tmpdir(); // Use the OS default temp directory
@@ -194,15 +195,15 @@ const handleImageUpload = async (req, res, next) => {
194
195
  processedPath = await processImage(originalPath, uploadDir);
195
196
 
196
197
  // --- Handle Alt Text ---
197
- // Validate and sanitize alt text from the request body
198
- const altSchema = Joi.string().max(500).allow('').optional();
199
- const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
200
-
201
- if (error) {
202
- return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
198
+ // Validate via the canonical schema shared with the MCP tool.
199
+ // Ghost's posts.feature_image_alt is varchar(191).
200
+ const altParse = featureImageAltSchema.safeParse(req.body.alt ?? undefined);
201
+ if (!altParse.success) {
202
+ return res
203
+ .status(400)
204
+ .json({ message: `Invalid alt text: ${altParse.error.issues[0].message}` });
203
205
  }
204
-
205
- const providedAlt = sanitizedAlt;
206
+ const providedAlt = altParse.data;
206
207
  // Generate a default alt text from the original filename if none provided
207
208
  const defaultAlt = getDefaultAltText(req.file.originalname);
208
209
  const altText = providedAlt || defaultAlt;