@jgardner04/ghost-mcp-server 1.0.0 → 1.1.1
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/README.md +0 -3
- package/package.json +21 -8
- package/src/config/mcp-config.js +31 -22
- package/src/controllers/imageController.js +62 -62
- package/src/controllers/postController.js +8 -8
- package/src/controllers/tagController.js +17 -20
- package/src/errors/index.js +49 -44
- package/src/index.js +56 -50
- package/src/mcp_server.js +151 -178
- package/src/mcp_server_enhanced.js +265 -259
- package/src/mcp_server_improved.js +217 -582
- package/src/middleware/errorMiddleware.js +69 -70
- package/src/resources/ResourceManager.js +143 -134
- package/src/routes/imageRoutes.js +9 -9
- package/src/routes/postRoutes.js +22 -28
- package/src/routes/tagRoutes.js +12 -14
- package/src/services/__tests__/ghostService.test.js +118 -0
- package/src/services/ghostService.js +34 -46
- package/src/services/ghostServiceImproved.js +125 -109
- package/src/services/imageProcessingService.js +15 -15
- package/src/services/postService.js +22 -22
- package/src/utils/logger.js +50 -50
- package/src/utils/urlValidator.js +37 -38
|
@@ -1,657 +1,292 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import { createPostService } from "./services/postService.js";
|
|
12
|
-
import {
|
|
13
|
-
uploadImage as uploadGhostImage,
|
|
14
|
-
getTags as getGhostTags,
|
|
15
|
-
createTag as createGhostTag,
|
|
16
|
-
} from "./services/ghostService.js";
|
|
17
|
-
import { processImage } from "./services/imageProcessingService.js";
|
|
18
|
-
import axios from "axios";
|
|
19
|
-
import { validateImageUrl, createSecureAxiosConfig } from "./utils/urlValidator.js";
|
|
20
|
-
import fs from "fs";
|
|
21
|
-
import path from "path";
|
|
22
|
-
import os from "os";
|
|
23
|
-
import { v4 as uuidv4 } from "uuid";
|
|
24
|
-
import express from "express";
|
|
25
|
-
import { WebSocketServer } from "ws";
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import crypto from 'crypto';
|
|
26
11
|
|
|
27
12
|
// Load environment variables
|
|
28
13
|
dotenv.config();
|
|
29
14
|
|
|
30
|
-
|
|
15
|
+
// Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
|
|
16
|
+
let ghostService = null;
|
|
17
|
+
let postService = null;
|
|
18
|
+
let imageProcessingService = null;
|
|
19
|
+
let urlValidator = null;
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
version: "1.0.0",
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// --- Error Response Standardization ---
|
|
43
|
-
class MCPError extends Error {
|
|
44
|
-
constructor(message, code = "UNKNOWN_ERROR", details = {}) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.code = code;
|
|
47
|
-
this.details = details;
|
|
21
|
+
const loadServices = async () => {
|
|
22
|
+
if (!ghostService) {
|
|
23
|
+
ghostService = await import('./services/ghostService.js');
|
|
24
|
+
postService = await import('./services/postService.js');
|
|
25
|
+
imageProcessingService = await import('./services/imageProcessingService.js');
|
|
26
|
+
urlValidator = await import('./utils/urlValidator.js');
|
|
48
27
|
}
|
|
49
|
-
}
|
|
28
|
+
};
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
30
|
+
// Generate UUID without external dependency
|
|
31
|
+
const generateUuid = () => crypto.randomUUID();
|
|
32
|
+
|
|
33
|
+
// Helper function for default alt text
|
|
34
|
+
const getDefaultAltText = (filePath) => {
|
|
35
|
+
try {
|
|
36
|
+
const originalFilename = path.basename(filePath).split('.').slice(0, -1).join('.');
|
|
37
|
+
const nameWithoutIds = originalFilename
|
|
38
|
+
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, '')
|
|
39
|
+
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, '');
|
|
40
|
+
return nameWithoutIds.replace(/[-_]/g, ' ').trim() || 'Uploaded image';
|
|
41
|
+
} catch (_e) {
|
|
42
|
+
return 'Uploaded image';
|
|
43
|
+
}
|
|
64
44
|
};
|
|
65
45
|
|
|
66
|
-
//
|
|
46
|
+
// Create server instance with new API
|
|
47
|
+
const server = new McpServer({
|
|
48
|
+
name: 'ghost-mcp-server',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
});
|
|
67
51
|
|
|
68
|
-
|
|
52
|
+
// --- Register Tools ---
|
|
69
53
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
slug: { type: "string", description: "URL-friendly version of the name" },
|
|
80
|
-
description: {
|
|
81
|
-
type: ["string", "null"],
|
|
82
|
-
description: "Optional description for the tag",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
required: ["id", "name", "slug"],
|
|
54
|
+
// Get Tags Tool
|
|
55
|
+
server.tool(
|
|
56
|
+
'ghost_get_tags',
|
|
57
|
+
'Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.',
|
|
58
|
+
{
|
|
59
|
+
name: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Filter tags by exact name. If omitted, all tags are returned.'),
|
|
86
63
|
},
|
|
87
|
-
|
|
88
|
-
|
|
64
|
+
async ({ name }) => {
|
|
65
|
+
console.error(`Executing tool: ghost_get_tags`);
|
|
89
66
|
try {
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
67
|
+
await loadServices();
|
|
68
|
+
const tags = await ghostService.getTags();
|
|
69
|
+
let result = tags;
|
|
70
|
+
|
|
71
|
+
if (name) {
|
|
72
|
+
result = tags.filter((tag) => tag.name.toLowerCase() === name.toLowerCase());
|
|
73
|
+
console.error(`Filtered tags by name "${name}". Found ${result.length} match(es).`);
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`Retrieved ${tags.length} tags from Ghost.`);
|
|
97
76
|
}
|
|
98
|
-
|
|
99
|
-
return tag;
|
|
100
|
-
} catch (error) {
|
|
101
|
-
return handleToolError(error, "ghost_tag_fetch");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
mcpServer.addResource(ghostTagResource);
|
|
106
|
-
console.log(`Added Resource: ${ghostTagResource.name}`);
|
|
107
77
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
description: "Represents a post in Ghost CMS.",
|
|
112
|
-
schema: {
|
|
113
|
-
type: "object",
|
|
114
|
-
properties: {
|
|
115
|
-
id: { type: "string", description: "Unique ID of the post" },
|
|
116
|
-
uuid: { type: "string", description: "UUID of the post" },
|
|
117
|
-
title: { type: "string", description: "The title of the post" },
|
|
118
|
-
slug: {
|
|
119
|
-
type: "string",
|
|
120
|
-
description: "URL-friendly version of the title",
|
|
121
|
-
},
|
|
122
|
-
html: {
|
|
123
|
-
type: ["string", "null"],
|
|
124
|
-
description: "The post content as HTML",
|
|
125
|
-
},
|
|
126
|
-
plaintext: {
|
|
127
|
-
type: ["string", "null"],
|
|
128
|
-
description: "The post content as plain text",
|
|
129
|
-
},
|
|
130
|
-
feature_image: {
|
|
131
|
-
type: ["string", "null"],
|
|
132
|
-
description: "URL of the featured image",
|
|
133
|
-
},
|
|
134
|
-
feature_image_alt: {
|
|
135
|
-
type: ["string", "null"],
|
|
136
|
-
description: "Alt text for the featured image",
|
|
137
|
-
},
|
|
138
|
-
feature_image_caption: {
|
|
139
|
-
type: ["string", "null"],
|
|
140
|
-
description: "Caption for the featured image",
|
|
141
|
-
},
|
|
142
|
-
featured: {
|
|
143
|
-
type: "boolean",
|
|
144
|
-
description: "Whether the post is featured",
|
|
145
|
-
},
|
|
146
|
-
status: {
|
|
147
|
-
type: "string",
|
|
148
|
-
enum: ["draft", "published", "scheduled"],
|
|
149
|
-
description: "The status of the post",
|
|
150
|
-
},
|
|
151
|
-
visibility: {
|
|
152
|
-
type: "string",
|
|
153
|
-
enum: ["public", "members", "paid", "tiers"],
|
|
154
|
-
description: "The visibility level of the post",
|
|
155
|
-
},
|
|
156
|
-
created_at: {
|
|
157
|
-
type: "string",
|
|
158
|
-
format: "date-time",
|
|
159
|
-
description: "Date/time when the post was created",
|
|
160
|
-
},
|
|
161
|
-
updated_at: {
|
|
162
|
-
type: "string",
|
|
163
|
-
format: "date-time",
|
|
164
|
-
description: "Date/time when the post was last updated",
|
|
165
|
-
},
|
|
166
|
-
published_at: {
|
|
167
|
-
type: ["string", "null"],
|
|
168
|
-
format: "date-time",
|
|
169
|
-
description: "Date/time when the post was published",
|
|
170
|
-
},
|
|
171
|
-
custom_excerpt: {
|
|
172
|
-
type: ["string", "null"],
|
|
173
|
-
description: "Custom excerpt for the post",
|
|
174
|
-
},
|
|
175
|
-
tags: {
|
|
176
|
-
type: "array",
|
|
177
|
-
items: { $ref: "ghost/tag#/schema" },
|
|
178
|
-
description: "Associated tags",
|
|
179
|
-
},
|
|
180
|
-
meta_title: {
|
|
181
|
-
type: ["string", "null"],
|
|
182
|
-
description: "Custom meta title for SEO",
|
|
183
|
-
},
|
|
184
|
-
meta_description: {
|
|
185
|
-
type: ["string", "null"],
|
|
186
|
-
description: "Custom meta description for SEO",
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
required: ["id", "uuid", "title", "slug", "status"],
|
|
190
|
-
},
|
|
191
|
-
// Resource fetching handler
|
|
192
|
-
async fetch(uri) {
|
|
193
|
-
try {
|
|
194
|
-
// Extract post ID from URI (e.g., "ghost/post/123")
|
|
195
|
-
const postId = uri.split("/").pop();
|
|
196
|
-
|
|
197
|
-
// You'll need to implement a getPost service method
|
|
198
|
-
// For now, returning an error as this would require adding to ghostService.js
|
|
199
|
-
throw new MCPError(
|
|
200
|
-
"Post fetching not yet implemented",
|
|
201
|
-
"NOT_IMPLEMENTED",
|
|
202
|
-
{ postId }
|
|
203
|
-
);
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
80
|
+
};
|
|
204
81
|
} catch (error) {
|
|
205
|
-
|
|
82
|
+
console.error(`Error in ghost_get_tags:`, error);
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
206
87
|
}
|
|
207
88
|
}
|
|
208
|
-
|
|
209
|
-
mcpServer.addResource(ghostPostResource);
|
|
210
|
-
console.log(`Added Resource: ${ghostPostResource.name}`);
|
|
89
|
+
);
|
|
211
90
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
description: "The title of the post.",
|
|
226
|
-
},
|
|
227
|
-
html: {
|
|
228
|
-
type: "string",
|
|
229
|
-
description: "The HTML content of the post.",
|
|
230
|
-
},
|
|
231
|
-
status: {
|
|
232
|
-
type: "string",
|
|
233
|
-
enum: ["draft", "published", "scheduled"],
|
|
234
|
-
default: "draft",
|
|
235
|
-
description:
|
|
236
|
-
"The status of the post. Use 'scheduled' with a future published_at date.",
|
|
237
|
-
},
|
|
238
|
-
tags: {
|
|
239
|
-
type: "array",
|
|
240
|
-
items: { type: "string" },
|
|
241
|
-
description:
|
|
242
|
-
"Optional: List of tag names to associate with the post. Tags will be created if they don't exist.",
|
|
243
|
-
},
|
|
244
|
-
published_at: {
|
|
245
|
-
type: "string",
|
|
246
|
-
format: "date-time",
|
|
247
|
-
description:
|
|
248
|
-
"Optional: ISO 8601 date/time to publish the post. Required if status is 'scheduled'.",
|
|
249
|
-
},
|
|
250
|
-
custom_excerpt: {
|
|
251
|
-
type: "string",
|
|
252
|
-
description: "Optional: A custom short summary for the post.",
|
|
253
|
-
},
|
|
254
|
-
feature_image: {
|
|
255
|
-
type: "string",
|
|
256
|
-
format: "url",
|
|
257
|
-
description:
|
|
258
|
-
"Optional: URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.",
|
|
259
|
-
},
|
|
260
|
-
feature_image_alt: {
|
|
261
|
-
type: "string",
|
|
262
|
-
description: "Optional: Alt text for the featured image.",
|
|
263
|
-
},
|
|
264
|
-
feature_image_caption: {
|
|
265
|
-
type: "string",
|
|
266
|
-
description: "Optional: Caption for the featured image.",
|
|
267
|
-
},
|
|
268
|
-
meta_title: {
|
|
269
|
-
type: "string",
|
|
270
|
-
description:
|
|
271
|
-
"Optional: Custom title for SEO (max 300 chars). Defaults to post title if omitted.",
|
|
272
|
-
},
|
|
273
|
-
meta_description: {
|
|
274
|
-
type: "string",
|
|
275
|
-
description:
|
|
276
|
-
"Optional: Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.",
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
required: ["title", "html"],
|
|
280
|
-
},
|
|
281
|
-
outputSchema: {
|
|
282
|
-
$ref: "ghost/post#/schema",
|
|
91
|
+
// Create Tag Tool
|
|
92
|
+
server.tool(
|
|
93
|
+
'ghost_create_tag',
|
|
94
|
+
'Creates a new tag in Ghost CMS.',
|
|
95
|
+
{
|
|
96
|
+
name: z.string().describe('The name of the tag.'),
|
|
97
|
+
description: z.string().optional().describe('A description for the tag.'),
|
|
98
|
+
slug: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe(
|
|
102
|
+
'A URL-friendly slug for the tag. Will be auto-generated from the name if omitted.'
|
|
103
|
+
),
|
|
283
104
|
},
|
|
284
|
-
|
|
285
|
-
console.
|
|
286
|
-
`Executing tool: ${createPostTool.name} with input keys:`,
|
|
287
|
-
Object.keys(input)
|
|
288
|
-
);
|
|
105
|
+
async ({ name, description, slug }) => {
|
|
106
|
+
console.error(`Executing tool: ghost_create_tag with name: ${name}`);
|
|
289
107
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
108
|
+
await loadServices();
|
|
109
|
+
const createdTag = await ghostService.createTag({ name, description, slug });
|
|
110
|
+
console.error(`Tag created successfully. Tag ID: ${createdTag.id}`);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: 'text', text: JSON.stringify(createdTag, null, 2) }],
|
|
114
|
+
};
|
|
295
115
|
} catch (error) {
|
|
296
|
-
|
|
116
|
+
console.error(`Error in ghost_create_tag:`, error);
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
119
|
+
isError: true,
|
|
120
|
+
};
|
|
297
121
|
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
mcpServer.addTool(createPostTool);
|
|
301
|
-
console.log(`Added Tool: ${createPostTool.name}`);
|
|
122
|
+
}
|
|
123
|
+
);
|
|
302
124
|
|
|
303
125
|
// Upload Image Tool
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
},
|
|
316
|
-
alt: {
|
|
317
|
-
type: "string",
|
|
318
|
-
description:
|
|
319
|
-
"Optional: Alt text for the image. If omitted, a default will be generated from the filename.",
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
|
-
required: ["imageUrl"],
|
|
323
|
-
},
|
|
324
|
-
outputSchema: {
|
|
325
|
-
type: "object",
|
|
326
|
-
properties: {
|
|
327
|
-
url: {
|
|
328
|
-
type: "string",
|
|
329
|
-
format: "url",
|
|
330
|
-
description: "The final URL of the image hosted on Ghost.",
|
|
331
|
-
},
|
|
332
|
-
alt: {
|
|
333
|
-
type: "string",
|
|
334
|
-
description: "The alt text determined for the image.",
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
required: ["url", "alt"],
|
|
126
|
+
server.tool(
|
|
127
|
+
'ghost_upload_image',
|
|
128
|
+
'Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.',
|
|
129
|
+
{
|
|
130
|
+
imageUrl: z.string().describe('The publicly accessible URL of the image to upload.'),
|
|
131
|
+
alt: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe(
|
|
135
|
+
'Alt text for the image. If omitted, a default will be generated from the filename.'
|
|
136
|
+
),
|
|
338
137
|
},
|
|
339
|
-
|
|
340
|
-
console.
|
|
341
|
-
`Executing tool: ${uploadImageTool.name} for URL:`,
|
|
342
|
-
input.imageUrl
|
|
343
|
-
);
|
|
344
|
-
const { imageUrl, alt } = input;
|
|
138
|
+
async ({ imageUrl, alt }) => {
|
|
139
|
+
console.error(`Executing tool: ghost_upload_image for URL: ${imageUrl}`);
|
|
345
140
|
let downloadedPath = null;
|
|
346
141
|
let processedPath = null;
|
|
347
142
|
|
|
348
143
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
144
|
+
await loadServices();
|
|
145
|
+
|
|
146
|
+
// 1. Validate URL for SSRF protection
|
|
147
|
+
const urlValidation = urlValidator.validateImageUrl(imageUrl);
|
|
351
148
|
if (!urlValidation.isValid) {
|
|
352
149
|
throw new Error(`Invalid image URL: ${urlValidation.error}`);
|
|
353
150
|
}
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
const axiosConfig = createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
151
|
+
|
|
152
|
+
// 2. Download the image with security controls
|
|
153
|
+
const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
357
154
|
const response = await axios(axiosConfig);
|
|
358
155
|
const tempDir = os.tmpdir();
|
|
359
|
-
const extension = path.extname(imageUrl.split(
|
|
156
|
+
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
|
|
360
157
|
const originalFilenameHint =
|
|
361
|
-
path.basename(imageUrl.split(
|
|
362
|
-
|
|
363
|
-
downloadedPath = path.join(
|
|
364
|
-
tempDir,
|
|
365
|
-
`mcp-download-${uuidv4()}${extension}`
|
|
366
|
-
);
|
|
158
|
+
path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
|
|
159
|
+
downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
367
160
|
|
|
368
161
|
const writer = fs.createWriteStream(downloadedPath);
|
|
369
162
|
response.data.pipe(writer);
|
|
370
163
|
|
|
371
164
|
await new Promise((resolve, reject) => {
|
|
372
|
-
writer.on(
|
|
373
|
-
writer.on(
|
|
165
|
+
writer.on('finish', resolve);
|
|
166
|
+
writer.on('error', reject);
|
|
374
167
|
});
|
|
375
|
-
console.
|
|
168
|
+
console.error(`Downloaded image to temporary path: ${downloadedPath}`);
|
|
376
169
|
|
|
377
|
-
//
|
|
378
|
-
processedPath = await processImage(downloadedPath, tempDir);
|
|
379
|
-
console.
|
|
170
|
+
// 3. Process the image
|
|
171
|
+
processedPath = await imageProcessingService.processImage(downloadedPath, tempDir);
|
|
172
|
+
console.error(`Processed image path: ${processedPath}`);
|
|
380
173
|
|
|
381
|
-
//
|
|
174
|
+
// 4. Determine Alt Text
|
|
382
175
|
const defaultAlt = getDefaultAltText(originalFilenameHint);
|
|
383
176
|
const finalAltText = alt || defaultAlt;
|
|
384
|
-
console.
|
|
177
|
+
console.error(`Using alt text: "${finalAltText}"`);
|
|
385
178
|
|
|
386
|
-
//
|
|
387
|
-
const uploadResult = await
|
|
388
|
-
console.
|
|
179
|
+
// 5. Upload processed image to Ghost
|
|
180
|
+
const uploadResult = await ghostService.uploadImage(processedPath);
|
|
181
|
+
console.error(`Uploaded processed image to Ghost: ${uploadResult.url}`);
|
|
389
182
|
|
|
390
|
-
//
|
|
391
|
-
|
|
183
|
+
// 6. Return result
|
|
184
|
+
const result = {
|
|
392
185
|
url: uploadResult.url,
|
|
393
186
|
alt: finalAltText,
|
|
394
187
|
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
191
|
+
};
|
|
395
192
|
} catch (error) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
),
|
|
402
|
-
uploadImageTool.name
|
|
403
|
-
);
|
|
193
|
+
console.error(`Error in ghost_upload_image:`, error);
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
|
|
196
|
+
isError: true,
|
|
197
|
+
};
|
|
404
198
|
} finally {
|
|
405
|
-
//
|
|
199
|
+
// Cleanup temporary files
|
|
406
200
|
if (downloadedPath) {
|
|
407
201
|
fs.unlink(downloadedPath, (err) => {
|
|
408
|
-
if (err)
|
|
409
|
-
console.error(
|
|
410
|
-
"Error deleting temporary downloaded file:",
|
|
411
|
-
downloadedPath,
|
|
412
|
-
err
|
|
413
|
-
);
|
|
202
|
+
if (err) console.error('Error deleting temporary downloaded file:', downloadedPath, err);
|
|
414
203
|
});
|
|
415
204
|
}
|
|
416
205
|
if (processedPath && processedPath !== downloadedPath) {
|
|
417
206
|
fs.unlink(processedPath, (err) => {
|
|
418
|
-
if (err)
|
|
419
|
-
console.error(
|
|
420
|
-
"Error deleting temporary processed file:",
|
|
421
|
-
processedPath,
|
|
422
|
-
err
|
|
423
|
-
);
|
|
207
|
+
if (err) console.error('Error deleting temporary processed file:', processedPath, err);
|
|
424
208
|
});
|
|
425
209
|
}
|
|
426
210
|
}
|
|
427
|
-
},
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
// Helper function for default alt text
|
|
431
|
-
const getDefaultAltText = (filePath) => {
|
|
432
|
-
try {
|
|
433
|
-
const originalFilename = path
|
|
434
|
-
.basename(filePath)
|
|
435
|
-
.split(".")
|
|
436
|
-
.slice(0, -1)
|
|
437
|
-
.join(".");
|
|
438
|
-
const nameWithoutIds = originalFilename
|
|
439
|
-
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, "")
|
|
440
|
-
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, "");
|
|
441
|
-
return nameWithoutIds.replace(/[-_]/g, " ").trim() || "Uploaded image";
|
|
442
|
-
} catch (e) {
|
|
443
|
-
return "Uploaded image";
|
|
444
211
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
mcpServer.addTool(uploadImageTool);
|
|
448
|
-
console.log(`Added Tool: ${uploadImageTool.name}`);
|
|
212
|
+
);
|
|
449
213
|
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
214
|
+
// Create Post Tool
|
|
215
|
+
server.tool(
|
|
216
|
+
'ghost_create_post',
|
|
217
|
+
'Creates a new post in Ghost CMS.',
|
|
218
|
+
{
|
|
219
|
+
title: z.string().describe('The title of the post.'),
|
|
220
|
+
html: z.string().describe('The HTML content of the post.'),
|
|
221
|
+
status: z
|
|
222
|
+
.enum(['draft', 'published', 'scheduled'])
|
|
223
|
+
.optional()
|
|
224
|
+
.describe("The status of the post. Defaults to 'draft'."),
|
|
225
|
+
tags: z
|
|
226
|
+
.array(z.string())
|
|
227
|
+
.optional()
|
|
228
|
+
.describe(
|
|
229
|
+
"List of tag names to associate with the post. Tags will be created if they don't exist."
|
|
230
|
+
),
|
|
231
|
+
published_at: z
|
|
232
|
+
.string()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("ISO 8601 date/time to publish the post. Required if status is 'scheduled'."),
|
|
235
|
+
custom_excerpt: z.string().optional().describe('A custom short summary for the post.'),
|
|
236
|
+
feature_image: z
|
|
237
|
+
.string()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe(
|
|
240
|
+
'URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.'
|
|
241
|
+
),
|
|
242
|
+
feature_image_alt: z.string().optional().describe('Alt text for the featured image.'),
|
|
243
|
+
feature_image_caption: z.string().optional().describe('Caption for the featured image.'),
|
|
244
|
+
meta_title: z
|
|
245
|
+
.string()
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Custom title for SEO (max 300 chars). Defaults to post title if omitted.'),
|
|
248
|
+
meta_description: z
|
|
249
|
+
.string()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe(
|
|
252
|
+
'Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.'
|
|
253
|
+
),
|
|
468
254
|
},
|
|
469
|
-
|
|
470
|
-
console.
|
|
255
|
+
async (input) => {
|
|
256
|
+
console.error(`Executing tool: ghost_create_post with title: ${input.title}`);
|
|
471
257
|
try {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
(tag) => tag.name.toLowerCase() === input.name.toLowerCase()
|
|
476
|
-
);
|
|
477
|
-
console.log(
|
|
478
|
-
`Filtered tags by name "${input.name}". Found ${filteredTags.length} match(es).`
|
|
479
|
-
);
|
|
480
|
-
return filteredTags;
|
|
481
|
-
}
|
|
482
|
-
console.log(`Retrieved ${tags.length} tags from Ghost.`);
|
|
483
|
-
return tags;
|
|
484
|
-
} catch (error) {
|
|
485
|
-
return handleToolError(error, getTagsTool.name);
|
|
486
|
-
}
|
|
487
|
-
},
|
|
488
|
-
});
|
|
489
|
-
mcpServer.addTool(getTagsTool);
|
|
490
|
-
console.log(`Added Tool: ${getTagsTool.name}`);
|
|
258
|
+
await loadServices();
|
|
259
|
+
const createdPost = await postService.createPostService(input);
|
|
260
|
+
console.error(`Post created successfully. Post ID: ${createdPost.id}`);
|
|
491
261
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
description: "Creates a new tag in Ghost CMS.",
|
|
496
|
-
inputSchema: {
|
|
497
|
-
type: "object",
|
|
498
|
-
properties: {
|
|
499
|
-
name: {
|
|
500
|
-
type: "string",
|
|
501
|
-
description: "The name of the tag.",
|
|
502
|
-
},
|
|
503
|
-
description: {
|
|
504
|
-
type: "string",
|
|
505
|
-
description: "Optional: A description for the tag.",
|
|
506
|
-
},
|
|
507
|
-
slug: {
|
|
508
|
-
type: "string",
|
|
509
|
-
pattern: "^[a-z0-9\\-]+$",
|
|
510
|
-
description:
|
|
511
|
-
"Optional: A URL-friendly slug for the tag. Will be auto-generated from the name if omitted.",
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
required: ["name"],
|
|
515
|
-
},
|
|
516
|
-
outputSchema: {
|
|
517
|
-
$ref: "ghost/tag#/schema",
|
|
518
|
-
},
|
|
519
|
-
implementation: async (input) => {
|
|
520
|
-
console.log(
|
|
521
|
-
`Executing tool: ${createTagTool.name} with name:`,
|
|
522
|
-
input.name
|
|
523
|
-
);
|
|
524
|
-
try {
|
|
525
|
-
const createdTag = await createGhostTag(input);
|
|
526
|
-
console.log(
|
|
527
|
-
`Tool ${createTagTool.name} executed successfully. Tag ID: ${createdTag.id}`
|
|
528
|
-
);
|
|
529
|
-
return createdTag;
|
|
262
|
+
return {
|
|
263
|
+
content: [{ type: 'text', text: JSON.stringify(createdPost, null, 2) }],
|
|
264
|
+
};
|
|
530
265
|
} catch (error) {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
console.log(`Added Tool: ${createTagTool.name}`);
|
|
537
|
-
|
|
538
|
-
// --- Transport Configuration ---
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Start MCP Server with specified transport
|
|
542
|
-
* @param {string} transport - Transport type: 'stdio', 'http', 'websocket'
|
|
543
|
-
* @param {object} options - Transport-specific options
|
|
544
|
-
*/
|
|
545
|
-
const startMCPServer = async (transport = 'http', options = {}) => {
|
|
546
|
-
try {
|
|
547
|
-
console.log(`Starting MCP Server with ${transport} transport...`);
|
|
548
|
-
|
|
549
|
-
switch (transport) {
|
|
550
|
-
case 'stdio':
|
|
551
|
-
// Standard I/O transport - best for CLI tools
|
|
552
|
-
const stdioTransport = new StdioServerTransport();
|
|
553
|
-
await mcpServer.connect(stdioTransport);
|
|
554
|
-
console.log("MCP Server running on stdio transport");
|
|
555
|
-
break;
|
|
556
|
-
|
|
557
|
-
case 'http':
|
|
558
|
-
case 'sse':
|
|
559
|
-
// HTTP with Server-Sent Events - good for web clients
|
|
560
|
-
const port = options.port || 3001;
|
|
561
|
-
const app = express();
|
|
562
|
-
|
|
563
|
-
// CORS configuration for web clients
|
|
564
|
-
app.use((req, res, next) => {
|
|
565
|
-
res.header('Access-Control-Allow-Origin', options.cors || '*');
|
|
566
|
-
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
567
|
-
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
568
|
-
next();
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
// SSE endpoint
|
|
572
|
-
const sseTransport = new SSEServerTransport();
|
|
573
|
-
app.get('/mcp/sse', sseTransport.handler());
|
|
574
|
-
|
|
575
|
-
// Health check
|
|
576
|
-
app.get('/mcp/health', (req, res) => {
|
|
577
|
-
res.json({
|
|
578
|
-
status: 'ok',
|
|
579
|
-
transport: 'sse',
|
|
580
|
-
resources: mcpServer.listResources().map(r => r.name),
|
|
581
|
-
tools: mcpServer.listTools().map(t => t.name),
|
|
582
|
-
});
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
await mcpServer.connect(sseTransport);
|
|
586
|
-
|
|
587
|
-
const server = app.listen(port, () => {
|
|
588
|
-
console.log(`MCP Server (SSE) listening on port ${port}`);
|
|
589
|
-
console.log(`SSE endpoint: http://localhost:${port}/mcp/sse`);
|
|
590
|
-
console.log(`Health check: http://localhost:${port}/mcp/health`);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
// Store server instance for cleanup
|
|
594
|
-
mcpServer._httpServer = server;
|
|
595
|
-
break;
|
|
596
|
-
|
|
597
|
-
case 'websocket':
|
|
598
|
-
// WebSocket transport - best for real-time bidirectional communication
|
|
599
|
-
const wsPort = options.port || 3001;
|
|
600
|
-
const wss = new WebSocketServer({ port: wsPort });
|
|
601
|
-
|
|
602
|
-
wss.on('connection', async (ws) => {
|
|
603
|
-
console.log('New WebSocket connection');
|
|
604
|
-
const wsTransport = new WebSocketServerTransport(ws);
|
|
605
|
-
await mcpServer.connect(wsTransport);
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
console.log(`MCP Server (WebSocket) listening on port ${wsPort}`);
|
|
609
|
-
console.log(`WebSocket URL: ws://localhost:${wsPort}`);
|
|
610
|
-
|
|
611
|
-
// Store WebSocket server instance for cleanup
|
|
612
|
-
mcpServer._wss = wss;
|
|
613
|
-
break;
|
|
614
|
-
|
|
615
|
-
default:
|
|
616
|
-
throw new Error(`Unknown transport type: ${transport}`);
|
|
266
|
+
console.error(`Error in ghost_create_post:`, error);
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: 'text', text: `Error creating post: ${error.message}` }],
|
|
269
|
+
isError: true,
|
|
270
|
+
};
|
|
617
271
|
}
|
|
618
|
-
|
|
619
|
-
console.log("Available Resources:", mcpServer.listResources().map(r => r.name));
|
|
620
|
-
console.log("Available Tools:", mcpServer.listTools().map(t => t.name));
|
|
621
|
-
|
|
622
|
-
} catch (error) {
|
|
623
|
-
console.error("Failed to start MCP Server:", error);
|
|
624
|
-
process.exit(1);
|
|
625
272
|
}
|
|
626
|
-
|
|
273
|
+
);
|
|
627
274
|
|
|
628
|
-
//
|
|
629
|
-
const shutdown = async () => {
|
|
630
|
-
console.log("\nShutting down MCP Server...");
|
|
631
|
-
|
|
632
|
-
if (mcpServer._httpServer) {
|
|
633
|
-
mcpServer._httpServer.close();
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (mcpServer._wss) {
|
|
637
|
-
mcpServer._wss.close();
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
await mcpServer.close();
|
|
641
|
-
process.exit(0);
|
|
642
|
-
};
|
|
275
|
+
// --- Main Entry Point ---
|
|
643
276
|
|
|
644
|
-
|
|
645
|
-
|
|
277
|
+
async function main() {
|
|
278
|
+
console.error('Starting Ghost MCP Server...');
|
|
646
279
|
|
|
647
|
-
|
|
648
|
-
|
|
280
|
+
const transport = new StdioServerTransport();
|
|
281
|
+
await server.connect(transport);
|
|
649
282
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
283
|
+
console.error('Ghost MCP Server running on stdio transport');
|
|
284
|
+
console.error(
|
|
285
|
+
'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post'
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
main().catch((error) => {
|
|
290
|
+
console.error('Fatal error starting MCP server:', error);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|