rpg0-dotcms-mcp 0.0.0-stage → 1.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/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # rpg0-dotcms-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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_PLANS_DIR` | `~/.dotcms-mcp/plans` | Where pending change plans are stored |
34
+
35
+ ## Client setup
36
+
37
+ ### GitHub Copilot (VS Code)
38
+
39
+ 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.
40
+
41
+ ```json
42
+ {
43
+ "inputs": [
44
+ {
45
+ "id": "dotcms-token",
46
+ "type": "promptString",
47
+ "description": "dotCMS API token",
48
+ "password": true
49
+ }
50
+ ],
51
+ "servers": {
52
+ "dotcms": {
53
+ "type": "stdio",
54
+ "command": "npx",
55
+ "args": ["-y", "rpg0-dotcms-mcp"],
56
+ "env": {
57
+ "DOTCMS_URL": "https://your-dotcms.example.com",
58
+ "DOTCMS_TOKEN": "${input:dotcms-token}"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Claude Code
66
+
67
+ ```bash
68
+ claude mcp add dotcms --env DOTCMS_URL=https://your-dotcms.example.com --env DOTCMS_TOKEN=your-token -- npx -y rpg0-dotcms-mcp
69
+ ```
70
+
71
+ ### Claude Desktop, Cursor and other clients
72
+
73
+ Add the server to the client's MCP configuration:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "dotcms": {
79
+ "command": "npx",
80
+ "args": ["-y", "rpg0-dotcms-mcp"],
81
+ "env": {
82
+ "DOTCMS_URL": "https://your-dotcms.example.com",
83
+ "DOTCMS_TOKEN": "your-token"
84
+ }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ ## Tools
91
+
92
+ ### Always available (read-only)
93
+
94
+ | Tool | What it does |
95
+ | --- | --- |
96
+ | `list_content_types` | Lists content types, with optional name filter, base-type filter and paging |
97
+ | `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 |
98
+ | `list_vtl_files` | Finds VTL files by folder and/or file name, with paging |
99
+ | `read_vtl_file` | Returns the source of a VTL file by path or identifier, optionally for a specific site or language |
100
+
101
+ ### Only with `DOTCMS_ALLOW_WRITE=true`
102
+
103
+ | Tool | What it does |
104
+ | --- | --- |
105
+ | `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 |
106
+ | `apply_vtl_change` | Applies a previously created plan by its `planId` |
107
+
108
+ ## How writing works
109
+
110
+ 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.
111
+ 2. The plan is shown: instance, site, path, language, whether it publishes, and the changed lines.
112
+ 3. The assistant calls `apply_vtl_change` with the `planId`. Only what the plan contains can be written.
113
+
114
+ Safeguards:
115
+
116
+ - Paths outside `DOTCMS_WRITE_ROOTS` are rejected.
117
+ - A plan is valid for 30 minutes and can be used once.
118
+ - By default a change is saved as a working version; the live file is only replaced when publishing is explicitly requested.
119
+
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.
121
+
122
+ ### Optional: confirmation hook for Claude Code
123
+
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`:
125
+
126
+ ```json
127
+ {
128
+ "hooks": {
129
+ "PreToolUse": [
130
+ {
131
+ "matcher": "mcp__dotcms__apply_vtl_change",
132
+ "hooks": [
133
+ {
134
+ "type": "command",
135
+ "command": "npx -y -p rpg0-dotcms-mcp rpg0-dotcms-mcp-confirm-write"
136
+ }
137
+ ]
138
+ }
139
+ ]
140
+ }
141
+ }
142
+ ```
143
+
144
+ ## Troubleshooting
145
+
146
+ - **`Cannot reach dotCMS at …`** — the instance isn't running or isn't reachable from this machine. Check `DOTCMS_URL`.
147
+ - **401 / 403 responses** — the token is missing, expired, or belongs to a different instance. Tokens are per instance.
148
+ - **Write tools are missing** — set `DOTCMS_ALLOW_WRITE=true` and restart the server.
149
+ - **A write is rejected** — the path is outside `DOTCMS_WRITE_ROOTS`, or the plan expired; create a new plan.
150
+
151
+ ## Security notes
152
+
153
+ - Give the token only the permissions the assistant needs; use a read-only user if you don't need writes.
154
+ - Never commit the token. Prefer your client's secret prompt or an environment variable.
155
+ - The server sends requests only to `DOTCMS_URL`.
package/dotcms.js ADDED
@@ -0,0 +1,210 @@
1
+ // Shared dotCMS client used by the MCP server (index.js) and the confirmation hook.
2
+ //
3
+ // Config (env):
4
+ // DOTCMS_URL base URL of the dotCMS instance
5
+ // DOTCMS_TOKEN API token
6
+ // DOTCMS_ALLOW_WRITE "true" to expose the create/update tools (default: read-only)
7
+ // DOTCMS_WRITE_ROOTS comma-separated folders the write tools are limited to
8
+ // (default: /application/apivtl/,/application/vtl/custom-fields/)
9
+
10
+ export const BASE_URL = (process.env.DOTCMS_URL || "http://localhost:8080").replace(/\/+$/, "");
11
+ export const TOKEN = process.env.DOTCMS_TOKEN || "";
12
+ export const ALLOW_WRITE = /^(true|1|yes)$/i.test(process.env.DOTCMS_ALLOW_WRITE || "");
13
+ export const WRITE_ROOTS = (
14
+ process.env.DOTCMS_WRITE_ROOTS || "/application/apivtl/,/application/vtl/custom-fields/"
15
+ )
16
+ .split(",")
17
+ .map((root) => root.trim().replace(/^\/+|\/+$/g, ""))
18
+ .filter(Boolean)
19
+ .map((root) => `/${root}/`);
20
+
21
+ const authHeaders = (extra = {}) => (TOKEN ? { ...extra, Authorization: `Bearer ${TOKEN}` } : extra);
22
+
23
+ async function send(path, init = {}) {
24
+ try {
25
+ return await fetch(BASE_URL + path, { ...init, signal: AbortSignal.timeout(30_000) });
26
+ } catch (err) {
27
+ throw new Error(`Cannot reach dotCMS at ${BASE_URL}: ${err.message}`);
28
+ }
29
+ }
30
+
31
+ /** JSON request against the dotCMS REST API. `body` may be an object (sent as JSON) or FormData. */
32
+ export async function api(path, { method = "GET", params = {}, body } = {}) {
33
+ const query = new URLSearchParams();
34
+ for (const [key, value] of Object.entries(params)) {
35
+ if (value !== undefined && value !== null && value !== "") query.set(key, String(value));
36
+ }
37
+ const url = path + (query.size ? `?${query}` : "");
38
+
39
+ const isForm = body instanceof FormData;
40
+ const headers = authHeaders({ Accept: "application/json" });
41
+ if (body && !isForm) headers["Content-Type"] = "application/json";
42
+
43
+ const res = await send(url, {
44
+ method,
45
+ headers,
46
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
47
+ });
48
+ const text = await res.text();
49
+ if (!res.ok) {
50
+ const hint =
51
+ res.status === 401 || res.status === 403
52
+ ? " Check DOTCMS_TOKEN and the token user's permissions."
53
+ : res.status === 404
54
+ ? " Check the name/id and DOTCMS_URL."
55
+ : "";
56
+ throw new Error(`dotCMS responded ${res.status} ${res.statusText}.${hint} ${text.slice(0, 400)}`);
57
+ }
58
+ try {
59
+ return JSON.parse(text);
60
+ } catch {
61
+ throw new Error(`dotCMS returned a non-JSON response from ${path}`);
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Files
67
+ // ---------------------------------------------------------------------------
68
+
69
+ // Lucene special characters, except "/" which dotCMS accepts unescaped inside wildcards.
70
+ export const escapeLucene = (s) => s.replace(/[+\-!(){}[\]^"~*?:\\&|]/g, "\\$&");
71
+
72
+ export async function searchFiles(clauses, { limit = 50, offset = 0 } = {}) {
73
+ const data = await api("/api/content/_search", {
74
+ method: "POST",
75
+ body: { query: ["+baseType:4", ...clauses].join(" "), sort: "path asc", limit, offset },
76
+ });
77
+ const entity = data.entity || {};
78
+ return { total: entity.resultsSize ?? 0, files: entity.jsonObjectView?.contentlets || [] };
79
+ }
80
+
81
+ export const normalizePath = (path) => "/" + String(path).trim().replace(/^\/+/, "");
82
+
83
+ /**
84
+ * All versions (one per site/language) of the file at an exact path, or with an identifier.
85
+ */
86
+ export async function findFiles({ path, identifier, site }) {
87
+ let files;
88
+ if (identifier) {
89
+ ({ files } = await searchFiles([`+identifier:${escapeLucene(identifier)}`], { limit: 20 }));
90
+ } else {
91
+ const wanted = normalizePath(path).toLowerCase();
92
+ ({ files } = await searchFiles([`+path:*${escapeLucene(wanted)}`], { limit: 50 }));
93
+ files = files.filter((f) => f.path?.toLowerCase() === wanted);
94
+ }
95
+ if (site) files = files.filter((f) => f.hostName?.toLowerCase() === site.toLowerCase());
96
+ return files;
97
+ }
98
+
99
+ /** Pick one language version out of findFiles() results; throws a helpful error otherwise. */
100
+ export function pickVersion(files, { what, site, languageId }) {
101
+ if (!files.length) throw new Error(`File not found: ${what}${site ? ` on site ${site}` : ""}.`);
102
+ const sites = [...new Set(files.map((f) => f.hostName))];
103
+ if (sites.length > 1) {
104
+ throw new Error(`This path exists on several sites (${sites.join(", ")}). Pass 'site'.`);
105
+ }
106
+ const languages = [...new Set(files.map((f) => f.languageId))].sort((a, b) => a - b);
107
+ const file = files.find((f) => f.languageId === (languageId ?? languages[0]));
108
+ if (!file) {
109
+ throw new Error(
110
+ `No version of this file in language ${languageId}. Available language ids: ${languages.join(", ")}.`,
111
+ );
112
+ }
113
+ return { file, languages };
114
+ }
115
+
116
+ export async function downloadFile(file) {
117
+ const name = encodeURIComponent(file.fileName || file.name || "file.vtl");
118
+ // Inode first: it pins the exact language version. The identifier URL
119
+ // would silently return the default-language version instead.
120
+ const candidates = [
121
+ `/dA/${file.inode}/fileAsset/${name}`,
122
+ `/contentAsset/raw-data/${file.inode}/fileAsset`,
123
+ `/dA/${file.identifier}/fileAsset/${name}?language_id=${file.languageId ?? ""}`,
124
+ ];
125
+ const tried = [];
126
+ for (const path of candidates) {
127
+ const res = await send(path, { headers: authHeaders({ Accept: "text/plain, */*" }) });
128
+ const type = res.headers.get("content-type") || "";
129
+ // dotCMS answers with its HTML 404 page when the file may not be served.
130
+ if (res.ok && !(type.includes("text/html") && !/\.html?$/i.test(name))) {
131
+ return await res.text();
132
+ }
133
+ tried.push(`${path} -> ${res.status}`);
134
+ await res.body?.cancel();
135
+ }
136
+ throw new Error(
137
+ `dotCMS refused to serve the file contents (${tried.join("; ")}). ` +
138
+ (TOKEN
139
+ ? "The token user probably has no read permission on this file."
140
+ : "VTL files are not served to anonymous users: set DOTCMS_TOKEN."),
141
+ );
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Writing (API VTL files only)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Validate a path for the write tools and split it into folder + file name. */
149
+ export function parseWritePath(path) {
150
+ const full = normalizePath(path);
151
+ if (!/^[A-Za-z0-9._\-/]+$/.test(full) || full.includes("..") || full.includes("//")) {
152
+ throw new Error(`Invalid path '${path}': use only letters, digits, '.', '_', '-' and '/'.`);
153
+ }
154
+ const root = WRITE_ROOTS.find((r) => full.toLowerCase().startsWith(r.toLowerCase()));
155
+ if (!root) {
156
+ throw new Error(`Writing is limited to ${WRITE_ROOTS.join(" and ")} — '${full}' is outside of them.`);
157
+ }
158
+ if (!full.toLowerCase().endsWith(".vtl")) {
159
+ throw new Error(`Only .vtl files can be written, got '${full}'.`);
160
+ }
161
+ const cut = full.lastIndexOf("/");
162
+ const folder = full.slice(0, cut + 1);
163
+ const fileName = full.slice(cut + 1);
164
+ if (!fileName.replace(/\.vtl$/i, "")) throw new Error(`Missing file name in '${full}'.`);
165
+ if (folder.toLowerCase() === root.toLowerCase()) {
166
+ throw new Error(`Put the file in its own folder, e.g. ${root}my-name/${fileName}.`);
167
+ }
168
+ const kind = /\/apivtl\//i.test(root) ? "api" : /\/custom-fields\//i.test(root) ? "custom-field" : "vtl";
169
+ return { full, folder, fileName, root, kind };
170
+ }
171
+
172
+ /**
173
+ * Create a new file asset, or a new version of an existing one (when `identifier` is given),
174
+ * through the dotCMS workflow API. Not published unless `publish` is true.
175
+ */
176
+ export async function saveFile({ site, folder, fileName, content, identifier, languageId, publish }) {
177
+ const contentlet = {
178
+ contentType: "FileAsset",
179
+ title: fileName,
180
+ fileName,
181
+ hostFolder: `${site}:${folder}`,
182
+ };
183
+ if (identifier) contentlet.identifier = identifier;
184
+ if (languageId) contentlet.languageId = languageId;
185
+
186
+ const form = new FormData();
187
+ form.append("json", JSON.stringify({ contentlet }));
188
+ form.append("file", new Blob([content], { type: "text/plain" }), fileName);
189
+
190
+ const action = publish ? "PUBLISH" : identifier ? "EDIT" : "NEW";
191
+ const data = await api(`/api/v1/workflow/actions/default/fire/${action}`, {
192
+ method: "PUT",
193
+ params: { indexPolicy: "WAIT_FOR" },
194
+ body: form,
195
+ });
196
+ return { action, entity: data.entity || {} };
197
+ }
198
+
199
+ /** Make sure a folder exists. Returns an error message instead of throwing. */
200
+ export async function ensureFolder(site, folder) {
201
+ try {
202
+ await api(`/api/v1/folder/createfolders/${encodeURIComponent(site)}`, {
203
+ method: "POST",
204
+ body: [folder],
205
+ });
206
+ return null;
207
+ } catch (err) {
208
+ return err.message;
209
+ }
210
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code PreToolUse hook: the human check of the plan -> human check -> write flow.
3
+ //
4
+ // apply_vtl_change is the only dotcms MCP tool that writes. This hook never lets it through
5
+ // on its own: it loads the plan the call refers to and answers "ask", so Claude Code shows
6
+ // the plan (target, publish flag, diff, warnings) and the write only happens if the user
7
+ // approves it. A call without a valid, unexpired plan is denied. Other tools are left alone.
8
+
9
+ import fs from "node:fs";
10
+ import { loadPlan, renderPlan } from "../plans.js";
11
+
12
+ const decide = (permissionDecision, reason) => {
13
+ process.stdout.write(
14
+ JSON.stringify({
15
+ hookSpecificOutput: {
16
+ hookEventName: "PreToolUse",
17
+ permissionDecision,
18
+ permissionDecisionReason: reason,
19
+ },
20
+ }),
21
+ );
22
+ process.exit(0);
23
+ };
24
+
25
+ let input;
26
+ try {
27
+ input = JSON.parse(fs.readFileSync(0, "utf8"));
28
+ } catch {
29
+ process.exit(0); // not a hook payload: stay out of the way
30
+ }
31
+ if (input.tool_name !== "mcp__dotcms__apply_vtl_change") process.exit(0);
32
+
33
+ let plan;
34
+ try {
35
+ plan = loadPlan(input.tool_input?.planId);
36
+ } catch (err) {
37
+ decide("deny", `dotCMS write blocked: ${err.message}`);
38
+ }
39
+
40
+ decide("ask", `Approve this dotCMS change?\n\n${renderPlan(plan)}`);
package/index.js ADDED
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+ // Local MCP server for dotCMS.
3
+ //
4
+ // Read-only tools (always available):
5
+ // list_content_types, get_content_type_fields, list_vtl_files, read_vtl_file
6
+ // Write tools (only when DOTCMS_ALLOW_WRITE=true, limited to DOTCMS_WRITE_ROOTS):
7
+ // plan_vtl_change (plans only, writes nothing) -> apply_vtl_change (writes, human-confirmed)
8
+ //
9
+ // Config: see dotcms.js.
10
+
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ import {
15
+ ALLOW_WRITE,
16
+ BASE_URL,
17
+ TOKEN,
18
+ WRITE_ROOTS,
19
+ api,
20
+ downloadFile,
21
+ ensureFolder,
22
+ escapeLucene,
23
+ findFiles,
24
+ parseWritePath,
25
+ pickVersion,
26
+ saveFile,
27
+ searchFiles,
28
+ } from "./dotcms.js";
29
+ import { deletePlan, diffLines, loadPlan, renderPlan, savePlan } from "./plans.js";
30
+
31
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
32
+ const MAX_FILE_CHARS = 200_000;
33
+
34
+ // Field classes that only describe the edit-form layout, not real data.
35
+ const LAYOUT_FIELDS = new Set([
36
+ "RowField",
37
+ "ColumnField",
38
+ "TabDividerField",
39
+ "LineDividerField",
40
+ "ColumnBreakField",
41
+ ]);
42
+
43
+ const shortClass = (clazz = "") => clazz.split(".").pop().replace(/^Immutable/, "");
44
+
45
+ function mapField(field) {
46
+ const out = {
47
+ name: field.name,
48
+ variable: field.variable,
49
+ type: field.fieldType || shortClass(field.clazz).replace(/Field$/, ""),
50
+ class: shortClass(field.clazz),
51
+ dataType: field.dataType,
52
+ required: !!field.required,
53
+ unique: !!field.unique,
54
+ indexed: !!field.indexed,
55
+ searchable: !!field.searchable,
56
+ listed: !!field.listed,
57
+ system: !!field.fixed,
58
+ };
59
+ if (field.hint) out.hint = field.hint;
60
+ if (field.defaultValue) out.defaultValue = field.defaultValue;
61
+ if (field.values) out.values = field.values;
62
+ if (field.regexCheck) out.regexCheck = field.regexCheck;
63
+ if (field.relationships) out.relationships = field.relationships;
64
+ if (field.categories) out.categories = field.categories;
65
+ if (field.fieldVariables?.length) {
66
+ out.fieldVariables = Object.fromEntries(field.fieldVariables.map((v) => [v.key, v.value]));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const mapFile = (f) => ({
72
+ path: f.path,
73
+ name: f.fileName || f.name,
74
+ site: f.hostName,
75
+ identifier: f.identifier,
76
+ inode: f.inode,
77
+ languageId: f.languageId,
78
+ size: f.size,
79
+ modDate: f.modDate,
80
+ live: f.live,
81
+ description: f.description || undefined,
82
+ });
83
+
84
+ const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
85
+ const fail = (err) => ({ isError: true, content: [{ type: "text", text: err.message }] });
86
+
87
+ const server = new McpServer({ name: "dotcms", version: "1.1.0" });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Read-only tools
91
+ // ---------------------------------------------------------------------------
92
+
93
+ server.registerTool(
94
+ "list_content_types",
95
+ {
96
+ title: "List dotCMS content types",
97
+ description:
98
+ "Read-only. List content types in dotCMS (name, variable, base type, number of content entries). " +
99
+ "Use it to find the variable name of a content type before reading its fields.",
100
+ inputSchema: {
101
+ filter: z.string().optional().describe("Filter by name or variable (substring)"),
102
+ type: z
103
+ .enum(["CONTENT", "WIDGET", "FORM", "FILEASSET", "HTMLPAGE", "PERSONA", "VANITY_URL", "KEY_VALUE", "DOTASSET"])
104
+ .optional()
105
+ .describe("Only content types of this base type"),
106
+ page: z.number().int().min(1).default(1).describe("Page number"),
107
+ perPage: z.number().int().min(1).max(100).default(50).describe("Items per page"),
108
+ },
109
+ annotations: READ_ONLY,
110
+ },
111
+ async ({ filter, type, page, perPage }) => {
112
+ try {
113
+ const data = await api("/api/v1/contenttype", {
114
+ params: { filter, type, page, per_page: perPage, orderby: "name", direction: "ASC" },
115
+ });
116
+ const types = (data.entity || []).map((ct) => ({
117
+ name: ct.name,
118
+ variable: ct.variable,
119
+ id: ct.id,
120
+ baseType: ct.baseType,
121
+ description: ct.description || undefined,
122
+ entries: ct.nEntries,
123
+ system: !!ct.system,
124
+ }));
125
+ return ok({
126
+ page,
127
+ perPage,
128
+ total: data.pagination?.totalEntries,
129
+ count: types.length,
130
+ contentTypes: types,
131
+ });
132
+ } catch (err) {
133
+ return fail(err);
134
+ }
135
+ },
136
+ );
137
+
138
+ server.registerTool(
139
+ "get_content_type_fields",
140
+ {
141
+ title: "Get fields of a dotCMS content type",
142
+ description:
143
+ "Read-only. Return the fields of a dotCMS content type: name, variable, field type, data type, " +
144
+ "required/unique/indexed/searchable/listed flags, hint, default value, allowed values, " +
145
+ "relationships and field variables.",
146
+ inputSchema: {
147
+ contentType: z.string().min(1).describe("Content type variable name (e.g. 'Blog') or its id"),
148
+ includeLayout: z
149
+ .boolean()
150
+ .default(false)
151
+ .describe("Also include layout-only fields (rows, columns, tab dividers)"),
152
+ },
153
+ annotations: READ_ONLY,
154
+ },
155
+ async ({ contentType, includeLayout }) => {
156
+ try {
157
+ const data = await api(`/api/v1/contenttype/id/${encodeURIComponent(contentType)}`);
158
+ const ct = data.entity || {};
159
+ const fields = (ct.fields || [])
160
+ .filter((f) => includeLayout || !LAYOUT_FIELDS.has(shortClass(f.clazz)))
161
+ .map(mapField);
162
+ return ok({
163
+ name: ct.name,
164
+ variable: ct.variable,
165
+ id: ct.id,
166
+ baseType: ct.baseType,
167
+ description: ct.description || undefined,
168
+ urlMapPattern: ct.urlMapPattern || undefined,
169
+ detailPage: ct.detailPage || undefined,
170
+ fieldCount: fields.length,
171
+ fields,
172
+ });
173
+ } catch (err) {
174
+ return fail(err);
175
+ }
176
+ },
177
+ );
178
+
179
+ server.registerTool(
180
+ "list_vtl_files",
181
+ {
182
+ title: "List VTL files in dotCMS",
183
+ description:
184
+ "Read-only. Find Velocity (.vtl) files stored in dotCMS as file assets: container, template, " +
185
+ "theme, custom-field and scripting-API VTL files. Returns path, site, identifier, language, size and modification date.",
186
+ inputSchema: {
187
+ folder: z
188
+ .string()
189
+ .optional()
190
+ .describe("Only files whose path contains this folder, e.g. '/application/vtl' or 'containers/banner'"),
191
+ 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')"),
193
+ page: z.number().int().min(1).default(1).describe("Page number"),
194
+ perPage: z.number().int().min(1).max(100).default(50).describe("Items per page"),
195
+ },
196
+ annotations: READ_ONLY,
197
+ },
198
+ async ({ folder, name, extension, page, perPage }) => {
199
+ try {
200
+ const ext = escapeLucene(extension.replace(/^\./, "").toLowerCase());
201
+ const clauses = [`+path:*.${ext}`];
202
+ if (folder) {
203
+ const f = escapeLucene(folder.toLowerCase().replace(/^\/+|\/+$/g, ""));
204
+ clauses.push(`+path:*${f}/*`);
205
+ }
206
+ if (name) clauses.push(`+title:*${escapeLucene(name.toLowerCase())}*`);
207
+
208
+ const { total, files } = await searchFiles(clauses, {
209
+ limit: perPage,
210
+ offset: (page - 1) * perPage,
211
+ });
212
+ return ok({ page, perPage, total, count: files.length, files: files.map(mapFile) });
213
+ } catch (err) {
214
+ return fail(err);
215
+ }
216
+ },
217
+ );
218
+
219
+ server.registerTool(
220
+ "read_vtl_file",
221
+ {
222
+ title: "Read a VTL file from dotCMS",
223
+ description:
224
+ "Read-only. Return the source of a Velocity (.vtl) file stored in dotCMS. Pass either the full " +
225
+ "path (e.g. '/application/containers/banner/banner.vtl') or the file identifier from list_vtl_files.",
226
+ inputSchema: {
227
+ path: z.string().optional().describe("Full path of the file inside the site"),
228
+ identifier: z.string().optional().describe("File identifier (alternative to path)"),
229
+ site: z.string().optional().describe("Site host name, if the same path exists on several sites"),
230
+ languageId: z
231
+ .number()
232
+ .int()
233
+ .optional()
234
+ .describe("Language id, if the file has several language versions (default: the lowest id, usually the default language)"),
235
+ },
236
+ annotations: READ_ONLY,
237
+ },
238
+ async ({ path, identifier, site, languageId }) => {
239
+ try {
240
+ if (!path && !identifier) throw new Error("Pass either 'path' or 'identifier'.");
241
+ const files = await findFiles({ path, identifier, site });
242
+ const { file, languages } = pickVersion(files, { what: identifier || path, site, languageId });
243
+
244
+ const content = await downloadFile(file);
245
+ const truncated = content.length > MAX_FILE_CHARS;
246
+ return ok({
247
+ ...mapFile(file),
248
+ availableLanguageIds: languages,
249
+ truncated,
250
+ content: truncated ? content.slice(0, MAX_FILE_CHARS) : content,
251
+ });
252
+ } catch (err) {
253
+ return fail(err);
254
+ }
255
+ },
256
+ );
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Write tools — opt-in, limited to WRITE_ROOTS. Plan first, then the
260
+ // human check (hooks/confirm-write.mjs), then the write.
261
+ // ---------------------------------------------------------------------------
262
+
263
+ if (ALLOW_WRITE) {
264
+ const roots = WRITE_ROOTS.join(" and ");
265
+
266
+ server.registerTool(
267
+ "plan_vtl_change",
268
+ {
269
+ title: "Plan a VTL file change in dotCMS",
270
+ description:
271
+ "Step 1 of 2 for every write. Does NOT change dotCMS: it validates the target, compares the new " +
272
+ "source with the current file and returns a plan (action, target, diff, warnings) with a planId. " +
273
+ `Writable locations: ${roots} — scripting-API files (e.g. /application/apivtl/my-endpoint/get.vtl, ` +
274
+ "file name = HTTP method) and custom fields (e.g. /application/vtl/custom-fields/my-field/code.vtl). " +
275
+ "Whether it is a create or an update is detected from whether the file exists. " +
276
+ "Show the returned plan to the user, then call apply_vtl_change with the planId.",
277
+ inputSchema: {
278
+ path: z.string().min(1).describe(`Full path of the file, inside ${roots}`),
279
+ site: z
280
+ .string()
281
+ .optional()
282
+ .describe("Site host name. Required for a new file, or when the path exists on several sites"),
283
+ content: z
284
+ .string()
285
+ .min(1)
286
+ .max(MAX_FILE_CHARS)
287
+ .describe("Full Velocity source of the file (the whole file, not a patch)"),
288
+ languageId: z.number().int().optional().describe("Language id (default: the default language)"),
289
+ publish: z
290
+ .boolean()
291
+ .default(false)
292
+ .describe("Publish immediately. Default false: save a working version only, the live file stays as it is"),
293
+ },
294
+ annotations: READ_ONLY,
295
+ },
296
+ async ({ path, site, content, languageId, publish }) => {
297
+ try {
298
+ const target = parseWritePath(path);
299
+ const files = await findFiles({ path: target.full, site });
300
+
301
+ let action = "CREATE";
302
+ let base = null;
303
+ let current = "";
304
+ if (files.length) {
305
+ const { file } = pickVersion(files, { what: target.full, site, languageId });
306
+ action = "UPDATE";
307
+ base = { identifier: file.identifier, inode: file.inode, live: !!file.live };
308
+ site = file.hostName;
309
+ languageId = file.languageId;
310
+ current = await downloadFile(file);
311
+ } else if (!site) {
312
+ throw new Error(`${target.full} does not exist yet. Pass 'site' to plan creating it.`);
313
+ }
314
+
315
+ const changes = diffLines(current, content);
316
+ if (action === "UPDATE" && !changes.length) {
317
+ throw new Error("Nothing to do: the new content is identical to the current file.");
318
+ }
319
+
320
+ const warnings = [];
321
+ if (publish) warnings.push("publish is on — the change goes live as soon as it is applied.");
322
+ if (/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./.test(content)) {
323
+ warnings.push("the content contains what looks like an API token (JWT).");
324
+ }
325
+ const hints = [];
326
+ if (action === "UPDATE" && !publish && base.live) {
327
+ hints.push("The file has a live version: it keeps serving the old content until this change is published.");
328
+ }
329
+ if (target.kind === "custom-field" && action === "CREATE") {
330
+ hints.push(`To use it, set the Custom-Field value to: #dotParse("//${site}${target.full}")`);
331
+ }
332
+ if (target.kind === "api" && action === "CREATE") {
333
+ const endpoint = target.folder.slice(target.root.length).replace(/\/$/, "");
334
+ hints.push(
335
+ `Once published it answers ${target.fileName.replace(/\.vtl$/i, "").toUpperCase()} ${BASE_URL}/api/vtl/${endpoint}`,
336
+ );
337
+ }
338
+
339
+ const plan = savePlan({
340
+ action,
341
+ kind: target.kind,
342
+ instance: BASE_URL,
343
+ site,
344
+ path: target.full,
345
+ folder: target.folder,
346
+ fileName: target.fileName,
347
+ languageId,
348
+ publish,
349
+ content,
350
+ base,
351
+ changes,
352
+ warnings,
353
+ hints,
354
+ });
355
+
356
+ return {
357
+ content: [
358
+ {
359
+ type: "text",
360
+ text:
361
+ renderPlan(plan) +
362
+ `\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.",
364
+ },
365
+ ],
366
+ };
367
+ } catch (err) {
368
+ return fail(err);
369
+ }
370
+ },
371
+ );
372
+
373
+ server.registerTool(
374
+ "apply_vtl_change",
375
+ {
376
+ title: "Apply a planned VTL file change to dotCMS",
377
+ description:
378
+ "Step 2 of 2. WRITES to dotCMS: creates or updates the VTL file exactly as described by a plan from " +
379
+ "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; " +
381
+ "it is rejected if the file changed in dotCMS after the plan was made.",
382
+ inputSchema: {
383
+ planId: z.string().min(1).describe("planId returned by plan_vtl_change"),
384
+ },
385
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
386
+ },
387
+ async ({ planId }) => {
388
+ try {
389
+ const plan = loadPlan(planId);
390
+ if (plan.instance !== BASE_URL) {
391
+ throw new Error(`Plan ${plan.id} was made for ${plan.instance}, but this server is connected to ${BASE_URL}.`);
392
+ }
393
+
394
+ // The plan must still describe reality.
395
+ const files = (await findFiles({ path: plan.path, site: plan.site })).filter(
396
+ (f) => plan.action === "CREATE" || f.languageId === plan.languageId,
397
+ );
398
+ if (plan.action === "CREATE" && files.length) {
399
+ throw new Error(`${plan.path} was created on ${plan.site} after the plan was made. Make a new plan.`);
400
+ }
401
+ if (plan.action === "UPDATE" && files[0]?.inode !== plan.base.inode) {
402
+ throw new Error(`${plan.path} changed in dotCMS after the plan was made. Make a new plan.`);
403
+ }
404
+
405
+ const folderError = plan.action === "CREATE" ? await ensureFolder(plan.site, plan.folder) : null;
406
+ let saved;
407
+ try {
408
+ saved = await saveFile({
409
+ site: plan.site,
410
+ folder: plan.folder,
411
+ fileName: plan.fileName,
412
+ content: plan.content,
413
+ identifier: plan.base?.identifier,
414
+ languageId: plan.languageId,
415
+ publish: plan.publish,
416
+ });
417
+ } catch (err) {
418
+ if (folderError) err.message += ` (creating folder ${plan.folder} also failed: ${folderError})`;
419
+ throw err;
420
+ }
421
+ deletePlan(plan.id);
422
+
423
+ return ok({
424
+ result: `${plan.action === "CREATE" ? "Created" : "Updated"} ${plan.path} on ${plan.site}`,
425
+ planId: plan.id,
426
+ workflowAction: saved.action,
427
+ published: !!plan.publish,
428
+ note: plan.publish
429
+ ? "The file is live."
430
+ : "Saved as a working (unpublished) version. The live file is unchanged until it is published.",
431
+ identifier: saved.entity.identifier,
432
+ inode: saved.entity.inode,
433
+ languageId: saved.entity.languageId,
434
+ });
435
+ } catch (err) {
436
+ return fail(err);
437
+ }
438
+ },
439
+ );
440
+ }
441
+
442
+ await server.connect(new StdioServerTransport());
443
+ console.error(
444
+ `dotcms-mcp running (${BASE_URL}${TOKEN ? "" : ", no DOTCMS_TOKEN set"}, ` +
445
+ `${ALLOW_WRITE ? `writes enabled under ${WRITE_ROOTS.join(", ")}` : "read-only"})`,
446
+ );
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "rpg0-dotcms-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.1",
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",
27
+ "type": "module",
28
+ "main": "index.js",
29
+ "bin": {
30
+ "rpg0-dotcms-mcp": "index.js",
31
+ "rpg0-dotcms-mcp-confirm-write": "hooks/confirm-write.mjs"
32
+ },
33
+ "files": ["index.js", "dotcms.js", "plans.js", "hooks/"],
34
+ "scripts": { "start": "node index.js" },
35
+ "engines": { "node": ">=18" },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.12.0",
38
+ "zod": "^3.23.8"
39
+ }
40
+ }
package/plans.js ADDED
@@ -0,0 +1,111 @@
1
+ // Change plans: the "plan first" half of the plan -> human check -> write flow.
2
+ //
3
+ // plan_vtl_change stores a plan here; apply_vtl_change can only write what a stored
4
+ // plan contains, and the confirmation hook shows that same plan to the user.
5
+
6
+ import crypto from "node:crypto";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ // Kept outside the package folder: under npx the package lives in the npm cache, and the
12
+ // server and the confirmation hook must agree on one location however each was started.
13
+ const DIR = process.env.DOTCMS_PLANS_DIR || path.join(os.homedir(), ".dotcms-mcp", "plans");
14
+ export const PLAN_TTL_MS = 30 * 60 * 1000;
15
+ const MAX_SHOWN_LINES = 40;
16
+
17
+ const splitLines = (s) => (s === "" ? [] : s.replace(/\r\n/g, "\n").split("\n"));
18
+
19
+ /** Line diff via longest common subsequence; returns only "+ n: text" / "- n: text" lines. */
20
+ export function diffLines(oldText, newText) {
21
+ const a = splitLines(oldText);
22
+ const b = splitLines(newText);
23
+ const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
24
+ for (let i = a.length - 1; i >= 0; i--) {
25
+ for (let j = b.length - 1; j >= 0; j--) {
26
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
27
+ }
28
+ }
29
+ const out = [];
30
+ let i = 0;
31
+ let j = 0;
32
+ while (i < a.length || j < b.length) {
33
+ if (i < a.length && j < b.length && a[i] === b[j]) {
34
+ i++;
35
+ j++;
36
+ } else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
37
+ out.push(`+ ${j + 1}: ${b[j++]}`);
38
+ } else {
39
+ out.push(`- ${i + 1}: ${a[i++]}`);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const planFile = (id) => path.join(DIR, `${id}.json`);
46
+
47
+ function prune() {
48
+ if (!fs.existsSync(DIR)) return;
49
+ for (const name of fs.readdirSync(DIR)) {
50
+ const file = path.join(DIR, name);
51
+ try {
52
+ if (JSON.parse(fs.readFileSync(file, "utf8")).expiresAt < Date.now()) fs.rmSync(file);
53
+ } catch {
54
+ fs.rmSync(file, { force: true });
55
+ }
56
+ }
57
+ }
58
+
59
+ export function savePlan(plan) {
60
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
61
+ prune();
62
+ const id = crypto.randomBytes(6).toString("hex");
63
+ const stored = { ...plan, id, createdAt: Date.now(), expiresAt: Date.now() + PLAN_TTL_MS };
64
+ fs.writeFileSync(planFile(id), JSON.stringify(stored, null, 2), { mode: 0o600 });
65
+ return stored;
66
+ }
67
+
68
+ export function loadPlan(id) {
69
+ if (!/^[a-f0-9]{12}$/.test(String(id ?? ""))) {
70
+ throw new Error("Missing or malformed planId. Create a plan with plan_vtl_change first.");
71
+ }
72
+ if (!fs.existsSync(planFile(id))) {
73
+ throw new Error(`Plan ${id} does not exist (already applied, expired, or never created). Create a new plan with plan_vtl_change.`);
74
+ }
75
+ const plan = JSON.parse(fs.readFileSync(planFile(id), "utf8"));
76
+ if (plan.expiresAt < Date.now()) {
77
+ fs.rmSync(planFile(id), { force: true });
78
+ throw new Error(`Plan ${id} has expired (plans are valid for ${PLAN_TTL_MS / 60000} minutes). Create a new plan with plan_vtl_change.`);
79
+ }
80
+ return plan;
81
+ }
82
+
83
+ export const deletePlan = (id) => fs.rmSync(planFile(id), { force: true });
84
+
85
+ const KIND_LABEL = { api: "API VTL file", "custom-field": "custom field VTL file", vtl: "VTL file" };
86
+
87
+ /** The human-readable plan: shown by plan_vtl_change and again by the confirmation hook. */
88
+ export function renderPlan(plan) {
89
+ const added = plan.changes.filter((l) => l.startsWith("+")).length;
90
+ const shown =
91
+ plan.changes.length > MAX_SHOWN_LINES
92
+ ? [...plan.changes.slice(0, MAX_SHOWN_LINES), `… ${plan.changes.length - MAX_SHOWN_LINES} more changed lines`]
93
+ : plan.changes;
94
+ const minutesLeft = Math.max(0, Math.round((plan.expiresAt - Date.now()) / 60000));
95
+
96
+ return [
97
+ `PLAN ${plan.id}: ${plan.action} ${KIND_LABEL[plan.kind] || KIND_LABEL.vtl} in dotCMS`,
98
+ `Instance: ${plan.instance}`,
99
+ `Site: ${plan.site}`,
100
+ `Path: ${plan.path}`,
101
+ `Language: ${plan.languageId ?? "default"}`,
102
+ `Publish: ${plan.publish ? "YES — goes live immediately" : "no — working version only"}`,
103
+ `Size: ${splitLines(plan.content).length} lines, ${Buffer.byteLength(plan.content)} bytes`,
104
+ `Valid: ${minutesLeft} more minutes, one use`,
105
+ ...(plan.warnings.length ? ["", ...plan.warnings.map((w) => `WARNING: ${w}`)] : []),
106
+ ...(plan.hints.length ? ["", ...plan.hints] : []),
107
+ "",
108
+ plan.action === "CREATE" ? "New file:" : `Changes: +${added} / -${plan.changes.length - added} lines`,
109
+ ...shown,
110
+ ].join("\n");
111
+ }