drupal-mcp-connector 3.1.0 → 3.2.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/.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 +61 -0
- package/package.json +7 -7
- package/src/lib/backends/jsonapi.js +18 -5
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +16 -0
- package/src/lib/nested-draft.js +1404 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +5 -9
- package/src/tools/entities.js +5 -11
- package/src/tools/index.js +2 -1
- package/src/tools/nested.js +68 -0
- package/src/tools/nodes.js +1 -1
package/src/lib/operations.js
CHANGED
|
@@ -18,7 +18,7 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
|
|
|
18
18
|
"drupal_drush_updatedb", "drupal_drush_module_enable",
|
|
19
19
|
"drupal_drush_module_disable", "drupal_drush_user_create",
|
|
20
20
|
// v1.0 feature tools that perform writes but don't start with create_/update_:
|
|
21
|
-
"drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_",
|
|
21
|
+
"drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_", "drupal_draft_",
|
|
22
22
|
// Governed config write (also gated inside the handler by the config-write cap):
|
|
23
23
|
"drupal_config_set"];
|
|
24
24
|
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
|
+
import {
|
|
28
|
+
isCanonicalDraftType,
|
|
29
|
+
preflightGovernedCanonicalDraft,
|
|
30
|
+
resolveCanonicalDraftIdentity,
|
|
31
|
+
writeCanonicalModeratedEntity,
|
|
32
|
+
} from "./canonical-draft.js";
|
|
27
33
|
import { httpStatusOf } from "./error-status.js";
|
|
28
34
|
import {
|
|
29
35
|
writeDraft,
|
|
@@ -245,6 +251,20 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
|
245
251
|
* @throws {PatchTargetAmbiguousError}
|
|
246
252
|
*/
|
|
247
253
|
export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
|
|
254
|
+
if (isCanonicalDraftType(entityType)) {
|
|
255
|
+
// A readable unmoderated block or library item has no draft alias to
|
|
256
|
+
// prove. Identity resolution stays fail-closed when the entity is
|
|
257
|
+
// moderated or could not be read.
|
|
258
|
+
if (existing && !entityLooksModerated(existing)) {
|
|
259
|
+
return {
|
|
260
|
+
resourceVersion: undefined,
|
|
261
|
+
workingCopy: null,
|
|
262
|
+
liveVid: entityRevisionId(existing, entityType),
|
|
263
|
+
workingVid: null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return resolveCanonicalDraftIdentity(backend, { entityType, bundle, id, existing });
|
|
267
|
+
}
|
|
248
268
|
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
249
269
|
if (workingCopy?.id && workingCopy.id !== id) {
|
|
250
270
|
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
@@ -478,6 +498,17 @@ export async function prepareGuardedPatch(backend, {
|
|
|
478
498
|
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
479
499
|
return target;
|
|
480
500
|
}
|
|
501
|
+
if (target.governedDraft) {
|
|
502
|
+
try {
|
|
503
|
+
await preflightGovernedCanonicalDraft(backend, {
|
|
504
|
+
entityType, bundle, id, attributes, relationships, governedDraft: target.governedDraft,
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
throw rewriteStaleCopyError(err);
|
|
508
|
+
}
|
|
509
|
+
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
510
|
+
return target;
|
|
511
|
+
}
|
|
481
512
|
if (target.resourceVersion) {
|
|
482
513
|
const draftRevisionFor = (lang) => ({
|
|
483
514
|
liveVid: target.liveVid,
|
|
@@ -584,6 +615,9 @@ function explainDefaultLanguageRefusal(err, inferredLangcode) {
|
|
|
584
615
|
* @throws {PatchBlockedError|WorkingCopyStaleError|*}
|
|
585
616
|
*/
|
|
586
617
|
export async function updateEntityGuarded(backend, input) {
|
|
618
|
+
if (isCanonicalDraftType(input?.entityType) && !input?.draftRevision && !input?.resourceVersion) {
|
|
619
|
+
return writeCanonicalModeratedEntity(backend, input);
|
|
620
|
+
}
|
|
587
621
|
try {
|
|
588
622
|
if (input?.draftRevision) return await writeDraft(backend, input);
|
|
589
623
|
if (input?.resourceVersion) {
|
|
@@ -11,27 +11,73 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* JSON:API revision attribute for entity types whose revision key is known.
|
|
15
|
+
* `taxonomy_term`'s `drupal_internal__vid` is the vocabulary id, not a revision.
|
|
16
|
+
* Node and media revisions use `vid`. Paragraphs, library items, and custom
|
|
17
|
+
* blocks use `revision_id`.
|
|
18
|
+
*/
|
|
19
|
+
const REVISION_ATTRIBUTE = {
|
|
20
|
+
node: "drupal_internal__vid",
|
|
21
|
+
media: "drupal_internal__vid",
|
|
22
|
+
paragraph: "drupal_internal__revision_id",
|
|
23
|
+
paragraphs_library_item: "drupal_internal__revision_id",
|
|
24
|
+
block_content: "drupal_internal__revision_id",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} entity
|
|
29
|
+
* @param {object} fields
|
|
30
|
+
* @param {object} attrs
|
|
31
|
+
* @param {string} key
|
|
32
|
+
* @returns {unknown}
|
|
33
|
+
*/
|
|
34
|
+
function readRevisionAttribute(entity, fields, attrs, key) {
|
|
35
|
+
if (key === "drupal_internal__vid") {
|
|
36
|
+
return entity.vid
|
|
37
|
+
?? fields.drupal_internal__vid
|
|
38
|
+
?? attrs.drupal_internal__vid
|
|
39
|
+
?? entity.drupal_internal__vid;
|
|
40
|
+
}
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(fields, key)) return fields[key];
|
|
42
|
+
if (Object.prototype.hasOwnProperty.call(attrs, key)) return attrs[key];
|
|
43
|
+
if (Object.prototype.hasOwnProperty.call(entity, key)) return entity[key];
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} raw
|
|
16
49
|
* @returns {?number|string}
|
|
17
50
|
*/
|
|
18
|
-
|
|
19
|
-
if (!entity || typeof entity !== "object") return null;
|
|
20
|
-
const fields = entity.fields && typeof entity.fields === "object"
|
|
21
|
-
? entity.fields
|
|
22
|
-
: {};
|
|
23
|
-
const attrs = entity.attributes && typeof entity.attributes === "object"
|
|
24
|
-
? entity.attributes
|
|
25
|
-
: {};
|
|
26
|
-
const raw = entity.vid
|
|
27
|
-
?? fields.drupal_internal__vid
|
|
28
|
-
?? attrs.drupal_internal__vid
|
|
29
|
-
?? entity.drupal_internal__vid;
|
|
51
|
+
function normalizeRevisionId(raw) {
|
|
30
52
|
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
53
|
const n = Number(raw);
|
|
32
54
|
return Number.isFinite(n) ? n : raw;
|
|
33
55
|
}
|
|
34
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Read a revision id off a canonical (or raw-ish) entity body.
|
|
59
|
+
* When `entityType` is known, only that type's revision attribute is read.
|
|
60
|
+
* When it is omitted, a node-style vid is kept and `revision_id` is used
|
|
61
|
+
* only if no vid is present, so existing callers stay unchanged.
|
|
62
|
+
*
|
|
63
|
+
* @param {?object} entity
|
|
64
|
+
* @param {?string} [entityType]
|
|
65
|
+
* @returns {?number|string}
|
|
66
|
+
*/
|
|
67
|
+
export function entityRevisionId(entity, entityType) {
|
|
68
|
+
if (!entity || typeof entity !== "object") return null;
|
|
69
|
+
const type = entityType || entity.entityType || null;
|
|
70
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
71
|
+
const attrs = entity.attributes && typeof entity.attributes === "object" ? entity.attributes : {};
|
|
72
|
+
if (type === "taxonomy_term") return null;
|
|
73
|
+
if (type && REVISION_ATTRIBUTE[type]) {
|
|
74
|
+
return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, REVISION_ATTRIBUTE[type]));
|
|
75
|
+
}
|
|
76
|
+
const vid = readRevisionAttribute(entity, fields, attrs, "drupal_internal__vid");
|
|
77
|
+
if (vid !== undefined && vid !== null && vid !== "") return normalizeRevisionId(vid);
|
|
78
|
+
return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, "drupal_internal__revision_id"));
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
/**
|
|
36
82
|
* Attach distinct live vs working revision ids when both are known.
|
|
37
83
|
* Does not invent vids.
|
|
@@ -65,7 +111,8 @@ export async function attachWrittenRevisionPair({
|
|
|
65
111
|
backend, entityType, bundle, id, entity, liveVid,
|
|
66
112
|
}) {
|
|
67
113
|
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
-
|
|
114
|
+
if (entity._revisions) return entity;
|
|
115
|
+
let workingVid = entityRevisionId(entity, entityType);
|
|
69
116
|
if (workingVid !== null && String(workingVid) !== String(liveVid)) {
|
|
70
117
|
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
71
118
|
}
|
|
@@ -74,7 +121,7 @@ export async function attachWrittenRevisionPair({
|
|
|
74
121
|
const wc = await backend.getEntity({
|
|
75
122
|
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
76
123
|
});
|
|
77
|
-
workingVid = entityRevisionId(wc);
|
|
124
|
+
workingVid = entityRevisionId(wc, entityType);
|
|
78
125
|
} catch {
|
|
79
126
|
workingVid = null;
|
|
80
127
|
}
|
|
@@ -106,6 +153,9 @@ export async function readWrittenRevision({
|
|
|
106
153
|
// Sentinel returned the requested translation; a canonical read would replace
|
|
107
154
|
// it with live English, and the core working-copy alias may not address it.
|
|
108
155
|
if (langcode && patchResult) return patchResult;
|
|
156
|
+
if (patchResult?._revision?.source === "working-copy" || patchResult?._revision?.source === "latest-version") {
|
|
157
|
+
return patchResult;
|
|
158
|
+
}
|
|
109
159
|
if (!relationshipsSent) {
|
|
110
160
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
111
161
|
const fresh = await backend.getEntity({
|
package/src/tools/bulk.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
15
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
16
|
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
|
|
17
|
-
import { applySafeDraftDefault
|
|
17
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
18
|
+
import { readUpdateTarget } from "../lib/canonical-draft.js";
|
|
18
19
|
import {
|
|
19
20
|
resolveErrRelationships, embedParagraphRef,
|
|
20
21
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
@@ -103,14 +104,9 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
103
104
|
const item = rawItem || {};
|
|
104
105
|
try {
|
|
105
106
|
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
existing = (await backend.getEntity({ entityType, bundle, id: item.id })) ?? null;
|
|
110
|
-
} catch {
|
|
111
|
-
existing = null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
107
|
+
const existing = await readUpdateTarget(backend, {
|
|
108
|
+
entityType, bundle, id: item.id, attributes: item.attributes ?? {},
|
|
109
|
+
});
|
|
114
110
|
const attributes = await applySafeDraftDefault({
|
|
115
111
|
backend, entityType, bundle, id: item.id,
|
|
116
112
|
attributes: item.attributes ?? {},
|
package/src/tools/entities.js
CHANGED
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
|
-
import { applySafeDraftDefault
|
|
14
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
15
15
|
import {
|
|
16
16
|
resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
|
|
17
17
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
18
18
|
} from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { readUpdateTarget } from "../lib/canonical-draft.js";
|
|
21
22
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
22
23
|
import {
|
|
23
24
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -111,14 +112,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
111
112
|
// One pre-write read serves both the #131 draft default and the #171
|
|
112
113
|
// unrequested-status-change flag. Skipped when the caller pinned the
|
|
113
114
|
// moderation state explicitly (same condition the draft default uses).
|
|
114
|
-
|
|
115
|
-
if (!hasExplicitModerationState(attributes)) {
|
|
116
|
-
try {
|
|
117
|
-
existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
|
|
118
|
-
} catch {
|
|
119
|
-
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
120
|
-
}
|
|
121
|
-
}
|
|
115
|
+
const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
|
|
122
116
|
const safeAttributes = await applySafeDraftDefault({
|
|
123
117
|
backend, entityType, bundle, id, attributes, existingEntity: existing,
|
|
124
118
|
});
|
|
@@ -297,7 +291,7 @@ export const definitions = [
|
|
|
297
291
|
},
|
|
298
292
|
{
|
|
299
293
|
name: "drupal_entity_update",
|
|
300
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
|
|
294
|
+
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420).",
|
|
301
295
|
inputSchema: {
|
|
302
296
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
303
297
|
properties: {
|
|
@@ -308,7 +302,7 @@ export const definitions = [
|
|
|
308
302
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
309
303
|
attributes: { type: "object" },
|
|
310
304
|
relationships: { type: "object" },
|
|
311
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not.
|
|
305
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. An existing node draft (or a langcode translation draft), and a paragraphs_library_item or block_content whose inventory advertises open_draft, are checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
|
|
312
306
|
returning: RETURNING_SCHEMA,
|
|
313
307
|
},
|
|
314
308
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as references from "./references.js";
|
|
|
29
29
|
import * as bulk from "./bulk.js";
|
|
30
30
|
import * as translations from "./translations.js";
|
|
31
31
|
import * as paragraphs from "./paragraphs.js";
|
|
32
|
+
import * as nested from "./nested.js";
|
|
32
33
|
import * as structure from "./structure.js";
|
|
33
34
|
import * as redirects from "./redirects.js";
|
|
34
35
|
import * as search from "./search.js";
|
|
@@ -42,7 +43,7 @@ import * as codegen from "./codegen.js";
|
|
|
42
43
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
43
44
|
|
|
44
45
|
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
45
|
-
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
46
|
+
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, nested, structure, redirects, search, reportsExtra,
|
|
46
47
|
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
assertParagraphCreate(paragraphBundle) {
|
|
33
|
+
assertWriteAllowed(sec, "create", "paragraph", paragraphBundle);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const definitions = [
|
|
39
|
+
{
|
|
40
|
+
name: "drupal_draft_nested_components",
|
|
41
|
+
description:
|
|
42
|
+
"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.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
required: ["bundle", "id", "field", "parentId", "childField", "children"],
|
|
46
|
+
properties: {
|
|
47
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
48
|
+
bundle: { type: "string", description: "Node bundle machine name, e.g. 'page'" },
|
|
49
|
+
id: { type: "string", description: "Node UUID" },
|
|
50
|
+
field: { type: "string", description: "Host entity-reference-revisions field that pins the direct parent, e.g. 'field_components'" },
|
|
51
|
+
parentId: { type: "string", description: "UUID of the direct paragraph the published host pins. Not a nested child." },
|
|
52
|
+
childField: { type: "string", description: "Entity-reference-revisions field on that direct paragraph, e.g. 'field_items'" },
|
|
53
|
+
children: {
|
|
54
|
+
type: "array",
|
|
55
|
+
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.",
|
|
56
|
+
items: { type: "object" },
|
|
57
|
+
},
|
|
58
|
+
liveRevisionId: { type: "string", description: "Optional published revision id. Refused before any create when it is not the current live revision." },
|
|
59
|
+
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." },
|
|
60
|
+
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)." },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const handlers = {
|
|
67
|
+
drupal_draft_nested_components: draftNestedComponentsTool,
|
|
68
|
+
};
|
package/src/tools/nodes.js
CHANGED
|
@@ -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"],
|