drupal-mcp-connector 1.5.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 +36 -0
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +10 -0
- package/src/lib/backends/jsonapi.js +23 -0
- package/src/tools/nodes.js +137 -9
- package/src/tools/redirects.js +30 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ 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
|
+
|
|
10
46
|
## [1.5.0] - 2026-06-29
|
|
11
47
|
|
|
12
48
|
### Added
|
package/package.json
CHANGED
|
@@ -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.
|
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
|
@@ -149,13 +269,21 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
149
269
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
150
270
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
|
|
151
271
|
const backend = await resolveBackend(site);
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
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 };
|
|
159
287
|
}
|
|
160
288
|
|
|
161
289
|
/**
|
package/src/tools/redirects.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
/**
|