@proars/ghost-mcp-server 0.0.0-stage → 2.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/.env.example +2 -0
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/catalog.js +261 -0
- package/dist/client.js +249 -0
- package/dist/files.js +158 -0
- package/dist/index.js +727 -0
- package/dist/routes.js +101 -0
- package/package.json +60 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { assertSafePath, assertSafeSegment, buildForm, ghostRequest, GhostError, } from "./client.js";
|
|
6
|
+
import { accessNote, filterRoutes, hintFor, matchRoute, routeKey, ROUTES } from "./routes.js";
|
|
7
|
+
const SERVER_VERSION = "2.1.1";
|
|
8
|
+
const MAX_RESULT_CHARS = 100_000;
|
|
9
|
+
/** Calls Ghost and, on 401, 403 and 404, adds what the route catalog knows about the failure. */
|
|
10
|
+
async function request(method, path, query = {}, body, form, options) {
|
|
11
|
+
try {
|
|
12
|
+
return await ghostRequest(method, path, query, body, form, options);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof GhostError) {
|
|
16
|
+
const hint = hintFor(method, path, error.status);
|
|
17
|
+
if (hint) {
|
|
18
|
+
throw new GhostError(`${error.message}. Hint: ${hint}`, error.status, error.type);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const QUERY_DOCS = {
|
|
25
|
+
include: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Comma-separated relations to include, for example tags,authors.",
|
|
28
|
+
},
|
|
29
|
+
fields: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Comma-separated list of fields to return, for example id,title,slug.",
|
|
32
|
+
},
|
|
33
|
+
filter: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "NQL filter, for example status:published+tag:news.",
|
|
36
|
+
},
|
|
37
|
+
limit: {
|
|
38
|
+
type: "number",
|
|
39
|
+
description: "Records per page (Ghost default 15, maximum 100).",
|
|
40
|
+
},
|
|
41
|
+
page: { type: "number", description: "Page number, starting at 1." },
|
|
42
|
+
order: { type: "string", description: "Sort order, for example published_at desc." },
|
|
43
|
+
formats: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Comma-separated content formats to return: html, plaintext, lexical.",
|
|
46
|
+
},
|
|
47
|
+
source: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Set to html to send the post body as HTML instead of Lexical.",
|
|
50
|
+
},
|
|
51
|
+
save_revision: {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
description: "Set to true to save a revision of the post with this update.",
|
|
54
|
+
},
|
|
55
|
+
newsletter: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Newsletter slug. When publishing a post, emails it to that newsletter.",
|
|
58
|
+
},
|
|
59
|
+
email_segment: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "NQL member filter limiting email recipients: all, status:free or status:-free.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* MCP hints describe the effect of a tool, not its HTTP method. destructiveHint false promises that
|
|
66
|
+
* the tool only adds things; anything that overwrites or removes existing data must say true.
|
|
67
|
+
* - GET only reads.
|
|
68
|
+
* - POST creates (a record, a copy, an upload, an invite) and is additive unless the spec says it
|
|
69
|
+
* replaces something, like uploading a theme with the name of an installed one.
|
|
70
|
+
* - PUT overwrites fields, tags or the active theme, and DELETE removes: both are destructive.
|
|
71
|
+
*/
|
|
72
|
+
function annotationsFor(method, destructive) {
|
|
73
|
+
if (method === "GET") {
|
|
74
|
+
return { readOnlyHint: true, destructiveHint: false };
|
|
75
|
+
}
|
|
76
|
+
if (method === "POST") {
|
|
77
|
+
return { readOnlyHint: false, destructiveHint: destructive ?? false };
|
|
78
|
+
}
|
|
79
|
+
return { readOnlyHint: false, destructiveHint: true };
|
|
80
|
+
}
|
|
81
|
+
function pathParamsOf(path) {
|
|
82
|
+
return Array.from(path.matchAll(/\{(\w+)\}/g), (match) => match[1]);
|
|
83
|
+
}
|
|
84
|
+
function requireString(args, key) {
|
|
85
|
+
const value = args[key];
|
|
86
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
87
|
+
throw new Error(`Argument "${key}" is required and must be a non-empty string.`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function requireObject(args, key) {
|
|
92
|
+
const value = args[key];
|
|
93
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
94
|
+
throw new Error(`Argument "${key}" is required and must be an object.`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function collectQuery(args, names, defaults) {
|
|
99
|
+
const query = {};
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
const doc = QUERY_DOCS[name];
|
|
102
|
+
const value = args[name];
|
|
103
|
+
if (value === undefined || value === null || value === "") {
|
|
104
|
+
if (defaults[name] !== undefined) {
|
|
105
|
+
query[name] = defaults[name];
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value !== doc.type) {
|
|
110
|
+
throw new Error(`Argument "${name}" must be of type ${doc.type}.`);
|
|
111
|
+
}
|
|
112
|
+
if (doc.type === "number") {
|
|
113
|
+
const number = value;
|
|
114
|
+
if (!Number.isFinite(number) || number < 1) {
|
|
115
|
+
throw new Error(`Argument "${name}" must be a positive number.`);
|
|
116
|
+
}
|
|
117
|
+
query[name] = Math.floor(number);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
query[name] = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return query;
|
|
124
|
+
}
|
|
125
|
+
function toolFromSpec(spec) {
|
|
126
|
+
const pathParams = pathParamsOf(spec.path);
|
|
127
|
+
const queryNames = spec.query ?? [];
|
|
128
|
+
const defaults = spec.defaults ?? {};
|
|
129
|
+
const properties = {};
|
|
130
|
+
const required = [];
|
|
131
|
+
for (const param of pathParams) {
|
|
132
|
+
properties[param] = { type: "string", description: `Value for {${param}} in the path.` };
|
|
133
|
+
required.push(param);
|
|
134
|
+
}
|
|
135
|
+
for (const name of queryNames) {
|
|
136
|
+
const doc = QUERY_DOCS[name];
|
|
137
|
+
const defaultNote = defaults[name] !== undefined ? ` Default: ${defaults[name]}.` : "";
|
|
138
|
+
properties[name] = { type: doc.type, description: `${doc.description}${defaultNote}` };
|
|
139
|
+
}
|
|
140
|
+
if (spec.bodyKey) {
|
|
141
|
+
properties.data = {
|
|
142
|
+
type: "object",
|
|
143
|
+
description: spec.bodyDescription ??
|
|
144
|
+
`Fields of the ${spec.bodyKey} object, as in the Ghost Admin API docs.`,
|
|
145
|
+
};
|
|
146
|
+
required.push("data");
|
|
147
|
+
}
|
|
148
|
+
if (spec.upload) {
|
|
149
|
+
properties.file_path = {
|
|
150
|
+
type: "string",
|
|
151
|
+
description: "Absolute path to the local file to upload.",
|
|
152
|
+
};
|
|
153
|
+
required.push("file_path");
|
|
154
|
+
if (spec.upload.purpose) {
|
|
155
|
+
properties.purpose = {
|
|
156
|
+
type: "string",
|
|
157
|
+
enum: ["image", "profile_image", "icon"],
|
|
158
|
+
description: "Image purpose. profile_image and icon must be square; icon also accepts .ico. Default: image.",
|
|
159
|
+
};
|
|
160
|
+
properties.ref = {
|
|
161
|
+
type: "string",
|
|
162
|
+
description: "Optional reference string returned with the upload result.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
name: spec.name,
|
|
168
|
+
description: `${spec.description}${accessNote(spec.method, spec.path)} [${spec.method} /admin${spec.path}]`,
|
|
169
|
+
inputSchema: { type: "object", properties, required },
|
|
170
|
+
annotations: annotationsFor(spec.method, spec.destructive),
|
|
171
|
+
route: { method: spec.method, path: spec.path },
|
|
172
|
+
isMutation: () => spec.method !== "GET",
|
|
173
|
+
run: async (args) => {
|
|
174
|
+
let path = spec.path;
|
|
175
|
+
for (const param of pathParams) {
|
|
176
|
+
const value = requireString(args, param);
|
|
177
|
+
assertSafeSegment(param, value);
|
|
178
|
+
path = path.replace(`{${param}}`, encodeURIComponent(value));
|
|
179
|
+
}
|
|
180
|
+
const query = collectQuery(args, queryNames, defaults);
|
|
181
|
+
if (spec.upload) {
|
|
182
|
+
const fields = {};
|
|
183
|
+
if (spec.upload.purpose) {
|
|
184
|
+
const purpose = args.purpose ?? "image";
|
|
185
|
+
if (purpose !== "image" && purpose !== "profile_image" && purpose !== "icon") {
|
|
186
|
+
throw new Error('Argument "purpose" must be image, profile_image or icon.');
|
|
187
|
+
}
|
|
188
|
+
fields.purpose = purpose;
|
|
189
|
+
if (typeof args.ref === "string" && args.ref.length > 0) {
|
|
190
|
+
fields.ref = args.ref;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const form = await buildForm({ file: requireString(args, "file_path") }, fields);
|
|
194
|
+
return request(spec.method, path, query, undefined, form);
|
|
195
|
+
}
|
|
196
|
+
if (spec.bodyKey) {
|
|
197
|
+
const data = requireObject(args, "data");
|
|
198
|
+
if (spec.requireUpdatedAt && (typeof data.updated_at !== "string" || !data.updated_at)) {
|
|
199
|
+
throw new Error("data.updated_at is required: pass the current updated_at of the record (Ghost rejects stale updates).");
|
|
200
|
+
}
|
|
201
|
+
return request(spec.method, path, query, { [spec.bodyKey]: [data] });
|
|
202
|
+
}
|
|
203
|
+
return request(spec.method, path, query);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function specsForResource(resource) {
|
|
208
|
+
const specs = [];
|
|
209
|
+
const readQuery = resource.formats ? ["include", "fields", "formats"] : ["include", "fields"];
|
|
210
|
+
const listQuery = [...readQuery, "filter", "limit", "page", "order"];
|
|
211
|
+
for (const op of resource.ops) {
|
|
212
|
+
switch (op) {
|
|
213
|
+
case "browse":
|
|
214
|
+
specs.push({
|
|
215
|
+
name: `get_${resource.plural}`,
|
|
216
|
+
description: `List ${resource.label}s (paginated).${resource.browseDefaults ? " Returns a compact set of fields by default; pass fields to override." : ""}`,
|
|
217
|
+
method: "GET",
|
|
218
|
+
path: `/${resource.path}/`,
|
|
219
|
+
query: listQuery,
|
|
220
|
+
defaults: resource.browseDefaults,
|
|
221
|
+
});
|
|
222
|
+
break;
|
|
223
|
+
case "read":
|
|
224
|
+
specs.push({
|
|
225
|
+
name: `get_${resource.singular}`,
|
|
226
|
+
description: `Get one ${resource.label} by id.`,
|
|
227
|
+
method: "GET",
|
|
228
|
+
path: `/${resource.path}/{id}/`,
|
|
229
|
+
query: readQuery,
|
|
230
|
+
});
|
|
231
|
+
break;
|
|
232
|
+
case "readSlug":
|
|
233
|
+
specs.push({
|
|
234
|
+
name: `get_${resource.singular}_by_slug`,
|
|
235
|
+
description: `Get one ${resource.label} by slug.`,
|
|
236
|
+
method: "GET",
|
|
237
|
+
path: `/${resource.path}/slug/{slug}/`,
|
|
238
|
+
query: readQuery,
|
|
239
|
+
});
|
|
240
|
+
break;
|
|
241
|
+
case "add":
|
|
242
|
+
specs.push({
|
|
243
|
+
name: `create_${resource.singular}`,
|
|
244
|
+
description: `Create a ${resource.label}.`,
|
|
245
|
+
method: "POST",
|
|
246
|
+
path: `/${resource.path}/`,
|
|
247
|
+
query: resource.addQuery,
|
|
248
|
+
bodyKey: resource.path,
|
|
249
|
+
});
|
|
250
|
+
break;
|
|
251
|
+
case "edit":
|
|
252
|
+
specs.push({
|
|
253
|
+
name: `update_${resource.singular}`,
|
|
254
|
+
description: `Update a ${resource.label} by id.${resource.requireUpdatedAt ? " data must include the current updated_at." : ""}`,
|
|
255
|
+
method: "PUT",
|
|
256
|
+
path: `/${resource.path}/{id}/`,
|
|
257
|
+
query: resource.editQuery,
|
|
258
|
+
bodyKey: resource.path,
|
|
259
|
+
requireUpdatedAt: resource.requireUpdatedAt,
|
|
260
|
+
});
|
|
261
|
+
break;
|
|
262
|
+
case "delete":
|
|
263
|
+
specs.push({
|
|
264
|
+
name: `delete_${resource.singular}`,
|
|
265
|
+
description: `Permanently delete a ${resource.label} by id.`,
|
|
266
|
+
method: "DELETE",
|
|
267
|
+
path: `/${resource.path}/{id}/`,
|
|
268
|
+
});
|
|
269
|
+
break;
|
|
270
|
+
case "copy":
|
|
271
|
+
specs.push({
|
|
272
|
+
name: `copy_${resource.singular}`,
|
|
273
|
+
description: `Duplicate a ${resource.label} by id.`,
|
|
274
|
+
method: "POST",
|
|
275
|
+
path: `/${resource.path}/{id}/copy/`,
|
|
276
|
+
});
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return specs;
|
|
281
|
+
}
|
|
282
|
+
const COMPACT_FIELDS = "id,title,slug,status,updated_at,published_at,meta_title,meta_description,og_title,og_description";
|
|
283
|
+
const RESOURCES = [
|
|
284
|
+
{
|
|
285
|
+
path: "posts",
|
|
286
|
+
plural: "posts",
|
|
287
|
+
singular: "post",
|
|
288
|
+
label: "post",
|
|
289
|
+
ops: ["browse", "read", "readSlug", "add", "edit", "delete", "copy"],
|
|
290
|
+
formats: true,
|
|
291
|
+
requireUpdatedAt: true,
|
|
292
|
+
browseDefaults: { fields: COMPACT_FIELDS, include: "tags" },
|
|
293
|
+
addQuery: ["source"],
|
|
294
|
+
editQuery: ["source", "save_revision", "newsletter", "email_segment"],
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: "pages",
|
|
298
|
+
plural: "pages",
|
|
299
|
+
singular: "page",
|
|
300
|
+
label: "page",
|
|
301
|
+
ops: ["browse", "read", "readSlug", "add", "edit", "delete", "copy"],
|
|
302
|
+
formats: true,
|
|
303
|
+
requireUpdatedAt: true,
|
|
304
|
+
browseDefaults: { fields: COMPACT_FIELDS, include: "tags" },
|
|
305
|
+
addQuery: ["source"],
|
|
306
|
+
editQuery: ["source", "save_revision"],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
path: "tags",
|
|
310
|
+
plural: "tags",
|
|
311
|
+
singular: "tag",
|
|
312
|
+
label: "tag",
|
|
313
|
+
ops: ["browse", "read", "readSlug", "add", "edit", "delete"],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
path: "tiers",
|
|
317
|
+
plural: "tiers",
|
|
318
|
+
singular: "tier",
|
|
319
|
+
label: "membership tier",
|
|
320
|
+
ops: ["browse", "read", "add", "edit"],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
path: "newsletters",
|
|
324
|
+
plural: "newsletters",
|
|
325
|
+
singular: "newsletter",
|
|
326
|
+
label: "newsletter",
|
|
327
|
+
ops: ["browse", "read", "add", "edit"],
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
path: "offers",
|
|
331
|
+
plural: "offers",
|
|
332
|
+
singular: "offer",
|
|
333
|
+
label: "offer",
|
|
334
|
+
ops: ["browse", "read", "add", "edit"],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
path: "members",
|
|
338
|
+
plural: "members",
|
|
339
|
+
singular: "member",
|
|
340
|
+
label: "member",
|
|
341
|
+
ops: ["browse", "read", "add", "edit", "delete"],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
path: "labels",
|
|
345
|
+
plural: "labels",
|
|
346
|
+
singular: "label",
|
|
347
|
+
label: "member label",
|
|
348
|
+
ops: ["browse", "add", "edit", "delete"],
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
path: "users",
|
|
352
|
+
plural: "users",
|
|
353
|
+
singular: "user",
|
|
354
|
+
label: "staff user",
|
|
355
|
+
ops: ["browse", "read", "edit", "delete"],
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
path: "webhooks",
|
|
359
|
+
plural: "webhooks",
|
|
360
|
+
singular: "webhook",
|
|
361
|
+
label: "webhook",
|
|
362
|
+
ops: ["add", "edit", "delete"],
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
const STANDALONE_SPECS = [
|
|
366
|
+
{
|
|
367
|
+
name: "get_site",
|
|
368
|
+
description: "Get basic site info: title, description, logo, URL and Ghost version.",
|
|
369
|
+
method: "GET",
|
|
370
|
+
path: "/site/",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "get_settings",
|
|
374
|
+
description: "Get the site settings.",
|
|
375
|
+
method: "GET",
|
|
376
|
+
path: "/settings/",
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "get_roles",
|
|
380
|
+
description: "List staff roles.",
|
|
381
|
+
method: "GET",
|
|
382
|
+
path: "/roles/",
|
|
383
|
+
query: ["fields", "limit", "page", "order"],
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: "invite_user",
|
|
387
|
+
description: "Invite a staff user (data: email, role_id).",
|
|
388
|
+
method: "POST",
|
|
389
|
+
path: "/invites/",
|
|
390
|
+
bodyKey: "invites",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "upload_image",
|
|
394
|
+
description: "Upload one image from a local file path to Ghost storage; returns its URL.",
|
|
395
|
+
method: "POST",
|
|
396
|
+
path: "/images/upload/",
|
|
397
|
+
upload: { purpose: true },
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "upload_theme",
|
|
401
|
+
description: "Upload a theme from a local ZIP file. A theme with the same name is replaced, so this can overwrite an installed theme.",
|
|
402
|
+
method: "POST",
|
|
403
|
+
path: "/themes/upload/",
|
|
404
|
+
upload: {},
|
|
405
|
+
destructive: true,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "activate_theme",
|
|
409
|
+
description: "Activate an uploaded theme by name. This changes the live site design.",
|
|
410
|
+
method: "PUT",
|
|
411
|
+
path: "/themes/{name}/activate/",
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
const SEO_FIELDS = ["meta_title", "meta_description", "og_title", "og_description"];
|
|
415
|
+
const seoTool = {
|
|
416
|
+
name: "update_post_seo",
|
|
417
|
+
description: "Shortcut to update SEO fields of a post (meta_title, meta_description, og_title, og_description). Requires id and the current updated_at. At least one SEO field must be provided. [PUT /admin/posts/{id}/]",
|
|
418
|
+
inputSchema: {
|
|
419
|
+
type: "object",
|
|
420
|
+
properties: {
|
|
421
|
+
id: { type: "string", description: "Post id." },
|
|
422
|
+
updated_at: {
|
|
423
|
+
type: "string",
|
|
424
|
+
description: "Current updated_at value of the post, as returned by get_posts.",
|
|
425
|
+
},
|
|
426
|
+
meta_title: { type: "string", description: "SEO meta title." },
|
|
427
|
+
meta_description: { type: "string", description: "SEO meta description." },
|
|
428
|
+
og_title: { type: "string", description: "Open Graph title." },
|
|
429
|
+
og_description: { type: "string", description: "Open Graph description." },
|
|
430
|
+
},
|
|
431
|
+
required: ["id", "updated_at"],
|
|
432
|
+
},
|
|
433
|
+
// Both overwrite existing values (SEO fields, or the whole tag list), so they are destructive.
|
|
434
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
435
|
+
isMutation: () => true,
|
|
436
|
+
run: async (args) => {
|
|
437
|
+
const id = requireString(args, "id");
|
|
438
|
+
const updated_at = requireString(args, "updated_at");
|
|
439
|
+
const fields = {};
|
|
440
|
+
for (const key of SEO_FIELDS) {
|
|
441
|
+
const value = args[key];
|
|
442
|
+
if (value !== undefined) {
|
|
443
|
+
if (typeof value !== "string") {
|
|
444
|
+
throw new Error(`Argument "${key}" must be a string.`);
|
|
445
|
+
}
|
|
446
|
+
fields[key] = value;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (Object.keys(fields).length === 0) {
|
|
450
|
+
throw new Error(`Provide at least one of: ${SEO_FIELDS.join(", ")}.`);
|
|
451
|
+
}
|
|
452
|
+
assertSafeSegment("id", id);
|
|
453
|
+
const result = await request("PUT", `/posts/${encodeURIComponent(id)}/`, {}, { posts: [{ updated_at, ...fields }] });
|
|
454
|
+
const post = result.posts?.[0] ?? {};
|
|
455
|
+
return {
|
|
456
|
+
id: post.id,
|
|
457
|
+
updated_at: post.updated_at,
|
|
458
|
+
meta_title: post.meta_title,
|
|
459
|
+
meta_description: post.meta_description,
|
|
460
|
+
og_title: post.og_title,
|
|
461
|
+
og_description: post.og_description,
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
const tagsTool = {
|
|
466
|
+
name: "update_post_tags",
|
|
467
|
+
description: "Shortcut to replace the tags of a post (tag names; missing tags are created). Requires id and the current updated_at. An empty list removes all tags. [PUT /admin/posts/{id}/]",
|
|
468
|
+
inputSchema: {
|
|
469
|
+
type: "object",
|
|
470
|
+
properties: {
|
|
471
|
+
id: { type: "string", description: "Post id." },
|
|
472
|
+
updated_at: {
|
|
473
|
+
type: "string",
|
|
474
|
+
description: "Current updated_at value of the post, as returned by get_posts.",
|
|
475
|
+
},
|
|
476
|
+
tags: {
|
|
477
|
+
type: "array",
|
|
478
|
+
items: { type: "string" },
|
|
479
|
+
description: 'Tag names, for example ["SEO", "Ghost"].',
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
required: ["id", "updated_at", "tags"],
|
|
483
|
+
},
|
|
484
|
+
// Both overwrite existing values (SEO fields, or the whole tag list), so they are destructive.
|
|
485
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
486
|
+
isMutation: () => true,
|
|
487
|
+
run: async (args) => {
|
|
488
|
+
const id = requireString(args, "id");
|
|
489
|
+
const updated_at = requireString(args, "updated_at");
|
|
490
|
+
const tags = args.tags;
|
|
491
|
+
if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) {
|
|
492
|
+
throw new Error('Argument "tags" is required and must be an array of strings.');
|
|
493
|
+
}
|
|
494
|
+
const tagNames = Array.from(new Set(tags.map((tagName) => tagName.trim())));
|
|
495
|
+
if (tagNames.some((tagName) => tagName.length === 0)) {
|
|
496
|
+
throw new Error('Argument "tags" must not contain empty tag names.');
|
|
497
|
+
}
|
|
498
|
+
assertSafeSegment("id", id);
|
|
499
|
+
const result = await request("PUT", `/posts/${encodeURIComponent(id)}/`, {}, { posts: [{ updated_at, tags: tagNames.map((tagName) => ({ name: tagName })) }] });
|
|
500
|
+
const post = result.posts?.[0] ?? {};
|
|
501
|
+
return {
|
|
502
|
+
id: post.id,
|
|
503
|
+
updated_at: post.updated_at,
|
|
504
|
+
tags: (post.tags ?? []).map((tag) => tag.name),
|
|
505
|
+
};
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
function stringMap(value, name) {
|
|
509
|
+
const result = {};
|
|
510
|
+
for (const [key, item] of Object.entries(requireObject({ [name]: value }, name))) {
|
|
511
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
512
|
+
throw new Error(`"${name}.${key}" must be a non-empty string.`);
|
|
513
|
+
}
|
|
514
|
+
result[key] = item;
|
|
515
|
+
}
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
const rawTool = {
|
|
519
|
+
name: "ghost_api_request",
|
|
520
|
+
description: "Call any Ghost Admin API endpoint directly, including every route without a dedicated tool (find routes with ghost_list_endpoints). The path is relative to /ghost/api/admin, for example /labels/slug/vip/. Supports JSON bodies, multipart file uploads (files) and saving file downloads (save_to). If a POST, PUT or DELETE returns a file, set save_to on that first call: its answer cannot be fetched again by repeating the call. Routes marked U, SESSION or FLOW in the catalog need a staff user or browser session and fail with an integration key. DELETE removes data permanently.",
|
|
521
|
+
inputSchema: {
|
|
522
|
+
type: "object",
|
|
523
|
+
properties: {
|
|
524
|
+
method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE"], description: "HTTP method." },
|
|
525
|
+
path: {
|
|
526
|
+
type: "string",
|
|
527
|
+
description: "Path starting with /, relative to /ghost/api/admin, without query string.",
|
|
528
|
+
},
|
|
529
|
+
query: {
|
|
530
|
+
type: "object",
|
|
531
|
+
description: "Query parameters as a flat object, for example { limit: 20, include: 'tags' }.",
|
|
532
|
+
},
|
|
533
|
+
body: { type: "object", description: "JSON body for POST and PUT requests." },
|
|
534
|
+
files: {
|
|
535
|
+
type: "object",
|
|
536
|
+
description: "Multipart upload: map of form field name to absolute local file path, for example { file: '/tmp/a.png' }. Field names per route are listed by ghost_list_endpoints (upload).",
|
|
537
|
+
},
|
|
538
|
+
fields: {
|
|
539
|
+
type: "object",
|
|
540
|
+
description: "Extra multipart text fields, for example { purpose: 'image' }. Used with files.",
|
|
541
|
+
},
|
|
542
|
+
save_to: {
|
|
543
|
+
type: "string",
|
|
544
|
+
description: "Absolute path of a new file to store the response in. Use it for downloads (CSV, YAML, ZIP). Existing files are never overwritten.",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
required: ["method", "path"],
|
|
548
|
+
},
|
|
549
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
550
|
+
isMutation: (args) => args.method !== "GET",
|
|
551
|
+
run: async (args) => {
|
|
552
|
+
const method = args.method;
|
|
553
|
+
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "DELETE") {
|
|
554
|
+
throw new Error('Argument "method" must be GET, POST, PUT or DELETE.');
|
|
555
|
+
}
|
|
556
|
+
const path = requireString(args, "path");
|
|
557
|
+
assertSafePath(path);
|
|
558
|
+
const query = {};
|
|
559
|
+
if (args.query !== undefined) {
|
|
560
|
+
for (const [key, value] of Object.entries(requireObject(args, "query"))) {
|
|
561
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
562
|
+
throw new Error(`Query parameter "${key}" must be a string, number or boolean.`);
|
|
563
|
+
}
|
|
564
|
+
query[key] = value;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const options = {};
|
|
568
|
+
if (args.save_to !== undefined) {
|
|
569
|
+
options.saveTo = requireString(args, "save_to");
|
|
570
|
+
}
|
|
571
|
+
const route = matchRoute(method, path);
|
|
572
|
+
if (args.files !== undefined) {
|
|
573
|
+
if (args.body !== undefined) {
|
|
574
|
+
throw new Error('Use either "body" (JSON) or "files" (multipart), not both.');
|
|
575
|
+
}
|
|
576
|
+
const files = stringMap(args.files, "files");
|
|
577
|
+
const fields = args.fields === undefined ? {} : stringMap(args.fields, "fields");
|
|
578
|
+
const form = await buildForm(files, fields);
|
|
579
|
+
return request(method, path, query, undefined, form, options);
|
|
580
|
+
}
|
|
581
|
+
if (route?.upload && args.body === undefined) {
|
|
582
|
+
throw new Error(`This route takes a multipart upload. Pass files, for example { ${route.upload.map((field) => `${field}: "/absolute/path"`).join(", ")} }.`);
|
|
583
|
+
}
|
|
584
|
+
const body = args.body === undefined ? undefined : requireObject(args, "body");
|
|
585
|
+
return request(method, path, query, body, undefined, options);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
const MAX_ENDPOINTS_PER_CALL = 300;
|
|
589
|
+
const endpointsTool = {
|
|
590
|
+
name: "ghost_list_endpoints",
|
|
591
|
+
description: `Search the catalog of all ${ROUTES.length} known Ghost Admin API routes (method, path, access class, feature flag, upload fields, and the dedicated tool if there is one). Use it to find a route, then call it with its dedicated tool or with ghost_api_request. Access classes: I = allowed for Admin API integration keys, U = needs a staff user token or session, SESSION = needs a browser cookie session, FLOW = login/setup flow, PUBLIC = no authentication.`,
|
|
592
|
+
inputSchema: {
|
|
593
|
+
type: "object",
|
|
594
|
+
properties: {
|
|
595
|
+
group: {
|
|
596
|
+
type: "string",
|
|
597
|
+
description: "Resource group or first path segment, for example posts, members, stats.",
|
|
598
|
+
},
|
|
599
|
+
method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE"], description: "HTTP method." },
|
|
600
|
+
access: {
|
|
601
|
+
type: "string",
|
|
602
|
+
enum: ["I", "U", "SESSION", "FLOW", "PUBLIC"],
|
|
603
|
+
description: "Access class.",
|
|
604
|
+
},
|
|
605
|
+
search: { type: "string", description: "Text to look for in the path, summary or handler." },
|
|
606
|
+
without_dedicated_tool: {
|
|
607
|
+
type: "boolean",
|
|
608
|
+
description: "Only list routes that have no dedicated tool (reachable via ghost_api_request).",
|
|
609
|
+
},
|
|
610
|
+
offset: { type: "number", description: "Number of matching routes to skip. Default 0." },
|
|
611
|
+
limit: {
|
|
612
|
+
type: "number",
|
|
613
|
+
description: `Maximum routes to return (default 50, maximum ${MAX_ENDPOINTS_PER_CALL}).`,
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
618
|
+
run: async (args) => {
|
|
619
|
+
const optionalString = (key) => {
|
|
620
|
+
const value = args[key];
|
|
621
|
+
if (value === undefined) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
if (typeof value !== "string") {
|
|
625
|
+
throw new Error(`Argument "${key}" must be a string.`);
|
|
626
|
+
}
|
|
627
|
+
return value;
|
|
628
|
+
};
|
|
629
|
+
const optionalCount = (key, fallback, min, max) => {
|
|
630
|
+
const value = args[key];
|
|
631
|
+
if (value === undefined) {
|
|
632
|
+
return fallback;
|
|
633
|
+
}
|
|
634
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
635
|
+
throw new Error(`Argument "${key}" must be a number.`);
|
|
636
|
+
}
|
|
637
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
638
|
+
};
|
|
639
|
+
const matches = filterRoutes({
|
|
640
|
+
group: optionalString("group"),
|
|
641
|
+
method: optionalString("method"),
|
|
642
|
+
access: optionalString("access"),
|
|
643
|
+
search: optionalString("search"),
|
|
644
|
+
withoutTool: args.without_dedicated_tool === true,
|
|
645
|
+
}, (route) => toolNameForRoute(route));
|
|
646
|
+
const offset = optionalCount("offset", 0, 0, matches.length);
|
|
647
|
+
const limit = optionalCount("limit", 50, 1, MAX_ENDPOINTS_PER_CALL);
|
|
648
|
+
const page = matches.slice(offset, offset + limit);
|
|
649
|
+
return {
|
|
650
|
+
total: matches.length,
|
|
651
|
+
returned: page.length,
|
|
652
|
+
offset,
|
|
653
|
+
endpoints: page.map((route) => ({
|
|
654
|
+
method: route.method,
|
|
655
|
+
path: route.path,
|
|
656
|
+
access: route.access,
|
|
657
|
+
group: route.group,
|
|
658
|
+
summary: route.summary,
|
|
659
|
+
documented: route.documented,
|
|
660
|
+
tool: toolNameForRoute(route) ?? null,
|
|
661
|
+
...(route.flag ? { flag: route.flag } : {}),
|
|
662
|
+
...(route.upload ? { upload: route.upload } : {}),
|
|
663
|
+
...(route.tokenUrl ? { token_url: true } : {}),
|
|
664
|
+
})),
|
|
665
|
+
};
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
const TOOLS = [
|
|
669
|
+
...RESOURCES.flatMap(specsForResource).map(toolFromSpec),
|
|
670
|
+
...STANDALONE_SPECS.map(toolFromSpec),
|
|
671
|
+
seoTool,
|
|
672
|
+
tagsTool,
|
|
673
|
+
endpointsTool,
|
|
674
|
+
rawTool,
|
|
675
|
+
];
|
|
676
|
+
const TOOLS_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
|
|
677
|
+
const TOOL_BY_ROUTE = new Map();
|
|
678
|
+
for (const tool of TOOLS) {
|
|
679
|
+
if (tool.route) {
|
|
680
|
+
TOOL_BY_ROUTE.set(routeKey(tool.route.method, tool.route.path), tool.name);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function toolNameForRoute(route) {
|
|
684
|
+
return TOOL_BY_ROUTE.get(routeKey(route.method, route.path));
|
|
685
|
+
}
|
|
686
|
+
const server = new Server({ name: "ghost-mcp-server", version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
687
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
688
|
+
tools: TOOLS.map(({ name, description, inputSchema, annotations }) => ({
|
|
689
|
+
name,
|
|
690
|
+
description,
|
|
691
|
+
inputSchema: inputSchema,
|
|
692
|
+
annotations,
|
|
693
|
+
})),
|
|
694
|
+
}));
|
|
695
|
+
function serialize(result, mutation) {
|
|
696
|
+
const text = JSON.stringify(result, null, 2) ?? "null";
|
|
697
|
+
if (text.length <= MAX_RESULT_CHARS) {
|
|
698
|
+
return text;
|
|
699
|
+
}
|
|
700
|
+
const head = `${text.slice(0, MAX_RESULT_CHARS)}\n... truncated: the response has ${text.length} characters.`;
|
|
701
|
+
if (mutation) {
|
|
702
|
+
// The request has already changed something: never suggest running it again just to see more.
|
|
703
|
+
return `${head} The request was already carried out: do not repeat it to see more. Check the result with a read tool (a get_* tool, or ghost_api_request with GET) and fields, filter or limit.`;
|
|
704
|
+
}
|
|
705
|
+
return `${head} Narrow it with fields, filter or limit, or save a download with save_to.`;
|
|
706
|
+
}
|
|
707
|
+
server.setRequestHandler(CallToolRequestSchema, async (call) => {
|
|
708
|
+
const tool = TOOLS_BY_NAME.get(call.params.name);
|
|
709
|
+
if (!tool) {
|
|
710
|
+
return {
|
|
711
|
+
isError: true,
|
|
712
|
+
content: [{ type: "text", text: `Unknown tool: ${call.params.name}` }],
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const args = (call.params.arguments ?? {});
|
|
717
|
+
const result = await tool.run(args);
|
|
718
|
+
return { content: [{ type: "text", text: serialize(result, tool.isMutation?.(args) ?? false) }] };
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
722
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
const transport = new StdioServerTransport();
|
|
726
|
+
await server.connect(transport);
|
|
727
|
+
console.error(`ghost-mcp-server running on stdio with ${TOOLS.length} tools`);
|