rpg0-dotcms-mcp 1.1.0 → 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 ADDED
@@ -0,0 +1,162 @@
1
+ # rpg0-dotcms-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for [dotCMS](https://www.dotcms.com). It lets an AI assistant (GitHub Copilot, Claude Code, Claude Desktop, Cursor, …) inspect content types and their fields, and read — and optionally write — Velocity (VTL) files in a dotCMS instance.
4
+
5
+ - **Read-only by default.** Write tools are only exposed when you opt in.
6
+ - **Writes are plan-first.** A change is planned and shown as a diff before anything is saved, and is limited to folders you allow.
7
+ - **No install step.** Runs over stdio through `npx`.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js 18 or newer
12
+ - A dotCMS instance reachable from the machine that runs your AI client
13
+ - A dotCMS API token (dotCMS admin → your user → *API Access Tokens*)
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ DOTCMS_URL=https://your-dotcms.example.com DOTCMS_TOKEN=your-token npx -y rpg0-dotcms-mcp
19
+ ```
20
+
21
+ The server speaks MCP over stdio, so you normally don't run it by hand — your AI client starts it using one of the configurations below.
22
+
23
+ ## Configuration
24
+
25
+ All configuration is through environment variables.
26
+
27
+ | Variable | Default | Purpose |
28
+ | --- | --- | --- |
29
+ | `DOTCMS_URL` | `http://localhost:8080` | Base URL of the dotCMS instance |
30
+ | `DOTCMS_TOKEN` | *(none)* | API token, sent as a `Bearer` token |
31
+ | `DOTCMS_ALLOW_WRITE` | `false` | Set to `true` to expose the write tools |
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 |
35
+ | `DOTCMS_PLANS_DIR` | `~/.dotcms-mcp/plans` | Where pending change plans are stored |
36
+
37
+ ## Client setup
38
+
39
+ ### GitHub Copilot (VS Code)
40
+
41
+ Create `.vscode/mcp.json` in your workspace, then start the server from the editor and use Copilot Chat in Agent mode. VS Code prompts for the token once and stores it securely.
42
+
43
+ ```json
44
+ {
45
+ "inputs": [
46
+ {
47
+ "id": "dotcms-token",
48
+ "type": "promptString",
49
+ "description": "dotCMS API token",
50
+ "password": true
51
+ }
52
+ ],
53
+ "servers": {
54
+ "dotcms": {
55
+ "type": "stdio",
56
+ "command": "npx",
57
+ "args": ["-y", "rpg0-dotcms-mcp"],
58
+ "env": {
59
+ "DOTCMS_URL": "https://your-dotcms.example.com",
60
+ "DOTCMS_TOKEN": "${input:dotcms-token}"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### Claude Code
68
+
69
+ ```bash
70
+ claude mcp add dotcms --env DOTCMS_URL=https://your-dotcms.example.com --env DOTCMS_TOKEN=your-token -- npx -y rpg0-dotcms-mcp
71
+ ```
72
+
73
+ ### Claude Desktop, Cursor and other clients
74
+
75
+ Add the server to the client's MCP configuration:
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "dotcms": {
81
+ "command": "npx",
82
+ "args": ["-y", "rpg0-dotcms-mcp"],
83
+ "env": {
84
+ "DOTCMS_URL": "https://your-dotcms.example.com",
85
+ "DOTCMS_TOKEN": "your-token"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ## Tools
93
+
94
+ ### Always available (read-only)
95
+
96
+ | Tool | What it does |
97
+ | --- | --- |
98
+ | `list_content_types` | Lists content types, with optional name filter, base-type filter and paging |
99
+ | `get_content_type_fields` | Returns the fields of a content type (variable, type, data type, required/unique/indexed flags, field variables, relationships). Layout-only fields are hidden unless requested |
100
+ | `list_vtl_files` | Finds VTL files by folder and/or file name, with paging |
101
+ | `read_vtl_file` | Returns the source of a VTL file by path or identifier, optionally for a specific site or language |
102
+
103
+ The two file tools only return files whose extension is listed in `DOTCMS_READ_EXTENSIONS`.
104
+
105
+ ### Only with `DOTCMS_ALLOW_WRITE=true`
106
+
107
+ | Tool | What it does |
108
+ | --- | --- |
109
+ | `plan_vtl_change` | Prepares a create or update of a VTL file and returns a plan: target, publish flag, line diff and warnings. Writes nothing |
110
+ | `apply_vtl_change` | Applies a previously created plan by its `planId` |
111
+
112
+ ## How writing works
113
+
114
+ 1. The assistant calls `plan_vtl_change` with the full new file content. The server compares it with the current file and stores a plan.
115
+ 2. The plan is shown: instance, site, path, language, whether it publishes, and the changed lines.
116
+ 3. The assistant calls `apply_vtl_change` with the `planId`. Only what the plan contains can be written.
117
+
118
+ Safeguards:
119
+
120
+ - Paths outside `DOTCMS_WRITE_ROOTS` are rejected.
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.
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.
125
+
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.
127
+
128
+ ### Optional: confirmation hook for Claude Code
129
+
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`:
131
+
132
+ ```json
133
+ {
134
+ "hooks": {
135
+ "PreToolUse": [
136
+ {
137
+ "matcher": "mcp__.*__apply_vtl_change",
138
+ "hooks": [
139
+ {
140
+ "type": "command",
141
+ "command": "npx -y -p rpg0-dotcms-mcp rpg0-dotcms-mcp-confirm-write"
142
+ }
143
+ ]
144
+ }
145
+ ]
146
+ }
147
+ }
148
+ ```
149
+
150
+ ## Troubleshooting
151
+
152
+ - **`Cannot reach dotCMS at …`** — the instance isn't running or isn't reachable from this machine. Check `DOTCMS_URL`.
153
+ - **401 / 403 responses** — the token is missing, expired, or belongs to a different instance. Tokens are per instance.
154
+ - **Write tools are missing** — set `DOTCMS_ALLOW_WRITE=true` and restart the server.
155
+ - **A write is rejected** — the path is outside `DOTCMS_WRITE_ROOTS`, or the plan expired; create a new plan.
156
+
157
+ ## Security notes
158
+
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`.
161
+ - Never commit the token. Prefer your client's secret prompt or an environment variable.
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,7 +1,29 @@
1
1
  {
2
2
  "name": "rpg0-dotcms-mcp",
3
- "version": "1.1.0",
4
- "description": "MCP server for inspecting dotCMS content types and their fields, and reading/writing VTL files",
3
+ "version": "1.1.2",
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
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "dotcms",
10
+ "cms",
11
+ "velocity",
12
+ "vtl",
13
+ "content-types",
14
+ "copilot",
15
+ "claude",
16
+ "ai"
17
+ ],
18
+ "homepage": "https://github.com/DenysDatsiv/dotcms-mcp#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/DenysDatsiv/dotcms-mcp.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/DenysDatsiv/dotcms-mcp/issues"
25
+ },
26
+ "author": "Denys Datsiv",
5
27
  "type": "module",
6
28
  "main": "index.js",
7
29
  "bin": {
@@ -13,6 +35,6 @@
13
35
  "engines": { "node": ">=18" },
14
36
  "dependencies": {
15
37
  "@modelcontextprotocol/sdk": "^1.12.0",
16
- "zod": "^3.23.8"
38
+ "zod": "^4.6.6"
17
39
  }
18
40
  }