@jgardner04/ghost-mcp-server 1.13.4 → 1.14.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/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
export const MAX_BASE64_BYTES = 5 * 1024 * 1024; // 5 MB decoded — respects MCP transport limits
|
|
7
|
+
|
|
8
|
+
const EXT_BY_MIME = {
|
|
9
|
+
'image/jpeg': '.jpg',
|
|
10
|
+
'image/jpg': '.jpg',
|
|
11
|
+
'image/png': '.png',
|
|
12
|
+
'image/gif': '.gif',
|
|
13
|
+
'image/webp': '.webp',
|
|
14
|
+
'image/svg+xml': '.svg',
|
|
15
|
+
'image/vnd.microsoft.icon': '.ico',
|
|
16
|
+
'image/x-icon': '.ico',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a caller-supplied local image path against `GHOST_MCP_IMAGE_ROOT`.
|
|
21
|
+
*
|
|
22
|
+
* Local-file input is *opt-in*: without the env var set, this function
|
|
23
|
+
* refuses every path. That prevents a compromised MCP client from reading
|
|
24
|
+
* arbitrary files via the upload tool. When set, the path must:
|
|
25
|
+
* - resolve inside the root,
|
|
26
|
+
* - exist,
|
|
27
|
+
* - not be a symlink that escapes the root.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} inputPath - Absolute or relative path supplied by caller.
|
|
30
|
+
* @returns {Promise<string>} Absolute real path to the image file.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveLocalImagePath(inputPath) {
|
|
33
|
+
const root = process.env.GHOST_MCP_IMAGE_ROOT;
|
|
34
|
+
if (!root) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'imagePath input is disabled: GHOST_MCP_IMAGE_ROOT is not set. ' +
|
|
37
|
+
'Set it to the directory from which local uploads are allowed.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (typeof inputPath !== 'string' || inputPath.length === 0) {
|
|
41
|
+
throw new Error('imagePath must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Canonicalize the root too — on macOS `/var` resolves to `/private/var`
|
|
45
|
+
// via realpath, which would otherwise cause false symlink-escape errors.
|
|
46
|
+
let canonicalRoot;
|
|
47
|
+
try {
|
|
48
|
+
canonicalRoot = await fs.realpath(path.resolve(root));
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`GHOST_MCP_IMAGE_ROOT does not exist: ${root}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolved = path.resolve(inputPath);
|
|
54
|
+
// First check the textual path — catches `..` traversal before any FS I/O.
|
|
55
|
+
const resolvedStart = path.resolve(root);
|
|
56
|
+
const textuallyInside =
|
|
57
|
+
resolved === resolvedStart || resolved.startsWith(resolvedStart + path.sep);
|
|
58
|
+
if (!textuallyInside) {
|
|
59
|
+
throw new Error(`imagePath is outside the allowed root (${canonicalRoot})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = await fs.stat(resolved);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`imagePath does not exist: ${resolved}`);
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isFile()) {
|
|
69
|
+
throw new Error(`imagePath is not a regular file: ${resolved}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve symlinks and re-check containment against the canonical root.
|
|
73
|
+
const realPath = await fs.realpath(resolved);
|
|
74
|
+
const realInside = realPath === canonicalRoot || realPath.startsWith(canonicalRoot + path.sep);
|
|
75
|
+
if (!realInside) {
|
|
76
|
+
throw new Error(`imagePath symlink escapes the allowed root (${canonicalRoot})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return realPath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decode a base64-encoded image to a fresh temp file.
|
|
84
|
+
*
|
|
85
|
+
* Accepts either a bare base64 string or a full `data:<mime>;base64,<data>`
|
|
86
|
+
* URI. Caps the decoded payload at MAX_BASE64_BYTES to respect MCP
|
|
87
|
+
* JSON-RPC transport limits — base64 payloads are inline in the tool
|
|
88
|
+
* call, and stdio transports choke on very large frames.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} base64 - Raw base64 or data URI.
|
|
91
|
+
* @param {string} mimeType - MIME type (used to pick the temp file extension).
|
|
92
|
+
* @returns {Promise<string>} Absolute path to the decoded temp file.
|
|
93
|
+
*/
|
|
94
|
+
export async function decodeBase64ToTempFile(base64, mimeType) {
|
|
95
|
+
if (typeof base64 !== 'string' || base64.length === 0) {
|
|
96
|
+
throw new Error('imageBase64 must be a non-empty string');
|
|
97
|
+
}
|
|
98
|
+
const ext = EXT_BY_MIME[(mimeType || '').toLowerCase()];
|
|
99
|
+
if (!ext) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Unsupported mimeType: ${mimeType}. Allowed: ${Object.keys(EXT_BY_MIME).join(', ')}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Strip optional "data:<mime>;base64," prefix.
|
|
106
|
+
const payload = base64.startsWith('data:') ? base64.split(',', 2)[1] : base64;
|
|
107
|
+
if (!payload) throw new Error('Invalid base64 payload');
|
|
108
|
+
|
|
109
|
+
// Reject obviously non-base64 input cheaply before allocating.
|
|
110
|
+
if (!/^[A-Za-z0-9+/=\s]+$/.test(payload)) {
|
|
111
|
+
throw new Error('Invalid base64 input');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const buf = Buffer.from(payload, 'base64');
|
|
115
|
+
if (buf.length === 0) {
|
|
116
|
+
throw new Error('Invalid base64 input');
|
|
117
|
+
}
|
|
118
|
+
if (buf.length > MAX_BASE64_BYTES) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`imageBase64 decoded size (${buf.length} bytes) exceeds the 5MB limit for MCP transport`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const outPath = path.join(os.tmpdir(), `mcp-b64-${crypto.randomUUID()}${ext}`);
|
|
125
|
+
await fs.writeFile(outPath, buf);
|
|
126
|
+
return outPath;
|
|
127
|
+
}
|