drupal-mcp-connector 1.8.1 → 2.1.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.
@@ -9,7 +9,11 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
- import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
12
+ import {
13
+ resolveSecurityConfig, redactCanonicalEntity,
14
+ assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
15
+ } from "../lib/security.js";
16
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
13
17
  import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
18
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
15
19
 
@@ -156,6 +160,7 @@ function pageOf({ limit = 20, offset = 0 }) {
156
160
  async function getNode({ site: siteName, type, id }) {
157
161
  const site = getSiteConfig(siteName);
158
162
  const sec = resolveSecurityConfig(site);
163
+ assertReadAllowed(sec, "node", type);
159
164
  const backend = await resolveBackend(site);
160
165
  const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
161
166
  return entity ? redactCanonicalEntity(entity, sec, "node") : null;
@@ -172,6 +177,7 @@ async function getNode({ site: siteName, type, id }) {
172
177
  async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
173
178
  const site = getSiteConfig(siteName);
174
179
  const sec = resolveSecurityConfig(site);
180
+ assertReadAllowed(sec, "node", type);
175
181
  const backend = await resolveBackend(site);
176
182
  const allFilters = [...filters];
177
183
  if (status !== undefined) allFilters.push({ field: "status", op: "eq", value: status });
@@ -195,10 +201,12 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
195
201
  async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
196
202
  const site = getSiteConfig(siteName);
197
203
  const sec = resolveSecurityConfig(site);
204
+ const bundle = type || "article";
205
+ assertReadAllowed(sec, "node", bundle);
198
206
  const backend = await resolveBackend(site);
199
207
  const filters = [{ field: "title", op: "contains", value: query }];
200
208
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
201
- const res = await backend.listEntities({ entityType: "node", bundle: type || "article", filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
209
+ const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
202
210
  return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
203
211
  }
204
212
 
@@ -225,6 +233,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
225
233
  */
226
234
  async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
227
235
  const site = getSiteConfig(siteName);
236
+ const sec = resolveSecurityConfig(site);
237
+ assertWriteAllowed(sec, "create", "node", type);
228
238
  const attributes = { title, ...fields };
229
239
  if (moderationState !== undefined) {
230
240
  attributes.moderation_state = moderationState;
@@ -233,7 +243,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
233
243
  }
234
244
  const bodyAttr = buildBodyAttribute(body, summary);
235
245
  if (bodyAttr) attributes.body = bodyAttr;
236
- assertPublishAllowed(resolveSecurityConfig(site), attributes);
246
+ assertPublishAllowed(sec, attributes);
237
247
  if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
238
248
  const backend = await resolveBackend(site);
239
249
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
@@ -256,6 +266,11 @@ async function createNode({ site: siteName, type, title, body, summary, status,
256
266
  * bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
257
267
  * types. `moderationState` takes precedence; both are optional on update.
258
268
  *
269
+ * Safe default (#131): when the target is a *published* moderated node and the
270
+ * caller did not pass `moderationState` / `moderation_state`, the write is
271
+ * forced to `moderation_state: draft` so it becomes a forward revision rather
272
+ * than mutating the live default revision. Opt out by passing an explicit state.
273
+ *
259
274
  * Alias hardening: a partial update that doesn't touch `path` can still lose the
260
275
  * node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
261
276
  * it on save. To preserve the existing alias, when the caller supplies no `path`
@@ -269,16 +284,22 @@ async function createNode({ site: siteName, type, title, body, summary, status,
269
284
  */
270
285
  async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
271
286
  const site = getSiteConfig(siteName);
272
- const attributes = { ...fields };
287
+ const sec = resolveSecurityConfig(site);
288
+ assertWriteAllowed(sec, "update", "node", type);
289
+ const backend = await resolveBackend(site);
290
+ let attributes = { ...fields };
273
291
  if (title !== undefined) attributes.title = title;
274
292
  if (moderationState !== undefined) attributes.moderation_state = moderationState;
275
293
  else if (status !== undefined) attributes.status = status;
276
294
  const bodyAttr = buildBodyAttribute(body, summary);
277
295
  if (bodyAttr) attributes.body = bodyAttr;
278
- assertPublishAllowed(resolveSecurityConfig(site), attributes);
296
+ // #131: published moderated nodes without an explicit state → draft forward revision.
297
+ // Runs before the publish gate and on dryRun so previews match the real write.
298
+ attributes = await applySafeDraftDefault({
299
+ backend, entityType: "node", bundle: type, id, attributes,
300
+ });
301
+ assertPublishAllowed(sec, attributes);
279
302
  if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
280
- const backend = await resolveBackend(site);
281
- const sec = resolveSecurityConfig(site);
282
303
  // Alias handling (DEV-116): an explicit `path.alias` is set in place by
283
304
  // round-tripping the existing alias's pid (no duplicate); a path-less update
284
305
  // re-pins the current alias *with its pid* so the save can't revert/duplicate
@@ -304,6 +325,8 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
304
325
  */
305
326
  async function deleteNode({ site: siteName, type, id, dryRun = false }) {
306
327
  const site = getSiteConfig(siteName);
328
+ const sec = resolveSecurityConfig(site);
329
+ assertDeleteAllowed(sec, "node", type, id);
307
330
  if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
308
331
  const backend = await resolveBackend(site);
309
332
  await backend.deleteEntity({ entityType: "node", bundle: type, id });
@@ -379,7 +402,7 @@ export const definitions = [
379
402
  },
380
403
  {
381
404
  name: "drupal_update_node",
382
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.",
405
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`.",
383
406
  inputSchema: {
384
407
  type: "object", required: ["type", "id"],
385
408
  properties: {
@@ -390,7 +413,7 @@ export const definitions = [
390
413
  body: { type: "string" },
391
414
  summary: { type: "string" },
392
415
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
393
- moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
416
+ moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
394
417
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
395
418
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }." },
396
419
  dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
@@ -19,11 +19,15 @@
19
19
  * - `status_code` defaults to 301 and can be set to 302 (or another redirect
20
20
  * code) explicitly on create, and changed on an existing redirect via update.
21
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.
22
+ * Redirect module ≥ dev-1.x makes redirects publishable: an `enabled` base field
23
+ * (the published key) decides whether the redirect fires. This connector does not
24
+ * send `enabled` — sites govern that flag server-side (mcp_sentinel's publish
25
+ * gate exempted it as routing metadata as of its post-1.9 release; on older
26
+ * sentinel releases a deny-publish profile silently disables agent-created
27
+ * redirects — verify `enabled` after create on such sites). On pre-publishable
28
+ * redirect releases the field is absent and every redirect with a valid source
29
+ * is active. Both tools are governed: writes assert create/update permission for
30
+ * the `redirect` entity type against the per-site security policy.
27
31
  */
28
32
 
29
33
  import { getSiteConfig } from "../lib/config.js";
@@ -8,7 +8,10 @@
8
8
 
9
9
  import { getSiteConfig } from "../lib/config.js";
10
10
  import { resolveBackend } from "../lib/backends/index.js";
11
- import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
11
+ import {
12
+ resolveSecurityConfig, redactCanonicalEntity,
13
+ assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
14
+ } from "../lib/security.js";
12
15
 
13
16
  // ---------------------------------------------------------------------------
14
17
  // Implementations
@@ -22,6 +25,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
22
25
  */
23
26
  async function listVocabularies({ site: siteName }) {
24
27
  const site = getSiteConfig(siteName);
28
+ assertReadAllowed(resolveSecurityConfig(site), "taxonomy_term");
25
29
  const backend = await resolveBackend(site);
26
30
  return backend.listBundles("taxonomy_term");
27
31
  }
@@ -35,6 +39,7 @@ async function listVocabularies({ site: siteName }) {
35
39
  async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset = 0 }) {
36
40
  const site = getSiteConfig(siteName);
37
41
  const sec = resolveSecurityConfig(site);
42
+ assertReadAllowed(sec, "taxonomy_term", vocabulary);
38
43
  const backend = await resolveBackend(site);
39
44
  const res = await backend.listEntities({
40
45
  entityType: "taxonomy_term", bundle: vocabulary,
@@ -56,6 +61,7 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
56
61
  async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
57
62
  const site = getSiteConfig(siteName);
58
63
  const sec = resolveSecurityConfig(site);
64
+ assertReadAllowed(sec, "taxonomy_term", vocabulary);
59
65
  const backend = await resolveBackend(site);
60
66
  const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
61
67
  return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
@@ -70,6 +76,7 @@ async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
70
76
  */
71
77
  async function createTaxonomyTerm({ site: siteName, vocabulary, name, description, weight = 0, parentId }) {
72
78
  const site = getSiteConfig(siteName);
79
+ assertWriteAllowed(resolveSecurityConfig(site), "create", "taxonomy_term", vocabulary);
73
80
  const backend = await resolveBackend(site);
74
81
  const attributes = { name, weight };
75
82
  if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
@@ -88,6 +95,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
88
95
  */
89
96
  async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
90
97
  const site = getSiteConfig(siteName);
98
+ assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
91
99
  const backend = await resolveBackend(site);
92
100
  const attributes = {};
93
101
  if (name !== undefined) attributes.name = name;
@@ -105,6 +113,7 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
105
113
  */
106
114
  async function deleteTaxonomyTerm({ site: siteName, vocabulary, id }) {
107
115
  const site = getSiteConfig(siteName);
116
+ assertDeleteAllowed(resolveSecurityConfig(site), "taxonomy_term", vocabulary, id);
108
117
  const backend = await resolveBackend(site);
109
118
  await backend.deleteEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
110
119
  return { success: true, deletedId: id };