@jgardner04/ghost-mcp-server 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -8
- package/src/mcp_server_improved.js +175 -573
- package/src/services/__tests__/ghostService.test.js +116 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
"./mcp": "./src/mcp_server_improved.js"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|
|
13
|
-
"ghost-mcp-server": "src/index.js",
|
|
14
|
-
"ghost-mcp": "src/mcp_server_improved.js"
|
|
13
|
+
"ghost-mcp-server": "./src/index.js",
|
|
14
|
+
"ghost-mcp": "./src/mcp_server_improved.js"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"start:mcp": "node src/mcp_server_improved.js",
|
|
40
40
|
"start:mcp:stdio": "MCP_TRANSPORT=stdio node src/mcp_server_improved.js",
|
|
41
41
|
"start:mcp:http": "MCP_TRANSPORT=http node src/mcp_server_improved.js",
|
|
42
|
-
"start:mcp:websocket": "MCP_TRANSPORT=websocket node src/mcp_server_improved.js"
|
|
42
|
+
"start:mcp:websocket": "MCP_TRANSPORT=websocket node src/mcp_server_improved.js",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest watch",
|
|
45
|
+
"test:coverage": "vitest run --coverage"
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
45
48
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
@@ -63,7 +66,8 @@
|
|
|
63
66
|
"ora": "^7.0.1",
|
|
64
67
|
"sanitize-html": "^2.17.0",
|
|
65
68
|
"sharp": "^0.34.1",
|
|
66
|
-
"winston": "^3.17.0"
|
|
69
|
+
"winston": "^3.17.0",
|
|
70
|
+
"zod": "^3.25.76"
|
|
67
71
|
},
|
|
68
72
|
"keywords": [
|
|
69
73
|
"ghost",
|
|
@@ -82,8 +86,8 @@
|
|
|
82
86
|
},
|
|
83
87
|
"license": "MIT",
|
|
84
88
|
"devDependencies": {
|
|
85
|
-
"@
|
|
86
|
-
"
|
|
87
|
-
"
|
|
89
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
90
|
+
"semantic-release": "^25.0.2",
|
|
91
|
+
"vitest": "^4.0.15"
|
|
88
92
|
}
|
|
89
93
|
}
|
|
@@ -1,369 +1,155 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
MCPServer,
|
|
4
|
-
Resource,
|
|
5
|
-
Tool,
|
|
6
|
-
} from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
-
import {
|
|
9
|
-
import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/websocket.js";
|
|
4
|
+
import { z } from "zod";
|
|
10
5
|
import dotenv from "dotenv";
|
|
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
6
|
import axios from "axios";
|
|
19
|
-
import { validateImageUrl, createSecureAxiosConfig } from "./utils/urlValidator.js";
|
|
20
7
|
import fs from "fs";
|
|
21
8
|
import path from "path";
|
|
22
9
|
import os from "os";
|
|
23
|
-
import
|
|
24
|
-
import express from "express";
|
|
25
|
-
import { WebSocketServer } from "ws";
|
|
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
|
|
37
|
+
.basename(filePath)
|
|
38
|
+
.split(".")
|
|
39
|
+
.slice(0, -1)
|
|
40
|
+
.join(".");
|
|
41
|
+
const nameWithoutIds = originalFilename
|
|
42
|
+
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, "")
|
|
43
|
+
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, "");
|
|
44
|
+
return nameWithoutIds.replace(/[-_]/g, " ").trim() || "Uploaded image";
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return "Uploaded image";
|
|
47
|
+
}
|
|
64
48
|
};
|
|
65
49
|
|
|
66
|
-
//
|
|
50
|
+
// Create server instance with new API
|
|
51
|
+
const server = new McpServer({
|
|
52
|
+
name: "ghost-mcp-server",
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
});
|
|
67
55
|
|
|
68
|
-
|
|
56
|
+
// --- Register Tools ---
|
|
69
57
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
properties: {
|
|
77
|
-
id: { type: "string", description: "Unique ID of the tag" },
|
|
78
|
-
name: { type: "string", description: "The name of the tag" },
|
|
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"],
|
|
58
|
+
// Get Tags Tool
|
|
59
|
+
server.tool(
|
|
60
|
+
"ghost_get_tags",
|
|
61
|
+
"Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.",
|
|
62
|
+
{
|
|
63
|
+
name: z.string().optional().describe("Filter tags by exact name. If omitted, all tags are returned."),
|
|
86
64
|
},
|
|
87
|
-
|
|
88
|
-
|
|
65
|
+
async ({ name }) => {
|
|
66
|
+
console.error(`Executing tool: ghost_get_tags`);
|
|
89
67
|
try {
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
68
|
+
await loadServices();
|
|
69
|
+
const tags = await ghostService.getTags();
|
|
70
|
+
let result = tags;
|
|
71
|
+
|
|
72
|
+
if (name) {
|
|
73
|
+
result = tags.filter(
|
|
74
|
+
(tag) => tag.name.toLowerCase() === name.toLowerCase()
|
|
75
|
+
);
|
|
76
|
+
console.error(`Filtered tags by name "${name}". Found ${result.length} match(es).`);
|
|
77
|
+
} else {
|
|
78
|
+
console.error(`Retrieved ${tags.length} tags from Ghost.`);
|
|
97
79
|
}
|
|
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
80
|
|
|
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
|
-
);
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
83
|
+
};
|
|
204
84
|
} catch (error) {
|
|
205
|
-
|
|
85
|
+
console.error(`Error in ghost_get_tags:`, error);
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
206
90
|
}
|
|
207
91
|
}
|
|
208
|
-
|
|
209
|
-
mcpServer.addResource(ghostPostResource);
|
|
210
|
-
console.log(`Added Resource: ${ghostPostResource.name}`);
|
|
211
|
-
|
|
212
|
-
// --- Define Tools (with improved error handling) ---
|
|
92
|
+
);
|
|
213
93
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
properties: {
|
|
223
|
-
title: {
|
|
224
|
-
type: "string",
|
|
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",
|
|
94
|
+
// Create Tag Tool
|
|
95
|
+
server.tool(
|
|
96
|
+
"ghost_create_tag",
|
|
97
|
+
"Creates a new tag in Ghost CMS.",
|
|
98
|
+
{
|
|
99
|
+
name: z.string().describe("The name of the tag."),
|
|
100
|
+
description: z.string().optional().describe("A description for the tag."),
|
|
101
|
+
slug: z.string().optional().describe("A URL-friendly slug for the tag. Will be auto-generated from the name if omitted."),
|
|
283
102
|
},
|
|
284
|
-
|
|
285
|
-
console.
|
|
286
|
-
`Executing tool: ${createPostTool.name} with input keys:`,
|
|
287
|
-
Object.keys(input)
|
|
288
|
-
);
|
|
103
|
+
async ({ name, description, slug }) => {
|
|
104
|
+
console.error(`Executing tool: ghost_create_tag with name: ${name}`);
|
|
289
105
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
106
|
+
await loadServices();
|
|
107
|
+
const createdTag = await ghostService.createTag({ name, description, slug });
|
|
108
|
+
console.error(`Tag created successfully. Tag ID: ${createdTag.id}`);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: "text", text: JSON.stringify(createdTag, null, 2) }],
|
|
112
|
+
};
|
|
295
113
|
} catch (error) {
|
|
296
|
-
|
|
114
|
+
console.error(`Error in ghost_create_tag:`, error);
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
297
119
|
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
mcpServer.addTool(createPostTool);
|
|
301
|
-
console.log(`Added Tool: ${createPostTool.name}`);
|
|
120
|
+
}
|
|
121
|
+
);
|
|
302
122
|
|
|
303
123
|
// Upload Image Tool
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
properties: {
|
|
311
|
-
imageUrl: {
|
|
312
|
-
type: "string",
|
|
313
|
-
format: "url",
|
|
314
|
-
description: "The publicly accessible URL of the image to upload.",
|
|
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"],
|
|
124
|
+
server.tool(
|
|
125
|
+
"ghost_upload_image",
|
|
126
|
+
"Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.",
|
|
127
|
+
{
|
|
128
|
+
imageUrl: z.string().describe("The publicly accessible URL of the image to upload."),
|
|
129
|
+
alt: z.string().optional().describe("Alt text for the image. If omitted, a default will be generated from the filename."),
|
|
323
130
|
},
|
|
324
|
-
|
|
325
|
-
|
|
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"],
|
|
338
|
-
},
|
|
339
|
-
implementation: async (input) => {
|
|
340
|
-
console.log(
|
|
341
|
-
`Executing tool: ${uploadImageTool.name} for URL:`,
|
|
342
|
-
input.imageUrl
|
|
343
|
-
);
|
|
344
|
-
const { imageUrl, alt } = input;
|
|
131
|
+
async ({ imageUrl, alt }) => {
|
|
132
|
+
console.error(`Executing tool: ghost_upload_image for URL: ${imageUrl}`);
|
|
345
133
|
let downloadedPath = null;
|
|
346
134
|
let processedPath = null;
|
|
347
135
|
|
|
348
136
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
137
|
+
await loadServices();
|
|
138
|
+
|
|
139
|
+
// 1. Validate URL for SSRF protection
|
|
140
|
+
const urlValidation = urlValidator.validateImageUrl(imageUrl);
|
|
351
141
|
if (!urlValidation.isValid) {
|
|
352
142
|
throw new Error(`Invalid image URL: ${urlValidation.error}`);
|
|
353
143
|
}
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
const axiosConfig = createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
144
|
+
|
|
145
|
+
// 2. Download the image with security controls
|
|
146
|
+
const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
357
147
|
const response = await axios(axiosConfig);
|
|
358
148
|
const tempDir = os.tmpdir();
|
|
359
149
|
const extension = path.extname(imageUrl.split("?")[0]) || ".tmp";
|
|
360
150
|
const originalFilenameHint =
|
|
361
|
-
path.basename(imageUrl.split("?")[0]) ||
|
|
362
|
-
|
|
363
|
-
downloadedPath = path.join(
|
|
364
|
-
tempDir,
|
|
365
|
-
`mcp-download-${uuidv4()}${extension}`
|
|
366
|
-
);
|
|
151
|
+
path.basename(imageUrl.split("?")[0]) || `image-${generateUuid()}${extension}`;
|
|
152
|
+
downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
367
153
|
|
|
368
154
|
const writer = fs.createWriteStream(downloadedPath);
|
|
369
155
|
response.data.pipe(writer);
|
|
@@ -372,286 +158,102 @@ const uploadImageTool = new Tool({
|
|
|
372
158
|
writer.on("finish", resolve);
|
|
373
159
|
writer.on("error", reject);
|
|
374
160
|
});
|
|
375
|
-
console.
|
|
161
|
+
console.error(`Downloaded image to temporary path: ${downloadedPath}`);
|
|
376
162
|
|
|
377
|
-
//
|
|
378
|
-
processedPath = await processImage(downloadedPath, tempDir);
|
|
379
|
-
console.
|
|
163
|
+
// 3. Process the image
|
|
164
|
+
processedPath = await imageProcessingService.processImage(downloadedPath, tempDir);
|
|
165
|
+
console.error(`Processed image path: ${processedPath}`);
|
|
380
166
|
|
|
381
|
-
//
|
|
167
|
+
// 4. Determine Alt Text
|
|
382
168
|
const defaultAlt = getDefaultAltText(originalFilenameHint);
|
|
383
169
|
const finalAltText = alt || defaultAlt;
|
|
384
|
-
console.
|
|
170
|
+
console.error(`Using alt text: "${finalAltText}"`);
|
|
385
171
|
|
|
386
|
-
//
|
|
387
|
-
const uploadResult = await
|
|
388
|
-
console.
|
|
172
|
+
// 5. Upload processed image to Ghost
|
|
173
|
+
const uploadResult = await ghostService.uploadImage(processedPath);
|
|
174
|
+
console.error(`Uploaded processed image to Ghost: ${uploadResult.url}`);
|
|
389
175
|
|
|
390
|
-
//
|
|
391
|
-
|
|
176
|
+
// 6. Return result
|
|
177
|
+
const result = {
|
|
392
178
|
url: uploadResult.url,
|
|
393
179
|
alt: finalAltText,
|
|
394
180
|
};
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
184
|
+
};
|
|
395
185
|
} catch (error) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
),
|
|
402
|
-
uploadImageTool.name
|
|
403
|
-
);
|
|
186
|
+
console.error(`Error in ghost_upload_image:`, error);
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: `Error uploading image: ${error.message}` }],
|
|
189
|
+
isError: true,
|
|
190
|
+
};
|
|
404
191
|
} finally {
|
|
405
|
-
//
|
|
192
|
+
// Cleanup temporary files
|
|
406
193
|
if (downloadedPath) {
|
|
407
194
|
fs.unlink(downloadedPath, (err) => {
|
|
408
|
-
if (err)
|
|
409
|
-
console.error(
|
|
410
|
-
"Error deleting temporary downloaded file:",
|
|
411
|
-
downloadedPath,
|
|
412
|
-
err
|
|
413
|
-
);
|
|
195
|
+
if (err) console.error("Error deleting temporary downloaded file:", downloadedPath, err);
|
|
414
196
|
});
|
|
415
197
|
}
|
|
416
198
|
if (processedPath && processedPath !== downloadedPath) {
|
|
417
199
|
fs.unlink(processedPath, (err) => {
|
|
418
|
-
if (err)
|
|
419
|
-
console.error(
|
|
420
|
-
"Error deleting temporary processed file:",
|
|
421
|
-
processedPath,
|
|
422
|
-
err
|
|
423
|
-
);
|
|
200
|
+
if (err) console.error("Error deleting temporary processed file:", processedPath, err);
|
|
424
201
|
});
|
|
425
202
|
}
|
|
426
203
|
}
|
|
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
204
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
mcpServer.addTool(uploadImageTool);
|
|
448
|
-
console.log(`Added Tool: ${uploadImageTool.name}`);
|
|
205
|
+
);
|
|
449
206
|
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
type: "array",
|
|
467
|
-
items: { $ref: "ghost/tag#/schema" },
|
|
207
|
+
// Create Post Tool
|
|
208
|
+
server.tool(
|
|
209
|
+
"ghost_create_post",
|
|
210
|
+
"Creates a new post in Ghost CMS.",
|
|
211
|
+
{
|
|
212
|
+
title: z.string().describe("The title of the post."),
|
|
213
|
+
html: z.string().describe("The HTML content of the post."),
|
|
214
|
+
status: z.enum(["draft", "published", "scheduled"]).optional().describe("The status of the post. Defaults to 'draft'."),
|
|
215
|
+
tags: z.array(z.string()).optional().describe("List of tag names to associate with the post. Tags will be created if they don't exist."),
|
|
216
|
+
published_at: z.string().optional().describe("ISO 8601 date/time to publish the post. Required if status is 'scheduled'."),
|
|
217
|
+
custom_excerpt: z.string().optional().describe("A custom short summary for the post."),
|
|
218
|
+
feature_image: z.string().optional().describe("URL of the image (e.g., from ghost_upload_image tool) to use as the featured image."),
|
|
219
|
+
feature_image_alt: z.string().optional().describe("Alt text for the featured image."),
|
|
220
|
+
feature_image_caption: z.string().optional().describe("Caption for the featured image."),
|
|
221
|
+
meta_title: z.string().optional().describe("Custom title for SEO (max 300 chars). Defaults to post title if omitted."),
|
|
222
|
+
meta_description: z.string().optional().describe("Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted."),
|
|
468
223
|
},
|
|
469
|
-
|
|
470
|
-
console.
|
|
224
|
+
async (input) => {
|
|
225
|
+
console.error(`Executing tool: ghost_create_post with title: ${input.title}`);
|
|
471
226
|
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}`);
|
|
227
|
+
await loadServices();
|
|
228
|
+
const createdPost = await postService.createPostService(input);
|
|
229
|
+
console.error(`Post created successfully. Post ID: ${createdPost.id}`);
|
|
491
230
|
|
|
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;
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: JSON.stringify(createdPost, null, 2) }],
|
|
233
|
+
};
|
|
530
234
|
} 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}`);
|
|
235
|
+
console.error(`Error in ghost_create_post:`, error);
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: `Error creating post: ${error.message}` }],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
617
240
|
}
|
|
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
241
|
}
|
|
626
|
-
|
|
242
|
+
);
|
|
627
243
|
|
|
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
|
-
};
|
|
244
|
+
// --- Main Entry Point ---
|
|
643
245
|
|
|
644
|
-
|
|
645
|
-
|
|
246
|
+
async function main() {
|
|
247
|
+
console.error("Starting Ghost MCP Server...");
|
|
646
248
|
|
|
647
|
-
|
|
648
|
-
|
|
249
|
+
const transport = new StdioServerTransport();
|
|
250
|
+
await server.connect(transport);
|
|
649
251
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
252
|
+
console.error("Ghost MCP Server running on stdio transport");
|
|
253
|
+
console.error("Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main().catch((error) => {
|
|
257
|
+
console.error("Fatal error starting MCP server:", error);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock the Ghost Admin API
|
|
4
|
+
vi.mock('@tryghost/admin-api', () => {
|
|
5
|
+
const GhostAdminAPI = vi.fn(function() {
|
|
6
|
+
return {
|
|
7
|
+
posts: {
|
|
8
|
+
add: vi.fn()
|
|
9
|
+
},
|
|
10
|
+
tags: {
|
|
11
|
+
add: vi.fn(),
|
|
12
|
+
browse: vi.fn()
|
|
13
|
+
},
|
|
14
|
+
site: {
|
|
15
|
+
read: vi.fn()
|
|
16
|
+
},
|
|
17
|
+
images: {
|
|
18
|
+
upload: vi.fn()
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
default: GhostAdminAPI
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Mock dotenv
|
|
29
|
+
vi.mock('dotenv', () => ({
|
|
30
|
+
default: {
|
|
31
|
+
config: vi.fn()
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Mock logger
|
|
36
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
37
|
+
createContextLogger: vi.fn(() => ({
|
|
38
|
+
apiRequest: vi.fn(),
|
|
39
|
+
apiResponse: vi.fn(),
|
|
40
|
+
apiError: vi.fn(),
|
|
41
|
+
warn: vi.fn(),
|
|
42
|
+
error: vi.fn()
|
|
43
|
+
}))
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Import after setting up mocks and environment
|
|
47
|
+
import { createPost, createTag, getTags } from '../ghostService.js';
|
|
48
|
+
|
|
49
|
+
describe('ghostService', () => {
|
|
50
|
+
describe('createPost', () => {
|
|
51
|
+
it('should throw error when title is missing', async () => {
|
|
52
|
+
await expect(createPost({})).rejects.toThrow('Post title is required');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should set default status to draft when not provided', async () => {
|
|
56
|
+
const postData = { title: 'Test Post', html: '<p>Content</p>' };
|
|
57
|
+
|
|
58
|
+
// The function should call the API with default status
|
|
59
|
+
try {
|
|
60
|
+
await createPost(postData);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Expected to fail since we're using a mock, but we can verify the behavior
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(postData.title).toBe('Test Post');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('createTag', () => {
|
|
70
|
+
it('should throw error when tag name is missing', async () => {
|
|
71
|
+
await expect(createTag({})).rejects.toThrow('Tag name is required');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should accept valid tag data', async () => {
|
|
75
|
+
const tagData = { name: 'Test Tag', slug: 'test-tag' };
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await createTag(tagData);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Expected to fail with mock, but validates input handling
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
expect(tagData.name).toBe('Test Tag');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getTags', () => {
|
|
88
|
+
it('should reject tag names with invalid characters', async () => {
|
|
89
|
+
await expect(getTags("'; DROP TABLE tags; --")).rejects.toThrow('Tag name contains invalid characters');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should accept valid tag names', async () => {
|
|
93
|
+
const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
|
|
94
|
+
|
|
95
|
+
for (const name of validNames) {
|
|
96
|
+
try {
|
|
97
|
+
await getTags(name);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Expected to fail with mock, but should not throw validation error
|
|
100
|
+
expect(error.message).not.toContain('invalid characters');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle tag names without filter when name is not provided', async () => {
|
|
106
|
+
try {
|
|
107
|
+
await getTags();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Expected to fail with mock
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Should not throw validation error
|
|
113
|
+
expect(true).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|