drupal-mcp-connector 3.1.0 → 3.2.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tool: governed nested-paragraph draft (#421).
3
+ *
4
+ * Direct component attributes stay on drupal_update_node `components`.
5
+ * A nested child is a new unpublished paragraph, pinned only by the host
6
+ * draft. This tool never calls updateEntity on a paragraph.
7
+ */
8
+
9
+ import { getSiteConfig } from "../lib/config.js";
10
+ import { resolveBackend } from "../lib/backends/index.js";
11
+ import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
12
+ import { draftNestedComponents } from "../lib/nested-draft.js";
13
+
14
+ /**
15
+ * @param {object} args
16
+ * @returns {Promise<object>}
17
+ */
18
+ async function draftNestedComponentsTool({
19
+ site: siteName, bundle, id, field, parentId, childField, children,
20
+ liveRevisionId, resumeParentId, dryRun = false,
21
+ }) {
22
+ const site = getSiteConfig(siteName);
23
+ const sec = resolveSecurityConfig(site);
24
+ assertWriteAllowed(sec, "update", "node", bundle);
25
+ assertPublishAllowed(sec, { moderation_state: "draft" });
26
+ const backend = await resolveBackend(site);
27
+ return draftNestedComponents(backend, {
28
+ entityType: "node", bundle, id, field, parentId, childField, children,
29
+ liveRevisionId, resumeParentId,
30
+ }, {
31
+ dryRun,
32
+ site,
33
+ assertParagraphCreate(paragraphBundle) {
34
+ assertWriteAllowed(sec, "create", "paragraph", paragraphBundle);
35
+ },
36
+ });
37
+ }
38
+
39
+ export const definitions = [
40
+ {
41
+ name: "drupal_draft_nested_components",
42
+ description:
43
+ "Replace nested paragraphs on a node by creating new unpublished paragraphs and pinning a new direct parent from the node's unpublished draft. children is the full new list: { op: 'keep', id } copies an existing child pin, { op: 'replace', id, type, attributes, translations? } or { op: 'insert', type, attributes, translations? } creates a new child. Omitting a child leaves it off the draft only; it is not deleted. The published host and every paragraph it already pins stay unchanged. Never edits a live child and never sends nested UUIDs as mcp_components (use drupal_update_node components for a direct paragraph's attributes). Reusable library items and from_library children are refused; update the library item with drupal_entity_update. The first host draft requires MCP Sentinel 2.29.0 (open_draft). An existing working copy continues through /mcp-draft. When the host inventory advertises nested_replacement, one /mcp-draft carries meta.mcp_nested_replacement (parent is the parentId input), the connector creates no paragraphs, and it does not also send the host paragraph field. dryRun then uses that non-saving preflight (serverPreflight sentinel_draft). Hosts that do not advertise nested_replacement keep this sequence, including resumeParentId, and dryRun leaves serverPreflight none. New paragraphs are created in the host's default language. The new parent keeps the published parent's other references and replaces only the child field. Non-default translations on the component must be supplied on each replace or insert; they are created on the new paragraphs, and the component's own translations are copied onto the new parent. dryRun checks paragraph-create permission, creates nothing, and does not evaluate the host payload. If paragraph creates succeed and the host pin fails, the error lists the prepared UUIDs. When a parent was prepared, retry with resumeParentId and the same children list; that retry checks the prepared paragraph's fields, other references, and child revision pins before any host write. A failure before a parent exists says to delete the prepared paragraphs and does not offer a host retry. A lost response is uncertain until a re-read proves the prepared revision is pinned; the same UUID at another revision is not success. A second call after success is refused while the forward draft no longer pins the published component. Governed by the site security policy. Nothing is published.",
44
+ inputSchema: {
45
+ type: "object",
46
+ required: ["bundle", "id", "field", "parentId", "childField", "children"],
47
+ properties: {
48
+ site: { type: "string", description: "Named site (omit for default)" },
49
+ bundle: { type: "string", description: "Node bundle machine name, e.g. 'page'" },
50
+ id: { type: "string", description: "Node UUID" },
51
+ field: { type: "string", description: "Host entity-reference-revisions field that pins the direct parent, e.g. 'field_components'" },
52
+ parentId: { type: "string", description: "UUID of the direct paragraph the published host pins. Not a nested child." },
53
+ childField: { type: "string", description: "Entity-reference-revisions field on that direct paragraph, e.g. 'field_items'" },
54
+ children: {
55
+ type: "array",
56
+ description: "Full new child list, in order. keep: { op, id }. replace: { op, id, type, attributes, translations? }. insert: { op, type, attributes, translations? }. translations is [{ langcode, attributes }] for each non-default language the component already has. Relationships inside a child are refused.",
57
+ items: { type: "object" },
58
+ },
59
+ liveRevisionId: { type: "string", description: "Optional published revision id. Refused before any create when it is not the current live revision." },
60
+ resumeParentId: { type: "string", description: "Prepared parent UUID from a previous attempt whose host pin did not finish. Retries only the host pin. Must not be a paragraph the published host already pins." },
61
+ dryRun: { type: "boolean", default: false, description: "When nested_replacement is advertised, run that non-saving /mcp-draft preflight and create nothing (checks.serverPreflight is sentinel_draft). Otherwise read the host and component, then return without creating paragraphs or evaluating the host draft payload (checks.serverPreflight is none)." },
62
+ },
63
+ },
64
+ },
65
+ ];
66
+
67
+ export const handlers = {
68
+ drupal_draft_nested_components: draftNestedComponentsTool,
69
+ };
@@ -516,7 +516,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
516
516
  const written = await writeComponentDraft(backend, {
517
517
  entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
518
518
  components: normalizedComponents, langcode,
519
- }, { dryRun });
519
+ }, { dryRun, site });
520
520
  const shaped = dryRun ? written : shapeWriteResponse(written, returning);
521
521
  return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
522
522
  }
@@ -691,7 +691,7 @@ export const definitions = [
691
691
  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 }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
692
692
  components: {
693
693
  type: "array",
694
- description: "Field changes for paragraphs this node already references directly (hero, text blocks, CTA banners), saved in the same unpublished draft revision as the node fields. Each entry is { id: paragraph UUID, type: paragraph bundle such as 'p_hero', attributes: { field: value } }. Each changed paragraph gets a new revision that only the draft points at; the live page does not change until the draft is published. Opens the working copy from live when none exists. Default language only. Attribute values only: references (for example a media field) are refused, as are nested paragraphs and paragraphs the node does not reference. Requires MCP Sentinel 2.29.0 or later; older hosts are refused before any write, with no direct paragraph write. A `path` change is refused on this path. The connector's paragraph write policy applies to each component. After the write each component is read back at its working revision and the submitted values compared; a value that did not land is an error. The result carries `_components` (each component's live and working pins and the fields verified), which survives `returning: 'minimal'`.",
694
+ description: "Field changes for paragraphs this node already references directly (hero, text blocks, CTA banners), saved in the same unpublished draft revision as the node fields. Each entry is { id: paragraph UUID, type: paragraph bundle such as 'p_hero', attributes: { field: value } }. Each changed paragraph gets a new revision that only the draft points at; the live page does not change until the draft is published. Opens the working copy from live when none exists. Default language only. Attribute values only: references (for example a media field) are refused, as are paragraphs the node does not reference. A nested paragraph is refused before any write; use drupal_draft_nested_components. Requires MCP Sentinel 2.29.0 or later; older hosts are refused before any write, with no direct paragraph write. A `path` change is refused on this path. The connector's paragraph write policy applies to each component. After the write each component is read back at its working revision and the submitted values compared; a value that did not land is an error. The result carries `_components` (each component's live and working pins and the fields verified), which survives `returning: 'minimal'`.",
695
695
  items: {
696
696
  type: "object",
697
697
  required: ["id", "type", "attributes"],
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { getSiteConfig } from "../lib/config.js";
38
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
38
39
  import { resolveBackend } from "../lib/backends/index.js";
39
40
  import {
40
41
  resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
@@ -93,7 +94,11 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
93
94
  const sec = resolveSecurityConfig(site);
94
95
  assertWriteAllowed(sec, "create", "paragraph", paragraphType);
95
96
  const backend = await resolveBackend(site);
96
- const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
97
+ const formatted = await applyAllowedFormatsToAttributes({
98
+ backend, site, entityType: "paragraph", bundle: paragraphType,
99
+ attributes: { ...attributes }, defaultBodyFormat: false,
100
+ });
101
+ const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes: formatted });
97
102
  const bundle = paragraph.bundle || paragraphType;
98
103
  const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
99
104
  if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
@@ -122,6 +127,10 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
122
127
  const sec = resolveSecurityConfig(site);
123
128
  assertWriteAllowed(sec, "update", "paragraph", paragraphType);
124
129
  const backend = await resolveBackend(site);
130
+ const formatted = await applyAllowedFormatsToAttributes({
131
+ backend, site, entityType: "paragraph", bundle: paragraphType,
132
+ attributes: { ...attributes }, defaultBodyFormat: false,
133
+ });
125
134
  let paragraph;
126
135
  if (langcode) {
127
136
  const targetLang = assertDraftLangcode(langcode);
@@ -132,11 +141,11 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
132
141
  throw new Error("Paragraph translation update requires a paragraph revision ID (the host pin).");
133
142
  }
134
143
  paragraph = await writeDraft(backend, {
135
- entityType: "paragraph", bundle: paragraphType, id, attributes, langcode: targetLang, draftState,
144
+ entityType: "paragraph", bundle: paragraphType, id, attributes: formatted, langcode: targetLang, draftState,
136
145
  draftRevision: { revisionId: pinned },
137
146
  });
138
147
  } else {
139
- paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
148
+ paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes: formatted });
140
149
  }
141
150
  const bundle = paragraph.bundle || paragraphType;
142
151
  const resolvedRevisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
@@ -28,6 +28,7 @@ import {
28
28
  } from "../lib/security.js";
29
29
  import { validateUuid, validateMachineName } from "../lib/validate.js";
30
30
  import { applySafeDraftDefault } from "../lib/moderation-default.js";
31
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
31
32
  import { omitLiveComputedMetatag } from "../lib/entity-response.js";
32
33
  import { entityRevisionId } from "../lib/write-revision.js";
33
34
  import { paragraphRevisionId } from "../lib/err-relationships.js";
@@ -160,7 +161,10 @@ async function createTranslation({
160
161
  }
161
162
 
162
163
  let draftRevision;
163
- let drafted = { ...attributes };
164
+ let drafted = await applyAllowedFormatsToAttributes({
165
+ backend, site, entityType, bundle: type, attributes: { ...attributes }, existingEntity: existing,
166
+ defaultBodyFormat: false,
167
+ });
164
168
  delete drafted.langcode;
165
169
  if (entityType === "paragraph") {
166
170
  const pinned = revisionId ?? paragraphRevisionId(existing);