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 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
- - A plan is valid for 30 minutes and can be used once.
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
- Your AI client's own tool-approval prompt is the final check before `apply_vtl_change` runs, so keep approval enabled for that tool.
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. It expects the server to be registered under the name `dotcms`. Add it to your Claude Code `settings.json`:
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": "mcp__dotcms__apply_vtl_change",
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],
@@ -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
- if (input.tool_name !== "mcp__dotcms__apply_vtl_change") process.exit(0);
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.0" });
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("File extension to look for (default 'vtl')"),
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 — the user will be asked to confirm.",
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
- "The user is shown the plan and asked to confirm every call. A plan works once and expires after 30 minutes; " +
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.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": "^3.23.8"
38
+ "zod": "^4.6.6"
39
39
  }
40
40
  }