drupal-mcp-connector 2.7.3 → 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/.agents/commands/drupal-audit-config-best-practices.md +15 -0
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +4 -5
- package/.agents/commands/drupal-block-user.md +19 -0
- package/{.claude → .agents}/commands/drupal-bulk-create.md +4 -5
- package/{.claude → .agents}/commands/drupal-bulk-update.md +4 -5
- package/.agents/commands/drupal-config-get.md +18 -0
- package/.agents/commands/drupal-config-list.md +16 -0
- package/.agents/commands/drupal-config-set.md +19 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +21 -0
- package/.agents/commands/drupal-create-block.md +20 -0
- package/{.claude → .agents}/commands/drupal-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-node.md +7 -8
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-redirect.md +4 -5
- package/.agents/commands/drupal-create-taxonomy-term.md +22 -0
- package/{.claude → .agents}/commands/drupal-create-translation.md +4 -5
- package/.agents/commands/drupal-create-user.md +23 -0
- package/.agents/commands/drupal-delete-media.md +21 -0
- package/.agents/commands/drupal-delete-node.md +22 -0
- package/.agents/commands/drupal-delete-taxonomy-term.md +21 -0
- package/{.claude → .agents}/commands/drupal-describe-fields.md +4 -5
- package/.agents/commands/drupal-drush-cache-rebuild.md +15 -0
- package/.agents/commands/drupal-drush-config-export.md +15 -0
- package/.agents/commands/drupal-drush-config-import.md +15 -0
- package/.agents/commands/drupal-drush-config-status.md +15 -0
- package/.agents/commands/drupal-drush-cron.md +15 -0
- package/.agents/commands/drupal-drush-module-disable.md +20 -0
- package/.agents/commands/drupal-drush-module-enable.md +18 -0
- package/.agents/commands/drupal-drush-module-list.md +16 -0
- package/.agents/commands/drupal-drush-security-updates.md +15 -0
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +4 -5
- package/.agents/commands/drupal-drush-status.md +15 -0
- package/.agents/commands/drupal-drush-updatedb.md +15 -0
- package/.agents/commands/drupal-drush-user-create.md +21 -0
- package/.agents/commands/drupal-drush-user-list.md +18 -0
- package/.agents/commands/drupal-drush-watchdog.md +18 -0
- package/{.claude → .agents}/commands/drupal-entity-create.md +4 -5
- package/.agents/commands/drupal-entity-delete.md +23 -0
- package/.agents/commands/drupal-entity-get.md +21 -0
- package/{.claude → .agents}/commands/drupal-entity-list.md +4 -5
- package/{.claude → .agents}/commands/drupal-entity-update.md +7 -8
- package/.agents/commands/drupal-find-orphaned-media.md +17 -0
- package/.agents/commands/drupal-get-entity-schema.md +19 -0
- package/.agents/commands/drupal-get-media.md +19 -0
- package/.agents/commands/drupal-get-node.md +19 -0
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-get-revision.md +4 -5
- package/.agents/commands/drupal-get-taxonomy-term.md +19 -0
- package/.agents/commands/drupal-get-taxonomy-terms.md +20 -0
- package/.agents/commands/drupal-get-user-by-name.md +18 -0
- package/.agents/commands/drupal-get-user.md +18 -0
- package/{.claude → .agents}/commands/drupal-governance-status.md +4 -5
- package/.agents/commands/drupal-graphql-introspect.md +16 -0
- package/{.claude → .agents}/commands/drupal-graphql.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-blocks.md +4 -5
- package/.agents/commands/drupal-list-content-types.md +15 -0
- package/.agents/commands/drupal-list-entity-types.md +15 -0
- package/.agents/commands/drupal-list-media-types.md +15 -0
- package/.agents/commands/drupal-list-media.md +20 -0
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +4 -5
- package/.agents/commands/drupal-list-moderation-states.md +19 -0
- package/{.claude → .agents}/commands/drupal-list-nodes.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-revisions.md +4 -5
- package/.agents/commands/drupal-list-roles.md +15 -0
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +4 -5
- package/.agents/commands/drupal-list-users.md +19 -0
- package/.agents/commands/drupal-list-vocabularies.md +15 -0
- package/.agents/commands/drupal-mcp-whoami.md +15 -0
- package/.agents/commands/drupal-report-404-log.md +16 -0
- package/.agents/commands/drupal-report-accessibility-audit.md +17 -0
- package/.agents/commands/drupal-report-alias-coverage.md +17 -0
- package/.agents/commands/drupal-report-broken-embeds.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +4 -5
- package/.agents/commands/drupal-report-cache-config.md +15 -0
- package/.agents/commands/drupal-report-config-drift.md +15 -0
- package/.agents/commands/drupal-report-content-by-author.md +17 -0
- package/.agents/commands/drupal-report-content-summary.md +15 -0
- package/.agents/commands/drupal-report-duplicate-content.md +17 -0
- package/.agents/commands/drupal-report-field-completeness.md +20 -0
- package/.agents/commands/drupal-report-menu-integrity.md +16 -0
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +4 -5
- package/.agents/commands/drupal-report-module-audit.md +15 -0
- package/.agents/commands/drupal-report-orphan-pages.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +4 -5
- package/.agents/commands/drupal-report-permission-audit.md +15 -0
- package/.agents/commands/drupal-report-pii-exposure.md +18 -0
- package/.agents/commands/drupal-report-readability.md +18 -0
- package/.agents/commands/drupal-report-recently-published.md +17 -0
- package/.agents/commands/drupal-report-redirect-health.md +16 -0
- package/.agents/commands/drupal-report-revision-hotspots.md +17 -0
- package/.agents/commands/drupal-report-scheduled-content.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +4 -5
- package/.agents/commands/drupal-report-seo-meta-coverage.md +18 -0
- package/.agents/commands/drupal-report-stale-content.md +19 -0
- package/.agents/commands/drupal-report-status-report.md +16 -0
- package/.agents/commands/drupal-report-taxonomy-usage.md +21 -0
- package/.agents/commands/drupal-report-text-format-audit.md +15 -0
- package/.agents/commands/drupal-report-translation-coverage.md +18 -0
- package/.agents/commands/drupal-report-unpublished.md +17 -0
- package/.agents/commands/drupal-report-user-activity.md +17 -0
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +19 -0
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +4 -5
- package/{.claude → .agents}/commands/drupal-revert-revision.md +4 -5
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +4 -5
- package/.agents/commands/drupal-search-content.md +21 -0
- package/.agents/commands/drupal-search.md +20 -0
- package/.agents/commands/drupal-security-info.md +15 -0
- package/.agents/commands/drupal-set-moderation-state.md +20 -0
- package/.agents/commands/drupal-site-info.md +15 -0
- package/.agents/commands/drupal-update-media.md +22 -0
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-node.md +10 -11
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-redirect.md +4 -5
- package/.agents/commands/drupal-update-taxonomy-term.md +22 -0
- package/.agents/commands/drupal-update-user.md +24 -0
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-upload-file.md +4 -5
- package/CHANGELOG.md +80 -0
- package/README.md +23 -12
- 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 +99 -30
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/operations.js +39 -2
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +15 -1
- package/src/lib/server-tools.js +9 -0
- package/src/lib/site-target.js +78 -0
- package/src/lib/tool-prompts.js +2 -1
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/config.js +5 -2
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +24 -3
- package/src/tools/moderation.js +88 -9
- package/src/tools/nodes.js +55 -43
- package/.claude/commands/drupal-audit-config-best-practices.md +0 -16
- package/.claude/commands/drupal-block-user.md +0 -20
- package/.claude/commands/drupal-config-get.md +0 -19
- package/.claude/commands/drupal-config-list.md +0 -17
- package/.claude/commands/drupal-config-set.md +0 -20
- package/.claude/commands/drupal-content-by-moderation-state.md +0 -22
- package/.claude/commands/drupal-create-block.md +0 -21
- package/.claude/commands/drupal-create-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-create-user.md +0 -24
- package/.claude/commands/drupal-delete-media.md +0 -22
- package/.claude/commands/drupal-delete-node.md +0 -23
- package/.claude/commands/drupal-delete-taxonomy-term.md +0 -22
- package/.claude/commands/drupal-drush-cache-rebuild.md +0 -16
- package/.claude/commands/drupal-drush-config-export.md +0 -16
- package/.claude/commands/drupal-drush-config-import.md +0 -16
- package/.claude/commands/drupal-drush-config-status.md +0 -16
- package/.claude/commands/drupal-drush-cron.md +0 -16
- package/.claude/commands/drupal-drush-module-disable.md +0 -21
- package/.claude/commands/drupal-drush-module-enable.md +0 -19
- package/.claude/commands/drupal-drush-module-list.md +0 -17
- package/.claude/commands/drupal-drush-security-updates.md +0 -16
- package/.claude/commands/drupal-drush-status.md +0 -16
- package/.claude/commands/drupal-drush-updatedb.md +0 -16
- package/.claude/commands/drupal-drush-user-create.md +0 -22
- package/.claude/commands/drupal-drush-user-list.md +0 -19
- package/.claude/commands/drupal-drush-watchdog.md +0 -19
- package/.claude/commands/drupal-entity-delete.md +0 -24
- package/.claude/commands/drupal-entity-get.md +0 -22
- package/.claude/commands/drupal-find-orphaned-media.md +0 -18
- package/.claude/commands/drupal-get-entity-schema.md +0 -20
- package/.claude/commands/drupal-get-media.md +0 -20
- package/.claude/commands/drupal-get-node.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-term.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-terms.md +0 -21
- package/.claude/commands/drupal-get-user-by-name.md +0 -19
- package/.claude/commands/drupal-get-user.md +0 -19
- package/.claude/commands/drupal-graphql-introspect.md +0 -17
- package/.claude/commands/drupal-list-content-types.md +0 -16
- package/.claude/commands/drupal-list-entity-types.md +0 -16
- package/.claude/commands/drupal-list-media-types.md +0 -16
- package/.claude/commands/drupal-list-media.md +0 -21
- package/.claude/commands/drupal-list-moderation-states.md +0 -20
- package/.claude/commands/drupal-list-roles.md +0 -16
- package/.claude/commands/drupal-list-users.md +0 -20
- package/.claude/commands/drupal-list-vocabularies.md +0 -16
- package/.claude/commands/drupal-mcp-whoami.md +0 -16
- package/.claude/commands/drupal-report-404-log.md +0 -17
- package/.claude/commands/drupal-report-accessibility-audit.md +0 -18
- package/.claude/commands/drupal-report-alias-coverage.md +0 -18
- package/.claude/commands/drupal-report-broken-embeds.md +0 -18
- package/.claude/commands/drupal-report-cache-config.md +0 -16
- package/.claude/commands/drupal-report-config-drift.md +0 -16
- package/.claude/commands/drupal-report-content-by-author.md +0 -18
- package/.claude/commands/drupal-report-content-summary.md +0 -16
- package/.claude/commands/drupal-report-duplicate-content.md +0 -18
- package/.claude/commands/drupal-report-field-completeness.md +0 -21
- package/.claude/commands/drupal-report-menu-integrity.md +0 -17
- package/.claude/commands/drupal-report-module-audit.md +0 -16
- package/.claude/commands/drupal-report-orphan-pages.md +0 -18
- package/.claude/commands/drupal-report-permission-audit.md +0 -16
- package/.claude/commands/drupal-report-pii-exposure.md +0 -19
- package/.claude/commands/drupal-report-readability.md +0 -19
- package/.claude/commands/drupal-report-recently-published.md +0 -18
- package/.claude/commands/drupal-report-redirect-health.md +0 -17
- package/.claude/commands/drupal-report-revision-hotspots.md +0 -18
- package/.claude/commands/drupal-report-scheduled-content.md +0 -18
- package/.claude/commands/drupal-report-seo-meta-coverage.md +0 -19
- package/.claude/commands/drupal-report-stale-content.md +0 -20
- package/.claude/commands/drupal-report-status-report.md +0 -17
- package/.claude/commands/drupal-report-taxonomy-usage.md +0 -22
- package/.claude/commands/drupal-report-text-format-audit.md +0 -16
- package/.claude/commands/drupal-report-translation-coverage.md +0 -19
- package/.claude/commands/drupal-report-unpublished.md +0 -18
- package/.claude/commands/drupal-report-user-activity.md +0 -18
- package/.claude/commands/drupal-report-workflow-bottlenecks.md +0 -20
- package/.claude/commands/drupal-search-content.md +0 -22
- package/.claude/commands/drupal-search.md +0 -21
- package/.claude/commands/drupal-security-info.md +0 -16
- package/.claude/commands/drupal-set-moderation-state.md +0 -21
- package/.claude/commands/drupal-site-info.md +0 -16
- package/.claude/commands/drupal-update-media.md +0 -23
- package/.claude/commands/drupal-update-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-update-user.md +0 -25
package/src/lib/drupal-fetch.js
CHANGED
|
@@ -7,6 +7,13 @@ import { createReadStream, statSync } from "fs";
|
|
|
7
7
|
import { basename } from "path";
|
|
8
8
|
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
9
9
|
import { clearToken } from "./oauth.js";
|
|
10
|
+
import {
|
|
11
|
+
accountNorthboundBody,
|
|
12
|
+
consumeBudgetIfEnforced,
|
|
13
|
+
isCollectionJsonApiPath,
|
|
14
|
+
northboundHeaders,
|
|
15
|
+
sourceBudgetDenial,
|
|
16
|
+
} from "./data-flow.js";
|
|
10
17
|
import {
|
|
11
18
|
assertUploadPathAllowed,
|
|
12
19
|
sanitizeUploadFilename,
|
|
@@ -15,6 +22,25 @@ import {
|
|
|
15
22
|
|
|
16
23
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read a 2xx body once. Prefers text() so byte accounting can see the payload;
|
|
27
|
+
* falls back to json() for test doubles that only implement that.
|
|
28
|
+
* @param {object} res node-fetch Response or test double.
|
|
29
|
+
* @returns {Promise<{json: object|null, text: string}>}
|
|
30
|
+
*/
|
|
31
|
+
async function readOkBody(res) {
|
|
32
|
+
if (typeof res.text === "function") {
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
if (!text) return { json: null, text: "" };
|
|
35
|
+
try {
|
|
36
|
+
return { json: JSON.parse(text), text };
|
|
37
|
+
} catch {
|
|
38
|
+
return { json: null, text };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { json: await res.json(), text: "" };
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
/**
|
|
19
45
|
* Standard JSON:API request against a site.
|
|
20
46
|
*
|
|
@@ -28,30 +54,38 @@ const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
|
28
54
|
*/
|
|
29
55
|
export async function drupalFetch(site, path, options = {}) {
|
|
30
56
|
const url = `${site.baseUrl}${path}`;
|
|
57
|
+
const collection = isCollectionJsonApiPath(path);
|
|
58
|
+
let paid = false;
|
|
31
59
|
|
|
32
|
-
async function attempt() {
|
|
60
|
+
async function attempt(isRetry = false) {
|
|
61
|
+
consumeBudgetIfEnforced("request", 1, { retry: isRetry || paid });
|
|
62
|
+
if (collection) consumeBudgetIfEnforced("page", 1, { retry: isRetry || paid });
|
|
63
|
+
paid = true;
|
|
33
64
|
return fetch(url, {
|
|
34
65
|
...options,
|
|
35
66
|
headers: {
|
|
36
67
|
"Content-Type": JSON_API_CONTENT_TYPE,
|
|
37
68
|
Accept: JSON_API_CONTENT_TYPE,
|
|
38
69
|
...clientHeaders(),
|
|
39
|
-
...(await authHeadersAsync(site)),
|
|
40
70
|
...(options.headers || {}),
|
|
71
|
+
...northboundHeaders(),
|
|
72
|
+
...(await authHeadersAsync(site)),
|
|
41
73
|
},
|
|
42
74
|
});
|
|
43
75
|
}
|
|
44
76
|
|
|
45
|
-
let res = await attempt();
|
|
77
|
+
let res = await attempt(false);
|
|
46
78
|
|
|
47
79
|
// OAuth sites: a 401 may mean the token expired server-side. Refresh once.
|
|
48
80
|
if (res.status === 401 && site.oauth) {
|
|
49
81
|
clearToken(site);
|
|
50
|
-
res = await attempt();
|
|
82
|
+
res = await attempt(true);
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
if (!res.ok) {
|
|
54
86
|
const body = await res.text();
|
|
87
|
+
const mapped = sourceBudgetDenial(body);
|
|
88
|
+
if (mapped) throw mapped;
|
|
55
89
|
let detail = body;
|
|
56
90
|
try {
|
|
57
91
|
const parsed = JSON.parse(body);
|
|
@@ -64,7 +98,9 @@ export async function drupalFetch(site, path, options = {}) {
|
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
if (res.status === 204) return null; // No Content (e.g. DELETE success)
|
|
67
|
-
|
|
101
|
+
const { json, text } = await readOkBody(res);
|
|
102
|
+
accountNorthboundBody(json, text);
|
|
103
|
+
return json;
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
/**
|
|
@@ -78,12 +114,14 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
78
114
|
const endpoint = site.graphqlEndpoint || "/graphql";
|
|
79
115
|
const url = `${site.baseUrl}${endpoint}`;
|
|
80
116
|
|
|
117
|
+
consumeBudgetIfEnforced("request");
|
|
81
118
|
const res = await fetch(url, {
|
|
82
119
|
method: "POST",
|
|
83
120
|
headers: {
|
|
84
121
|
"Content-Type": "application/json",
|
|
85
122
|
Accept: "application/json",
|
|
86
123
|
...clientHeaders(),
|
|
124
|
+
...northboundHeaders(),
|
|
87
125
|
...(await authHeadersAsync(site)),
|
|
88
126
|
},
|
|
89
127
|
body: JSON.stringify(body),
|
|
@@ -94,10 +132,14 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
94
132
|
// cached token so the next request re-acquires, then surface the error.
|
|
95
133
|
if (res.status === 401 && site.oauth) clearToken(site);
|
|
96
134
|
const text = await res.text();
|
|
135
|
+
const mapped = sourceBudgetDenial(text);
|
|
136
|
+
if (mapped) throw mapped;
|
|
97
137
|
throw new Error(`GraphQL request failed ${res.status}: ${text}`);
|
|
98
138
|
}
|
|
99
139
|
|
|
100
|
-
|
|
140
|
+
const { json, text } = await readOkBody(res);
|
|
141
|
+
accountNorthboundBody(json, text);
|
|
142
|
+
return json;
|
|
101
143
|
}
|
|
102
144
|
|
|
103
145
|
/**
|
|
@@ -130,6 +172,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
130
172
|
const stat = statSync(safePath);
|
|
131
173
|
const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
|
|
132
174
|
|
|
175
|
+
consumeBudgetIfEnforced("request");
|
|
133
176
|
const res = await fetch(url, {
|
|
134
177
|
method: "POST",
|
|
135
178
|
headers: {
|
|
@@ -137,6 +180,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
137
180
|
"Content-Disposition": `file; filename="${filename}"`,
|
|
138
181
|
Accept: JSON_API_CONTENT_TYPE,
|
|
139
182
|
...clientHeaders(),
|
|
183
|
+
...northboundHeaders(),
|
|
140
184
|
...(await authHeadersAsync(site)),
|
|
141
185
|
},
|
|
142
186
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
@@ -147,6 +191,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
147
191
|
|
|
148
192
|
if (!res.ok) {
|
|
149
193
|
const body = await res.text();
|
|
194
|
+
const mapped = sourceBudgetDenial(body);
|
|
195
|
+
if (mapped) throw mapped;
|
|
150
196
|
throw new Error(`File upload failed ${res.status}: ${body}`);
|
|
151
197
|
}
|
|
152
198
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoritative text-format resolution for node writes (#168).
|
|
3
|
+
*
|
|
4
|
+
* `backend.getEntitySchema` is sampling-only: it can label a field
|
|
5
|
+
* `text_formatted` / `text_with_summary` but does NOT expose Field API
|
|
6
|
+
* `allowed_formats`. Never invent that list from `defaultTextFormat` or
|
|
7
|
+
* `FALLBACK_TEXT_FORMAT` (`full_html`).
|
|
8
|
+
*
|
|
9
|
+
* Resolution chain for a field definition:
|
|
10
|
+
* 1. `backend.getFieldDefinition` — JSON:API `field_config` via an internal
|
|
11
|
+
* adapter fetch (not `drupal_entity_get`; `field_config` is on the agent
|
|
12
|
+
* deny list).
|
|
13
|
+
* 2. Drush `config:get field.field.{entityType}.{bundle}.{field}` when a
|
|
14
|
+
* Drush bridge is configured.
|
|
15
|
+
* 3. If the definition cannot be resolved: return null. Callers keep the
|
|
16
|
+
* historical default chain only while the list is unknown. Once the list
|
|
17
|
+
* is known, never persist a format outside it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { drushConfigured } from "./audit-sources.js";
|
|
21
|
+
import { validateMachineName } from "./validate.js";
|
|
22
|
+
import { parseDrush, sshDrush } from "../tools/drush.js";
|
|
23
|
+
|
|
24
|
+
/** Last-resort body format when the Field API list cannot be resolved. */
|
|
25
|
+
export const FALLBACK_TEXT_FORMAT = "full_html";
|
|
26
|
+
|
|
27
|
+
const FORMATTED_FIELD_TYPES = new Set(["text", "text_long", "text_with_summary"]);
|
|
28
|
+
|
|
29
|
+
const SKIP_FORMAT_FIELDS = new Set([
|
|
30
|
+
"title", "status", "moderation_state", "path", "promote", "sticky",
|
|
31
|
+
"created", "changed", "langcode",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a Field API `allowed_formats` value to a string list.
|
|
36
|
+
* @param {*} raw settings.allowed_formats from field_config / Drush.
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function asFormatList(raw) {
|
|
40
|
+
if (Array.isArray(raw)) return raw.filter((f) => typeof f === "string" && f);
|
|
41
|
+
if (raw && typeof raw === "object") {
|
|
42
|
+
return [...new Map(Object.entries(raw)).values()].filter((f) => typeof f === "string" && f);
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a field_config resource or `field.field.*` config object.
|
|
49
|
+
* @param {object} obj Config / JSON:API attributes.
|
|
50
|
+
* @param {string} [fallbackName] Field name when the object omits `field_name`.
|
|
51
|
+
* @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
|
|
52
|
+
*/
|
|
53
|
+
export function parseFieldConfigObject(obj, fallbackName) {
|
|
54
|
+
if (!obj || typeof obj !== "object") return null;
|
|
55
|
+
const attrs = new Map(Object.entries(obj));
|
|
56
|
+
const fieldName = attrs.get("field_name") || fallbackName;
|
|
57
|
+
if (typeof fieldName !== "string" || !fieldName) return null;
|
|
58
|
+
const settings = attrs.get("settings");
|
|
59
|
+
const settingsMap = settings && typeof settings === "object" && !Array.isArray(settings)
|
|
60
|
+
? new Map(Object.entries(settings))
|
|
61
|
+
: new Map();
|
|
62
|
+
const fieldType = attrs.get("field_type");
|
|
63
|
+
return {
|
|
64
|
+
fieldName,
|
|
65
|
+
fieldType: typeof fieldType === "string" ? fieldType : null,
|
|
66
|
+
allowedFormats: asFormatList(settingsMap.get("allowed_formats")),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pick the config object out of a Drush `config:get --format=json` payload.
|
|
72
|
+
* @param {*} raw Parsed Drush JSON.
|
|
73
|
+
* @param {string} configName `field.field.{type}.{bundle}.{field}`.
|
|
74
|
+
* @returns {?object}
|
|
75
|
+
*/
|
|
76
|
+
function unwrapDrushConfig(raw, configName) {
|
|
77
|
+
if (!raw || typeof raw !== "object") return null;
|
|
78
|
+
const map = new Map(Object.entries(raw));
|
|
79
|
+
if (map.has("field_type") || map.has("settings")) return raw;
|
|
80
|
+
const named = map.get(configName);
|
|
81
|
+
if (named && typeof named === "object") return named;
|
|
82
|
+
if (map.size === 1) {
|
|
83
|
+
const only = [...map.values()][0];
|
|
84
|
+
if (only && typeof only === "object") return only;
|
|
85
|
+
}
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a field definition via Drush `config:get` (chain step 2).
|
|
91
|
+
* @param {object} site Site config with `drushSsh`.
|
|
92
|
+
* @param {string} entityType
|
|
93
|
+
* @param {string} bundle
|
|
94
|
+
* @param {string} fieldName
|
|
95
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
96
|
+
*/
|
|
97
|
+
export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldName) {
|
|
98
|
+
validateMachineName(entityType, "entityType");
|
|
99
|
+
validateMachineName(bundle, "bundle");
|
|
100
|
+
validateMachineName(fieldName, "fieldName");
|
|
101
|
+
const configName = `field.field.${entityType}.${bundle}.${fieldName}`;
|
|
102
|
+
try {
|
|
103
|
+
const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
|
|
104
|
+
return parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve Field API metadata. See the file header for the chain.
|
|
112
|
+
* @param {object} backend Resolved backend.
|
|
113
|
+
* @param {object} site Site config.
|
|
114
|
+
* @param {string} entityType
|
|
115
|
+
* @param {string} bundle
|
|
116
|
+
* @param {string} fieldName
|
|
117
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
118
|
+
*/
|
|
119
|
+
export async function resolveFieldDefinition(backend, site, entityType, bundle, fieldName) {
|
|
120
|
+
if (typeof backend?.getFieldDefinition === "function") {
|
|
121
|
+
try {
|
|
122
|
+
const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
|
|
123
|
+
if (def) return parseFieldConfigObject({
|
|
124
|
+
field_name: def.fieldName,
|
|
125
|
+
field_type: def.fieldType,
|
|
126
|
+
settings: { allowed_formats: def.allowedFormats },
|
|
127
|
+
}, fieldName) ?? def;
|
|
128
|
+
} catch {
|
|
129
|
+
// JSON:API field_config is optional; try Drush next.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (drushConfigured(site)) {
|
|
133
|
+
const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
|
|
134
|
+
if (def) return def;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Choose the format to persist, or throw before mutation.
|
|
141
|
+
*
|
|
142
|
+
* Known list + exactly one entry → that format when the caller omits one.
|
|
143
|
+
* Known list + caller format outside it → refuse.
|
|
144
|
+
* Known list + several entries + omitted format → site `defaultTextFormat`
|
|
145
|
+
* only if it is in the list; otherwise refuse (never `full_html` when it
|
|
146
|
+
* is not allowed).
|
|
147
|
+
* Unknown list (`allowedFormats` is null): historical default chain only
|
|
148
|
+
* when `defaultWhenUnknown` is true (body); otherwise leave format omitted.
|
|
149
|
+
*
|
|
150
|
+
* @param {{fieldName: string, requested: ?string, allowedFormats: ?string[],
|
|
151
|
+
* site?: object, defaultWhenUnknown?: boolean}} input
|
|
152
|
+
* @returns {string|undefined}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveTextFormat({
|
|
155
|
+
fieldName, requested, allowedFormats, site, defaultWhenUnknown = false,
|
|
156
|
+
}) {
|
|
157
|
+
const asked = requested === undefined || requested === null || requested === ""
|
|
158
|
+
? undefined
|
|
159
|
+
: String(requested);
|
|
160
|
+
|
|
161
|
+
if (!Array.isArray(allowedFormats)) {
|
|
162
|
+
if (asked !== undefined) return asked;
|
|
163
|
+
if (!defaultWhenUnknown) return undefined;
|
|
164
|
+
return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (allowedFormats.length === 0) {
|
|
168
|
+
if (asked !== undefined) return asked;
|
|
169
|
+
if (!defaultWhenUnknown) return undefined;
|
|
170
|
+
return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (asked !== undefined) {
|
|
174
|
+
if (!allowedFormats.includes(asked)) {
|
|
175
|
+
throw textFormatError(fieldName, asked, allowedFormats);
|
|
176
|
+
}
|
|
177
|
+
return asked;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (allowedFormats.length === 1) {
|
|
181
|
+
return allowedFormats[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const siteDefault = site?.defaultTextFormat;
|
|
185
|
+
if (siteDefault && allowedFormats.includes(siteDefault)) {
|
|
186
|
+
return siteDefault;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw textFormatError(fieldName, siteDefault ?? FALLBACK_TEXT_FORMAT, allowedFormats);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} fieldName
|
|
194
|
+
* @param {string} requested
|
|
195
|
+
* @param {string[]} allowed
|
|
196
|
+
* @returns {Error}
|
|
197
|
+
*/
|
|
198
|
+
function textFormatError(fieldName, requested, allowed) {
|
|
199
|
+
return new Error(
|
|
200
|
+
`Field "${fieldName}" does not allow format "${requested}" ` +
|
|
201
|
+
`(allowed: ${allowed.join(", ")}).`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a payload value is already a formatted-text object.
|
|
207
|
+
* @param {*} value
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
function isFormattedShape(value) {
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
212
|
+
if (!Object.prototype.hasOwnProperty.call(value, "value")) return false;
|
|
213
|
+
return ["format", "summary", "processed"].some((k) => Object.prototype.hasOwnProperty.call(value, k));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {*} value
|
|
218
|
+
* @returns {string|undefined}
|
|
219
|
+
*/
|
|
220
|
+
function requestedFormatOf(value) {
|
|
221
|
+
if (value && typeof value === "object" && !Array.isArray(value)
|
|
222
|
+
&& Object.prototype.hasOwnProperty.call(value, "format")) {
|
|
223
|
+
return value.format;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {*} raw
|
|
230
|
+
* @param {string|undefined} format
|
|
231
|
+
* @returns {{value: *, format?: string, summary?: *}}
|
|
232
|
+
*/
|
|
233
|
+
function normalizeFormattedValue(raw, format) {
|
|
234
|
+
if (typeof raw === "string") {
|
|
235
|
+
const out = { value: raw };
|
|
236
|
+
if (format !== undefined) out.format = format;
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
const out = { value: raw.value };
|
|
240
|
+
if (format !== undefined) out.format = format;
|
|
241
|
+
if (Object.prototype.hasOwnProperty.call(raw, "summary")) out.summary = raw.summary;
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Default and validate text formats on a write attribute map (mutates it).
|
|
247
|
+
* Same checks run for dry-run and real writes so a disallowed format never
|
|
248
|
+
* reaches create/update.
|
|
249
|
+
*
|
|
250
|
+
* @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object}} input
|
|
251
|
+
* @returns {Promise<object>} The same attributes object, with formats resolved.
|
|
252
|
+
*/
|
|
253
|
+
export async function applyAllowedFormatsToAttributes({
|
|
254
|
+
backend, site, entityType, bundle, attributes,
|
|
255
|
+
}) {
|
|
256
|
+
const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
|
|
257
|
+
for (const fieldName of names) {
|
|
258
|
+
const value = new Map(Object.entries(attributes)).get(fieldName);
|
|
259
|
+
if (value === undefined || value === null) continue;
|
|
260
|
+
if (typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) continue;
|
|
261
|
+
|
|
262
|
+
const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
|
|
263
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
264
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
265
|
+
const isBody = fieldName === "body";
|
|
266
|
+
const shaped = isFormattedShape(value);
|
|
267
|
+
|
|
268
|
+
if (def && !formattedType && !restricted && !isBody) continue;
|
|
269
|
+
if (!def && !shaped && !isBody) continue;
|
|
270
|
+
|
|
271
|
+
const format = resolveTextFormat({
|
|
272
|
+
fieldName,
|
|
273
|
+
requested: requestedFormatOf(value),
|
|
274
|
+
allowedFormats: def ? def.allowedFormats : null,
|
|
275
|
+
site,
|
|
276
|
+
defaultWhenUnknown: isBody,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (format === undefined && !formattedType && !isBody && !shaped) continue;
|
|
280
|
+
Object.assign(attributes, Object.fromEntries([
|
|
281
|
+
[fieldName, normalizeFormattedValue(value, format)],
|
|
282
|
+
]));
|
|
283
|
+
}
|
|
284
|
+
return attributes;
|
|
285
|
+
}
|
package/src/lib/operations.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* warnings surfaced to users can never drift apart.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { graphqlHasMutation } from "./security.js";
|
|
14
|
+
|
|
13
15
|
export const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
|
|
14
16
|
"drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
|
|
15
17
|
"drupal_drush_config_export", "drupal_drush_config_import",
|
|
@@ -39,8 +41,13 @@ export function inferOperation(toolName) {
|
|
|
39
41
|
// Generic entity tools carry their target type/bundle in ARGS, not the tool name,
|
|
40
42
|
// so the security middleware gates them inside their handlers (assertDeleteAllowed /
|
|
41
43
|
// assertWriteAllowed with full context) and inferOperation() intentionally leaves
|
|
42
|
-
// them classified "read".
|
|
43
|
-
// still
|
|
44
|
+
// them classified "read". Write-site gating and the user-facing confirm-first
|
|
45
|
+
// WARNING still classify them by name.
|
|
46
|
+
const ENTITY_WRITE_TOOLS = new Set([
|
|
47
|
+
"drupal_entity_create",
|
|
48
|
+
"drupal_entity_update",
|
|
49
|
+
"drupal_entity_delete",
|
|
50
|
+
]);
|
|
44
51
|
const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -55,3 +62,33 @@ const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
|
|
|
55
62
|
export function isDestructiveTool(toolName) {
|
|
56
63
|
return inferOperation(toolName) === "delete" || DESTRUCTIVE_ENTITY_TOOLS.has(toolName);
|
|
57
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a tool is a write or delete regardless of how inferOperation classifies it.
|
|
68
|
+
* Covers the generic `drupal_entity_{create,update,delete}` tools that self-gate
|
|
69
|
+
* inside their handlers and are therefore left as "read" by prefix inference.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} toolName - The MCP tool name.
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function isWriteLikeTool(toolName) {
|
|
75
|
+
if (ENTITY_WRITE_TOOLS.has(toolName)) return true;
|
|
76
|
+
const op = inferOperation(toolName);
|
|
77
|
+
return op === "write" || op === "delete";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether this invocation mutates the target. GraphQL is a write only when the
|
|
82
|
+
* document contains a mutation; a query is still a read.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} toolName
|
|
85
|
+
* @param {object} [args]
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isWriteLikeCall(toolName, args = {}) {
|
|
89
|
+
if (isWriteLikeTool(toolName)) return true;
|
|
90
|
+
if (inferOperation(toolName) === "graphql" && typeof args.query === "string") {
|
|
91
|
+
return graphqlHasMutation(args.query);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|