drupal-mcp-connector 2.13.0 → 2.14.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-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +66 -12
- package/bin/drupal-mcp-anchor.js +52 -0
- package/bin/drupal-mcp-edge.js +17 -1
- package/config/config.example.json +6 -0
- package/package.json +3 -2
- package/src/lib/anchor.js +338 -0
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/config.js +39 -0
- package/src/lib/contracts/approval.js +5 -0
- package/src/lib/draft-write.js +57 -0
- package/src/lib/evidence.js +607 -0
- package/src/lib/http-auth.js +65 -3
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +559 -15
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +1 -2
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
package/src/lib/relay/frames.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Relay frame codec (#232).
|
|
3
3
|
*
|
|
4
4
|
* Length-prefixed JSON frames for the edge/agent channel, promoted from the
|
|
5
|
-
*
|
|
5
|
+
* outbound-relay lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
|
|
6
6
|
* authentication, entitlement, or header policy lives here.
|
|
7
7
|
*
|
|
8
8
|
* Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame
|
package/src/lib/usage.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Attributable usage, quotas, and abuse signals on the relay edge (#256
|
|
3
|
-
* DEV-126).
|
|
2
|
+
* Attributable usage, quotas, and abuse signals on the relay edge (#256).
|
|
4
3
|
*
|
|
5
4
|
* Metering at the seam, lab bounds. Every edge decision (allow or deny) and
|
|
6
5
|
* every fan-down receipt is a record keyed by the grant-resolved tenant and
|
package/src/tools/bulk.js
CHANGED
|
@@ -119,13 +119,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
119
119
|
assertPublishAllowed(sec, attributes);
|
|
120
120
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
121
121
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
122
|
-
entityType, bundle, id: item.id, existing, attributes,
|
|
122
|
+
entityType, bundle, id: item.id, existing, attributes, relationships: resolvedRelationships,
|
|
123
123
|
});
|
|
124
124
|
const entity = await updateEntityGuarded(backend, {
|
|
125
125
|
entityType, bundle, id: item.id,
|
|
126
126
|
attributes,
|
|
127
127
|
relationships: resolvedRelationships,
|
|
128
128
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
129
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
129
130
|
});
|
|
130
131
|
updated += 1;
|
|
131
132
|
results.push({ index, success: true, id: entity?.id ?? item.id });
|
package/src/tools/entities.js
CHANGED
|
@@ -118,7 +118,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
118
118
|
assertPublishAllowed(sec, safeAttributes);
|
|
119
119
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
120
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
121
|
-
entityType, bundle, id, existing, attributes: safeAttributes,
|
|
121
|
+
entityType, bundle, id, existing, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
122
122
|
});
|
|
123
123
|
if (dryRun) {
|
|
124
124
|
return {
|
|
@@ -129,6 +129,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
129
129
|
const result = await updateEntityGuarded(backend, {
|
|
130
130
|
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
131
131
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
132
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
132
133
|
});
|
|
133
134
|
const written = await readWrittenRevision({
|
|
134
135
|
backend, entityType, bundle, id,
|
|
@@ -280,7 +281,7 @@ export const definitions = [
|
|
|
280
281
|
},
|
|
281
282
|
{
|
|
282
283
|
name: "drupal_entity_update",
|
|
283
|
-
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
|
|
284
|
+
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.",
|
|
284
285
|
inputSchema: {
|
|
285
286
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
286
287
|
properties: {
|
|
@@ -290,7 +291,7 @@ export const definitions = [
|
|
|
290
291
|
id: { type: "string" },
|
|
291
292
|
attributes: { type: "object" },
|
|
292
293
|
relationships: { type: "object" },
|
|
293
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
294
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
294
295
|
returning: RETURNING_SCHEMA,
|
|
295
296
|
},
|
|
296
297
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -44,11 +44,11 @@ function normalizeAlias(value) {
|
|
|
44
44
|
* Resolve the `path` attribute to send on an alias-aware node write so the alias
|
|
45
45
|
* actually persists, and decide whether a rename redirect is needed.
|
|
46
46
|
*
|
|
47
|
-
* The bug (
|
|
47
|
+
* The bug (fixed in 1.5.1): JSON:API deserializes `{ alias, pathauto }` onto the node's
|
|
48
48
|
* `path` field, dropping the existing alias's `pid`; Drupal's `PathItem::postSave`
|
|
49
49
|
* then *creates a duplicate* `path_alias` (the older one stays canonical) instead
|
|
50
50
|
* of updating in place. The fix is to round-trip the existing `pid` so the update
|
|
51
|
-
* is in place.
|
|
51
|
+
* is in place. The 1.5.0 path-omitted "preserve" had the same defect (no `pid`),
|
|
52
52
|
* so it is fixed here too.
|
|
53
53
|
*
|
|
54
54
|
* @param {object} args - { backend, type, id, providedPath, isCreate }.
|
|
@@ -274,7 +274,7 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
274
274
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
275
275
|
}
|
|
276
276
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
277
|
-
// `path` is omitted so pathauto generates the alias (
|
|
277
|
+
// `path` is omitted so pathauto generates the alias (see the 1.5.1 alias fix).
|
|
278
278
|
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
279
279
|
if (pathAttr === undefined) delete attributes.path;
|
|
280
280
|
else attributes.path = pathAttr;
|
|
@@ -344,11 +344,10 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
344
344
|
// #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
|
|
345
345
|
// list would persist empty — fail the whole write instead.
|
|
346
346
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
347
|
-
// #201 / #166: probe the
|
|
348
|
-
//
|
|
349
|
-
// fails the probe with revision-surgery language.
|
|
347
|
+
// #201 / #166: probe the write endpoint. Addressable drafts use Sentinel's
|
|
348
|
+
// governed continuation endpoint; stray revisions still fail the probe.
|
|
350
349
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
351
|
-
entityType: "node", bundle: type, id, existing, attributes,
|
|
350
|
+
entityType: "node", bundle: type, id, existing, attributes, relationships: resolvedRelationships,
|
|
352
351
|
});
|
|
353
352
|
if (dryRun) {
|
|
354
353
|
const preview = {
|
|
@@ -357,7 +356,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
357
356
|
};
|
|
358
357
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
359
358
|
}
|
|
360
|
-
// Alias handling (
|
|
359
|
+
// Alias handling (1.5.1 alias fix): an explicit `path.alias` is set in place by
|
|
361
360
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
362
361
|
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
363
362
|
// it. A rename (alias changed) also gets a 301 redirect from the old path.
|
|
@@ -367,6 +366,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
367
366
|
const patched = await updateEntityGuarded(backend, {
|
|
368
367
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
369
368
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
369
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
370
370
|
});
|
|
371
371
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
372
372
|
// #169: when relationships were sent, the canonical re-read is the published
|
|
@@ -477,7 +477,7 @@ export const definitions = [
|
|
|
477
477
|
},
|
|
478
478
|
{
|
|
479
479
|
name: "drupal_update_node",
|
|
480
|
-
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`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
480
|
+
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`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on 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); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
481
481
|
inputSchema: {
|
|
482
482
|
type: "object", required: ["type", "id"],
|
|
483
483
|
properties: {
|
|
@@ -492,7 +492,7 @@ export const definitions = [
|
|
|
492
492
|
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'." },
|
|
493
493
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
494
494
|
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." },
|
|
495
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
495
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
496
496
|
returning: RETURNING_SCHEMA,
|
|
497
497
|
},
|
|
498
498
|
},
|