drupal-mcp-connector 2.25.0 → 3.0.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-site-health.md +1 -1
- package/.agents/commands/drupal-entity-update.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 +5 -3
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +114 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/reports-support.js +68 -0
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +17 -9
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +109 -39
- 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/revisions.js +2 -1
- package/src/tools/scheduler.js +142 -27
- package/src/tools/search.js +16 -12
- package/src/tools/translations.js +7 -1
package/src/tools/reports.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
14
|
-
import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince } from "../lib/reports-support.js";
|
|
14
|
+
import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
|
|
15
15
|
import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -68,11 +68,11 @@ async function contentSummary({ site: siteName }) {
|
|
|
68
68
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
69
69
|
*/
|
|
70
70
|
async function staleContent({ site: siteName, type, days = 180, status, limit = 50 }) {
|
|
71
|
+
const contentType = requireContentType(type, "drupal_report_stale_content");
|
|
71
72
|
const site = getSiteConfig(siteName);
|
|
72
73
|
const sec = resolveSecurityConfig(site);
|
|
73
|
-
assertReadAllowed(sec, "node",
|
|
74
|
+
assertReadAllowed(sec, "node", contentType);
|
|
74
75
|
const backend = await resolveBackend(site);
|
|
75
|
-
const contentType = type || "article";
|
|
76
76
|
// `changed` is an integer Unix timestamp (seconds). Filter with epoch seconds,
|
|
77
77
|
// not an ISO string — PostgreSQL rejects the string against an integer column.
|
|
78
78
|
const cutoffMs = Date.now() - days * 86400000;
|
|
@@ -112,11 +112,11 @@ async function staleContent({ site: siteName, type, days = 180, status, limit =
|
|
|
112
112
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
113
113
|
*/
|
|
114
114
|
async function contentByAuthor({ site: siteName, type, limit = 100 }) {
|
|
115
|
+
const contentType = requireContentType(type, "drupal_report_content_by_author");
|
|
115
116
|
const site = getSiteConfig(siteName);
|
|
116
117
|
const sec = resolveSecurityConfig(site);
|
|
117
|
-
assertReadAllowed(sec, "node",
|
|
118
|
+
assertReadAllowed(sec, "node", contentType);
|
|
118
119
|
const backend = await resolveBackend(site);
|
|
119
|
-
const contentType = type || "article";
|
|
120
120
|
const entities = await collectEntities(
|
|
121
121
|
backend,
|
|
122
122
|
{ entityType: "node", bundle: contentType, include: ["uid"] },
|
|
@@ -141,7 +141,10 @@ async function contentByAuthor({ site: siteName, type, limit = 100 }) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* Recently published content
|
|
144
|
+
* Recently published content, newest first. With `type`, reads that bundle.
|
|
145
|
+
* Without it, reads every node bundle the connector may read, merges by
|
|
146
|
+
* `created`, and keeps the newest `limit`; a bundle that fails to load is
|
|
147
|
+
* listed in `bundleErrors` (#403).
|
|
145
148
|
*
|
|
146
149
|
* @param {object} args - { site?, type?, limit? }.
|
|
147
150
|
* @returns {Promise<object>} The most recently created published nodes.
|
|
@@ -152,22 +155,42 @@ async function recentlyPublished({ site: siteName, type, limit = 20 }) {
|
|
|
152
155
|
const sec = resolveSecurityConfig(site);
|
|
153
156
|
assertReadAllowed(sec, "node", type);
|
|
154
157
|
const backend = await resolveBackend(site);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
const bundles = type ? [type] : await readableNodeBundles(backend, sec);
|
|
159
|
+
const nodes = [];
|
|
160
|
+
const bundleErrors = [];
|
|
161
|
+
let approximate = false;
|
|
162
|
+
for (const bundle of bundles) {
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await backend.listEntities({
|
|
166
|
+
entityType: "node", bundle,
|
|
167
|
+
filters: [{ field: "status", op: "eq", value: true }],
|
|
168
|
+
sort: [{ field: "created", dir: "desc" }],
|
|
169
|
+
page: { limit },
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (type) throw err;
|
|
173
|
+
bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
approximate = approximate || (res.approximate ?? false);
|
|
177
|
+
for (const n of res.entities) {
|
|
178
|
+
nodes.push({
|
|
179
|
+
id: n.id,
|
|
180
|
+
title: n.title,
|
|
181
|
+
created: n.created,
|
|
182
|
+
changed: n.changed,
|
|
183
|
+
path: n.url,
|
|
184
|
+
...(type ? {} : { contentType: bundle }),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!type) nodes.sort((x, y) => String(y.created ?? "").localeCompare(String(x.created ?? "")));
|
|
161
189
|
return {
|
|
162
|
-
contentType: type ||
|
|
163
|
-
approximate
|
|
164
|
-
nodes:
|
|
165
|
-
|
|
166
|
-
title: n.title,
|
|
167
|
-
created: n.created,
|
|
168
|
-
changed: n.changed,
|
|
169
|
-
path: n.url,
|
|
170
|
-
})),
|
|
190
|
+
contentType: type || null,
|
|
191
|
+
approximate,
|
|
192
|
+
nodes: nodes.slice(0, limit),
|
|
193
|
+
...(bundleErrors.length ? { bundleErrors } : {}),
|
|
171
194
|
};
|
|
172
195
|
}
|
|
173
196
|
|
|
@@ -255,12 +278,13 @@ async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 10
|
|
|
255
278
|
* Per-term counts that error out are reported as null nodeCount.
|
|
256
279
|
*
|
|
257
280
|
* @param {object} args - { site?, vocabulary, contentType?, referenceField?, limit? }.
|
|
258
|
-
* `referenceField` defaults to `field_{vocabulary}`; `contentType`
|
|
281
|
+
* `referenceField` defaults to `field_{vocabulary}`; `contentType` is required.
|
|
259
282
|
* @returns {Promise<object>} Terms sorted by usage, plus an unused-term count.
|
|
260
283
|
* @throws {Error} If no vocabulary is supplied.
|
|
261
284
|
* @throws {SecurityError} If reading the vocabulary is not permitted.
|
|
262
285
|
*/
|
|
263
286
|
async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenceField, limit = 100 }) {
|
|
287
|
+
const ctype = requireContentType(contentType, "drupal_report_taxonomy_usage", "contentType");
|
|
264
288
|
const site = getSiteConfig(siteName);
|
|
265
289
|
const sec = resolveSecurityConfig(site);
|
|
266
290
|
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
@@ -272,7 +296,6 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
|
|
|
272
296
|
limit
|
|
273
297
|
);
|
|
274
298
|
const field = referenceField || `field_${vocabulary}`;
|
|
275
|
-
const ctype = contentType || "article";
|
|
276
299
|
let approximate = false;
|
|
277
300
|
const results = await Promise.all(terms.map(async (term) => {
|
|
278
301
|
try {
|
|
@@ -306,9 +329,10 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
|
|
|
306
329
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
307
330
|
*/
|
|
308
331
|
async function revisionHotspots({ site: siteName, type, limit = 20 }) {
|
|
332
|
+
const contentType = requireContentType(type, "drupal_report_revision_hotspots");
|
|
309
333
|
const site = getSiteConfig(siteName);
|
|
310
334
|
const sec = resolveSecurityConfig(site);
|
|
311
|
-
assertReadAllowed(sec, "node",
|
|
335
|
+
assertReadAllowed(sec, "node", contentType);
|
|
312
336
|
const backend = await resolveBackend(site);
|
|
313
337
|
if (!backend.capabilities().revisions) {
|
|
314
338
|
return gatedReport(
|
|
@@ -317,7 +341,6 @@ async function revisionHotspots({ site: siteName, type, limit = 20 }) {
|
|
|
317
341
|
"This backend does not expose entity revisions. Use a JSON:API site."
|
|
318
342
|
);
|
|
319
343
|
}
|
|
320
|
-
const contentType = type || "article";
|
|
321
344
|
const recent = await backend.listEntities({
|
|
322
345
|
entityType: "node", bundle: contentType,
|
|
323
346
|
sort: [{ field: "changed", dir: "desc" }],
|
|
@@ -431,11 +454,11 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
|
|
|
431
454
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
432
455
|
*/
|
|
433
456
|
async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
457
|
+
const contentType = requireContentType(type, "drupal_report_seo_audit");
|
|
434
458
|
const site = getSiteConfig(siteName);
|
|
435
459
|
const sec = resolveSecurityConfig(site);
|
|
436
|
-
assertReadAllowed(sec, "node",
|
|
460
|
+
assertReadAllowed(sec, "node", contentType);
|
|
437
461
|
const backend = await resolveBackend(site);
|
|
438
|
-
const contentType = type || "article";
|
|
439
462
|
const issueKeys = ["missingMetaDescription", "titleTooLong", "titleTooShort", "thinContent"];
|
|
440
463
|
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
441
464
|
const entities = await collectEntities(
|
|
@@ -524,11 +547,11 @@ function resolveJsonapiMetaSource(entities) {
|
|
|
524
547
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
525
548
|
*/
|
|
526
549
|
async function accessibilityAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
550
|
+
const contentType = requireContentType(type, "drupal_report_accessibility_audit");
|
|
527
551
|
const site = getSiteConfig(siteName);
|
|
528
552
|
const sec = resolveSecurityConfig(site);
|
|
529
|
-
assertReadAllowed(sec, "node",
|
|
553
|
+
assertReadAllowed(sec, "node", contentType);
|
|
530
554
|
const backend = await resolveBackend(site);
|
|
531
|
-
const contentType = type || "article";
|
|
532
555
|
const issueKeys = ["imagesWithoutAlt", "inlineH1", "nonDescriptiveLinkText", "tablesWithoutCaption"];
|
|
533
556
|
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
534
557
|
const badLinkText = />\s*(click here|read more|learn more|here|more|link)\s*</i;
|
|
@@ -572,10 +595,10 @@ export const definitions = [
|
|
|
572
595
|
name: "drupal_report_stale_content",
|
|
573
596
|
description: "Find content that hasn't been updated in N days. Returns a sorted list with titles, status, and days-since-update.",
|
|
574
597
|
inputSchema: {
|
|
575
|
-
type: "object",
|
|
598
|
+
type: "object", required: ["type"],
|
|
576
599
|
properties: {
|
|
577
600
|
site: { type: "string" },
|
|
578
|
-
type: { type: "string", description: "Content type (
|
|
601
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
579
602
|
days: { type: "number", default: 180, description: "Stale threshold in days" },
|
|
580
603
|
status: { type: "boolean", description: "Filter by publish status" },
|
|
581
604
|
limit: { type: "number", default: 50 },
|
|
@@ -586,10 +609,10 @@ export const definitions = [
|
|
|
586
609
|
name: "drupal_report_content_by_author",
|
|
587
610
|
description: "Count nodes per author for a given content type. Returns author UUIDs and counts sorted by most prolific.",
|
|
588
611
|
inputSchema: {
|
|
589
|
-
type: "object",
|
|
612
|
+
type: "object", required: ["type"],
|
|
590
613
|
properties: {
|
|
591
614
|
site: { type: "string" },
|
|
592
|
-
type: { type: "string", description: "Content type (
|
|
615
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
593
616
|
limit: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
594
617
|
},
|
|
595
618
|
},
|
|
@@ -601,7 +624,7 @@ export const definitions = [
|
|
|
601
624
|
type: "object",
|
|
602
625
|
properties: {
|
|
603
626
|
site: { type: "string" },
|
|
604
|
-
type: { type: "string", description: "Content type (
|
|
627
|
+
type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
|
|
605
628
|
limit: { type: "number", default: 20 },
|
|
606
629
|
},
|
|
607
630
|
},
|
|
@@ -623,11 +646,11 @@ export const definitions = [
|
|
|
623
646
|
name: "drupal_report_taxonomy_usage",
|
|
624
647
|
description: "Count how many nodes use each term in a vocabulary. Identifies over-used, under-used, and orphaned terms.",
|
|
625
648
|
inputSchema: {
|
|
626
|
-
type: "object", required: ["vocabulary"],
|
|
649
|
+
type: "object", required: ["vocabulary", "contentType"],
|
|
627
650
|
properties: {
|
|
628
651
|
site: { type: "string" },
|
|
629
652
|
vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags', 'category'" },
|
|
630
|
-
contentType: { type: "string", description: "Content type
|
|
653
|
+
contentType: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
631
654
|
referenceField: { type: "string", description: "Field referencing the vocabulary (default: field_{vocabulary})" },
|
|
632
655
|
limit: { type: "number", default: 100 },
|
|
633
656
|
},
|
|
@@ -637,10 +660,10 @@ export const definitions = [
|
|
|
637
660
|
name: "drupal_report_revision_hotspots",
|
|
638
661
|
description: "Find nodes with the most revision activity — useful for spotting churn or content that needs editorial process review. Requires Drupal 9.3+ JSON:API revisions.",
|
|
639
662
|
inputSchema: {
|
|
640
|
-
type: "object",
|
|
663
|
+
type: "object", required: ["type"],
|
|
641
664
|
properties: {
|
|
642
665
|
site: { type: "string" },
|
|
643
|
-
type: { type: "string", description: "Content type (
|
|
666
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
644
667
|
limit: { type: "number", default: 20 },
|
|
645
668
|
},
|
|
646
669
|
},
|
|
@@ -661,10 +684,10 @@ export const definitions = [
|
|
|
661
684
|
name: "drupal_report_seo_audit",
|
|
662
685
|
description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.",
|
|
663
686
|
inputSchema: {
|
|
664
|
-
type: "object",
|
|
687
|
+
type: "object", required: ["type"],
|
|
665
688
|
properties: {
|
|
666
689
|
site: { type: "string" },
|
|
667
|
-
type: { type: "string", description: "Content type (
|
|
690
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
668
691
|
sampleSize: { type: "number", default: 100 },
|
|
669
692
|
},
|
|
670
693
|
},
|
|
@@ -673,10 +696,10 @@ export const definitions = [
|
|
|
673
696
|
name: "drupal_report_accessibility_audit",
|
|
674
697
|
description: "Accessibility audit for body content: images without alt text, inline H1 tags, non-descriptive link text ('click here', 'read more'), and tables without captions.",
|
|
675
698
|
inputSchema: {
|
|
676
|
-
type: "object",
|
|
699
|
+
type: "object", required: ["type"],
|
|
677
700
|
properties: {
|
|
678
701
|
site: { type: "string" },
|
|
679
|
-
type: { type: "string", description: "Content type (
|
|
702
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
680
703
|
sampleSize: { type: "number", default: 100 },
|
|
681
704
|
},
|
|
682
705
|
},
|
package/src/tools/revisions.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../lib/security.js";
|
|
26
26
|
import {
|
|
27
27
|
assertDraftLangcode, readDraftTranslation, readNodeDraftInventory, readTranslationInventory,
|
|
28
|
+
isUnpublishedWorkingDraft,
|
|
28
29
|
} from "../lib/sentinel-draft.js";
|
|
29
30
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
30
31
|
import { changedAheadOfRevision, prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
@@ -221,7 +222,7 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
|
|
|
221
222
|
const targetLang = assertDraftLangcode(langcode);
|
|
222
223
|
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
223
224
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
224
|
-
if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || workingRow
|
|
225
|
+
if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || !isUnpublishedWorkingDraft(workingRow)) {
|
|
225
226
|
throw new Error("No unpublished working translation is addressable for this revision.");
|
|
226
227
|
}
|
|
227
228
|
const entity = await readDraftTranslation(backend, {
|
package/src/tools/scheduler.js
CHANGED
|
@@ -6,32 +6,81 @@
|
|
|
6
6
|
* the schedule on two entity fields — `publish_on` and `unpublish_on` — which the
|
|
7
7
|
* module adds to a content type when scheduling is enabled for that bundle.
|
|
8
8
|
*
|
|
9
|
+
* On a bundle under Content Moderation, Scheduler Content Moderation
|
|
10
|
+
* Integration also needs `publish_state` / `unpublish_state`. Dates without
|
|
11
|
+
* those states are accepted by Drupal and then fail at cron (#402).
|
|
12
|
+
*
|
|
9
13
|
* This is a thin write over the canonical backend's updateEntity: the supplied
|
|
10
|
-
* timestamps are passed straight through as those
|
|
14
|
+
* timestamps are passed straight through as those attributes. Scheduler
|
|
11
15
|
* accepts an ISO 8601 datetime string or a Unix epoch; we accept either form and
|
|
12
16
|
* forward it unchanged (the JSON:API backend coerces datetime fields as needed).
|
|
13
17
|
*
|
|
14
18
|
* Capability degradation: if the Scheduler module is not installed, or the bundle
|
|
15
|
-
* does not have the publish_on / unpublish_on fields, the
|
|
16
|
-
*
|
|
17
|
-
* (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.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
31
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
22
32
|
import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
33
|
+
import { entityLooksModerated } from "../lib/moderation-default.js";
|
|
34
|
+
import { fieldPresence } from "../lib/reports-support.js";
|
|
35
|
+
import { httpStatusOf } from "../lib/error-status.js";
|
|
23
36
|
|
|
24
37
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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).
|
|
28
44
|
*
|
|
29
45
|
* @param {Error} err - The error thrown by the backend write.
|
|
30
46
|
* @returns {boolean}
|
|
31
47
|
*/
|
|
32
48
|
export function looksLikeUnknownField(err) {
|
|
33
|
-
const msg = String(err?.message || err || "");
|
|
34
|
-
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;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a schema or entity exposes a Scheduler attribute.
|
|
76
|
+
* @param {?object} entity Canonical entity.
|
|
77
|
+
* @param {?object} schema Bundle schema from getEntitySchema.
|
|
78
|
+
* @param {string} field Field machine name.
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function schedulerFieldPresent(entity, schema, field) {
|
|
82
|
+
if (entity && fieldPresence(entity, field).present) return true;
|
|
83
|
+
return Boolean(schema?.attributes && Object.prototype.hasOwnProperty.call(schema.attributes, field));
|
|
35
84
|
}
|
|
36
85
|
|
|
37
86
|
/**
|
|
@@ -41,12 +90,21 @@ export function looksLikeUnknownField(err) {
|
|
|
41
90
|
* canonical updateEntity. At least one of publishOn / unpublishOn must be given.
|
|
42
91
|
* Each value is forwarded unchanged (ISO 8601 string or epoch integer).
|
|
43
92
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
|
|
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.
|
|
48
104
|
*/
|
|
49
|
-
async function schedulePublish({
|
|
105
|
+
async function schedulePublish({
|
|
106
|
+
site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
|
|
107
|
+
}) {
|
|
50
108
|
if (publishOn === undefined && unpublishOn === undefined) {
|
|
51
109
|
throw new Error("Provide at least one of publishOn or unpublishOn to schedule the node.");
|
|
52
110
|
}
|
|
@@ -55,9 +113,53 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
|
|
|
55
113
|
assertWriteAllowed(sec, "update", "node", type);
|
|
56
114
|
const backend = await resolveBackend(site);
|
|
57
115
|
|
|
116
|
+
let existing = null;
|
|
117
|
+
if (typeof backend.getEntity === "function") {
|
|
118
|
+
try {
|
|
119
|
+
existing = await backend.getEntity({ entityType: "node", bundle: type, id }) ?? null;
|
|
120
|
+
} catch {
|
|
121
|
+
existing = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let schema = null;
|
|
125
|
+
if (existing && typeof backend.getEntitySchema === "function") {
|
|
126
|
+
try {
|
|
127
|
+
schema = await backend.getEntitySchema("node", type);
|
|
128
|
+
} catch {
|
|
129
|
+
schema = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
);
|
|
149
|
+
}
|
|
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
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
58
158
|
const attributes = {};
|
|
59
159
|
if (publishOn !== undefined) attributes.publish_on = publishOn;
|
|
60
160
|
if (unpublishOn !== undefined) attributes.unpublish_on = unpublishOn;
|
|
161
|
+
if (publishState !== undefined) attributes.publish_state = publishState;
|
|
162
|
+
if (unpublishState !== undefined) attributes.unpublish_state = unpublishState;
|
|
61
163
|
|
|
62
164
|
let updated;
|
|
63
165
|
try {
|
|
@@ -65,14 +167,20 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
|
|
|
65
167
|
} catch (err) {
|
|
66
168
|
if (looksLikeUnknownField(err)) {
|
|
67
169
|
throw new Error(
|
|
68
|
-
`Could not set Scheduler dates on '${type}':
|
|
69
|
-
"
|
|
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. " +
|
|
70
173
|
`Backend error: ${err?.message || err}`,
|
|
71
174
|
);
|
|
72
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);
|
|
73
180
|
throw err;
|
|
74
181
|
}
|
|
75
|
-
|
|
182
|
+
const out = updated ? redactCanonicalEntity(updated, sec, "node") : updated;
|
|
183
|
+
return out && warnings.length ? { ...out, warnings } : out;
|
|
76
184
|
}
|
|
77
185
|
|
|
78
186
|
// ---------------------------------------------------------------------------
|
|
@@ -84,19 +192,26 @@ export const definitions = [
|
|
|
84
192
|
name: "drupal_schedule_publish",
|
|
85
193
|
description:
|
|
86
194
|
"Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
|
|
87
|
-
"Sets the publish_on and unpublish_on fields on the node. " +
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
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.",
|
|
92
205
|
inputSchema: {
|
|
93
206
|
type: "object", required: ["type", "id"],
|
|
94
207
|
properties: {
|
|
95
|
-
site:
|
|
96
|
-
type:
|
|
97
|
-
id:
|
|
98
|
-
publishOn:
|
|
99
|
-
unpublishOn:
|
|
208
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
209
|
+
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
210
|
+
id: { type: "string", description: "Node UUID" },
|
|
211
|
+
publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
|
|
212
|
+
unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
|
|
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." },
|
|
100
215
|
},
|
|
101
216
|
},
|
|
102
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
|
},
|
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
supportsRevisePublishedTranslation,
|
|
46
46
|
supportsReviseOverWorkingCopy,
|
|
47
47
|
supportsSentinelDraft,
|
|
48
|
+
isUnpublishedWorkingDraft,
|
|
49
|
+
isCarriedPublishedLanguage,
|
|
50
|
+
carriedPublishedLanguageError,
|
|
48
51
|
} from "../lib/sentinel-draft.js";
|
|
49
52
|
import { mapTranslationRow } from "../lib/translation-rows.js";
|
|
50
53
|
import { dryRunChecks, PREFLIGHT_SENTINEL_DRAFT } from "../lib/dry-run-checks.js";
|
|
@@ -179,13 +182,16 @@ async function createTranslation({
|
|
|
179
182
|
if (hasWorking) {
|
|
180
183
|
const workingRow = (draftRevision.inventory?.working?.translations ?? [])
|
|
181
184
|
.find((row) => row?.langcode === targetLang);
|
|
182
|
-
if (workingRow && workingRow
|
|
185
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow)) {
|
|
183
186
|
const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
|
|
184
187
|
throw new Error(
|
|
185
188
|
"This language is already an unpublished draft on the working copy. " +
|
|
186
189
|
`Continue it with ${continuer} and langcode. Revise was not attempted.`,
|
|
187
190
|
);
|
|
188
191
|
}
|
|
192
|
+
if (workingRow && isCarriedPublishedLanguage(workingRow)) {
|
|
193
|
+
throw carriedPublishedLanguageError(targetLang);
|
|
194
|
+
}
|
|
189
195
|
// Sentinel 2.25.0+ builds the draft on the named working copy; the
|
|
190
196
|
// pair is sent as If-Match "live:working" by createTranslationDraft.
|
|
191
197
|
if (!supportsReviseOverWorkingCopy(draftRevision)) {
|