rpg0-dotcms-mcp 1.1.1 → 1.1.2
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 +11 -4
- package/dotcms.js +41 -0
- package/hooks/confirm-write.mjs +2 -1
- package/index.js +65 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ All configuration is through environment variables.
|
|
|
30
30
|
| `DOTCMS_TOKEN` | *(none)* | API token, sent as a `Bearer` token |
|
|
31
31
|
| `DOTCMS_ALLOW_WRITE` | `false` | Set to `true` to expose the write tools |
|
|
32
32
|
| `DOTCMS_WRITE_ROOTS` | `/application/apivtl/,/application/vtl/custom-fields/` | Comma-separated folders the write tools are limited to |
|
|
33
|
+
| `DOTCMS_READ_EXTENSIONS` | `vtl` | Comma-separated file extensions the read tools may return |
|
|
34
|
+
| `DOTCMS_CONFIRM` | *(on)* | Set to `off` to skip the server's own confirmation prompt before a write |
|
|
33
35
|
| `DOTCMS_PLANS_DIR` | `~/.dotcms-mcp/plans` | Where pending change plans are stored |
|
|
34
36
|
|
|
35
37
|
## Client setup
|
|
@@ -98,6 +100,8 @@ Add the server to the client's MCP configuration:
|
|
|
98
100
|
| `list_vtl_files` | Finds VTL files by folder and/or file name, with paging |
|
|
99
101
|
| `read_vtl_file` | Returns the source of a VTL file by path or identifier, optionally for a specific site or language |
|
|
100
102
|
|
|
103
|
+
The two file tools only return files whose extension is listed in `DOTCMS_READ_EXTENSIONS`.
|
|
104
|
+
|
|
101
105
|
### Only with `DOTCMS_ALLOW_WRITE=true`
|
|
102
106
|
|
|
103
107
|
| Tool | What it does |
|
|
@@ -114,21 +118,23 @@ Add the server to the client's MCP configuration:
|
|
|
114
118
|
Safeguards:
|
|
115
119
|
|
|
116
120
|
- Paths outside `DOTCMS_WRITE_ROOTS` are rejected.
|
|
117
|
-
-
|
|
121
|
+
- The site must be a plain host name.
|
|
122
|
+
- A plan is valid for 30 minutes, can be used once, and only in the server session that created it. A plan file that was edited on disk is refused.
|
|
118
123
|
- By default a change is saved as a working version; the live file is only replaced when publishing is explicitly requested.
|
|
124
|
+
- Before writing, the server asks you to confirm through your client when the client supports MCP elicitation (VS Code does). Declining writes nothing. Set `DOTCMS_CONFIRM=off` to turn this prompt off.
|
|
119
125
|
|
|
120
|
-
|
|
126
|
+
If your client does not support elicitation, its own tool-approval prompt is the only check before `apply_vtl_change` runs, so keep approval enabled for that tool and do not auto-approve it.
|
|
121
127
|
|
|
122
128
|
### Optional: confirmation hook for Claude Code
|
|
123
129
|
|
|
124
|
-
The package ships a `PreToolUse` hook that shows the full plan and asks for approval every time `apply_vtl_change` is called, and denies calls without a valid plan.
|
|
130
|
+
The package ships a `PreToolUse` hook that shows the full plan and asks for approval every time `apply_vtl_change` is called, and denies calls without a valid plan. If you use it, you can set `DOTCMS_CONFIRM=off` to avoid being asked twice. Add it to your Claude Code `settings.json`:
|
|
125
131
|
|
|
126
132
|
```json
|
|
127
133
|
{
|
|
128
134
|
"hooks": {
|
|
129
135
|
"PreToolUse": [
|
|
130
136
|
{
|
|
131
|
-
"matcher": "
|
|
137
|
+
"matcher": "mcp__.*__apply_vtl_change",
|
|
132
138
|
"hooks": [
|
|
133
139
|
{
|
|
134
140
|
"type": "command",
|
|
@@ -151,5 +157,6 @@ The package ships a `PreToolUse` hook that shows the full plan and asks for appr
|
|
|
151
157
|
## Security notes
|
|
152
158
|
|
|
153
159
|
- Give the token only the permissions the assistant needs; use a read-only user if you don't need writes.
|
|
160
|
+
- Use `https` for any dotCMS that isn't on your own machine; the server warns at startup when the token would travel over plain `http`.
|
|
154
161
|
- Never commit the token. Prefer your client's secret prompt or an environment variable.
|
|
155
162
|
- The server sends requests only to `DOTCMS_URL`.
|
package/dotcms.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// DOTCMS_ALLOW_WRITE "true" to expose the create/update tools (default: read-only)
|
|
7
7
|
// DOTCMS_WRITE_ROOTS comma-separated folders the write tools are limited to
|
|
8
8
|
// (default: /application/apivtl/,/application/vtl/custom-fields/)
|
|
9
|
+
// DOTCMS_READ_EXTENSIONS comma-separated file extensions the read tools may return (default: vtl)
|
|
10
|
+
// DOTCMS_CONFIRM "off" to skip the server's own confirmation prompt before a write
|
|
11
|
+
// (default: ask through the client when it supports MCP elicitation)
|
|
9
12
|
|
|
10
13
|
export const BASE_URL = (process.env.DOTCMS_URL || "http://localhost:8080").replace(/\/+$/, "");
|
|
11
14
|
export const TOKEN = process.env.DOTCMS_TOKEN || "";
|
|
@@ -18,6 +21,42 @@ export const WRITE_ROOTS = (
|
|
|
18
21
|
.filter(Boolean)
|
|
19
22
|
.map((root) => `/${root}/`);
|
|
20
23
|
|
|
24
|
+
export const READ_EXTENSIONS = (process.env.DOTCMS_READ_EXTENSIONS || "vtl")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((ext) => ext.trim().replace(/^\./, "").toLowerCase())
|
|
27
|
+
.filter((ext) => /^[a-z0-9]+$/.test(ext));
|
|
28
|
+
export const CONFIRM = !/^(off|false|0|no)$/i.test(process.env.DOTCMS_CONFIRM || "");
|
|
29
|
+
|
|
30
|
+
/** True when the token would travel unencrypted to another machine. */
|
|
31
|
+
export const INSECURE_TRANSPORT = (() => {
|
|
32
|
+
try {
|
|
33
|
+
const { protocol, hostname } = new URL(BASE_URL);
|
|
34
|
+
const local = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
|
|
35
|
+
return !!TOKEN && protocol === "http:" && !local;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
/** A site is a host name; anything else could change where `site:folder` points. */
|
|
42
|
+
export function assertSite(site) {
|
|
43
|
+
if (!/^[A-Za-z0-9]([A-Za-z0-9._-]{0,251}[A-Za-z0-9])?$/.test(String(site ?? ""))) {
|
|
44
|
+
throw new Error(`Invalid site '${site}': pass the site host name, e.g. 'demo.dotcms.com'.`);
|
|
45
|
+
}
|
|
46
|
+
return site;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Throws unless the file name ends in one of DOTCMS_READ_EXTENSIONS. */
|
|
50
|
+
export function assertReadable(fileName) {
|
|
51
|
+
const ext = String(fileName ?? "").split(".").pop().toLowerCase();
|
|
52
|
+
if (!String(fileName ?? "").includes(".") || !READ_EXTENSIONS.includes(ext)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`'${fileName}' is not a ${READ_EXTENSIONS.map((e) => `.${e}`).join(" / ")} file. ` +
|
|
55
|
+
"This server only reads the extensions listed in DOTCMS_READ_EXTENSIONS.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
21
60
|
const authHeaders = (extra = {}) => (TOKEN ? { ...extra, Authorization: `Bearer ${TOKEN}` } : extra);
|
|
22
61
|
|
|
23
62
|
async function send(path, init = {}) {
|
|
@@ -174,6 +213,7 @@ export function parseWritePath(path) {
|
|
|
174
213
|
* through the dotCMS workflow API. Not published unless `publish` is true.
|
|
175
214
|
*/
|
|
176
215
|
export async function saveFile({ site, folder, fileName, content, identifier, languageId, publish }) {
|
|
216
|
+
assertSite(site);
|
|
177
217
|
const contentlet = {
|
|
178
218
|
contentType: "FileAsset",
|
|
179
219
|
title: fileName,
|
|
@@ -199,6 +239,7 @@ export async function saveFile({ site, folder, fileName, content, identifier, la
|
|
|
199
239
|
/** Make sure a folder exists. Returns an error message instead of throwing. */
|
|
200
240
|
export async function ensureFolder(site, folder) {
|
|
201
241
|
try {
|
|
242
|
+
assertSite(site);
|
|
202
243
|
await api(`/api/v1/folder/createfolders/${encodeURIComponent(site)}`, {
|
|
203
244
|
method: "POST",
|
|
204
245
|
body: [folder],
|
package/hooks/confirm-write.mjs
CHANGED
|
@@ -28,7 +28,8 @@ try {
|
|
|
28
28
|
} catch {
|
|
29
29
|
process.exit(0); // not a hook payload: stay out of the way
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
// Whatever name the server was registered under.
|
|
32
|
+
if (!/^mcp__.+__apply_vtl_change$/.test(String(input.tool_name ?? ""))) process.exit(0);
|
|
32
33
|
|
|
33
34
|
let plan;
|
|
34
35
|
try {
|
package/index.js
CHANGED
|
@@ -10,13 +10,19 @@
|
|
|
10
10
|
|
|
11
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import crypto from "node:crypto";
|
|
13
14
|
import { z } from "zod";
|
|
14
15
|
import {
|
|
15
16
|
ALLOW_WRITE,
|
|
16
17
|
BASE_URL,
|
|
18
|
+
CONFIRM,
|
|
19
|
+
INSECURE_TRANSPORT,
|
|
20
|
+
READ_EXTENSIONS,
|
|
17
21
|
TOKEN,
|
|
18
22
|
WRITE_ROOTS,
|
|
19
23
|
api,
|
|
24
|
+
assertReadable,
|
|
25
|
+
assertSite,
|
|
20
26
|
downloadFile,
|
|
21
27
|
ensureFolder,
|
|
22
28
|
escapeLucene,
|
|
@@ -84,7 +90,7 @@ const mapFile = (f) => ({
|
|
|
84
90
|
const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
|
|
85
91
|
const fail = (err) => ({ isError: true, content: [{ type: "text", text: err.message }] });
|
|
86
92
|
|
|
87
|
-
const server = new McpServer({ name: "dotcms", version: "1.1.
|
|
93
|
+
const server = new McpServer({ name: "dotcms", version: "1.1.2" });
|
|
88
94
|
|
|
89
95
|
// ---------------------------------------------------------------------------
|
|
90
96
|
// Read-only tools
|
|
@@ -189,7 +195,7 @@ server.registerTool(
|
|
|
189
195
|
.optional()
|
|
190
196
|
.describe("Only files whose path contains this folder, e.g. '/application/vtl' or 'containers/banner'"),
|
|
191
197
|
name: z.string().optional().describe("Only files whose file name contains this text, e.g. 'banner'"),
|
|
192
|
-
extension: z.string().default("vtl").describe(
|
|
198
|
+
extension: z.string().default("vtl").describe(`File extension to look for (default 'vtl'; allowed: ${READ_EXTENSIONS.join(", ")})`),
|
|
193
199
|
page: z.number().int().min(1).default(1).describe("Page number"),
|
|
194
200
|
perPage: z.number().int().min(1).max(100).default(50).describe("Items per page"),
|
|
195
201
|
},
|
|
@@ -197,6 +203,7 @@ server.registerTool(
|
|
|
197
203
|
},
|
|
198
204
|
async ({ folder, name, extension, page, perPage }) => {
|
|
199
205
|
try {
|
|
206
|
+
assertReadable(`x.${extension.replace(/^\./, "")}`);
|
|
200
207
|
const ext = escapeLucene(extension.replace(/^\./, "").toLowerCase());
|
|
201
208
|
const clauses = [`+path:*.${ext}`];
|
|
202
209
|
if (folder) {
|
|
@@ -240,6 +247,7 @@ server.registerTool(
|
|
|
240
247
|
if (!path && !identifier) throw new Error("Pass either 'path' or 'identifier'.");
|
|
241
248
|
const files = await findFiles({ path, identifier, site });
|
|
242
249
|
const { file, languages } = pickVersion(files, { what: identifier || path, site, languageId });
|
|
250
|
+
assertReadable(file.fileName || file.name || file.path);
|
|
243
251
|
|
|
244
252
|
const content = await downloadFile(file);
|
|
245
253
|
const truncated = content.length > MAX_FILE_CHARS;
|
|
@@ -263,6 +271,37 @@ server.registerTool(
|
|
|
263
271
|
if (ALLOW_WRITE) {
|
|
264
272
|
const roots = WRITE_ROOTS.join(" and ");
|
|
265
273
|
|
|
274
|
+
// Fingerprints of the plans this process created. apply_vtl_change refuses a plan whose
|
|
275
|
+
// file on disk no longer matches, so editing a plan file cannot change what gets written.
|
|
276
|
+
const planFingerprints = new Map();
|
|
277
|
+
const fingerprint = (plan) => crypto.createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Ask the user through the client (MCP elicitation) when it can. Returns "confirmed",
|
|
281
|
+
* "declined", or "unsupported" when the client cannot ask — its own tool approval is then
|
|
282
|
+
* the only check.
|
|
283
|
+
*/
|
|
284
|
+
async function confirmWithUser(plan) {
|
|
285
|
+
if (!CONFIRM || !server.server.getClientCapabilities()?.elicitation) return "unsupported";
|
|
286
|
+
let answer;
|
|
287
|
+
try {
|
|
288
|
+
answer = await server.server.elicitInput({
|
|
289
|
+
message: `Apply this change to dotCMS?\n\n${renderPlan(plan)}`,
|
|
290
|
+
requestedSchema: {
|
|
291
|
+
type: "object",
|
|
292
|
+
properties: {
|
|
293
|
+
apply: { type: "boolean", title: "Apply this change", description: `${plan.action} ${plan.path} on ${plan.site}` },
|
|
294
|
+
},
|
|
295
|
+
required: ["apply"],
|
|
296
|
+
},
|
|
297
|
+
}, { timeout: 10 * 60 * 1000 }); // give the user time to read the diff
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (/does not support/i.test(err.message)) return "unsupported";
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
return answer.action === "accept" && answer.content?.apply === true ? "confirmed" : "declined";
|
|
303
|
+
}
|
|
304
|
+
|
|
266
305
|
server.registerTool(
|
|
267
306
|
"plan_vtl_change",
|
|
268
307
|
{
|
|
@@ -296,6 +335,7 @@ if (ALLOW_WRITE) {
|
|
|
296
335
|
async ({ path, site, content, languageId, publish }) => {
|
|
297
336
|
try {
|
|
298
337
|
const target = parseWritePath(path);
|
|
338
|
+
if (site !== undefined) assertSite(site);
|
|
299
339
|
const files = await findFiles({ path: target.full, site });
|
|
300
340
|
|
|
301
341
|
let action = "CREATE";
|
|
@@ -352,6 +392,7 @@ if (ALLOW_WRITE) {
|
|
|
352
392
|
warnings,
|
|
353
393
|
hints,
|
|
354
394
|
});
|
|
395
|
+
planFingerprints.set(plan.id, fingerprint(plan));
|
|
355
396
|
|
|
356
397
|
return {
|
|
357
398
|
content: [
|
|
@@ -360,7 +401,7 @@ if (ALLOW_WRITE) {
|
|
|
360
401
|
text:
|
|
361
402
|
renderPlan(plan) +
|
|
362
403
|
`\n\nplanId: ${plan.id}\nNothing has been written. Show this plan to the user; to carry it out call ` +
|
|
363
|
-
"apply_vtl_change with this planId
|
|
404
|
+
"apply_vtl_change with this planId.",
|
|
364
405
|
},
|
|
365
406
|
],
|
|
366
407
|
};
|
|
@@ -377,7 +418,8 @@ if (ALLOW_WRITE) {
|
|
|
377
418
|
description:
|
|
378
419
|
"Step 2 of 2. WRITES to dotCMS: creates or updates the VTL file exactly as described by a plan from " +
|
|
379
420
|
"plan_vtl_change. Takes only the planId, so nothing but the planned content can be written. " +
|
|
380
|
-
"
|
|
421
|
+
"Only call it after the user has seen the plan and agreed to it. When the client supports it, the server " +
|
|
422
|
+
"also asks the user to confirm. A plan works once, only in the server session that made it, and expires after 30 minutes; " +
|
|
381
423
|
"it is rejected if the file changed in dotCMS after the plan was made.",
|
|
382
424
|
inputSchema: {
|
|
383
425
|
planId: z.string().min(1).describe("planId returned by plan_vtl_change"),
|
|
@@ -387,9 +429,24 @@ if (ALLOW_WRITE) {
|
|
|
387
429
|
async ({ planId }) => {
|
|
388
430
|
try {
|
|
389
431
|
const plan = loadPlan(planId);
|
|
432
|
+
if (planFingerprints.get(plan.id) !== fingerprint(plan)) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Plan ${plan.id} was not made by this server session, or its file was changed afterwards. Make a new plan with plan_vtl_change.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
390
437
|
if (plan.instance !== BASE_URL) {
|
|
391
438
|
throw new Error(`Plan ${plan.id} was made for ${plan.instance}, but this server is connected to ${BASE_URL}.`);
|
|
392
439
|
}
|
|
440
|
+
// Never trust the stored plan for where it may write: check it again.
|
|
441
|
+
const target = parseWritePath(plan.path);
|
|
442
|
+
if (target.folder !== plan.folder || target.fileName !== plan.fileName) {
|
|
443
|
+
throw new Error(`Plan ${plan.id} is inconsistent. Make a new plan with plan_vtl_change.`);
|
|
444
|
+
}
|
|
445
|
+
assertSite(plan.site);
|
|
446
|
+
|
|
447
|
+
if ((await confirmWithUser(plan)) === "declined") {
|
|
448
|
+
throw new Error(`The user declined plan ${plan.id}. Nothing was written.`);
|
|
449
|
+
}
|
|
393
450
|
|
|
394
451
|
// The plan must still describe reality.
|
|
395
452
|
const files = (await findFiles({ path: plan.path, site: plan.site })).filter(
|
|
@@ -419,6 +476,7 @@ if (ALLOW_WRITE) {
|
|
|
419
476
|
throw err;
|
|
420
477
|
}
|
|
421
478
|
deletePlan(plan.id);
|
|
479
|
+
planFingerprints.delete(plan.id);
|
|
422
480
|
|
|
423
481
|
return ok({
|
|
424
482
|
result: `${plan.action === "CREATE" ? "Created" : "Updated"} ${plan.path} on ${plan.site}`,
|
|
@@ -444,3 +502,6 @@ console.error(
|
|
|
444
502
|
`dotcms-mcp running (${BASE_URL}${TOKEN ? "" : ", no DOTCMS_TOKEN set"}, ` +
|
|
445
503
|
`${ALLOW_WRITE ? `writes enabled under ${WRITE_ROOTS.join(", ")}` : "read-only"})`,
|
|
446
504
|
);
|
|
505
|
+
if (INSECURE_TRANSPORT) {
|
|
506
|
+
console.error("WARNING: DOTCMS_URL is plain http on a remote host — the API token is sent unencrypted. Use https.");
|
|
507
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpg0-dotcms-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "MCP server for dotCMS: inspect content types and fields, and read or safely write Velocity (VTL) files from AI assistants such as GitHub Copilot and Claude",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
"engines": { "node": ">=18" },
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
38
|
-
"zod": "^
|
|
38
|
+
"zod": "^4.6.6"
|
|
39
39
|
}
|
|
40
40
|
}
|