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.
- package/.agents/commands/drupal-draft-nested-components.md +26 -0
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +21 -2
- package/package.json +7 -7
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +22 -6
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +24 -2
- package/src/lib/dispatch.js +7 -0
- package/src/lib/field-definition.js +146 -41
- package/src/lib/nested-draft.js +1420 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/text-format-context.js +248 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +17 -12
- package/src/tools/entities.js +16 -14
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +9 -2
- package/src/tools/nested.js +69 -0
- package/src/tools/nodes.js +2 -2
- package/src/tools/paragraphs.js +12 -3
- package/src/tools/translations.js +5 -1
|
@@ -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
|
+
};
|
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
|
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"],
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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);
|