drupal-mcp-connector 1.4.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.1] - 2026-06-29
11
+
12
+ ### Fixed
13
+ - **Node URL aliases set via the connector now actually persist (DEV-116).** Setting an
14
+ alias with `drupal_update_node` (`fields.path = { alias, pathauto: 0 }`) returned
15
+ success but silently reverted, causing nav 404s. Root cause: JSON:API deserialized the
16
+ `path` field without the existing alias's **`pid`**, so Drupal's `PathItem::postSave`
17
+ *created a duplicate* `path_alias` (the older one stayed canonical) instead of updating
18
+ in place. The connector now reads the current alias's `pid` (new
19
+ `backend.getPathInfo`) and round-trips it, so the alias is **updated in place** — one
20
+ canonical alias, no duplicate. Verified end-to-end over JSON:API on Drupal 11.
21
+ - **Path-less updates no longer create duplicate aliases.** The DEV-114 "preserve" path
22
+ re-pinned the current alias *without* its `pid`, hitting the same duplicate bug; it now
23
+ round-trips the `pid` too.
24
+ - **Honest write responses.** `drupal_create_node` / `drupal_update_node` now **re-read**
25
+ the node after writing and return the *persisted* `url`, instead of echoing the
26
+ requested value (which masked the revert).
27
+
28
+ ### Added
29
+ - **Automatic rename redirect.** When an explicit alias change replaces a different
30
+ existing alias, the connector creates a 301 redirect from the old path to the node
31
+ (`entity:node/<id>`, alias-independent), so the previous URL keeps resolving. Idempotent
32
+ — skipped when a redirect for that source already exists or the alias is unchanged.
33
+ - **`backend.getPathInfo(ref)`** on the backend interface — exposes the raw `path` field
34
+ (`alias` / `pid` / `langcode`) and internal id; default returns nulls (read-only/
35
+ path-less backends are unaffected). `buildRedirectAttributes()` is now exported from the
36
+ redirects module for reuse.
37
+
38
+ ### Notes
39
+ - Connector-created nodes still rely on Pathauto to generate their alias when no explicit
40
+ `path` is given. A **separate, server-side** Pathauto pattern misconfiguration (some
41
+ `pathauto.pattern.*` had `bundles` stored as a sequential array instead of the
42
+ associative map the `entity_bundle` condition requires) prevented alias generation for
43
+ affected bundles (e.g. `industry`, `platform`); that fix lives in the Drupal site
44
+ (webcms), not in the connector.
45
+
46
+ ## [1.5.0] - 2026-06-29
47
+
48
+ ### Added
49
+ - **`drupal_update_paragraph`** — update an existing Paragraph entity's field values
50
+ in place (partial JSON:API PATCH) by bundle + UUID, so component / key-capability
51
+ paragraphs can be maintained end-to-end without re-embedding (DEV-114).
52
+ - **`drupal_update_menu_link`** — update a menu link by UUID (rename, re-weight,
53
+ re-target, re-parent, enable/disable). `enabled` is preserved across edits unless
54
+ passed explicitly (DEV-114).
55
+ - **`drupal_create_menu_link`** now accepts **`parent`** (nest under a parent link
56
+ plugin id) and **`enabled`** on create, and creates links **enabled by default** so
57
+ they render immediately — closing the "menu links created disabled / no parent on
58
+ create" gap (DEV-114).
59
+
60
+ ### Fixed
61
+ - **Menu links no longer silently regress to disabled.** Every menu-link write now
62
+ asserts `enabled` explicitly (default true on create; the current value re-pinned on
63
+ update), so an unrelated edit can't drop a live link to disabled through the JSON:API
64
+ write path (DEV-114).
65
+ - **Node updates preserve the existing URL alias.** When `drupal_update_node` is called
66
+ without a `path`, the connector reads the current alias and re-pins it
67
+ (`{ alias, pathauto: 0 }`) so a save can't let Pathauto revert the alias to a stale
68
+ value. Pass `fields.path` to set the alias explicitly (DEV-114).
69
+ - **Intermittent `drupal_create_menu_link` 422 "path '/…' is inaccessible".** This is a
70
+ transient path-validator/access-cache race in Drupal's `LinkAccessConstraint`; menu-link
71
+ create/update now retries once after a short delay when it hits that specific error.
72
+ Prefer an `entity:node/<id>` target over `internal:/<alias>` to avoid the alias
73
+ resolution step entirely (DEV-114).
74
+
10
75
  ## [1.4.0] - 2026-06-29
11
76
 
12
77
  ### 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -71,6 +71,16 @@ export class Backend {
71
71
  */
72
72
  async getEntity(_ref) { return notImplemented("getEntity"); }
73
73
 
74
+ /**
75
+ * Read the raw `path` field (alias/pid/langcode) and internal id of an entity,
76
+ * for callers that must round-trip the alias `pid` on an in-place update (the
77
+ * canonical entity only exposes `path.alias` as `url`). Optional capability:
78
+ * the default returns nulls so read-only/path-less backends are safe. See DEV-116.
79
+ * @param {{entityType: string, bundle: string, id: string}} _ref
80
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
81
+ */
82
+ async getPathInfo(_ref) { return { alias: null, pid: null, langcode: null, drupalId: null }; }
83
+
74
84
  /**
75
85
  * Create an entity.
76
86
  * @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} _input
@@ -253,6 +253,29 @@ export class JsonApiBackend extends Backend {
253
253
  return data?.data ? this.toCanonical(data.data) : null;
254
254
  }
255
255
 
256
+ /**
257
+ * Read the raw `path` field (alias + pid + langcode) and internal id of an
258
+ * entity. The canonical entity only surfaces `path.alias` as `url`, but a
259
+ * correct in-place alias *update* must round-trip the existing alias's `pid`
260
+ * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
261
+ * — so this method exposes it. Returns nulls for entities/backends without a
262
+ * path field. See DEV-116.
263
+ * @param {{entityType: string, bundle: string, id: string}} ref
264
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
265
+ */
266
+ async getPathInfo({ entityType, bundle, id }) {
267
+ validateUuid(id);
268
+ const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
269
+ const attrs = data?.data?.attributes ?? {};
270
+ const path = attrs.path ?? null;
271
+ return {
272
+ alias: path?.alias ?? null,
273
+ pid: path?.pid ?? null,
274
+ langcode: path?.langcode ?? attrs.langcode ?? null,
275
+ drupalId: attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null,
276
+ };
277
+ }
278
+
256
279
  /**
257
280
  * Issue a JSON:API write, transparently retrying once without the `status`
258
281
  * attribute if the target bundle is under a content_moderation workflow.
@@ -9,7 +9,118 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
- import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
12
+ import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed } from "../lib/security.js";
13
+ import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
14
+
15
+ /** Fallback language for an alias when the node exposes none. */
16
+ const DEFAULT_ALIAS_LANGCODE = "en";
17
+
18
+ /**
19
+ * Normalize a URL-alias path for storage/comparison: trim, ensure a single
20
+ * leading slash, drop a trailing slash (except root).
21
+ * @param {*} value A raw alias.
22
+ * @returns {?string} The normalized alias, or null when empty.
23
+ */
24
+ function normalizeAlias(value) {
25
+ if (value === undefined || value === null) return null;
26
+ let s = String(value).trim();
27
+ if (!s) return null;
28
+ if (!s.startsWith("/")) s = `/${s}`;
29
+ if (s.length > 1) s = s.replace(/\/+$/, "");
30
+ return s;
31
+ }
32
+
33
+ /**
34
+ * Resolve the `path` attribute to send on an alias-aware node write so the alias
35
+ * actually persists, and decide whether a rename redirect is needed.
36
+ *
37
+ * The bug (DEV-116): JSON:API deserializes `{ alias, pathauto }` onto the node's
38
+ * `path` field, dropping the existing alias's `pid`; Drupal's `PathItem::postSave`
39
+ * then *creates a duplicate* `path_alias` (the older one stays canonical) instead
40
+ * of updating in place. The fix is to round-trip the existing `pid` so the update
41
+ * is in place. DEV-114's path-omitted "preserve" had the same defect (no `pid`),
42
+ * so it is fixed here too.
43
+ *
44
+ * @param {object} args - { backend, type, id, providedPath, isCreate }.
45
+ * @returns {Promise<{pathAttr: (object|undefined), redirect: ?object}>}
46
+ * `pathAttr` is the `path` value to send (or undefined to omit, letting pathauto
47
+ * run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
48
+ */
49
+ async function resolvePathWrite({ backend, type, id, providedPath, isCreate }) {
50
+ const info = id
51
+ ? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
52
+ : {};
53
+ const oldAlias = normalizeAlias(info.alias);
54
+ const langcode = info.langcode || DEFAULT_ALIAS_LANGCODE;
55
+
56
+ if (providedPath && typeof providedPath === "object") {
57
+ // Explicit alias set/replace → manual alias, round-trip the existing pid so
58
+ // Drupal updates in place rather than creating a duplicate.
59
+ if (providedPath.alias) {
60
+ const newAlias = normalizeAlias(providedPath.alias);
61
+ const pathAttr = { alias: newAlias, pathauto: false, langcode };
62
+ if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
63
+ const redirect = !isCreate && oldAlias && oldAlias !== newAlias
64
+ ? { from: oldAlias, to: newAlias, nid: info.drupalId }
65
+ : null;
66
+ return { pathAttr, redirect };
67
+ }
68
+ // Caller passed `path` without an alias (e.g. re-enabling pathauto) — respect it as-is.
69
+ return { pathAttr: providedPath, redirect: null };
70
+ }
71
+
72
+ // No explicit path on update → preserve the current alias *with its pid* so the
73
+ // save can neither revert nor duplicate it.
74
+ if (!isCreate && oldAlias) {
75
+ const pathAttr = { alias: oldAlias, pathauto: false, langcode };
76
+ if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
77
+ return { pathAttr, redirect: null };
78
+ }
79
+
80
+ // Create without an explicit path → omit it so pathauto generates the alias.
81
+ return { pathAttr: undefined, redirect: null };
82
+ }
83
+
84
+ /**
85
+ * Best-effort create a 301 redirect from a node's old alias to the node after a
86
+ * rename, so the previous URL keeps resolving. Governed like any redirect write;
87
+ * never fails the node update (failures are reported, not thrown). Idempotent:
88
+ * skips when a redirect already exists for the source.
89
+ *
90
+ * @param {object} backend Resolved backend.
91
+ * @param {object} sec Resolved security config.
92
+ * @param {{from: string, to: string, nid: ?(number|string)}} redirect
93
+ * @returns {Promise<object>} Outcome `{ created, ... }` for the response.
94
+ */
95
+ async function createRenameRedirect(backend, sec, redirect) {
96
+ try {
97
+ assertWriteAllowed(sec, "create", REDIRECT_ENTITY_TYPE, REDIRECT_ENTITY_TYPE);
98
+ } catch {
99
+ return { created: false, reason: "redirect creation not permitted by policy", source: redirect.from };
100
+ }
101
+ // Prefer an alias-independent target so a future rename can't break the redirect.
102
+ const target = redirect.nid !== undefined && redirect.nid !== null
103
+ ? `entity:node/${redirect.nid}`
104
+ : redirect.to;
105
+ const sourceStored = redirect.from.replace(/^\/+/, "");
106
+ try {
107
+ const existing = await backend.listEntities({
108
+ entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
109
+ filters: [{ field: "redirect_source.path", op: "eq", value: sourceStored }],
110
+ page: { limit: 1 },
111
+ }).catch(() => null);
112
+ if (existing?.entities?.length) {
113
+ return { created: false, reason: "redirect already exists", source: redirect.from };
114
+ }
115
+ await backend.createEntity({
116
+ entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
117
+ attributes: buildRedirectAttributes(redirect.from, target, 301),
118
+ });
119
+ return { created: true, source: redirect.from, target };
120
+ } catch (err) {
121
+ return { created: false, reason: err?.message || String(err), source: redirect.from };
122
+ }
123
+ }
13
124
 
14
125
  /**
15
126
  * Build a Drupal body field descriptor from plain HTML + optional summary.
@@ -119,7 +230,16 @@ async function createNode({ site: siteName, type, title, body, summary, status,
119
230
  if (bodyAttr) attributes.body = bodyAttr;
120
231
  if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes };
121
232
  const backend = await resolveBackend(site);
122
- return backend.createEntity({ entityType: "node", bundle: type, attributes });
233
+ // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
234
+ // `path` is omitted so pathauto generates the alias (DEV-116).
235
+ const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
236
+ if (pathAttr === undefined) delete attributes.path;
237
+ else attributes.path = pathAttr;
238
+ const created = await backend.createEntity({ entityType: "node", bundle: type, attributes });
239
+ // Honest response: re-read so the persisted alias (explicit or pathauto-generated)
240
+ // is reflected rather than the pre-alias write response.
241
+ const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
242
+ return fresh ?? created;
123
243
  }
124
244
 
125
245
  /**
@@ -130,6 +250,12 @@ async function createNode({ site: siteName, type, title, body, summary, status,
130
250
  * bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
131
251
  * types. `moderationState` takes precedence; both are optional on update.
132
252
  *
253
+ * Alias hardening: a partial update that doesn't touch `path` can still lose the
254
+ * node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
255
+ * it on save. To preserve the existing alias, when the caller supplies no `path`
256
+ * the current alias is read back and re-pinned (`{ alias, pathauto: 0 }`). Pass
257
+ * `fields.path` explicitly to set/replace the alias yourself.
258
+ *
133
259
  * @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
134
260
  * @returns {Promise<object>} The updated node descriptor.
135
261
  */
@@ -143,7 +269,21 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
143
269
  if (bodyAttr) attributes.body = bodyAttr;
144
270
  if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
145
271
  const backend = await resolveBackend(site);
146
- return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
272
+ const sec = resolveSecurityConfig(site);
273
+ // Alias handling (DEV-116): an explicit `path.alias` is set in place by
274
+ // round-tripping the existing alias's pid (no duplicate); a path-less update
275
+ // re-pins the current alias *with its pid* so the save can't revert/duplicate
276
+ // it. A rename (alias changed) also gets a 301 redirect from the old path.
277
+ const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
278
+ if (pathAttr === undefined) delete attributes.path;
279
+ else attributes.path = pathAttr;
280
+ await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
281
+ const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
282
+ // Honest response: re-read persisted state so the returned `url` is the alias
283
+ // that actually resolves, never the just-sent value.
284
+ const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
285
+ if (fresh && redirectResult) return { ...fresh, _redirect: redirectResult };
286
+ return fresh ?? { id };
147
287
  }
148
288
 
149
289
  /**
@@ -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
  };
@@ -83,6 +83,32 @@ function assertStatusCode(code) {
83
83
  return code;
84
84
  }
85
85
 
86
+ /** Entity type id for redirects, exported so side-effect callers can reuse it. */
87
+ export const REDIRECT_ENTITY_TYPE = REDIRECT_TYPE;
88
+
89
+ /**
90
+ * Build the JSON:API attribute map for a `redirect` entity from a plain
91
+ * source/target, applying the leading-slash and link-URI normalization that
92
+ * keeps a created redirect live. Exported so other tools (e.g. the node
93
+ * rename-redirect side-effect in nodes.js) create redirects identically.
94
+ *
95
+ * @param {string} source Old path (leading slash optional; stripped).
96
+ * @param {string} target Destination path / `entity:node/ID` / absolute URL.
97
+ * @param {number} [statusCode] HTTP redirect code (default 301).
98
+ * @param {string} [language] Redirect language (default 'und').
99
+ * @returns {object} The redirect attribute map for backend.createEntity.
100
+ * @throws {Error} If the status code is unsupported.
101
+ */
102
+ export function buildRedirectAttributes(source, target, statusCode = 301, language = "und") {
103
+ assertStatusCode(statusCode);
104
+ return {
105
+ redirect_source: { path: normalizeSource(source), query: null },
106
+ redirect_redirect: { uri: normalizeTargetUri(target) },
107
+ status_code: statusCode,
108
+ language,
109
+ };
110
+ }
111
+
86
112
  /**
87
113
  * Create an active URL redirect.
88
114
  *
@@ -98,18 +124,14 @@ function assertStatusCode(code) {
98
124
  async function createRedirect({ site: siteName, source, target, statusCode = 301, language = "und" }) {
99
125
  if (!source) throw new Error("A redirect 'source' path is required (e.g. '/old-path').");
100
126
  if (!target) throw new Error("A redirect 'target' is required (a path, 'entity:node/ID', or an absolute URL).");
101
- assertStatusCode(statusCode);
102
127
  const site = getSiteConfig(siteName);
103
128
  const sec = resolveSecurityConfig(site);
104
129
  assertWriteAllowed(sec, "create", REDIRECT_TYPE, REDIRECT_TYPE);
105
130
  const backend = await resolveBackend(site);
106
- const attributes = {
107
- redirect_source: { path: normalizeSource(source), query: null },
108
- redirect_redirect: { uri: normalizeTargetUri(target) },
109
- status_code: statusCode,
110
- language,
111
- };
112
- return backend.createEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, attributes });
131
+ return backend.createEntity({
132
+ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE,
133
+ attributes: buildRedirectAttributes(source, target, statusCode, language),
134
+ });
113
135
  }
114
136
 
115
137
  /**
@@ -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
- return backend.createEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, attributes });
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: { type: "string" },
173
- title: { type: "string", description: "Link label shown in the menu" },
174
- link: { type: "string", description: "Target URI, e.g. 'internal:/about', 'entity:node/42', or 'https://example.com'" },
175
- menu: { type: "string", description: "Menu machine name to place the link in, e.g. 'main' or 'footer'" },
176
- weight: { type: "number", default: 0, description: "Ordering weight within the menu (lower sorts first)" },
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
  };