@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/dist/client.js ADDED
@@ -0,0 +1,249 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { extname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import dotenv from "dotenv";
5
+ import { readUploadFile, reserveDownload } from "./files.js";
6
+ // The project .env is read only when the configuration is not already in the environment, and
7
+ // never when GHOST_MCP_SKIP_DOTENV=1. GHOST_MCP_ENV_FILE points at a different file. Variables that
8
+ // are already set always win over the file.
9
+ const configSupplied = Boolean(process.env.GHOST_API_URL) && Boolean(process.env.GHOST_ADMIN_API_KEY);
10
+ if (!configSupplied && process.env.GHOST_MCP_SKIP_DOTENV !== "1") {
11
+ dotenv.config({
12
+ path: process.env.GHOST_MCP_ENV_FILE || fileURLToPath(new URL("../.env", import.meta.url)),
13
+ });
14
+ }
15
+ const GHOST_API_URL = process.env.GHOST_API_URL;
16
+ const GHOST_ADMIN_API_KEY = process.env.GHOST_ADMIN_API_KEY;
17
+ if (!GHOST_API_URL || !GHOST_ADMIN_API_KEY) {
18
+ throw new Error("Missing configuration: GHOST_API_URL and GHOST_ADMIN_API_KEY must be set in the environment or in a .env file next to package.json, or in the file named by GHOST_MCP_ENV_FILE (see .env.example).");
19
+ }
20
+ if (!/^[0-9a-f]{24}:[0-9a-f]{64}$/i.test(GHOST_ADMIN_API_KEY)) {
21
+ throw new Error("GHOST_ADMIN_API_KEY has an invalid format. Expected {id}:{secret}, where id is 24 hex characters and secret is 64 hex characters (Ghost Admin → Settings → Integrations).");
22
+ }
23
+ const API_BASE = `${GHOST_API_URL.replace(/\/+$/, "")}/ghost/api/admin`;
24
+ const [KEY_ID, KEY_SECRET] = GHOST_ADMIN_API_KEY.split(":");
25
+ const ACCEPT_VERSION = "v5.0";
26
+ const REQUEST_TIMEOUT_MS = 120_000;
27
+ const MAX_INLINE_TEXT_BYTES = 200_000;
28
+ export class GhostError extends Error {
29
+ status;
30
+ type;
31
+ constructor(message, status, type) {
32
+ super(message);
33
+ this.name = "GhostError";
34
+ this.status = status;
35
+ this.type = type;
36
+ }
37
+ }
38
+ /** The request never produced an HTTP answer (connection lost, timeout, refused). */
39
+ export class GhostNetworkError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = "GhostNetworkError";
43
+ }
44
+ }
45
+ export function createToken() {
46
+ const now = Math.floor(Date.now() / 1000);
47
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT", kid: KEY_ID })).toString("base64url");
48
+ const payload = Buffer.from(JSON.stringify({ iat: now, exp: now + 300, aud: "/admin/" })).toString("base64url");
49
+ const signature = createHmac("sha256", Buffer.from(KEY_SECRET, "hex"))
50
+ .update(`${header}.${payload}`)
51
+ .digest("base64url");
52
+ return `${header}.${payload}.${signature}`;
53
+ }
54
+ /** Rejects paths that could leave /ghost/api/admin (dot segments, encoded slashes, query strings). */
55
+ export function assertSafePath(path) {
56
+ const unsafe = !path.startsWith("/") ||
57
+ /[?#\\\s\u0000-\u001f]/.test(path) ||
58
+ /\.\.|\/\//.test(path) ||
59
+ /%(2e|2f|5c|00)/i.test(path);
60
+ if (unsafe) {
61
+ throw new Error('Path must start with "/", be relative to /ghost/api/admin, and contain no "?", "#", "..", "//", backslashes, whitespace or encoded dots and slashes.');
62
+ }
63
+ }
64
+ /** Rejects a value that would become a dot segment when placed in a URL path. */
65
+ export function assertSafeSegment(name, value) {
66
+ if (value === "." || value === "..") {
67
+ throw new Error(`Argument "${name}" must not be "." or "..".`);
68
+ }
69
+ }
70
+ const MIME_TYPES = {
71
+ ".png": "image/png",
72
+ ".jpg": "image/jpeg",
73
+ ".jpeg": "image/jpeg",
74
+ ".gif": "image/gif",
75
+ ".webp": "image/webp",
76
+ ".svg": "image/svg+xml",
77
+ ".ico": "image/x-icon",
78
+ ".zip": "application/zip",
79
+ ".csv": "text/csv",
80
+ ".json": "application/json",
81
+ ".yaml": "application/x-yaml",
82
+ ".yml": "application/x-yaml",
83
+ ".mp4": "video/mp4",
84
+ ".webm": "video/webm",
85
+ ".ogv": "video/ogg",
86
+ ".mp3": "audio/mpeg",
87
+ ".wav": "audio/vnd.wav",
88
+ ".ogg": "audio/ogg",
89
+ ".m4a": "audio/mp4",
90
+ ".pdf": "application/pdf",
91
+ ".txt": "text/plain",
92
+ ".xml": "application/xml",
93
+ };
94
+ /** Ghost checks both the extension and the MIME type of an upload, so the type must match the file. */
95
+ export function mimeFor(fileName) {
96
+ return MIME_TYPES[extname(fileName).toLowerCase()] ?? "application/octet-stream";
97
+ }
98
+ export async function buildForm(files, fields = {}) {
99
+ const form = new FormData();
100
+ for (const [name, value] of Object.entries(fields)) {
101
+ form.append(name, value);
102
+ }
103
+ for (const [field, filePath] of Object.entries(files)) {
104
+ const { buffer, name } = await readUploadFile(`File path for "${field}"`, filePath);
105
+ form.append(field, new Blob([buffer], { type: mimeFor(name) }), name);
106
+ }
107
+ return form;
108
+ }
109
+ function buildQuery(query) {
110
+ const params = new URLSearchParams();
111
+ for (const [key, value] of Object.entries(query)) {
112
+ params.set(key, String(value));
113
+ }
114
+ const text = params.toString();
115
+ return text ? `?${text}` : "";
116
+ }
117
+ // Errors that prove the request was never sent, so repeating it is safe.
118
+ const NOT_SENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ERR_INVALID_URL"]);
119
+ function networkError(method, error) {
120
+ const failure = error;
121
+ const code = failure?.cause?.code ?? failure?.code;
122
+ const detail = code ?? failure?.cause?.message ?? failure?.message ?? String(error);
123
+ if (code && NOT_SENT_CODES.has(code)) {
124
+ return new GhostNetworkError(`Ghost request failed: could not connect (${detail}). Nothing was sent. Check GHOST_API_URL.`);
125
+ }
126
+ if (method === "GET") {
127
+ return new GhostNetworkError(`Ghost request failed: ${detail}.`);
128
+ }
129
+ return new GhostNetworkError(`Ghost request failed after it may have been sent: ${detail}. The operation may or may not have completed: check Ghost before repeating this ${method} request.`);
130
+ }
131
+ function redirectError(method, status, location) {
132
+ let target = "an unknown location";
133
+ if (location) {
134
+ try {
135
+ const url = new URL(location, `${API_BASE}/`);
136
+ target = `${url.origin}${url.pathname}`;
137
+ }
138
+ catch {
139
+ target = "an unreadable location";
140
+ }
141
+ }
142
+ const boundary = "Redirects are never followed, so the API key and the request body cannot leave the Admin API.";
143
+ const fix = "set GHOST_API_URL to the canonical URL of your Ghost site (for example its https:// address or its admin domain)";
144
+ if (method === "GET") {
145
+ return new GhostError(`Ghost API ${status} redirect to ${target}. ${boundary} Fix it: ${fix}, then send the request again.`, status);
146
+ }
147
+ // A redirect is an answer, so Ghost has already received this POST, PUT or DELETE and may have
148
+ // carried it out (a 303 after a POST usually means exactly that).
149
+ return new GhostError(`Ghost API ${status} redirect to ${target}. The ${method} request was already sent, so the operation may have completed: check in Ghost whether it did before repeating it. ${boundary} After checking, ${fix}.`, status);
150
+ }
151
+ export async function ghostFetch(method, path, query = {}, body, form) {
152
+ const headers = {
153
+ Authorization: `Ghost ${createToken()}`,
154
+ "Accept-Version": ACCEPT_VERSION,
155
+ };
156
+ let payload;
157
+ if (form) {
158
+ payload = form;
159
+ }
160
+ else if (body !== undefined && method !== "GET") {
161
+ headers["Content-Type"] = "application/json";
162
+ payload = JSON.stringify(body);
163
+ }
164
+ let response;
165
+ let buffer;
166
+ try {
167
+ response = await fetch(`${API_BASE}${path}${buildQuery(query)}`, {
168
+ method,
169
+ headers,
170
+ body: payload,
171
+ redirect: "manual",
172
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
173
+ });
174
+ buffer = Buffer.from(await response.arrayBuffer());
175
+ }
176
+ catch (error) {
177
+ throw networkError(method, error);
178
+ }
179
+ const contentType = response.headers.get("content-type") ?? "";
180
+ if (response.status >= 300 && response.status < 400) {
181
+ throw redirectError(method, response.status, response.headers.get("location"));
182
+ }
183
+ if (!response.ok) {
184
+ let first = undefined;
185
+ try {
186
+ first = JSON.parse(buffer.toString("utf8"))?.errors?.[0];
187
+ }
188
+ catch {
189
+ first = undefined;
190
+ }
191
+ if (first?.message) {
192
+ const type = first.type ? `${first.type}: ` : "";
193
+ const context = first.context ? ` (${first.context})` : "";
194
+ throw new GhostError(`Ghost API ${response.status} ${type}${first.message}${context}`, response.status, first.type);
195
+ }
196
+ throw new GhostError(`Ghost API ${response.status} ${response.statusText}`, response.status);
197
+ }
198
+ return { status: response.status, contentType, body: buffer };
199
+ }
200
+ /** Sends a request and converts any kind of successful response into a JSON-serializable value. */
201
+ export async function ghostRequest(method, path, query = {}, body, form, options = {}) {
202
+ // The destination is validated and created before the request, so a bad save_to can never fail
203
+ // after Ghost has already carried out a change.
204
+ const reservation = options.saveTo ? await reserveDownload(options.saveTo) : undefined;
205
+ let response;
206
+ try {
207
+ response = await ghostFetch(method, path, query, body, form);
208
+ }
209
+ catch (error) {
210
+ await reservation?.discard();
211
+ throw error;
212
+ }
213
+ const { status, contentType, body: buffer } = response;
214
+ if (reservation) {
215
+ try {
216
+ await reservation.write(buffer);
217
+ }
218
+ catch (error) {
219
+ await reservation.discard();
220
+ const failure = error;
221
+ throw new Error(`The ${method} request was sent and Ghost answered ${status}, but saving the response to ${options.saveTo} failed (${failure?.code ?? failure?.message}). The remote operation may have completed: do not repeat a POST, PUT or DELETE without checking Ghost first.`);
222
+ }
223
+ return { ok: true, status, saved_to: options.saveTo, bytes: buffer.length, content_type: contentType };
224
+ }
225
+ if (buffer.length === 0) {
226
+ return { ok: true, status };
227
+ }
228
+ if (/json/i.test(contentType)) {
229
+ try {
230
+ return JSON.parse(buffer.toString("utf8"));
231
+ }
232
+ catch {
233
+ // fall through and return the body as text
234
+ }
235
+ }
236
+ const isText = /^text\/|json|xml|yaml|javascript/i.test(contentType);
237
+ if (isText && buffer.length <= MAX_INLINE_TEXT_BYTES) {
238
+ return { ok: true, status, content_type: contentType, text: buffer.toString("utf8") };
239
+ }
240
+ return {
241
+ ok: true,
242
+ status,
243
+ content_type: contentType,
244
+ bytes: buffer.length,
245
+ note: method === "GET"
246
+ ? "The response is binary or too large to show. Repeat the call with save_to set to an absolute file path to store it."
247
+ : `The ${method} request was already carried out and Ghost answered ${status}. The response is binary or too large to show, and it was not saved because save_to was not set. Do not repeat the request to get it again: check the result in Ghost. Set save_to on the first call of a request that returns a file.`,
248
+ };
249
+ }
package/dist/files.js ADDED
@@ -0,0 +1,158 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, open, realpath, stat, unlink } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
+ // O_NOFOLLOW is not defined on every platform; 0 simply means "no extra flag".
5
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
6
+ /**
7
+ * Local paths come from the AI client and can be influenced by site content, so nothing in a
8
+ * hidden folder (.ssh, .aws, .config and similar) is ever read or written. A segment is hidden when
9
+ * it starts with ".". This is checked on the path as written and again on the resolved path, so a
10
+ * symlink cannot lead into a hidden place.
11
+ */
12
+ export function assertNotHidden(label, path, resolvedFrom) {
13
+ const segments = path.split(/[\\/]/);
14
+ // ".." is not a hidden name. Say what is wrong instead of blaming a hidden folder.
15
+ if (segments.includes("..")) {
16
+ throw new Error(`${label} must not contain ".." segments; give the full path without them: ${path}`);
17
+ }
18
+ if (segments.some((segment) => segment.startsWith(".") && segment.length > 1)) {
19
+ const origin = resolvedFrom ? ` (${resolvedFrom} resolves to ${path})` : `: ${path}`;
20
+ throw new Error(`${label} must not point into a hidden file or folder (a path segment starting with ".")${origin}`);
21
+ }
22
+ }
23
+ function describe(error) {
24
+ const failure = error;
25
+ return failure?.code ?? failure?.message ?? String(error);
26
+ }
27
+ /**
28
+ * Reads a local file for an upload.
29
+ *
30
+ * The path must be absolute and, once every symlink is resolved, free of hidden segments. The
31
+ * resolved file is opened without following a final symlink and must be the same regular file that
32
+ * was validated, so a file swapped in between the check and the read is refused. A local process
33
+ * that can rewrite directories while a call is running is outside what this can defend against.
34
+ */
35
+ export async function readUploadFile(label, path) {
36
+ if (!isAbsolute(path)) {
37
+ throw new Error(`${label} must be an absolute path: ${path}`);
38
+ }
39
+ assertNotHidden(label, path);
40
+ let resolved;
41
+ try {
42
+ resolved = await realpath(path);
43
+ }
44
+ catch (error) {
45
+ throw new Error(`${label} cannot be read (${describe(error)}): ${path}`);
46
+ }
47
+ assertNotHidden(label, resolved, path);
48
+ const expected = await stat(resolved);
49
+ if (!expected.isFile()) {
50
+ throw new Error(`${label} must be a regular file: ${path}`);
51
+ }
52
+ const handle = await open(resolved, constants.O_RDONLY | NOFOLLOW);
53
+ try {
54
+ const actual = await handle.stat();
55
+ if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) {
56
+ throw new Error(`${label} changed while it was being opened: ${path}`);
57
+ }
58
+ return { buffer: await handle.readFile(), name: basename(path) };
59
+ }
60
+ finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+ /**
65
+ * Reserves a new file for a download before the request is sent.
66
+ *
67
+ * The parent directory must exist, be a directory, be writable, and, once resolved, contain no
68
+ * hidden segment. The file itself is created exclusively (never following a symlink, owner-only
69
+ * permissions), so an existing file, a symlink or a concurrent call makes this fail instead of
70
+ * overwriting anything. Every check happens before the request, so a bad path never leaves a
71
+ * half-done change in Ghost.
72
+ */
73
+ export async function reserveDownload(saveTo) {
74
+ if (!isAbsolute(saveTo)) {
75
+ throw new Error(`save_to must be an absolute path: ${saveTo}`);
76
+ }
77
+ // Checked on the spelling as given: basename() and join() below would silently drop a trailing
78
+ // separator, create a file the caller did not name, and report a path that cannot be opened.
79
+ if (/[\\/]$/.test(saveTo)) {
80
+ throw new Error(`save_to must name a file, not a folder: remove the trailing separator from ${saveTo}`);
81
+ }
82
+ if (/[\u0000-\u001f]/.test(saveTo)) {
83
+ throw new Error("save_to must not contain control characters.");
84
+ }
85
+ assertNotHidden("save_to", saveTo);
86
+ const name = basename(saveTo);
87
+ if (name === "" || name === "." || name === "..") {
88
+ throw new Error(`save_to must end with a file name: ${saveTo}`);
89
+ }
90
+ const parent = dirname(saveTo);
91
+ let resolvedParent;
92
+ try {
93
+ resolvedParent = await realpath(parent);
94
+ }
95
+ catch (error) {
96
+ const code = error?.code;
97
+ if (code === "ENOENT") {
98
+ throw new Error(`save_to directory does not exist: ${parent}`);
99
+ }
100
+ if (code === "ENOTDIR") {
101
+ throw new Error(`save_to parent is not a directory: ${parent}`);
102
+ }
103
+ throw new Error(`save_to directory cannot be used (${describe(error)}): ${parent}`);
104
+ }
105
+ assertNotHidden("save_to", resolvedParent, parent);
106
+ if (!(await stat(resolvedParent)).isDirectory()) {
107
+ throw new Error(`save_to parent is not a directory: ${parent}`);
108
+ }
109
+ try {
110
+ await access(resolvedParent, constants.W_OK | constants.X_OK);
111
+ }
112
+ catch {
113
+ throw new Error(`save_to directory is not writable: ${parent}`);
114
+ }
115
+ const target = join(resolvedParent, name);
116
+ let handle;
117
+ try {
118
+ handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600);
119
+ }
120
+ catch (error) {
121
+ const code = error?.code;
122
+ if (code === "EEXIST" || code === "ELOOP") {
123
+ throw new Error(`save_to already exists, choose a new file name: ${saveTo}`);
124
+ }
125
+ throw new Error(`save_to cannot be created (${describe(error)}): ${saveTo}`);
126
+ }
127
+ const created = await handle.stat();
128
+ let closed = false;
129
+ const close = async () => {
130
+ if (!closed) {
131
+ closed = true;
132
+ await handle.close().catch(() => undefined);
133
+ }
134
+ };
135
+ return {
136
+ async write(buffer) {
137
+ try {
138
+ await handle.writeFile(buffer);
139
+ }
140
+ finally {
141
+ await close();
142
+ }
143
+ },
144
+ async discard() {
145
+ await close();
146
+ try {
147
+ const current = await lstat(target);
148
+ // Only remove the file this call created, never a different file that took its place.
149
+ if (current.ino === created.ino && current.dev === created.dev) {
150
+ await unlink(target);
151
+ }
152
+ }
153
+ catch {
154
+ // already gone
155
+ }
156
+ },
157
+ };
158
+ }