@jgardner04/ghost-mcp-server 1.1.11 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Store tool implementations for testing
4
+ const toolImplementations = {};
5
+
6
+ // Mock MCP SDK
7
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => {
8
+ const mockServerInstance = {
9
+ addResource: vi.fn(),
10
+ addTool: vi.fn(),
11
+ listen: vi.fn().mockResolvedValue(undefined),
12
+ listResources: vi.fn().mockReturnValue([{ name: 'ghost/tag' }, { name: 'ghost/post' }]),
13
+ listTools: vi
14
+ .fn()
15
+ .mockReturnValue([
16
+ { name: 'ghost_create_post' },
17
+ { name: 'ghost_upload_image' },
18
+ { name: 'ghost_get_tags' },
19
+ { name: 'ghost_create_tag' },
20
+ ]),
21
+ };
22
+
23
+ return {
24
+ MCPServer: class MockMCPServer {
25
+ constructor() {
26
+ Object.assign(this, mockServerInstance);
27
+ }
28
+
29
+ static getInstance() {
30
+ return mockServerInstance;
31
+ }
32
+ },
33
+ Resource: class MockResource {
34
+ constructor(config) {
35
+ this.name = config.name;
36
+ this.description = config.description;
37
+ this.schema = config.schema;
38
+ }
39
+ },
40
+ Tool: class MockTool {
41
+ constructor(config) {
42
+ toolImplementations[config.name] = config.implementation;
43
+ this.name = config.name;
44
+ this.description = config.description;
45
+ this.inputSchema = config.inputSchema;
46
+ this.outputSchema = config.outputSchema;
47
+ this.implementation = config.implementation;
48
+ }
49
+ },
50
+ };
51
+ });
52
+
53
+ // Mock dotenv
54
+ vi.mock('dotenv', () => ({
55
+ default: { config: vi.fn() },
56
+ }));
57
+
58
+ // Mock services
59
+ const mockCreatePostService = vi.fn();
60
+ const mockUploadGhostImage = vi.fn();
61
+ const mockGetGhostTags = vi.fn();
62
+ const mockCreateGhostTag = vi.fn();
63
+ const mockProcessImage = vi.fn();
64
+
65
+ vi.mock('../services/postService.js', () => ({
66
+ createPostService: (...args) => mockCreatePostService(...args),
67
+ }));
68
+
69
+ vi.mock('../services/ghostService.js', () => ({
70
+ uploadImage: (...args) => mockUploadGhostImage(...args),
71
+ getTags: (...args) => mockGetGhostTags(...args),
72
+ createTag: (...args) => mockCreateGhostTag(...args),
73
+ }));
74
+
75
+ vi.mock('../services/imageProcessingService.js', () => ({
76
+ processImage: (...args) => mockProcessImage(...args),
77
+ }));
78
+
79
+ // Mock axios
80
+ const mockAxios = vi.fn();
81
+ vi.mock('axios', () => ({
82
+ default: (...args) => mockAxios(...args),
83
+ }));
84
+
85
+ // Mock fs
86
+ const mockUnlink = vi.fn((path, cb) => cb(null));
87
+ const mockCreateWriteStream = vi.fn();
88
+ vi.mock('fs', () => ({
89
+ default: {
90
+ unlink: (...args) => mockUnlink(...args),
91
+ createWriteStream: (...args) => mockCreateWriteStream(...args),
92
+ },
93
+ }));
94
+
95
+ // Mock os
96
+ vi.mock('os', () => ({
97
+ default: { tmpdir: vi.fn().mockReturnValue('/tmp') },
98
+ }));
99
+
100
+ // Mock uuid
101
+ vi.mock('uuid', () => ({
102
+ v4: vi.fn().mockReturnValue('test-uuid-1234'),
103
+ }));
104
+
105
+ // Mock urlValidator
106
+ const mockValidateImageUrl = vi.fn();
107
+ const mockCreateSecureAxiosConfig = vi.fn();
108
+ vi.mock('../utils/urlValidator.js', () => ({
109
+ validateImageUrl: (...args) => mockValidateImageUrl(...args),
110
+ createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
111
+ }));
112
+
113
+ // Mock logger
114
+ vi.mock('../utils/logger.js', () => ({
115
+ createContextLogger: vi.fn().mockReturnValue({
116
+ info: vi.fn(),
117
+ error: vi.fn(),
118
+ warn: vi.fn(),
119
+ debug: vi.fn(),
120
+ toolExecution: vi.fn(),
121
+ toolSuccess: vi.fn(),
122
+ toolError: vi.fn(),
123
+ fileOperation: vi.fn(),
124
+ }),
125
+ }));
126
+
127
+ describe('mcp_server', () => {
128
+ let mcpServerModule;
129
+
130
+ beforeEach(async () => {
131
+ vi.clearAllMocks();
132
+ // Import module once to register tools
133
+ if (!mcpServerModule) {
134
+ mcpServerModule = await import('../mcp_server.js');
135
+ }
136
+ });
137
+
138
+ describe('ghost_create_post tool', () => {
139
+ it('should call createPostService with input and return result', async () => {
140
+ const createdPost = {
141
+ id: '1',
142
+ title: 'Test Post',
143
+ html: '<p>Content</p>',
144
+ status: 'draft',
145
+ };
146
+ mockCreatePostService.mockResolvedValue(createdPost);
147
+
148
+ const input = { title: 'Test Post', html: '<p>Content</p>' };
149
+ const result = await toolImplementations.ghost_create_post(input);
150
+
151
+ expect(mockCreatePostService).toHaveBeenCalledWith(input);
152
+ expect(result).toEqual(createdPost);
153
+ });
154
+
155
+ it('should handle tags and other optional fields', async () => {
156
+ const createdPost = { id: '1', title: 'Test', tags: ['tech'] };
157
+ mockCreatePostService.mockResolvedValue(createdPost);
158
+
159
+ const input = {
160
+ title: 'Test',
161
+ html: '<p>Content</p>',
162
+ tags: ['tech'],
163
+ status: 'published',
164
+ feature_image: 'https://example.com/image.jpg',
165
+ };
166
+ const result = await toolImplementations.ghost_create_post(input);
167
+
168
+ expect(mockCreatePostService).toHaveBeenCalledWith(input);
169
+ expect(result).toEqual(createdPost);
170
+ });
171
+
172
+ it('should throw wrapped error when service fails', async () => {
173
+ mockCreatePostService.mockRejectedValue(new Error('Database error'));
174
+
175
+ const input = { title: 'Test Post', html: '<p>Content</p>' };
176
+
177
+ await expect(toolImplementations.ghost_create_post(input)).rejects.toThrow(
178
+ 'Failed to create Ghost post: Database error'
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('ghost_upload_image tool', () => {
184
+ beforeEach(() => {
185
+ mockValidateImageUrl.mockReturnValue({
186
+ isValid: true,
187
+ sanitizedUrl: 'https://example.com/image.jpg',
188
+ });
189
+ mockCreateSecureAxiosConfig.mockReturnValue({
190
+ url: 'https://example.com/image.jpg',
191
+ responseType: 'stream',
192
+ });
193
+
194
+ const mockWriter = {
195
+ on: vi.fn((event, cb) => {
196
+ if (event === 'finish') setTimeout(cb, 0);
197
+ return mockWriter;
198
+ }),
199
+ };
200
+ mockCreateWriteStream.mockReturnValue(mockWriter);
201
+
202
+ const mockStream = { pipe: vi.fn().mockReturnValue(mockWriter) };
203
+ mockAxios.mockResolvedValue({ data: mockStream });
204
+
205
+ mockProcessImage.mockResolvedValue('/tmp/processed-image.jpg');
206
+ mockUploadGhostImage.mockResolvedValue({
207
+ url: 'https://ghost.com/content/images/image.jpg',
208
+ });
209
+ });
210
+
211
+ it('should validate image URL for SSRF protection', async () => {
212
+ mockValidateImageUrl.mockReturnValue({
213
+ isValid: false,
214
+ error: 'Private IP address not allowed',
215
+ });
216
+
217
+ const input = { imageUrl: 'http://192.168.1.1/image.jpg' };
218
+
219
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
220
+ 'Invalid image URL: Private IP address not allowed'
221
+ );
222
+ });
223
+
224
+ it('should download, process, and upload image successfully', async () => {
225
+ const input = { imageUrl: 'https://example.com/image.jpg' };
226
+ const result = await toolImplementations.ghost_upload_image(input);
227
+
228
+ expect(mockValidateImageUrl).toHaveBeenCalledWith(input.imageUrl);
229
+ expect(mockAxios).toHaveBeenCalled();
230
+ expect(mockProcessImage).toHaveBeenCalled();
231
+ expect(mockUploadGhostImage).toHaveBeenCalled();
232
+ expect(result.url).toBe('https://ghost.com/content/images/image.jpg');
233
+ });
234
+
235
+ it('should use provided alt text', async () => {
236
+ const input = { imageUrl: 'https://example.com/image.jpg', alt: 'My custom alt' };
237
+ const result = await toolImplementations.ghost_upload_image(input);
238
+
239
+ expect(result.alt).toBe('My custom alt');
240
+ });
241
+
242
+ it('should generate default alt text from filename', async () => {
243
+ const input = { imageUrl: 'https://example.com/beautiful-sunset.jpg' };
244
+ const result = await toolImplementations.ghost_upload_image(input);
245
+
246
+ expect(result.alt).toBeTruthy();
247
+ expect(result.alt).not.toBe('');
248
+ });
249
+
250
+ it('should cleanup temporary files on success', async () => {
251
+ const input = { imageUrl: 'https://example.com/image.jpg' };
252
+ await toolImplementations.ghost_upload_image(input);
253
+
254
+ expect(mockUnlink).toHaveBeenCalled();
255
+ });
256
+
257
+ it('should cleanup temporary files on error', async () => {
258
+ mockUploadGhostImage.mockRejectedValue(new Error('Upload failed'));
259
+
260
+ const input = { imageUrl: 'https://example.com/image.jpg' };
261
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow();
262
+
263
+ expect(mockUnlink).toHaveBeenCalled();
264
+ });
265
+
266
+ it('should handle download errors', async () => {
267
+ mockAxios.mockRejectedValue(new Error('Network error'));
268
+
269
+ const input = { imageUrl: 'https://example.com/image.jpg' };
270
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
271
+ 'Failed to upload image from URL'
272
+ );
273
+ });
274
+
275
+ it('should handle processing errors', async () => {
276
+ mockProcessImage.mockRejectedValue(new Error('Invalid image format'));
277
+
278
+ const input = { imageUrl: 'https://example.com/image.jpg' };
279
+ await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
280
+ 'Failed to upload image from URL'
281
+ );
282
+ });
283
+ });
284
+
285
+ describe('ghost_get_tags tool', () => {
286
+ it('should get all tags without filter', async () => {
287
+ const tags = [
288
+ { id: '1', name: 'Tag1', slug: 'tag1' },
289
+ { id: '2', name: 'Tag2', slug: 'tag2' },
290
+ ];
291
+ mockGetGhostTags.mockResolvedValue(tags);
292
+
293
+ const result = await toolImplementations.ghost_get_tags({});
294
+
295
+ expect(mockGetGhostTags).toHaveBeenCalledWith(undefined);
296
+ expect(result).toEqual(tags);
297
+ });
298
+
299
+ it('should filter tags by name', async () => {
300
+ const tags = [{ id: '1', name: 'Technology', slug: 'technology' }];
301
+ mockGetGhostTags.mockResolvedValue(tags);
302
+
303
+ const result = await toolImplementations.ghost_get_tags({ name: 'Technology' });
304
+
305
+ expect(mockGetGhostTags).toHaveBeenCalledWith('Technology');
306
+ expect(result).toEqual(tags);
307
+ });
308
+
309
+ it('should return empty array when no tags match', async () => {
310
+ mockGetGhostTags.mockResolvedValue([]);
311
+
312
+ const result = await toolImplementations.ghost_get_tags({ name: 'NonExistent' });
313
+
314
+ expect(result).toEqual([]);
315
+ });
316
+
317
+ it('should throw wrapped error when service fails', async () => {
318
+ mockGetGhostTags.mockRejectedValue(new Error('API error'));
319
+
320
+ await expect(toolImplementations.ghost_get_tags({})).rejects.toThrow(
321
+ 'Failed to get Ghost tags: API error'
322
+ );
323
+ });
324
+ });
325
+
326
+ describe('ghost_create_tag tool', () => {
327
+ it('should create tag with name only', async () => {
328
+ const newTag = { id: '1', name: 'NewTag', slug: 'newtag' };
329
+ mockCreateGhostTag.mockResolvedValue(newTag);
330
+
331
+ const input = { name: 'NewTag' };
332
+ const result = await toolImplementations.ghost_create_tag(input);
333
+
334
+ expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
335
+ expect(result).toEqual(newTag);
336
+ });
337
+
338
+ it('should create tag with all fields', async () => {
339
+ const newTag = {
340
+ id: '1',
341
+ name: 'Tech',
342
+ slug: 'technology',
343
+ description: 'Tech articles',
344
+ };
345
+ mockCreateGhostTag.mockResolvedValue(newTag);
346
+
347
+ const input = { name: 'Tech', slug: 'technology', description: 'Tech articles' };
348
+ const result = await toolImplementations.ghost_create_tag(input);
349
+
350
+ expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
351
+ expect(result).toEqual(newTag);
352
+ });
353
+
354
+ it('should throw wrapped error when service fails', async () => {
355
+ mockCreateGhostTag.mockRejectedValue(new Error('Duplicate tag'));
356
+
357
+ await expect(toolImplementations.ghost_create_tag({ name: 'Existing' })).rejects.toThrow(
358
+ 'Failed to create Ghost tag: Duplicate tag'
359
+ );
360
+ });
361
+ });
362
+
363
+ describe('startMCPServer', () => {
364
+ it('should export startMCPServer function', async () => {
365
+ const { startMCPServer } = await import('../mcp_server.js');
366
+ expect(typeof startMCPServer).toBe('function');
367
+ });
368
+ });
369
+
370
+ describe('module exports', () => {
371
+ it('should export mcpServer', async () => {
372
+ const module = await import('../mcp_server.js');
373
+ expect(module.mcpServer).toBeDefined();
374
+ });
375
+
376
+ it('should export startMCPServer', async () => {
377
+ const module = await import('../mcp_server.js');
378
+ expect(module.startMCPServer).toBeDefined();
379
+ });
380
+ });
381
+ });
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // Mock dotenv before importing config
4
+ vi.mock('dotenv', () => ({
5
+ default: { config: vi.fn() },
6
+ }));
7
+
8
+ describe('mcp-config', () => {
9
+ const originalEnv = process.env;
10
+
11
+ beforeEach(() => {
12
+ vi.resetModules();
13
+ // Reset environment variables to a clean state
14
+ process.env = {
15
+ ...originalEnv,
16
+ GHOST_ADMIN_API_URL: 'https://ghost.example.com',
17
+ GHOST_ADMIN_API_KEY: 'test-api-key',
18
+ };
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env = originalEnv;
23
+ });
24
+
25
+ describe('mcpConfig object', () => {
26
+ describe('transport configuration', () => {
27
+ it('should use default transport type "http" when not specified', async () => {
28
+ delete process.env.MCP_TRANSPORT;
29
+ const { mcpConfig } = await import('../mcp-config.js');
30
+ expect(mcpConfig.transport.type).toBe('http');
31
+ });
32
+
33
+ it('should use MCP_TRANSPORT env var when specified', async () => {
34
+ process.env.MCP_TRANSPORT = 'websocket';
35
+ const { mcpConfig } = await import('../mcp-config.js');
36
+ expect(mcpConfig.transport.type).toBe('websocket');
37
+ });
38
+
39
+ it('should use default port 3001 when not specified', async () => {
40
+ delete process.env.MCP_PORT;
41
+ const { mcpConfig } = await import('../mcp-config.js');
42
+ expect(mcpConfig.transport.port).toBe(3001);
43
+ });
44
+
45
+ it('should use MCP_PORT env var when specified', async () => {
46
+ process.env.MCP_PORT = '4000';
47
+ const { mcpConfig } = await import('../mcp-config.js');
48
+ expect(mcpConfig.transport.port).toBe(4000);
49
+ });
50
+
51
+ it('should use default CORS "*" when not specified', async () => {
52
+ delete process.env.MCP_CORS;
53
+ const { mcpConfig } = await import('../mcp-config.js');
54
+ expect(mcpConfig.transport.cors).toBe('*');
55
+ });
56
+
57
+ it('should use MCP_CORS env var when specified', async () => {
58
+ process.env.MCP_CORS = 'https://example.com';
59
+ const { mcpConfig } = await import('../mcp-config.js');
60
+ expect(mcpConfig.transport.cors).toBe('https://example.com');
61
+ });
62
+
63
+ it('should use default SSE endpoint when not specified', async () => {
64
+ delete process.env.MCP_SSE_ENDPOINT;
65
+ const { mcpConfig } = await import('../mcp-config.js');
66
+ expect(mcpConfig.transport.sseEndpoint).toBe('/mcp/sse');
67
+ });
68
+
69
+ it('should use default WebSocket path when not specified', async () => {
70
+ delete process.env.MCP_WS_PATH;
71
+ const { mcpConfig } = await import('../mcp-config.js');
72
+ expect(mcpConfig.transport.wsPath).toBe('/');
73
+ });
74
+
75
+ it('should use default heartbeat interval when not specified', async () => {
76
+ delete process.env.MCP_WS_HEARTBEAT;
77
+ const { mcpConfig } = await import('../mcp-config.js');
78
+ expect(mcpConfig.transport.wsHeartbeatInterval).toBe(30000);
79
+ });
80
+ });
81
+
82
+ describe('metadata configuration', () => {
83
+ it('should use default server name when not specified', async () => {
84
+ delete process.env.MCP_SERVER_NAME;
85
+ const { mcpConfig } = await import('../mcp-config.js');
86
+ expect(mcpConfig.metadata.name).toBe('Ghost CMS Manager');
87
+ });
88
+
89
+ it('should use MCP_SERVER_NAME env var when specified', async () => {
90
+ process.env.MCP_SERVER_NAME = 'My Ghost Server';
91
+ const { mcpConfig } = await import('../mcp-config.js');
92
+ expect(mcpConfig.metadata.name).toBe('My Ghost Server');
93
+ });
94
+
95
+ it('should use default description when not specified', async () => {
96
+ delete process.env.MCP_SERVER_DESC;
97
+ const { mcpConfig } = await import('../mcp-config.js');
98
+ expect(mcpConfig.metadata.description).toContain('MCP Server to manage a Ghost CMS');
99
+ });
100
+
101
+ it('should use default version when not specified', async () => {
102
+ delete process.env.MCP_SERVER_VERSION;
103
+ const { mcpConfig } = await import('../mcp-config.js');
104
+ expect(mcpConfig.metadata.version).toBe('1.0.0');
105
+ });
106
+ });
107
+
108
+ describe('errorHandling configuration', () => {
109
+ it('should not include stack trace in production', async () => {
110
+ process.env.NODE_ENV = 'production';
111
+ const { mcpConfig } = await import('../mcp-config.js');
112
+ expect(mcpConfig.errorHandling.includeStackTrace).toBe(false);
113
+ });
114
+
115
+ it('should include stack trace in development', async () => {
116
+ process.env.NODE_ENV = 'development';
117
+ const { mcpConfig } = await import('../mcp-config.js');
118
+ expect(mcpConfig.errorHandling.includeStackTrace).toBe(true);
119
+ });
120
+
121
+ it('should use default max retries when not specified', async () => {
122
+ delete process.env.MCP_MAX_RETRIES;
123
+ const { mcpConfig } = await import('../mcp-config.js');
124
+ expect(mcpConfig.errorHandling.maxRetries).toBe(3);
125
+ });
126
+
127
+ it('should use default retry delay when not specified', async () => {
128
+ delete process.env.MCP_RETRY_DELAY;
129
+ const { mcpConfig } = await import('../mcp-config.js');
130
+ expect(mcpConfig.errorHandling.retryDelay).toBe(1000);
131
+ });
132
+ });
133
+
134
+ describe('logging configuration', () => {
135
+ it('should use default log level when not specified', async () => {
136
+ delete process.env.MCP_LOG_LEVEL;
137
+ const { mcpConfig } = await import('../mcp-config.js');
138
+ expect(mcpConfig.logging.level).toBe('info');
139
+ });
140
+
141
+ it('should use MCP_LOG_LEVEL env var when specified', async () => {
142
+ process.env.MCP_LOG_LEVEL = 'debug';
143
+ const { mcpConfig } = await import('../mcp-config.js');
144
+ expect(mcpConfig.logging.level).toBe('debug');
145
+ });
146
+
147
+ it('should use default log format when not specified', async () => {
148
+ delete process.env.MCP_LOG_FORMAT;
149
+ const { mcpConfig } = await import('../mcp-config.js');
150
+ expect(mcpConfig.logging.format).toBe('json');
151
+ });
152
+ });
153
+
154
+ describe('security configuration', () => {
155
+ it('should include API key when specified', async () => {
156
+ process.env.MCP_API_KEY = 'secret-key';
157
+ const { mcpConfig } = await import('../mcp-config.js');
158
+ expect(mcpConfig.security.apiKey).toBe('secret-key');
159
+ });
160
+
161
+ it('should have undefined API key when not specified', async () => {
162
+ delete process.env.MCP_API_KEY;
163
+ const { mcpConfig } = await import('../mcp-config.js');
164
+ expect(mcpConfig.security.apiKey).toBeUndefined();
165
+ });
166
+
167
+ it('should use default allowed origins when not specified', async () => {
168
+ delete process.env.MCP_ALLOWED_ORIGINS;
169
+ const { mcpConfig } = await import('../mcp-config.js');
170
+ expect(mcpConfig.security.allowedOrigins).toEqual(['*']);
171
+ });
172
+
173
+ it('should parse comma-separated allowed origins', async () => {
174
+ process.env.MCP_ALLOWED_ORIGINS = 'https://a.com,https://b.com';
175
+ const { mcpConfig } = await import('../mcp-config.js');
176
+ expect(mcpConfig.security.allowedOrigins).toEqual(['https://a.com', 'https://b.com']);
177
+ });
178
+ });
179
+ });
180
+
181
+ describe('getTransportConfig', () => {
182
+ it('should return stdio config for stdio transport', async () => {
183
+ process.env.MCP_TRANSPORT = 'stdio';
184
+ const { getTransportConfig } = await import('../mcp-config.js');
185
+ const config = getTransportConfig();
186
+ expect(config).toEqual({ type: 'stdio' });
187
+ });
188
+
189
+ it('should return SSE config for http transport', async () => {
190
+ process.env.MCP_TRANSPORT = 'http';
191
+ process.env.MCP_PORT = '3001';
192
+ const { getTransportConfig } = await import('../mcp-config.js');
193
+ const config = getTransportConfig();
194
+ expect(config.type).toBe('sse');
195
+ expect(config.port).toBe(3001);
196
+ expect(config.cors).toBeDefined();
197
+ expect(config.endpoint).toBeDefined();
198
+ });
199
+
200
+ it('should return SSE config for sse transport', async () => {
201
+ process.env.MCP_TRANSPORT = 'sse';
202
+ const { getTransportConfig } = await import('../mcp-config.js');
203
+ const config = getTransportConfig();
204
+ expect(config.type).toBe('sse');
205
+ });
206
+
207
+ it('should return websocket config for websocket transport', async () => {
208
+ process.env.MCP_TRANSPORT = 'websocket';
209
+ process.env.MCP_PORT = '3002';
210
+ process.env.MCP_WS_PATH = '/ws';
211
+ process.env.MCP_WS_HEARTBEAT = '15000';
212
+ const { getTransportConfig } = await import('../mcp-config.js');
213
+ const config = getTransportConfig();
214
+ expect(config).toEqual({
215
+ type: 'websocket',
216
+ port: 3002,
217
+ path: '/ws',
218
+ heartbeatInterval: 15000,
219
+ });
220
+ });
221
+
222
+ it('should throw error for unknown transport type', async () => {
223
+ process.env.MCP_TRANSPORT = 'invalid-transport';
224
+ const { getTransportConfig } = await import('../mcp-config.js');
225
+ expect(() => getTransportConfig()).toThrow('Unknown transport type: invalid-transport');
226
+ });
227
+ });
228
+
229
+ describe('validateConfig', () => {
230
+ it('should return true when configuration is valid', async () => {
231
+ process.env.GHOST_ADMIN_API_URL = 'https://ghost.example.com';
232
+ process.env.GHOST_ADMIN_API_KEY = 'test-api-key';
233
+ process.env.MCP_TRANSPORT = 'http';
234
+ process.env.MCP_PORT = '3001';
235
+ const { validateConfig } = await import('../mcp-config.js');
236
+ expect(validateConfig()).toBe(true);
237
+ });
238
+
239
+ it('should throw error when GHOST_ADMIN_API_URL is missing', async () => {
240
+ delete process.env.GHOST_ADMIN_API_URL;
241
+ process.env.GHOST_ADMIN_API_KEY = 'test-api-key';
242
+ const { validateConfig } = await import('../mcp-config.js');
243
+ expect(() => validateConfig()).toThrow('Missing GHOST_ADMIN_API_URL');
244
+ });
245
+
246
+ it('should throw error when GHOST_ADMIN_API_KEY is missing', async () => {
247
+ process.env.GHOST_ADMIN_API_URL = 'https://ghost.example.com';
248
+ delete process.env.GHOST_ADMIN_API_KEY;
249
+ const { validateConfig } = await import('../mcp-config.js');
250
+ expect(() => validateConfig()).toThrow('Missing GHOST_ADMIN_API_KEY');
251
+ });
252
+
253
+ it('should throw error for invalid transport type', async () => {
254
+ process.env.MCP_TRANSPORT = 'invalid';
255
+ const { validateConfig } = await import('../mcp-config.js');
256
+ expect(() => validateConfig()).toThrow('Invalid transport type');
257
+ });
258
+
259
+ it('should throw error for invalid port (0)', async () => {
260
+ process.env.MCP_TRANSPORT = 'http';
261
+ process.env.MCP_PORT = '0';
262
+ const { validateConfig } = await import('../mcp-config.js');
263
+ expect(() => validateConfig()).toThrow('Invalid port');
264
+ });
265
+
266
+ it('should throw error for invalid port (negative)', async () => {
267
+ process.env.MCP_TRANSPORT = 'http';
268
+ process.env.MCP_PORT = '-1';
269
+ const { validateConfig } = await import('../mcp-config.js');
270
+ expect(() => validateConfig()).toThrow('Invalid port');
271
+ });
272
+
273
+ it('should throw error for invalid port (> 65535)', async () => {
274
+ process.env.MCP_TRANSPORT = 'http';
275
+ process.env.MCP_PORT = '70000';
276
+ const { validateConfig } = await import('../mcp-config.js');
277
+ expect(() => validateConfig()).toThrow('Invalid port');
278
+ });
279
+
280
+ it('should not validate port for stdio transport', async () => {
281
+ process.env.MCP_TRANSPORT = 'stdio';
282
+ process.env.MCP_PORT = '0'; // Invalid port, but not checked for stdio
283
+ const { validateConfig } = await import('../mcp-config.js');
284
+ expect(validateConfig()).toBe(true);
285
+ });
286
+
287
+ it('should accumulate multiple errors', async () => {
288
+ delete process.env.GHOST_ADMIN_API_URL;
289
+ delete process.env.GHOST_ADMIN_API_KEY;
290
+ process.env.MCP_TRANSPORT = 'invalid';
291
+ const { validateConfig } = await import('../mcp-config.js');
292
+ try {
293
+ validateConfig();
294
+ expect.fail('Should have thrown');
295
+ } catch (error) {
296
+ expect(error.message).toContain('Invalid transport type');
297
+ expect(error.message).toContain('GHOST_ADMIN_API_URL');
298
+ expect(error.message).toContain('GHOST_ADMIN_API_KEY');
299
+ }
300
+ });
301
+ });
302
+
303
+ describe('default export', () => {
304
+ it('should export mcpConfig as default', async () => {
305
+ const module = await import('../mcp-config.js');
306
+ expect(module.default).toBeDefined();
307
+ expect(module.default).toHaveProperty('transport');
308
+ expect(module.default).toHaveProperty('metadata');
309
+ });
310
+ });
311
+ });