drupal-mcp-connector 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/tools/nodes.js +12 -0
- package/src/tools/paragraphs.js +43 -0
- package/src/tools/structure.js +110 -10
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`drupal_update_paragraph`** — update an existing Paragraph entity's field values
|
|
14
|
+
in place (partial JSON:API PATCH) by bundle + UUID, so component / key-capability
|
|
15
|
+
paragraphs can be maintained end-to-end without re-embedding (DEV-114).
|
|
16
|
+
- **`drupal_update_menu_link`** — update a menu link by UUID (rename, re-weight,
|
|
17
|
+
re-target, re-parent, enable/disable). `enabled` is preserved across edits unless
|
|
18
|
+
passed explicitly (DEV-114).
|
|
19
|
+
- **`drupal_create_menu_link`** now accepts **`parent`** (nest under a parent link
|
|
20
|
+
plugin id) and **`enabled`** on create, and creates links **enabled by default** so
|
|
21
|
+
they render immediately — closing the "menu links created disabled / no parent on
|
|
22
|
+
create" gap (DEV-114).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Menu links no longer silently regress to disabled.** Every menu-link write now
|
|
26
|
+
asserts `enabled` explicitly (default true on create; the current value re-pinned on
|
|
27
|
+
update), so an unrelated edit can't drop a live link to disabled through the JSON:API
|
|
28
|
+
write path (DEV-114).
|
|
29
|
+
- **Node updates preserve the existing URL alias.** When `drupal_update_node` is called
|
|
30
|
+
without a `path`, the connector reads the current alias and re-pins it
|
|
31
|
+
(`{ alias, pathauto: 0 }`) so a save can't let Pathauto revert the alias to a stale
|
|
32
|
+
value. Pass `fields.path` to set the alias explicitly (DEV-114).
|
|
33
|
+
- **Intermittent `drupal_create_menu_link` 422 "path '/…' is inaccessible".** This is a
|
|
34
|
+
transient path-validator/access-cache race in Drupal's `LinkAccessConstraint`; menu-link
|
|
35
|
+
create/update now retries once after a short delay when it hits that specific error.
|
|
36
|
+
Prefer an `entity:node/<id>` target over `internal:/<alias>` to avoid the alias
|
|
37
|
+
resolution step entirely (DEV-114).
|
|
38
|
+
|
|
10
39
|
## [1.4.0] - 2026-06-29
|
|
11
40
|
|
|
12
41
|
### Added
|
package/README.md
CHANGED
|
@@ -71,8 +71,8 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
71
71
|
| **References** | Resolve a human name/title to an entity UUID for relationship fields |
|
|
72
72
|
| **Bulk** | Bulk create/update with per-item partial-failure reporting |
|
|
73
73
|
| **Translations** | List + create entity translations |
|
|
74
|
-
| **Paragraphs** | Create/get Paragraph components for embedding in host fields |
|
|
75
|
-
| **Structure** | Menu links + custom blocks (list/create) |
|
|
74
|
+
| **Paragraphs** | Create/update/get Paragraph components for embedding in host fields |
|
|
75
|
+
| **Structure** | Menu links (list/create/update, incl. `parent` + `enabled`) + custom blocks (list/create) |
|
|
76
76
|
| **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
|
|
77
77
|
| **Search** | Best-effort content search (title match; Search API/Solr-ready) |
|
|
78
78
|
| **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
|
package/package.json
CHANGED
package/src/tools/nodes.js
CHANGED
|
@@ -130,6 +130,12 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
130
130
|
* bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
|
|
131
131
|
* types. `moderationState` takes precedence; both are optional on update.
|
|
132
132
|
*
|
|
133
|
+
* Alias hardening: a partial update that doesn't touch `path` can still lose the
|
|
134
|
+
* node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
|
|
135
|
+
* it on save. To preserve the existing alias, when the caller supplies no `path`
|
|
136
|
+
* the current alias is read back and re-pinned (`{ alias, pathauto: 0 }`). Pass
|
|
137
|
+
* `fields.path` explicitly to set/replace the alias yourself.
|
|
138
|
+
*
|
|
133
139
|
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
|
|
134
140
|
* @returns {Promise<object>} The updated node descriptor.
|
|
135
141
|
*/
|
|
@@ -143,6 +149,12 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
143
149
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
144
150
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
|
|
145
151
|
const backend = await resolveBackend(site);
|
|
152
|
+
// Preserve the existing URL alias when the caller didn't set `path`: read the
|
|
153
|
+
// current alias and re-pin it (pathauto off) so the save can't revert it.
|
|
154
|
+
if (attributes.path === undefined) {
|
|
155
|
+
const current = await backend.getEntity({ entityType: "node", bundle: type, id });
|
|
156
|
+
if (current?.url) attributes.path = { alias: current.url, pathauto: 0 };
|
|
157
|
+
}
|
|
146
158
|
return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
147
159
|
}
|
|
148
160
|
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -84,6 +84,34 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
84
84
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Update an existing paragraph's field values. Mirrors createParagraph but
|
|
89
|
+
* targets an existing paragraph by UUID via a partial JSON:API PATCH, so only
|
|
90
|
+
* the supplied attributes are changed. The host entity's reference to the
|
|
91
|
+
* paragraph is unaffected (same UUID), so updating a paragraph in place is the
|
|
92
|
+
* way to maintain component / key-capability paragraphs without re-embedding.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} args - { site?, paragraphType, id, attributes? }.
|
|
95
|
+
* `attributes` are the paragraph field values to change, keyed by Drupal
|
|
96
|
+
* machine name (e.g. { field_body: { value, format } }).
|
|
97
|
+
* @returns {Promise<{paragraph: object, ref: {id: string, type: string},
|
|
98
|
+
* relationshipData: {type: string, id: string}, note: string}>}
|
|
99
|
+
* The updated paragraph plus the (unchanged) embedding ref.
|
|
100
|
+
* @throws {Error} If id is missing.
|
|
101
|
+
* @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
|
|
102
|
+
*/
|
|
103
|
+
async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
|
|
104
|
+
if (!id) throw new Error("A paragraph 'id' (UUID) is required to update an existing paragraph.");
|
|
105
|
+
const site = getSiteConfig(siteName);
|
|
106
|
+
const sec = resolveSecurityConfig(site);
|
|
107
|
+
assertWriteAllowed(sec, "update", "paragraph", paragraphType);
|
|
108
|
+
const backend = await resolveBackend(site);
|
|
109
|
+
const paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
|
|
110
|
+
const bundle = paragraph.bundle || paragraphType;
|
|
111
|
+
const ref = embedRef(bundle, paragraph.id);
|
|
112
|
+
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
/**
|
|
88
116
|
* Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
|
|
89
117
|
* annotate it with the embedding ref.
|
|
@@ -122,6 +150,20 @@ export const definitions = [
|
|
|
122
150
|
},
|
|
123
151
|
},
|
|
124
152
|
},
|
|
153
|
+
{
|
|
154
|
+
name: "drupal_update_paragraph",
|
|
155
|
+
description:
|
|
156
|
+
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object", required: ["paragraphType", "id"],
|
|
159
|
+
properties: {
|
|
160
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
161
|
+
paragraphType: { type: "string", description: "Paragraph type / bundle machine name, e.g. 'text', 'image', 'cta'" },
|
|
162
|
+
id: { type: "string", description: "Paragraph UUID" },
|
|
163
|
+
attributes: { type: "object", description: "Paragraph field values to change, keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }" },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
125
167
|
{
|
|
126
168
|
name: "drupal_get_paragraph",
|
|
127
169
|
description:
|
|
@@ -139,5 +181,6 @@ export const definitions = [
|
|
|
139
181
|
|
|
140
182
|
export const handlers = {
|
|
141
183
|
drupal_create_paragraph: createParagraph,
|
|
184
|
+
drupal_update_paragraph: updateParagraph,
|
|
142
185
|
drupal_get_paragraph: getParagraph,
|
|
143
186
|
};
|
package/src/tools/structure.js
CHANGED
|
@@ -23,6 +23,40 @@ import {
|
|
|
23
23
|
const MENU_LINK_TYPE = "menu_link_content";
|
|
24
24
|
const BLOCK_TYPE = "block_content";
|
|
25
25
|
|
|
26
|
+
// The intermittent menu-link create failure: Drupal's LinkAccessConstraint
|
|
27
|
+
// rejects a link whose target it can't (yet) resolve/access, surfacing as a
|
|
28
|
+
// "422 … path '/…' is inaccessible" error. For a valid, published alias this is
|
|
29
|
+
// a transient path-validator/access-cache race (it warms during the first
|
|
30
|
+
// attempt), so a single retry clears it. Prefer an `entity:node/<id>` target
|
|
31
|
+
// over `internal:/<alias>` to avoid the alias-resolution step entirely.
|
|
32
|
+
const INACCESSIBLE_PATH_RE = /\b422\b[\s\S]*inaccessible/i;
|
|
33
|
+
const MENU_LINK_RETRY_DELAY_MS = 250;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve after `ms` milliseconds.
|
|
37
|
+
* @param {number} ms Delay in milliseconds.
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
function sleep(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run a menu-link write, retrying once on the transient "422 path inaccessible"
|
|
46
|
+
* race. Any other error propagates immediately (no blind retries).
|
|
47
|
+
* @param {() => Promise<object>} fn The backend write to attempt.
|
|
48
|
+
* @returns {Promise<object>} The write result.
|
|
49
|
+
*/
|
|
50
|
+
async function writeMenuLinkWithRetry(fn) {
|
|
51
|
+
try {
|
|
52
|
+
return await fn();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (!INACCESSIBLE_PATH_RE.test(String(err?.message))) throw err;
|
|
55
|
+
await sleep(MENU_LINK_RETRY_DELAY_MS);
|
|
56
|
+
return fn();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Normalize limit/offset args into the backend's page descriptor.
|
|
28
62
|
*
|
|
@@ -73,13 +107,21 @@ async function listMenuLinks({ site: siteName, menu, limit = 20, offset = 0, sor
|
|
|
73
107
|
*
|
|
74
108
|
* The `link` field is a Drupal link field: it takes a `{ uri }` object where the
|
|
75
109
|
* URI is a Drupal-style target, e.g. `internal:/about`, `entity:node/42`, or an
|
|
76
|
-
* absolute `https://…` URL.
|
|
110
|
+
* absolute `https://…` URL. Prefer the `entity:node/<id>` form when linking to a
|
|
111
|
+
* node — it avoids the alias-resolution step that can trip the intermittent
|
|
112
|
+
* "path inaccessible" race.
|
|
113
|
+
*
|
|
114
|
+
* The link is created **enabled by default** so it renders immediately, and the
|
|
115
|
+
* `enabled` flag is always sent explicitly (the JSON:API write path could
|
|
116
|
+
* otherwise land the link disabled — the "menu links created disabled" gap).
|
|
117
|
+
* `parent` (a parent link plugin id such as `menu_link_content:<uuid>`) can be
|
|
118
|
+
* set at creation so child links nest without a follow-up update.
|
|
77
119
|
*
|
|
78
|
-
* @param {object} args - { site?, title, link, menu, weight? }.
|
|
120
|
+
* @param {object} args - { site?, title, link, menu, weight?, parent?, enabled? }.
|
|
79
121
|
* @returns {Promise<object>} The created menu-link descriptor from the backend.
|
|
80
122
|
* @throws {SecurityError} If creating menu_link_content is not permitted.
|
|
81
123
|
*/
|
|
82
|
-
async function createMenuLink({ site: siteName, title, link, menu, weight }) {
|
|
124
|
+
async function createMenuLink({ site: siteName, title, link, menu, weight, parent, enabled }) {
|
|
83
125
|
const site = getSiteConfig(siteName);
|
|
84
126
|
const sec = resolveSecurityConfig(site);
|
|
85
127
|
assertWriteAllowed(sec, "create", MENU_LINK_TYPE, MENU_LINK_TYPE);
|
|
@@ -89,8 +131,46 @@ async function createMenuLink({ site: siteName, title, link, menu, weight }) {
|
|
|
89
131
|
link: { uri: link },
|
|
90
132
|
menu_name: menu,
|
|
91
133
|
weight: weight === undefined ? 0 : weight,
|
|
134
|
+
enabled: enabled === undefined ? true : enabled,
|
|
92
135
|
};
|
|
93
|
-
|
|
136
|
+
if (parent !== undefined) attributes.parent = parent;
|
|
137
|
+
return writeMenuLinkWithRetry(() =>
|
|
138
|
+
backend.createEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, attributes }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Update a custom menu link by UUID. Only the supplied fields are sent (partial
|
|
143
|
+
* update). Crucially, `enabled` is always re-asserted — to the caller's value
|
|
144
|
+
* when changing it, otherwise to the link's current value read back from the
|
|
145
|
+
* site — so an unrelated edit (rename, re-weight, re-parent) can never silently
|
|
146
|
+
* regress a live link to disabled. Set `parent` to re-nest a link.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} args - { site?, id, title?, link?, menu?, weight?, parent?, enabled? }.
|
|
149
|
+
* @returns {Promise<object>} The updated menu-link descriptor from the backend.
|
|
150
|
+
* @throws {Error} If id is missing.
|
|
151
|
+
* @throws {SecurityError} If updating menu_link_content is not permitted.
|
|
152
|
+
*/
|
|
153
|
+
async function updateMenuLink({ site: siteName, id, title, link, menu, weight, parent, enabled }) {
|
|
154
|
+
if (!id) throw new Error("A menu link 'id' (UUID) is required to update an existing menu link.");
|
|
155
|
+
const site = getSiteConfig(siteName);
|
|
156
|
+
const sec = resolveSecurityConfig(site);
|
|
157
|
+
assertWriteAllowed(sec, "update", MENU_LINK_TYPE, MENU_LINK_TYPE);
|
|
158
|
+
const backend = await resolveBackend(site);
|
|
159
|
+
const attributes = {};
|
|
160
|
+
if (title !== undefined) attributes.title = title;
|
|
161
|
+
if (link !== undefined) attributes.link = { uri: link };
|
|
162
|
+
if (menu !== undefined) attributes.menu_name = menu;
|
|
163
|
+
if (weight !== undefined) attributes.weight = weight;
|
|
164
|
+
if (parent !== undefined) attributes.parent = parent;
|
|
165
|
+
if (enabled !== undefined) {
|
|
166
|
+
attributes.enabled = enabled;
|
|
167
|
+
} else {
|
|
168
|
+
const current = await backend.getEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id });
|
|
169
|
+
const currentEnabled = current?.fields?.enabled;
|
|
170
|
+
attributes.enabled = currentEnabled === undefined ? true : currentEnabled;
|
|
171
|
+
}
|
|
172
|
+
return writeMenuLinkWithRetry(() =>
|
|
173
|
+
backend.updateEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, attributes }));
|
|
94
174
|
}
|
|
95
175
|
|
|
96
176
|
// ---------------------------------------------------------------------------
|
|
@@ -165,15 +245,34 @@ export const definitions = [
|
|
|
165
245
|
},
|
|
166
246
|
{
|
|
167
247
|
name: "drupal_create_menu_link",
|
|
168
|
-
description: "Create a custom menu link. The link target is a Drupal URI such as 'internal:/about', 'entity:node/42', or an absolute 'https://…' URL. Checked against the site security config.",
|
|
248
|
+
description: "Create a custom menu link, enabled by default so it renders immediately. The link target is a Drupal URI such as 'internal:/about', 'entity:node/42', or an absolute 'https://…' URL — prefer 'entity:node/<id>' when linking to a node (avoids the alias-resolution 'path inaccessible' race). Set 'parent' (a parent link plugin id like 'menu_link_content:<uuid>') to nest the link, and 'enabled: false' to create it disabled. Checked against the site security config.",
|
|
169
249
|
inputSchema: {
|
|
170
250
|
type: "object", required: ["title", "link", "menu"],
|
|
171
251
|
properties: {
|
|
172
|
-
site:
|
|
173
|
-
title:
|
|
174
|
-
link:
|
|
175
|
-
menu:
|
|
176
|
-
weight:
|
|
252
|
+
site: { type: "string" },
|
|
253
|
+
title: { type: "string", description: "Link label shown in the menu" },
|
|
254
|
+
link: { type: "string", description: "Target URI, e.g. 'entity:node/42', 'internal:/about', or 'https://example.com'" },
|
|
255
|
+
menu: { type: "string", description: "Menu machine name to place the link in, e.g. 'main' or 'footer'" },
|
|
256
|
+
weight: { type: "number", default: 0, description: "Ordering weight within the menu (lower sorts first)" },
|
|
257
|
+
parent: { type: "string", description: "Parent link plugin id to nest under, e.g. 'menu_link_content:<uuid>'. Omit for a top-level link." },
|
|
258
|
+
enabled: { type: "boolean", default: true, description: "Whether the link is enabled (renders). Defaults to true." },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "drupal_update_menu_link",
|
|
264
|
+
description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Checked against the site security config.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: "object", required: ["id"],
|
|
267
|
+
properties: {
|
|
268
|
+
site: { type: "string" },
|
|
269
|
+
id: { type: "string", description: "Menu link UUID" },
|
|
270
|
+
title: { type: "string", description: "New link label. Omit to leave unchanged." },
|
|
271
|
+
link: { type: "string", description: "New target URI (e.g. 'entity:node/42'). Omit to leave unchanged." },
|
|
272
|
+
menu: { type: "string", description: "Move the link to this menu. Omit to leave unchanged." },
|
|
273
|
+
weight: { type: "number", description: "New ordering weight. Omit to leave unchanged." },
|
|
274
|
+
parent: { type: "string", description: "New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged." },
|
|
275
|
+
enabled: { type: "boolean", description: "Enable/disable the link. Omit to preserve the current state." },
|
|
177
276
|
},
|
|
178
277
|
},
|
|
179
278
|
},
|
|
@@ -213,6 +312,7 @@ export const definitions = [
|
|
|
213
312
|
export const handlers = {
|
|
214
313
|
drupal_list_menu_links: listMenuLinks,
|
|
215
314
|
drupal_create_menu_link: createMenuLink,
|
|
315
|
+
drupal_update_menu_link: updateMenuLink,
|
|
216
316
|
drupal_list_blocks: listBlocks,
|
|
217
317
|
drupal_create_block: createBlock,
|
|
218
318
|
};
|