drupal-mcp-connector 2.26.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/commands/drupal-audit-site-health.md +1 -1
- package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
- package/.agents/commands/drupal-report-alias-coverage.md +4 -2
- package/.agents/commands/drupal-report-broken-embeds.md +4 -2
- package/.agents/commands/drupal-report-broken-links.md +4 -2
- package/.agents/commands/drupal-report-content-by-author.md +4 -2
- package/.agents/commands/drupal-report-duplicate-content.md +4 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-orphan-pages.md +4 -2
- package/.agents/commands/drupal-report-orphaned-references.md +4 -2
- package/.agents/commands/drupal-report-pii-exposure.md +4 -2
- package/.agents/commands/drupal-report-readability.md +4 -2
- package/.agents/commands/drupal-report-recently-published.md +1 -1
- package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
- package/.agents/commands/drupal-report-scheduled-content.md +4 -4
- package/.agents/commands/drupal-report-seo-audit.md +4 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
- package/.agents/commands/drupal-report-stale-content.md +4 -2
- package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -2
- package/.agents/commands/drupal-report-unpublished.md +1 -1
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
- package/.agents/commands/drupal-schedule-publish.md +4 -4
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/CHANGELOG.md +53 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/http-auth.js +2 -2
- package/src/lib/reports-support.js +68 -0
- package/src/lib/verify.js +1 -1
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/nodes.js +12 -6
- package/src/tools/reports-content.js +106 -38
- package/src/tools/reports-extra.js +56 -32
- package/src/tools/reports-links.js +13 -13
- package/src/tools/reports.js +65 -42
- package/src/tools/scheduler.js +90 -62
- package/src/tools/search.js +16 -12
package/src/tools/scheduler.js
CHANGED
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* forward it unchanged (the JSON:API backend coerces datetime fields as needed).
|
|
17
17
|
*
|
|
18
18
|
* Capability degradation: if the Scheduler module is not installed, or the bundle
|
|
19
|
-
* does not have the publish_on / unpublish_on fields, the
|
|
20
|
-
*
|
|
21
|
-
* (while still surfacing the underlying backend error)
|
|
19
|
+
* does not have the publish_on / unpublish_on fields, JSON:API rejects the write
|
|
20
|
+
* because the attribute does not exist. We catch that and re-throw a clear,
|
|
21
|
+
* actionable message (while still surfacing the underlying backend error).
|
|
22
|
+
*
|
|
23
|
+
* The connector does not decide who may schedule which transition. Drupal's
|
|
24
|
+
* validation (SchedulerModerationTransitionAccess) and the site's MCP Sentinel
|
|
25
|
+
* policy are the gate; their refusals are surfaced verbatim. The only local
|
|
26
|
+
* refusal is a date with no matching state on a bundle that exposes the state
|
|
27
|
+
* field, because Drupal accepts that write and the schedule then never runs.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import { getSiteConfig } from "../lib/config.js";
|
|
@@ -26,18 +32,43 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
26
32
|
import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
27
33
|
import { entityLooksModerated } from "../lib/moderation-default.js";
|
|
28
34
|
import { fieldPresence } from "../lib/reports-support.js";
|
|
35
|
+
import { httpStatusOf } from "../lib/error-status.js";
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
* Does this backend error say a Scheduler attribute does not exist on the
|
|
39
|
+
* bundle? Only Drupal JSON:API's explicit form counts ("The attribute
|
|
40
|
+
* publish_on does not exist on the node--page resource type."). A 422
|
|
41
|
+
* validation detail names the field too (`publish_state: …`) and may say
|
|
42
|
+
* "invalid" or "unknown"; that is a refusal by the site, surfaced verbatim,
|
|
43
|
+
* not a missing capability (#402).
|
|
34
44
|
*
|
|
35
45
|
* @param {Error} err - The error thrown by the backend write.
|
|
36
46
|
* @returns {boolean}
|
|
37
47
|
*/
|
|
38
48
|
export function looksLikeUnknownField(err) {
|
|
39
|
-
const msg = String(err?.message || err || "");
|
|
40
|
-
return
|
|
49
|
+
const msg = String(err?.message || err || "").slice(0, 2000);
|
|
50
|
+
return /\battribute [\w.-]{1,128} does not exist on the [\w.-]{1,128} resource type\b/i.test(msg);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a site refusal so the reason is the first thing the caller reads. The
|
|
55
|
+
* original message (with Drupal's or Sentinel's reason) is kept verbatim and
|
|
56
|
+
* the HTTP status is carried over.
|
|
57
|
+
*
|
|
58
|
+
* @param {Error} err - The error thrown by the backend write.
|
|
59
|
+
* @param {string} type - Bundle machine name.
|
|
60
|
+
* @returns {Error}
|
|
61
|
+
*/
|
|
62
|
+
function siteRefusal(err, type) {
|
|
63
|
+
const status = httpStatusOf(err);
|
|
64
|
+
const reason = err?.message || String(err);
|
|
65
|
+
const out = new Error(
|
|
66
|
+
`The site refused the schedule on '${type}'${status ? ` (HTTP ${status})` : ""}. ` +
|
|
67
|
+
`Drupal validation or the site's MCP Sentinel policy decides scheduled transitions; the connector does not. Reason: ${reason}`,
|
|
68
|
+
);
|
|
69
|
+
if (status) out.status = status;
|
|
70
|
+
out.cause = err;
|
|
71
|
+
return out;
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
/**
|
|
@@ -59,14 +90,17 @@ function schedulerFieldPresent(entity, schema, field) {
|
|
|
59
90
|
* canonical updateEntity. At least one of publishOn / unpublishOn must be given.
|
|
60
91
|
* Each value is forwarded unchanged (ISO 8601 string or epoch integer).
|
|
61
92
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
93
|
+
* `publishState` / `unpublishState` are written as `publish_state` /
|
|
94
|
+
* `unpublish_state`. When the bundle exposes the matching state field, a date
|
|
95
|
+
* without its state is refused locally (#402); every other decision is Drupal's.
|
|
64
96
|
*
|
|
65
97
|
* @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
|
|
66
|
-
* @returns {Promise<object>} The redacted, updated node descriptor
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* if
|
|
98
|
+
* @returns {Promise<object>} The redacted, updated node descriptor, with
|
|
99
|
+
* `warnings` when a moderated bundle has no state field to pair a date with.
|
|
100
|
+
* @throws {Error} If neither timestamp is supplied, if connector policy forbids
|
|
101
|
+
* the write, if a date is missing its state on a bundle with the state field,
|
|
102
|
+
* if the site refuses the write (reason kept verbatim), or — degraded — if the
|
|
103
|
+
* Scheduler fields are unknown on the bundle.
|
|
70
104
|
*/
|
|
71
105
|
async function schedulePublish({
|
|
72
106
|
site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
|
|
@@ -96,40 +130,28 @@ async function schedulePublish({
|
|
|
96
130
|
}
|
|
97
131
|
}
|
|
98
132
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
);
|
|
116
|
-
}
|
|
133
|
+
// A state field on the bundle means Scheduler Content Moderation Integration
|
|
134
|
+
// is active there. Drupal accepts a date with no state, and the schedule then
|
|
135
|
+
// fails at every cron run, so that one case is refused here (#402).
|
|
136
|
+
const pairs = [
|
|
137
|
+
["publishOn", publishOn, "publishState", publishState, "publish_state", "published"],
|
|
138
|
+
["unpublishOn", unpublishOn, "unpublishState", unpublishState, "unpublish_state", "archived"],
|
|
139
|
+
];
|
|
140
|
+
const warnings = [];
|
|
141
|
+
for (const [dateArg, date, stateArg, state, stateField, example] of pairs) {
|
|
142
|
+
if (date === undefined || state !== undefined) continue;
|
|
143
|
+
if (schedulerFieldPresent(existing, schema, stateField)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`'${type}' is a moderated bundle with Scheduler Content Moderation Integration: pass ${stateArg} ` +
|
|
146
|
+
`(the moderation state to enter at ${dateArg}, e.g. '${example}'). A schedule without a state never runs: ` +
|
|
147
|
+
"Drupal accepts it, then cron logs 'Publishing failed' on every run.",
|
|
148
|
+
);
|
|
117
149
|
}
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"Moderated bundles need the Scheduler Content Moderation Integration module, and an unpublishState " +
|
|
124
|
-
"(e.g. 'draft') whenever unpublishOn is set. See connector #402."
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
if (unpublishState === undefined) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
"Moderated content requires unpublishState (the workflow state to enter at unpublishOn, e.g. 'draft'). " +
|
|
130
|
-
"Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
|
|
131
|
-
);
|
|
132
|
-
}
|
|
150
|
+
if (entityLooksModerated(existing)) {
|
|
151
|
+
warnings.push(
|
|
152
|
+
`'${type}' is moderated but exposes no ${stateField} field. Scheduler cannot change a moderated node ` +
|
|
153
|
+
`without the Scheduler Content Moderation Integration module and a ${stateArg}, so this ${dateArg} may never take effect.`,
|
|
154
|
+
);
|
|
133
155
|
}
|
|
134
156
|
}
|
|
135
157
|
|
|
@@ -145,15 +167,20 @@ async function schedulePublish({
|
|
|
145
167
|
} catch (err) {
|
|
146
168
|
if (looksLikeUnknownField(err)) {
|
|
147
169
|
throw new Error(
|
|
148
|
-
`Could not set Scheduler dates on '${type}':
|
|
149
|
-
"
|
|
150
|
-
"
|
|
170
|
+
`Could not set Scheduler dates on '${type}': a Scheduler field is not available on this bundle. ` +
|
|
171
|
+
"publish_on / unpublish_on need the Drupal Scheduler module enabled for this content type; " +
|
|
172
|
+
"publish_state / unpublish_state also need Scheduler Content Moderation Integration. " +
|
|
151
173
|
`Backend error: ${err?.message || err}`,
|
|
152
174
|
);
|
|
153
175
|
}
|
|
176
|
+
// 403 (access or Sentinel policy) and 422 (validation) are the site's
|
|
177
|
+
// decision on this write. Other failures pass through unchanged.
|
|
178
|
+
const status = httpStatusOf(err);
|
|
179
|
+
if (status === 403 || status === 422) throw siteRefusal(err, type);
|
|
154
180
|
throw err;
|
|
155
181
|
}
|
|
156
|
-
|
|
182
|
+
const out = updated ? redactCanonicalEntity(updated, sec, "node") : updated;
|
|
183
|
+
return out && warnings.length ? { ...out, warnings } : out;
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
// ---------------------------------------------------------------------------
|
|
@@ -165,15 +192,16 @@ export const definitions = [
|
|
|
165
192
|
name: "drupal_schedule_publish",
|
|
166
193
|
description:
|
|
167
194
|
"Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
|
|
168
|
-
"Sets the publish_on and unpublish_on fields on the node. " +
|
|
169
|
-
"
|
|
170
|
-
"(
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
"
|
|
175
|
-
"
|
|
176
|
-
"
|
|
195
|
+
"Sets the publish_on and unpublish_on fields on the node. Provide at least one of publishOn or unpublishOn. " +
|
|
196
|
+
"Moderated bundles (Content Moderation) also need a target state: pass publishState / unpublishState " +
|
|
197
|
+
"(moderation state machine names, e.g. 'published', 'archived'), written as publish_state / unpublish_state. " +
|
|
198
|
+
"These fields exist only when the Scheduler Content Moderation Integration module is enabled. When the bundle " +
|
|
199
|
+
"exposes a state field and its date is set without the state, the call fails, because Drupal would accept a " +
|
|
200
|
+
"schedule that never runs. When a moderated bundle has no state field, the dates are written and the result " +
|
|
201
|
+
"carries a warning. Whether this account may schedule a given transition is decided by the site (Drupal " +
|
|
202
|
+
"validation and MCP Sentinel policy), not the connector; a refusal returns the site's reason verbatim. " +
|
|
203
|
+
"Without the Scheduler module on the content type, the call fails with a capability error. " +
|
|
204
|
+
"Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged.",
|
|
177
205
|
inputSchema: {
|
|
178
206
|
type: "object", required: ["type", "id"],
|
|
179
207
|
properties: {
|
|
@@ -182,8 +210,8 @@ export const definitions = [
|
|
|
182
210
|
id: { type: "string", description: "Node UUID" },
|
|
183
211
|
publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
|
|
184
212
|
unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
|
|
185
|
-
publishState: { type: "string", description: "Moderation state to enter at publishOn (e.g. 'published').
|
|
186
|
-
unpublishState: { type: "string", description: "Moderation state to enter at unpublishOn (e.g. '
|
|
213
|
+
publishState: { type: "string", description: "Moderation state machine name to enter at publishOn (e.g. 'published'). Written as publish_state. Required when publishOn is set and the bundle has a publish_state field." },
|
|
214
|
+
unpublishState: { type: "string", description: "Moderation state machine name to enter at unpublishOn (e.g. 'archived'). Written as unpublish_state. Required when unpublishOn is set and the bundle has an unpublish_state field." },
|
|
187
215
|
},
|
|
188
216
|
},
|
|
189
217
|
},
|
package/src/tools/search.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Tool group: content search.
|
|
3
3
|
*
|
|
4
4
|
* Search API / Solr does not expose a JSON:API surface by default, so this tool
|
|
5
|
-
* provides a best-effort search: a JSON:API title
|
|
6
|
-
* content type. Results are flagged mode:"fallback" and the response notes that
|
|
5
|
+
* provides a best-effort search: a JSON:API title CONTAINS match over one
|
|
6
|
+
* content type, or over every node bundle when `type` is omitted (#403). Results are flagged mode:"fallback" and the response notes that
|
|
7
7
|
* true relevance ranking requires a Search API endpoint (or the Drush bridge).
|
|
8
8
|
* Read-only and redacted per the site policy.
|
|
9
9
|
*/
|
|
@@ -11,28 +11,32 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { resolveSecurityConfig, assertReadAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
14
|
+
import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* Search nodes
|
|
17
|
+
* Search nodes by a query string (title CONTAINS, best-effort). With `type`,
|
|
18
|
+
* searches that bundle; without it, every node bundle the connector may read,
|
|
19
|
+
* merged newest first. A bundle that fails to load fails the search.
|
|
17
20
|
* @param {object} args - { site?, query, type?, limit? }.
|
|
18
|
-
* @returns {Promise<{query, mode, note, results}>}
|
|
21
|
+
* @returns {Promise<{query, type, mode, note, results}>}
|
|
19
22
|
*/
|
|
20
|
-
async function search({ site: siteName, query, type
|
|
23
|
+
async function search({ site: siteName, query, type, limit = 10 }) {
|
|
21
24
|
if (!query || !String(query).trim()) throw new Error("A non-empty 'query' is required.");
|
|
22
25
|
const site = getSiteConfig(siteName);
|
|
23
26
|
const sec = resolveSecurityConfig(site);
|
|
24
27
|
assertReadAllowed(sec, "node", type);
|
|
25
28
|
const backend = await resolveBackend(site);
|
|
26
|
-
const
|
|
27
|
-
entityType: "node", bundle: type,
|
|
29
|
+
const descriptor = {
|
|
28
30
|
filters: [{ field: "title", op: "contains", value: query }],
|
|
29
31
|
sort: [{ field: "changed", dir: "desc" }],
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
};
|
|
33
|
+
const entities = type
|
|
34
|
+
? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
|
|
35
|
+
: await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
|
|
36
|
+
const results = entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
33
37
|
return {
|
|
34
38
|
query,
|
|
35
|
-
type,
|
|
39
|
+
type: type || null,
|
|
36
40
|
mode: "fallback",
|
|
37
41
|
note: "JSON:API title CONTAINS match. Relevance-ranked full-text search requires a Search API/Solr endpoint or the Drush bridge.",
|
|
38
42
|
results,
|
|
@@ -48,7 +52,7 @@ export const definitions = [
|
|
|
48
52
|
properties: {
|
|
49
53
|
site: { type: "string" },
|
|
50
54
|
query: { type: "string", description: "Search term" },
|
|
51
|
-
type: { type: "string", description: "Content type machine name (
|
|
55
|
+
type: { type: "string", description: "Content type machine name. Omit to search every node bundle (merged, newest first); if any bundle cannot be read the search fails and names it." },
|
|
52
56
|
limit: { type: "number", default: 10 },
|
|
53
57
|
},
|
|
54
58
|
},
|