drupal-mcp-connector 1.3.2 → 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 CHANGED
@@ -7,6 +7,51 @@ 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
+
39
+ ## [1.4.0] - 2026-06-29
40
+
41
+ ### Added
42
+ - **Redirect tools** (`drupal_create_redirect`, `drupal_update_redirect`) for the
43
+ contrib Redirect module. `drupal_create_redirect` produces a redirect that serves
44
+ its 301 (or chosen code) immediately: the source path's leading slash is stripped
45
+ to the module's stored, slash-less form (the classic "redirect saved but never
46
+ fires" cause), the destination is normalized to a Drupal link-field URI (a bare
47
+ path is wrapped as `internal:`, while `entity:node/ID` and absolute URLs pass
48
+ through), and `status_code` defaults to 301 with 302 (and 303/307/308) accepted.
49
+ `drupal_update_redirect` repoints an existing redirect's source/target or changes
50
+ its status code via a partial update — the path to activate/fix a redirect that
51
+ isn't firing. Both are governed by the per-site security policy (redirect writes /
52
+ `administer redirects`). Resolves the gap where connector-created redirects were
53
+ inactive and could not be enabled (DEV-111).
54
+
10
55
  ## [1.3.2] - 2026-06-27
11
56
 
12
57
  ### Fixed
package/README.md CHANGED
@@ -71,8 +71,9 @@ 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
+ | **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
76
77
  | **Search** | Best-effort content search (title match; Search API/Solr-ready) |
77
78
  | **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
78
79
  | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
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",
package/src/index.js CHANGED
@@ -65,6 +65,7 @@ import * as bulk from "./tools/bulk.js";
65
65
  import * as translations from "./tools/translations.js";
66
66
  import * as paragraphs from "./tools/paragraphs.js";
67
67
  import * as structure from "./tools/structure.js";
68
+ import * as redirects from "./tools/redirects.js";
68
69
  import * as search from "./tools/search.js";
69
70
  import * as reportsExtra from "./tools/reports-extra.js";
70
71
  import * as config from "./tools/config.js";
@@ -74,7 +75,7 @@ import * as config from "./tools/config.js";
74
75
  // ---------------------------------------------------------------------------
75
76
 
76
77
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
77
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, search, reportsExtra, config];
78
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra, config];
78
79
 
79
80
  // Flatten every module's tool definitions into one ListTools payload, and merge
80
81
  // their handler maps into a single closed dispatch table keyed by tool name.
@@ -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
 
@@ -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
  };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Tool group: URL redirects (the contrib Redirect module).
3
+ *
4
+ * A `redirect` entity maps an old/source path to a destination and fires an HTTP
5
+ * redirect (301 by default) when the source path is requested. Redirects are a
6
+ * single-bundle content entity (`redirect--redirect`) exposed over JSON:API, so
7
+ * they go through the shared backend like the other structural content tools.
8
+ *
9
+ * Why a dedicated tool (vs. the generic entity tools): the Redirect module's
10
+ * field shape is unforgiving and easy to get wrong, which produces a stored-but-
11
+ * dead redirect:
12
+ * - `redirect_source` stores the source path WITHOUT a leading slash. A source
13
+ * saved as "/old" never matches an incoming request for "old", so the
14
+ * redirect silently never fires. This tool strips the leading slash so a
15
+ * created redirect is live (serves its 301) immediately.
16
+ * - `redirect_redirect` is a Drupal link field — a bare "/new" must be wrapped
17
+ * as the URI "internal:/new". This tool normalizes destinations so callers
18
+ * can pass a plain path, an `entity:node/ID`, or an absolute URL.
19
+ * - `status_code` defaults to 301 and can be set to 302 (or another redirect
20
+ * code) explicitly on create, and changed on an existing redirect via update.
21
+ *
22
+ * Redirect entities have no separate enabled/disabled flag — a redirect with a
23
+ * valid source is active. "Enable an existing redirect" therefore means: correct
24
+ * its fields so it matches and fires, which is exactly what drupal_update_redirect
25
+ * does. Both tools are governed: writes assert create/update permission for the
26
+ * `redirect` entity type against the per-site security policy.
27
+ */
28
+
29
+ import { getSiteConfig } from "../lib/config.js";
30
+ import { resolveBackend } from "../lib/backends/index.js";
31
+ import { resolveSecurityConfig, assertWriteAllowed } from "../lib/security.js";
32
+
33
+ const REDIRECT_TYPE = "redirect";
34
+
35
+ // Redirect status codes the Redirect module supports. 301/302 are the common
36
+ // pair called for in the ticket; the rest are the other valid HTTP redirect
37
+ // codes, accepted so the tool isn't needlessly restrictive. 301 is the default.
38
+ const ALLOWED_STATUS_CODES = [301, 302, 303, 307, 308];
39
+
40
+ // Drupal URI schemes a destination may already carry; anything else that is a
41
+ // path gets wrapped as internal:.
42
+ const URI_SCHEME_RE = /^(https?:|mailto:|tel:|internal:|entity:|route:|base:)/i;
43
+
44
+ /**
45
+ * Normalize a source path to the Redirect module's stored form: trimmed, no
46
+ * leading slash. Storing a leading slash is the classic "redirect saved but
47
+ * never fires" bug, so this is the core of the fix.
48
+ *
49
+ * @param {string} source Raw source path, e.g. "/old-path" or "old-path".
50
+ * @returns {string} The source path without a leading slash.
51
+ */
52
+ function normalizeSource(source) {
53
+ return String(source).trim().replace(/^\/+/, "");
54
+ }
55
+
56
+ /**
57
+ * Normalize a redirect destination into a Drupal link-field URI. Absolute URLs
58
+ * and explicit Drupal URI schemes (entity:, internal:, route:, …) pass through
59
+ * unchanged; a bare path is wrapped as `internal:`.
60
+ *
61
+ * @param {string} target Destination path or URI.
62
+ * @returns {string} A Drupal link-field URI.
63
+ */
64
+ function normalizeTargetUri(target) {
65
+ const t = String(target).trim();
66
+ if (URI_SCHEME_RE.test(t)) return t;
67
+ return t.startsWith("/") ? `internal:${t}` : `internal:/${t}`;
68
+ }
69
+
70
+ /**
71
+ * Validate a requested status code against the supported redirect codes.
72
+ *
73
+ * @param {number} code The HTTP status code.
74
+ * @returns {number} The validated code.
75
+ * @throws {Error} If the code is not a supported redirect status code.
76
+ */
77
+ function assertStatusCode(code) {
78
+ if (!ALLOWED_STATUS_CODES.includes(code)) {
79
+ throw new Error(
80
+ `Unsupported redirect status code ${code}. Use one of: ${ALLOWED_STATUS_CODES.join(", ")} (301 is the default).`,
81
+ );
82
+ }
83
+ return code;
84
+ }
85
+
86
+ /**
87
+ * Create an active URL redirect.
88
+ *
89
+ * @param {object} args - { site?, source, target, statusCode?, language? }.
90
+ * `source` is the old path (a leading slash is fine; it is stripped to the
91
+ * stored form). `target` is the destination — a path ("/new"), an
92
+ * `entity:node/ID`, or an absolute URL. `statusCode` defaults to 301; pass 302
93
+ * for a temporary redirect. `language` defaults to 'und' (all languages).
94
+ * @returns {Promise<object>} The created redirect descriptor from the backend.
95
+ * @throws {Error} If source/target are missing or the status code is unsupported.
96
+ * @throws {SecurityError} If creating redirects is not permitted.
97
+ */
98
+ async function createRedirect({ site: siteName, source, target, statusCode = 301, language = "und" }) {
99
+ if (!source) throw new Error("A redirect 'source' path is required (e.g. '/old-path').");
100
+ if (!target) throw new Error("A redirect 'target' is required (a path, 'entity:node/ID', or an absolute URL).");
101
+ assertStatusCode(statusCode);
102
+ const site = getSiteConfig(siteName);
103
+ const sec = resolveSecurityConfig(site);
104
+ assertWriteAllowed(sec, "create", REDIRECT_TYPE, REDIRECT_TYPE);
105
+ 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 });
113
+ }
114
+
115
+ /**
116
+ * Update an existing redirect: repoint its source/target or change its status
117
+ * code. Only the provided fields are sent (a partial JSON:API PATCH), so an
118
+ * update that changes just the status code leaves source/target untouched.
119
+ *
120
+ * @param {object} args - { site?, id, source?, target?, statusCode? }.
121
+ * @returns {Promise<object>} The updated redirect descriptor from the backend.
122
+ * @throws {Error} If id is missing or the status code is unsupported.
123
+ * @throws {SecurityError} If updating redirects is not permitted.
124
+ */
125
+ async function updateRedirect({ site: siteName, id, source, target, statusCode }) {
126
+ if (!id) throw new Error("A redirect 'id' (UUID) is required to update an existing redirect.");
127
+ const site = getSiteConfig(siteName);
128
+ const sec = resolveSecurityConfig(site);
129
+ assertWriteAllowed(sec, "update", REDIRECT_TYPE, REDIRECT_TYPE);
130
+ const backend = await resolveBackend(site);
131
+ const attributes = {};
132
+ if (source !== undefined) attributes.redirect_source = { path: normalizeSource(source), query: null };
133
+ if (target !== undefined) attributes.redirect_redirect = { uri: normalizeTargetUri(target) };
134
+ if (statusCode !== undefined) attributes.status_code = assertStatusCode(statusCode);
135
+ return backend.updateEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, id, attributes });
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Tool definitions
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export const definitions = [
143
+ {
144
+ name: "drupal_create_redirect",
145
+ description:
146
+ "Create an active URL redirect (contrib Redirect module). The redirect serves its 301 (or chosen code) immediately: 'source' is the old path (a leading slash is fine — it is normalized to the module's stored, slash-less form so the redirect actually matches and fires), and 'target' is the destination as a path ('/new'), an 'entity:node/ID', or an absolute URL. status_code defaults to 301; pass 302 for a temporary redirect. Governed by the site security policy (needs redirect write / 'administer redirects').",
147
+ inputSchema: {
148
+ type: "object", required: ["source", "target"],
149
+ properties: {
150
+ site: { type: "string", description: "Named site (omit for default)" },
151
+ source: { type: "string", description: "Source/old path to redirect from, e.g. '/old-slug'. Leading slash optional." },
152
+ target: { type: "string", description: "Destination: a path ('/new-slug'), 'entity:node/42', or an absolute 'https://…' URL." },
153
+ statusCode: { type: "number", default: 301, description: "HTTP redirect status code. 301 (permanent, default) or 302 (temporary); 303/307/308 also accepted." },
154
+ language: { type: "string", default: "und", description: "Langcode the redirect applies to. Defaults to 'und' (all languages)." },
155
+ },
156
+ },
157
+ },
158
+ {
159
+ name: "drupal_update_redirect",
160
+ description:
161
+ "Update an existing redirect by UUID: repoint its source or target, or change its status code (e.g. 301↔302). Only the fields you pass are changed (partial update). Use this to activate/fix a redirect that isn't firing (e.g. one created with a stale source). Governed by the site security policy.",
162
+ inputSchema: {
163
+ type: "object", required: ["id"],
164
+ properties: {
165
+ site: { type: "string" },
166
+ id: { type: "string", description: "Redirect entity UUID" },
167
+ source: { type: "string", description: "New source/old path (leading slash optional). Omit to leave unchanged." },
168
+ target: { type: "string", description: "New destination path/URI. Omit to leave unchanged." },
169
+ statusCode: { type: "number", description: "New HTTP redirect status code (301/302/303/307/308). Omit to leave unchanged." },
170
+ },
171
+ },
172
+ },
173
+ ];
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Handler map
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export const handlers = {
180
+ drupal_create_redirect: createRedirect,
181
+ drupal_update_redirect: updateRedirect,
182
+ };
@@ -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
  };