drupal-mcp-connector 2.7.4 → 2.8.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/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
- package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
- package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
- package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
- package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
- package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-search.md +3 -4
- package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
- package/CHANGELOG.md +56 -0
- package/README.md +21 -11
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +36 -15
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +14 -0
- package/src/lib/server-tools.js +9 -0
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +3 -2
- package/src/tools/nodes.js +55 -43
package/src/lib/server-tools.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
26
26
|
import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
|
|
27
|
+
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
|
|
27
28
|
import { clearToken } from "./oauth.js";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -90,6 +91,7 @@ async function baseHeaders(site, sessionId) {
|
|
|
90
91
|
Accept: "application/json, text/event-stream",
|
|
91
92
|
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
92
93
|
...clientHeaders(),
|
|
94
|
+
...northboundHeaders(),
|
|
93
95
|
...(await authHeadersAsync(site)),
|
|
94
96
|
};
|
|
95
97
|
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
@@ -263,9 +265,12 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
263
265
|
let sessionId = await ensureSession(site, endpoint);
|
|
264
266
|
let refreshedAuth = false;
|
|
265
267
|
let reinitedSession = false;
|
|
268
|
+
let paid = false;
|
|
266
269
|
|
|
267
270
|
// Retry loop: at most one auth refresh and one session re-init, each replayed once.
|
|
268
271
|
while (true) {
|
|
272
|
+
consumeBudgetIfEnforced("request", 1, { retry: paid });
|
|
273
|
+
paid = true;
|
|
269
274
|
const res = await fetch(endpoint, {
|
|
270
275
|
method: "POST",
|
|
271
276
|
headers: await baseHeaders(site, sessionId),
|
|
@@ -289,6 +294,8 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
289
294
|
}
|
|
290
295
|
|
|
291
296
|
if (!res.ok) {
|
|
297
|
+
const mapped = sourceBudgetDenial(rawText);
|
|
298
|
+
if (mapped) throw mapped;
|
|
292
299
|
throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
|
|
293
300
|
}
|
|
294
301
|
|
|
@@ -303,6 +310,8 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
303
310
|
const result = body?.result;
|
|
304
311
|
if (result?.isError) {
|
|
305
312
|
const detail = extractTextContent(result) || "tool reported an error";
|
|
313
|
+
const mapped = sourceBudgetDenial(detail);
|
|
314
|
+
if (mapped) throw mapped;
|
|
306
315
|
throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
|
|
307
316
|
}
|
|
308
317
|
return result;
|
|
@@ -5,7 +5,79 @@
|
|
|
5
5
|
* published node — after a draft ERR attach it still shows the *old* refs.
|
|
6
6
|
* Prefer `rel:working-copy`. If that alias is not addressable, return the
|
|
7
7
|
* PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
|
|
8
|
+
*
|
|
9
|
+
* After a write, {@link attachWrittenRevisionPair} may add `_revisions`
|
|
10
|
+
* `{ live, working }` when both vids can be read honestly (#166).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read a revision id off a canonical (or raw-ish) entity body.
|
|
15
|
+
* @param {?object} entity
|
|
16
|
+
* @returns {?number|string}
|
|
17
|
+
*/
|
|
18
|
+
export function entityRevisionId(entity) {
|
|
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;
|
|
30
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
|
+
const n = Number(raw);
|
|
32
|
+
return Number.isFinite(n) ? n : raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attach distinct live vs working revision ids when both are known.
|
|
37
|
+
* Does not invent vids.
|
|
38
|
+
* @param {object} entity
|
|
39
|
+
* @param {{live: ?number|string, working: ?number|string}} pair
|
|
40
|
+
* @returns {object}
|
|
8
41
|
*/
|
|
42
|
+
export function attachRevisionPair(entity, { live, working }) {
|
|
43
|
+
if (!entity || live === null || live === undefined || working === null || working === undefined) {
|
|
44
|
+
return entity;
|
|
45
|
+
}
|
|
46
|
+
if (String(live) === String(working)) return entity;
|
|
47
|
+
return { ...entity, _revisions: { live, working } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* After a write, attach `_revisions` when a working copy is addressable
|
|
52
|
+
* and the live vid is already known. Omits the block when either side
|
|
53
|
+
* cannot be read — never invents a vid.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} args
|
|
56
|
+
* @param {object} args.backend
|
|
57
|
+
* @param {string} args.entityType
|
|
58
|
+
* @param {string} args.bundle
|
|
59
|
+
* @param {string} args.id
|
|
60
|
+
* @param {object} args.entity Write result to annotate.
|
|
61
|
+
* @param {?number|string} [args.liveVid]
|
|
62
|
+
* @returns {Promise<object>}
|
|
63
|
+
*/
|
|
64
|
+
export async function attachWrittenRevisionPair({
|
|
65
|
+
backend, entityType, bundle, id, entity, liveVid,
|
|
66
|
+
}) {
|
|
67
|
+
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
+
let workingVid = null;
|
|
69
|
+
if (typeof backend?.getEntity === "function") {
|
|
70
|
+
try {
|
|
71
|
+
const wc = await backend.getEntity({
|
|
72
|
+
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
73
|
+
});
|
|
74
|
+
workingVid = entityRevisionId(wc);
|
|
75
|
+
} catch {
|
|
76
|
+
workingVid = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
80
|
+
}
|
|
9
81
|
|
|
10
82
|
/**
|
|
11
83
|
* @param {object} args
|
|
@@ -16,15 +88,23 @@
|
|
|
16
88
|
* @param {boolean} args.relationshipsSent
|
|
17
89
|
* @param {?object} [args.patchResult] Canonicalised PATCH response body.
|
|
18
90
|
* @param {boolean} [args.preferCanonical] When no relationships were sent,
|
|
19
|
-
* re-GET the
|
|
91
|
+
* re-GET the written resource (nodes do this for the persisted alias).
|
|
92
|
+
* @param {?string} [args.resourceVersion] JSON:API revision selector used
|
|
93
|
+
* for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
|
|
94
|
+
* resource instead of the default revision so a draft PATCH is not
|
|
95
|
+
* replaced by the live body.
|
|
20
96
|
* @returns {Promise<object>} Entity to return, with `_revision` when relevant.
|
|
21
97
|
*/
|
|
22
98
|
export async function readWrittenRevision({
|
|
23
99
|
backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
|
|
100
|
+
resourceVersion,
|
|
24
101
|
}) {
|
|
25
102
|
if (!relationshipsSent) {
|
|
26
103
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
27
|
-
const fresh = await backend.getEntity({
|
|
104
|
+
const fresh = await backend.getEntity({
|
|
105
|
+
entityType, bundle, id,
|
|
106
|
+
...(resourceVersion ? { resourceVersion } : {}),
|
|
107
|
+
}).catch(() => null);
|
|
28
108
|
return fresh ?? patchResult ?? { id };
|
|
29
109
|
}
|
|
30
110
|
return patchResult ?? { id };
|
package/src/tools/bulk.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
resolveErrRelationships, embedParagraphRef,
|
|
20
20
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
21
21
|
} from "../lib/err-relationships.js";
|
|
22
|
-
import {
|
|
22
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -118,13 +118,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
118
118
|
});
|
|
119
119
|
assertPublishAllowed(sec, attributes);
|
|
120
120
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
121
|
-
await
|
|
122
|
-
|
|
121
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
122
|
+
entityType, bundle, id: item.id, existing, attributes,
|
|
123
123
|
});
|
|
124
124
|
const entity = await updateEntityGuarded(backend, {
|
|
125
125
|
entityType, bundle, id: item.id,
|
|
126
126
|
attributes,
|
|
127
127
|
relationships: resolvedRelationships,
|
|
128
|
+
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
128
129
|
});
|
|
129
130
|
updated += 1;
|
|
130
131
|
results.push({ index, success: true, id: entity?.id ?? item.id });
|
package/src/tools/entities.js
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
|
|
17
17
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
18
18
|
} from "../lib/err-relationships.js";
|
|
19
|
-
import { readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
-
import {
|
|
19
|
+
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import {
|
|
22
22
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
23
23
|
redactCanonicalEntity, getSecuritySummary,
|
|
@@ -117,8 +117,8 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
117
117
|
});
|
|
118
118
|
assertPublishAllowed(sec, safeAttributes);
|
|
119
119
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
|
-
await
|
|
121
|
-
|
|
120
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
121
|
+
entityType, bundle, id, existing, attributes: safeAttributes,
|
|
122
122
|
});
|
|
123
123
|
if (dryRun) {
|
|
124
124
|
return {
|
|
@@ -128,6 +128,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
128
128
|
}
|
|
129
129
|
const result = await updateEntityGuarded(backend, {
|
|
130
130
|
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
131
|
+
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
131
132
|
});
|
|
132
133
|
const written = await readWrittenRevision({
|
|
133
134
|
backend, entityType, bundle, id,
|
|
@@ -135,7 +136,10 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
135
136
|
patchResult: result,
|
|
136
137
|
preferCanonical: false,
|
|
137
138
|
});
|
|
138
|
-
|
|
139
|
+
const withRevs = await attachWrittenRevisionPair({
|
|
140
|
+
backend, entityType, bundle, id, entity: written, liveVid: patchTarget.liveVid,
|
|
141
|
+
});
|
|
142
|
+
return shapeWriteResponse(flagUnrequestedStatusChange(withRevs, existing, safeAttributes), returning);
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
@@ -276,7 +280,7 @@ export const definitions = [
|
|
|
276
280
|
},
|
|
277
281
|
{
|
|
278
282
|
name: "drupal_entity_update",
|
|
279
|
-
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 an id-mismatch PATCH preflight runs first (including dryRun)
|
|
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 an id-mismatch PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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.",
|
|
280
284
|
inputSchema: {
|
|
281
285
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
282
286
|
properties: {
|
|
@@ -286,7 +290,7 @@ export const definitions = [
|
|
|
286
290
|
id: { type: "string" },
|
|
287
291
|
attributes: { type: "object" },
|
|
288
292
|
relationships: { type: "object" },
|
|
289
|
-
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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
|
|
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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun." },
|
|
290
294
|
returning: RETURNING_SCHEMA,
|
|
291
295
|
},
|
|
292
296
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Each tool module exports `definitions` (the MCP `{name, description, inputSchema}`
|
|
5
5
|
* descriptors) and `handlers` (a name → function dispatch map). This module flattens
|
|
6
6
|
* all of them into the runtime payloads consumed by the server (src/index.js) AND by
|
|
7
|
-
* the offline slash-command generator (scripts/generate-commands.js
|
|
8
|
-
* list, the per-tool MCP prompts, and the
|
|
7
|
+
* the offline slash-command generator (scripts/generate-commands.js →
|
|
8
|
+
* `.agents/commands/`), so the tool list, the per-tool MCP prompts, and the
|
|
9
|
+
* `/drupal-*` command files can never drift.
|
|
9
10
|
*
|
|
10
11
|
* Importing this module has NO side effects (unlike src/index.js, which boots the
|
|
11
12
|
* transport), so generators and tests can import it freely.
|
package/src/tools/nodes.js
CHANGED
|
@@ -16,9 +16,11 @@ import {
|
|
|
16
16
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
17
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
18
18
|
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
19
|
-
import { readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
-
import {
|
|
19
|
+
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
21
22
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
23
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
22
24
|
|
|
23
25
|
/** Fallback language for an alias when the node exposes none. */
|
|
24
26
|
const DEFAULT_ALIAS_LANGCODE = "en";
|
|
@@ -130,22 +132,14 @@ async function createRenameRedirect(backend, sec, redirect) {
|
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
/**
|
|
134
|
-
* Fallback text format when neither the call nor the site config names one.
|
|
135
|
-
*
|
|
136
|
-
* Retained for backward compatibility only. A site whose text formats do not
|
|
137
|
-
* include `full_html` — or whose agent account may not use it — must set
|
|
138
|
-
* `defaultTextFormat` in its site config or pass `format` per call.
|
|
139
|
-
*/
|
|
140
|
-
const FALLBACK_TEXT_FORMAT = "full_html";
|
|
141
|
-
|
|
142
135
|
/**
|
|
143
136
|
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
144
137
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
138
|
+
* Format is left omitted when the caller did not pass one so
|
|
139
|
+
* `applyAllowedFormatsToAttributes` can honor Field API `allowed_formats`
|
|
140
|
+
* before applying the historical `defaultTextFormat` / `full_html` chain.
|
|
141
|
+
* Applying that fallback first would treat `full_html` as a caller format
|
|
142
|
+
* and refuse a single-allowed-format field (#168).
|
|
149
143
|
*
|
|
150
144
|
* `summary` is only included when the caller supplied it. Sending an empty
|
|
151
145
|
* string on every write would blank an existing body summary whenever a
|
|
@@ -154,17 +148,14 @@ const FALLBACK_TEXT_FORMAT = "full_html";
|
|
|
154
148
|
* @param {string} [body] - Body HTML; when undefined the field is omitted.
|
|
155
149
|
* @param {string} [summary] - Teaser/summary text; omitted when undefined.
|
|
156
150
|
* @param {string} [format] - Text format machine name.
|
|
157
|
-
* @
|
|
158
|
-
* @returns {{value: string, format: string, summary?: string}|undefined}
|
|
151
|
+
* @returns {{value: string, format?: string, summary?: string}|undefined}
|
|
159
152
|
* A body attribute object, or undefined when no body was supplied (so callers
|
|
160
153
|
* can skip the field on update rather than blanking it).
|
|
161
154
|
*/
|
|
162
|
-
function buildBodyAttribute(body, summary, format
|
|
155
|
+
function buildBodyAttribute(body, summary, format) {
|
|
163
156
|
if (body === undefined) return undefined;
|
|
164
|
-
const attr = {
|
|
165
|
-
|
|
166
|
-
format: format ?? site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT,
|
|
167
|
-
};
|
|
157
|
+
const attr = { value: body };
|
|
158
|
+
if (format !== undefined) attr.format = format;
|
|
168
159
|
if (summary !== undefined) attr.summary = summary;
|
|
169
160
|
return attr;
|
|
170
161
|
}
|
|
@@ -263,18 +254,25 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
263
254
|
const site = getSiteConfig(siteName);
|
|
264
255
|
const sec = resolveSecurityConfig(site);
|
|
265
256
|
assertWriteAllowed(sec, "create", "node", type);
|
|
257
|
+
const backend = await resolveBackend(site);
|
|
266
258
|
const attributes = { title, ...fields };
|
|
267
259
|
if (moderationState !== undefined) {
|
|
268
260
|
attributes.moderation_state = moderationState;
|
|
269
261
|
} else {
|
|
270
262
|
attributes.status = status === undefined ? false : status;
|
|
271
263
|
}
|
|
272
|
-
const bodyAttr = buildBodyAttribute(body, summary, format
|
|
264
|
+
const bodyAttr = buildBodyAttribute(body, summary, format);
|
|
273
265
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
266
|
+
await applyAllowedFormatsToAttributes({
|
|
267
|
+
backend, site, entityType: "node", bundle: type, attributes,
|
|
268
|
+
});
|
|
269
|
+
const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
|
|
274
270
|
assertPublishAllowed(sec, attributes);
|
|
275
|
-
const backend = await resolveBackend(site);
|
|
276
271
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
277
|
-
if (dryRun)
|
|
272
|
+
if (dryRun) {
|
|
273
|
+
const preview = { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
|
|
274
|
+
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
275
|
+
}
|
|
278
276
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
279
277
|
// `path` is omitted so pathauto generates the alias (DEV-116).
|
|
280
278
|
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
@@ -284,7 +282,8 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
284
282
|
// Honest response: re-read so the persisted alias (explicit or pathauto-generated)
|
|
285
283
|
// is reflected rather than the pre-alias write response.
|
|
286
284
|
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
|
|
287
|
-
|
|
285
|
+
const shaped = shapeWriteResponse(fresh ?? created, returning);
|
|
286
|
+
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
|
|
288
287
|
}
|
|
289
288
|
|
|
290
289
|
/**
|
|
@@ -316,12 +315,16 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
316
315
|
const sec = resolveSecurityConfig(site);
|
|
317
316
|
assertWriteAllowed(sec, "update", "node", type);
|
|
318
317
|
const backend = await resolveBackend(site);
|
|
318
|
+
const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
|
|
319
319
|
let attributes = { ...fields };
|
|
320
320
|
if (title !== undefined) attributes.title = title;
|
|
321
321
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
322
322
|
else if (status !== undefined) attributes.status = status;
|
|
323
|
-
const bodyAttr = buildBodyAttribute(body, summary, format
|
|
323
|
+
const bodyAttr = buildBodyAttribute(body, summary, format);
|
|
324
324
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
325
|
+
await applyAllowedFormatsToAttributes({
|
|
326
|
+
backend, site, entityType: "node", bundle: type, attributes,
|
|
327
|
+
});
|
|
325
328
|
// One pre-read serves the #131 draft default and the #171 unrequested-
|
|
326
329
|
// status-change flag. Skipped when the caller pinned the moderation state.
|
|
327
330
|
let existing = null;
|
|
@@ -341,16 +344,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
341
344
|
// #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
|
|
342
345
|
// list would persist empty — fail the whole write instead.
|
|
343
346
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
344
|
-
// #201: the
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
347
|
+
// #201 / #166: probe the same URL the write will hit. An addressable
|
|
348
|
+
// working copy is PATCHed via rel:working-copy; a stray revision still
|
|
349
|
+
// fails the probe with revision-surgery language.
|
|
350
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
351
|
+
entityType: "node", bundle: type, id, existing, attributes,
|
|
348
352
|
});
|
|
349
353
|
if (dryRun) {
|
|
350
|
-
|
|
354
|
+
const preview = {
|
|
351
355
|
dryRun: true, operation: "update", entityType: "node", bundle: type, id,
|
|
352
356
|
attributes, relationships: resolvedRelationships,
|
|
353
357
|
};
|
|
358
|
+
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
354
359
|
}
|
|
355
360
|
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
356
361
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
@@ -361,6 +366,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
361
366
|
else attributes.path = pathAttr;
|
|
362
367
|
const patched = await updateEntityGuarded(backend, {
|
|
363
368
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
369
|
+
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
364
370
|
});
|
|
365
371
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
366
372
|
// #169: when relationships were sent, the canonical re-read is the published
|
|
@@ -370,12 +376,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
370
376
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
371
377
|
patchResult: patched,
|
|
372
378
|
preferCanonical: true,
|
|
379
|
+
resourceVersion: patchTarget.resourceVersion,
|
|
380
|
+
});
|
|
381
|
+
const withRevs = await attachWrittenRevisionPair({
|
|
382
|
+
backend, entityType: "node", bundle: type, id, entity: fresh, liveVid: patchTarget.liveVid,
|
|
373
383
|
});
|
|
374
384
|
// #171: an unrequested published-state flip in the persisted node is
|
|
375
385
|
// reported via _statusChanged rather than returned as a clean success.
|
|
376
|
-
const flagged = flagUnrequestedStatusChange(
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
const flagged = flagUnrequestedStatusChange(withRevs, existing, attributes);
|
|
387
|
+
const shaped = flagged && redirectResult
|
|
388
|
+
? shapeWriteResponse({ ...flagged, _redirect: redirectResult }, returning)
|
|
389
|
+
: shapeWriteResponse(flagged ?? { id }, returning);
|
|
390
|
+
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
|
|
379
391
|
}
|
|
380
392
|
|
|
381
393
|
/**
|
|
@@ -452,11 +464,11 @@ export const definitions = [
|
|
|
452
464
|
type: { type: "string", description: "Content type machine name" },
|
|
453
465
|
title: { type: "string" },
|
|
454
466
|
body: { type: "string", description: "Body field HTML" },
|
|
455
|
-
summary: { type: "string", description: "Body summary/teaser — writes
|
|
456
|
-
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'.
|
|
467
|
+
summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
|
|
468
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
|
|
457
469
|
status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
|
|
458
470
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
459
|
-
fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
|
|
471
|
+
fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal 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. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
|
|
460
472
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }." },
|
|
461
473
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
|
|
462
474
|
returning: RETURNING_SCHEMA,
|
|
@@ -465,7 +477,7 @@ export const definitions = [
|
|
|
465
477
|
},
|
|
466
478
|
{
|
|
467
479
|
name: "drupal_update_node",
|
|
468
|
-
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 an id-mismatch PATCH preflight runs first — including on dryRun —
|
|
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 an id-mismatch PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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.",
|
|
469
481
|
inputSchema: {
|
|
470
482
|
type: "object", required: ["type", "id"],
|
|
471
483
|
properties: {
|
|
@@ -474,13 +486,13 @@ export const definitions = [
|
|
|
474
486
|
id: { type: "string", description: "Node UUID" },
|
|
475
487
|
title: { type: "string" },
|
|
476
488
|
body: { type: "string" },
|
|
477
|
-
summary: { type: "string", description: "Body summary/teaser — writes
|
|
478
|
-
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'.
|
|
489
|
+
summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
|
|
490
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
|
|
479
491
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
480
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'." },
|
|
481
|
-
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|
|
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." },
|
|
482
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." },
|
|
483
|
-
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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
|
|
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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun." },
|
|
484
496
|
returning: RETURNING_SCHEMA,
|
|
485
497
|
},
|
|
486
498
|
},
|