drupal-mcp-connector 0.6.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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Audit & reporting.
|
|
3
|
+
*
|
|
4
|
+
* Read-only content/user/SEO/accessibility audits, backend-agnostic (JSON:API
|
|
5
|
+
* or GraphQL). Reports consult backend capabilities and adapt: some mark
|
|
6
|
+
* results `approximate` when exact counts aren't available, and some gate
|
|
7
|
+
* (return a `gatedReport` { unavailable } payload) on backends that lack
|
|
8
|
+
* counts, filters, or revisions. Each handler asserts read access in-handler.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
12
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
|
+
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
14
|
+
import { collectEntities, gatedReport, fieldValue, daysSince } from "../lib/reports-support.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Report implementations
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* High-level content summary: node counts by type and status.
|
|
22
|
+
* Per-type counts that the policy blocks are reported as "access_denied"
|
|
23
|
+
* rather than aborting the whole report.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} args - { site? }.
|
|
26
|
+
* @returns {Promise<{site: string, approximate: boolean, grandTotal: number,
|
|
27
|
+
* byContentType: object[]}>} Inventory sorted by total descending.
|
|
28
|
+
* @throws {SecurityError} If reading nodes is not permitted at all.
|
|
29
|
+
*/
|
|
30
|
+
async function contentSummary({ site: siteName }) {
|
|
31
|
+
const site = getSiteConfig(siteName);
|
|
32
|
+
const sec = resolveSecurityConfig(site);
|
|
33
|
+
assertReadAllowed(sec, "node", null);
|
|
34
|
+
const backend = await resolveBackend(site);
|
|
35
|
+
const types = await backend.listContentTypes();
|
|
36
|
+
let approximate = false;
|
|
37
|
+
const summary = [];
|
|
38
|
+
for (const ct of types) {
|
|
39
|
+
const type = ct.id;
|
|
40
|
+
try {
|
|
41
|
+
const [pub, unpub, total] = await Promise.all([
|
|
42
|
+
backend.countEntities({ entityType: "node", bundle: type, filters: [{ field: "status", op: "eq", value: true }] }),
|
|
43
|
+
backend.countEntities({ entityType: "node", bundle: type, filters: [{ field: "status", op: "eq", value: false }] }),
|
|
44
|
+
backend.countEntities({ entityType: "node", bundle: type }),
|
|
45
|
+
]);
|
|
46
|
+
approximate = approximate || pub.approximate || unpub.approximate || total.approximate;
|
|
47
|
+
summary.push({ contentType: type, total: total.count, published: pub.count, unpublished: unpub.count });
|
|
48
|
+
} catch {
|
|
49
|
+
summary.push({ contentType: type, total: "access_denied", published: null, unpublished: null });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const grandTotal = summary.reduce((s, r) => s + (typeof r.total === "number" ? r.total : 0), 0);
|
|
53
|
+
return {
|
|
54
|
+
site: site._name,
|
|
55
|
+
approximate,
|
|
56
|
+
grandTotal,
|
|
57
|
+
byContentType: summary.sort((a, b) => (b.total || 0) - (a.total || 0)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stale content: nodes not updated within the last N days.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} args - { site?, type?, days?, status?, limit? }. The cutoff
|
|
65
|
+
* is computed as now minus `days`, then applied as a `changed < cutoff` filter.
|
|
66
|
+
* @returns {Promise<object>} Threshold metadata and the matching node list.
|
|
67
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
68
|
+
*/
|
|
69
|
+
async function staleContent({ site: siteName, type, days = 180, status, limit = 50 }) {
|
|
70
|
+
const site = getSiteConfig(siteName);
|
|
71
|
+
const sec = resolveSecurityConfig(site);
|
|
72
|
+
assertReadAllowed(sec, "node", type);
|
|
73
|
+
const backend = await resolveBackend(site);
|
|
74
|
+
const contentType = type || "article";
|
|
75
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
76
|
+
const filters = [{ field: "changed", op: "lt", value: cutoff }];
|
|
77
|
+
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
78
|
+
const res = await backend.listEntities({
|
|
79
|
+
entityType: "node", bundle: contentType,
|
|
80
|
+
filters,
|
|
81
|
+
sort: [{ field: "changed", dir: "asc" }],
|
|
82
|
+
page: { limit },
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
contentType,
|
|
86
|
+
stalenessThresholdDays: days,
|
|
87
|
+
cutoffDate: cutoff.slice(0, 10),
|
|
88
|
+
approximate: res.approximate ?? false,
|
|
89
|
+
totalStale: res.page?.total ?? res.entities.length,
|
|
90
|
+
nodes: res.entities.map((n) => ({
|
|
91
|
+
id: n.id,
|
|
92
|
+
title: n.title,
|
|
93
|
+
status: n.status ? "published" : "unpublished",
|
|
94
|
+
changed: n.changed,
|
|
95
|
+
path: n.url,
|
|
96
|
+
daysSinceUpdate: daysSince(n.changed),
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Content by author: how many nodes each user has created, for one type.
|
|
103
|
+
* Aggregation is by author UUID (resolve names with drupal_get_user).
|
|
104
|
+
*
|
|
105
|
+
* @param {object} args - { site?, type?, limit? }. limit caps nodes scanned.
|
|
106
|
+
* @returns {Promise<object>} Authors sorted by node count descending.
|
|
107
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
108
|
+
*/
|
|
109
|
+
async function contentByAuthor({ site: siteName, type, limit = 100 }) {
|
|
110
|
+
const site = getSiteConfig(siteName);
|
|
111
|
+
const sec = resolveSecurityConfig(site);
|
|
112
|
+
assertReadAllowed(sec, "node", type);
|
|
113
|
+
const backend = await resolveBackend(site);
|
|
114
|
+
const contentType = type || "article";
|
|
115
|
+
const entities = await collectEntities(
|
|
116
|
+
backend,
|
|
117
|
+
{ entityType: "node", bundle: contentType, include: ["uid"] },
|
|
118
|
+
limit
|
|
119
|
+
);
|
|
120
|
+
const counts = new Map();
|
|
121
|
+
for (const n of entities) {
|
|
122
|
+
const uid = n.relationships?.uid?.id;
|
|
123
|
+
if (!uid) continue;
|
|
124
|
+
counts.set(uid, (counts.get(uid) ?? 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
const rows = [...counts.entries()]
|
|
127
|
+
.sort((a, b) => b[1] - a[1])
|
|
128
|
+
.map(([authorUuid, nodeCount]) => ({ authorUuid, nodeCount }));
|
|
129
|
+
return {
|
|
130
|
+
contentType,
|
|
131
|
+
scanned: entities.length,
|
|
132
|
+
totalAuthors: rows.length,
|
|
133
|
+
authors: rows,
|
|
134
|
+
note: "Use drupal_get_user to resolve author UUIDs to names.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recently published content of a given type, newest first.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} args - { site?, type?, limit? }.
|
|
142
|
+
* @returns {Promise<object>} The most recently created published nodes.
|
|
143
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
144
|
+
*/
|
|
145
|
+
async function recentlyPublished({ site: siteName, type, limit = 20 }) {
|
|
146
|
+
const site = getSiteConfig(siteName);
|
|
147
|
+
const sec = resolveSecurityConfig(site);
|
|
148
|
+
assertReadAllowed(sec, "node", type);
|
|
149
|
+
const backend = await resolveBackend(site);
|
|
150
|
+
const res = await backend.listEntities({
|
|
151
|
+
entityType: "node", bundle: type || "article",
|
|
152
|
+
filters: [{ field: "status", op: "eq", value: true }],
|
|
153
|
+
sort: [{ field: "created", dir: "desc" }],
|
|
154
|
+
page: { limit },
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
contentType: type || "article",
|
|
158
|
+
approximate: res.approximate ?? false,
|
|
159
|
+
nodes: res.entities.map((n) => ({
|
|
160
|
+
id: n.id,
|
|
161
|
+
title: n.title,
|
|
162
|
+
created: n.created,
|
|
163
|
+
changed: n.changed,
|
|
164
|
+
path: n.url,
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Field completeness: what % of sampled nodes have optional fields populated.
|
|
171
|
+
* A field absent on every sampled node is treated as "not on this content
|
|
172
|
+
* type" and dropped from the results rather than counted as empty.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} args - { site?, type, fields?, sampleSize? }. `type` is
|
|
175
|
+
* required; `fields` defaults to common SEO/editorial field names.
|
|
176
|
+
* @returns {Promise<object>} Per-field populated/empty counts and percentages.
|
|
177
|
+
* @throws {Error} If no content type is supplied.
|
|
178
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
179
|
+
*/
|
|
180
|
+
async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 100 }) {
|
|
181
|
+
const site = getSiteConfig(siteName);
|
|
182
|
+
const sec = resolveSecurityConfig(site);
|
|
183
|
+
assertReadAllowed(sec, "node", type);
|
|
184
|
+
if (!type) throw new Error("fieldCompleteness requires a content type.");
|
|
185
|
+
const backend = await resolveBackend(site);
|
|
186
|
+
const fieldsToCheck = (fields && fields.length)
|
|
187
|
+
? fields
|
|
188
|
+
: ["body", "summary", "metaDescription", "field_meta_description", "image", "field_image", "tags", "field_tags"];
|
|
189
|
+
const entities = await collectEntities(
|
|
190
|
+
backend,
|
|
191
|
+
{ entityType: "node", bundle: type, filters: [{ field: "status", op: "eq", value: true }] },
|
|
192
|
+
sampleSize
|
|
193
|
+
);
|
|
194
|
+
const results = fieldsToCheck.map((field) => {
|
|
195
|
+
let populated = 0, empty = 0, missing = 0;
|
|
196
|
+
for (const e of entities) {
|
|
197
|
+
const v = fieldValue(e, [field]);
|
|
198
|
+
if (v === undefined) {
|
|
199
|
+
missing++;
|
|
200
|
+
} else if (v === null || v === "" || (typeof v === "object" && !v?.value && !(Array.isArray(v) && v.length))) {
|
|
201
|
+
empty++;
|
|
202
|
+
} else {
|
|
203
|
+
populated++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const checked = populated + empty;
|
|
207
|
+
return {
|
|
208
|
+
field,
|
|
209
|
+
populated,
|
|
210
|
+
empty,
|
|
211
|
+
notOnContentType: (missing === entities.length && entities.length > 0) ? true : undefined,
|
|
212
|
+
completenessPercent: checked > 0 ? Math.round((populated / checked) * 100) : null,
|
|
213
|
+
};
|
|
214
|
+
}).filter((r) => !r.notOnContentType);
|
|
215
|
+
return {
|
|
216
|
+
contentType: type,
|
|
217
|
+
sampleSize: entities.length,
|
|
218
|
+
approximate: false,
|
|
219
|
+
fields: results.sort((a, b) => (a.completenessPercent ?? 0) - (b.completenessPercent ?? 0)),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Taxonomy usage: how many nodes reference each term in a vocabulary.
|
|
225
|
+
* Per-term counts that error out are reported as null nodeCount.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} args - { site?, vocabulary, contentType?, referenceField?, limit? }.
|
|
228
|
+
* `referenceField` defaults to `field_{vocabulary}`; `contentType` to "article".
|
|
229
|
+
* @returns {Promise<object>} Terms sorted by usage, plus an unused-term count.
|
|
230
|
+
* @throws {Error} If no vocabulary is supplied.
|
|
231
|
+
* @throws {SecurityError} If reading the vocabulary is not permitted.
|
|
232
|
+
*/
|
|
233
|
+
async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenceField, limit = 100 }) {
|
|
234
|
+
const site = getSiteConfig(siteName);
|
|
235
|
+
const sec = resolveSecurityConfig(site);
|
|
236
|
+
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
237
|
+
if (!vocabulary) throw new Error("taxonomyUsage requires a vocabulary machine name.");
|
|
238
|
+
const backend = await resolveBackend(site);
|
|
239
|
+
const terms = await collectEntities(
|
|
240
|
+
backend,
|
|
241
|
+
{ entityType: "taxonomy_term", bundle: vocabulary, sort: [{ field: "name", dir: "asc" }] },
|
|
242
|
+
limit
|
|
243
|
+
);
|
|
244
|
+
const field = referenceField || `field_${vocabulary}`;
|
|
245
|
+
const ctype = contentType || "article";
|
|
246
|
+
let approximate = false;
|
|
247
|
+
const results = await Promise.all(terms.map(async (term) => {
|
|
248
|
+
try {
|
|
249
|
+
const c = await backend.countEntities({
|
|
250
|
+
entityType: "node", bundle: ctype,
|
|
251
|
+
filters: [{ field: `${field}.id`, op: "eq", value: term.id }],
|
|
252
|
+
});
|
|
253
|
+
approximate = approximate || c.approximate;
|
|
254
|
+
return { id: term.id, name: fieldValue(term, ["name"]) ?? null, nodeCount: c.count };
|
|
255
|
+
} catch {
|
|
256
|
+
return { id: term.id, name: fieldValue(term, ["name"]) ?? null, nodeCount: null };
|
|
257
|
+
}
|
|
258
|
+
}));
|
|
259
|
+
return {
|
|
260
|
+
vocabulary,
|
|
261
|
+
contentType: ctype,
|
|
262
|
+
referenceField: field,
|
|
263
|
+
approximate,
|
|
264
|
+
totalTerms: terms.length,
|
|
265
|
+
unusedTerms: results.filter((r) => r.nodeCount === 0).length,
|
|
266
|
+
terms: results.sort((a, b) => (b.nodeCount ?? -1) - (a.nodeCount ?? -1)),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Revision hotspots: nodes with the most revisions (heavy edit activity).
|
|
272
|
+
*
|
|
273
|
+
* @param {object} args - { site?, type?, limit? }.
|
|
274
|
+
* @returns {Promise<object>} Nodes sorted by revision count, or a gatedReport
|
|
275
|
+
* { unavailable } payload when the backend exposes no revisions.
|
|
276
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
277
|
+
*/
|
|
278
|
+
async function revisionHotspots({ site: siteName, type, limit = 20 }) {
|
|
279
|
+
const site = getSiteConfig(siteName);
|
|
280
|
+
const sec = resolveSecurityConfig(site);
|
|
281
|
+
assertReadAllowed(sec, "node", type);
|
|
282
|
+
const backend = await resolveBackend(site);
|
|
283
|
+
if (!backend.capabilities().revisions) {
|
|
284
|
+
return gatedReport(
|
|
285
|
+
"revision_hotspots",
|
|
286
|
+
"graphql",
|
|
287
|
+
"This backend does not expose entity revisions. Use a JSON:API site."
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const contentType = type || "article";
|
|
291
|
+
const recent = await backend.listEntities({
|
|
292
|
+
entityType: "node", bundle: contentType,
|
|
293
|
+
sort: [{ field: "changed", dir: "desc" }],
|
|
294
|
+
page: { limit },
|
|
295
|
+
});
|
|
296
|
+
const nodes = await Promise.all(recent.entities.map(async (node) => {
|
|
297
|
+
try {
|
|
298
|
+
const rev = await backend.rawQuery({
|
|
299
|
+
path: `/jsonapi/node/${contentType}/${node.id}/revisions?page[limit]=1`,
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
id: node.id,
|
|
303
|
+
title: node.title,
|
|
304
|
+
changed: node.changed,
|
|
305
|
+
revisionCount: rev.meta?.count ?? rev.data?.length ?? null,
|
|
306
|
+
path: node.url,
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
return { id: node.id, title: node.title, changed: node.changed, revisionCount: null };
|
|
310
|
+
}
|
|
311
|
+
}));
|
|
312
|
+
return {
|
|
313
|
+
contentType,
|
|
314
|
+
note: "Revision counts require Drupal 9.3+ with JSON:API revisions.",
|
|
315
|
+
nodes: nodes.sort((a, b) => (b.revisionCount ?? 0) - (a.revisionCount ?? 0)),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* User activity report: active vs blocked counts, never-logged-in users, and
|
|
321
|
+
* accounts inactive beyond a threshold. Login is a Unix timestamp in seconds,
|
|
322
|
+
* so the cutoff is computed in seconds and rendered back to ISO for output.
|
|
323
|
+
*
|
|
324
|
+
* @param {object} args - { site?, inactiveDays?, limit? }.
|
|
325
|
+
* @returns {Promise<object>} Summary counts plus the inactive-user list, or a
|
|
326
|
+
* gatedReport { unavailable } payload when login/status aren't exposed.
|
|
327
|
+
* @throws {SecurityError} If reading users is not permitted.
|
|
328
|
+
*/
|
|
329
|
+
async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
|
|
330
|
+
const site = getSiteConfig(siteName);
|
|
331
|
+
const sec = resolveSecurityConfig(site);
|
|
332
|
+
assertReadAllowed(sec, "user", "user");
|
|
333
|
+
const backend = await resolveBackend(site);
|
|
334
|
+
const available = backend.capabilities().fieldAvailability;
|
|
335
|
+
const fields = typeof available === "function" ? (await available("user", "user")) : null;
|
|
336
|
+
if (fields && (!fields.includes("login") || !fields.includes("status"))) {
|
|
337
|
+
return gatedReport(
|
|
338
|
+
"user_activity",
|
|
339
|
+
"graphql",
|
|
340
|
+
"This backend's user type does not expose login/status. Use a JSON:API site."
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const cutoffTs = Math.floor((Date.now() - inactiveDays * 86400000) / 1000);
|
|
344
|
+
const [active, blocked, neverLoggedIn] = await Promise.all([
|
|
345
|
+
backend.countEntities({ entityType: "user", bundle: "user", filters: [{ field: "status", op: "eq", value: true }] }),
|
|
346
|
+
backend.countEntities({ entityType: "user", bundle: "user", filters: [{ field: "status", op: "eq", value: false }] }),
|
|
347
|
+
backend.countEntities({ entityType: "user", bundle: "user", filters: [{ field: "status", op: "eq", value: true }, { field: "login", op: "eq", value: 0 }] }),
|
|
348
|
+
]);
|
|
349
|
+
let inactiveUsers = [];
|
|
350
|
+
try {
|
|
351
|
+
const res = await backend.listEntities({
|
|
352
|
+
entityType: "user", bundle: "user",
|
|
353
|
+
filters: [
|
|
354
|
+
{ field: "status", op: "eq", value: true },
|
|
355
|
+
{ field: "login", op: "lt", value: cutoffTs },
|
|
356
|
+
],
|
|
357
|
+
sort: [{ field: "login", dir: "asc" }],
|
|
358
|
+
page: { limit },
|
|
359
|
+
});
|
|
360
|
+
inactiveUsers = res.entities.map((u) => ({
|
|
361
|
+
id: u.id,
|
|
362
|
+
name: fieldValue(u, ["name"]),
|
|
363
|
+
lastLogin: fieldValue(u, ["login"])
|
|
364
|
+
? new Date(fieldValue(u, ["login"]) * 1000).toISOString()
|
|
365
|
+
: "never",
|
|
366
|
+
created: u.created,
|
|
367
|
+
}));
|
|
368
|
+
} catch {
|
|
369
|
+
inactiveUsers = [];
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
summary: {
|
|
373
|
+
activeAccounts: active.count,
|
|
374
|
+
blockedAccounts: blocked.count,
|
|
375
|
+
neverLoggedIn: neverLoggedIn.count,
|
|
376
|
+
inactiveThresholdDays: inactiveDays,
|
|
377
|
+
inactiveCount: inactiveUsers.length,
|
|
378
|
+
},
|
|
379
|
+
inactiveUsers,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* SEO audit: meta-description coverage, title length bounds, and thin content.
|
|
385
|
+
* Word count is computed from body HTML with tags stripped. Title thresholds
|
|
386
|
+
* (>60 too long, <20 too short) and the 300-word thin-content floor are SEO
|
|
387
|
+
* heuristics, not hard Drupal limits.
|
|
388
|
+
*
|
|
389
|
+
* @param {object} args - { site?, type?, sampleSize? }.
|
|
390
|
+
* @returns {Promise<object>} Issue lists keyed by category, with counts.
|
|
391
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
392
|
+
*/
|
|
393
|
+
async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
394
|
+
const site = getSiteConfig(siteName);
|
|
395
|
+
const sec = resolveSecurityConfig(site);
|
|
396
|
+
assertReadAllowed(sec, "node", type);
|
|
397
|
+
const backend = await resolveBackend(site);
|
|
398
|
+
const contentType = type || "article";
|
|
399
|
+
const issueKeys = ["missingMetaDescription", "titleTooLong", "titleTooShort", "thinContent"];
|
|
400
|
+
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
401
|
+
const entities = await collectEntities(
|
|
402
|
+
backend,
|
|
403
|
+
{ entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
|
|
404
|
+
sampleSize
|
|
405
|
+
);
|
|
406
|
+
for (const n of entities) {
|
|
407
|
+
const title = n.title ?? "";
|
|
408
|
+
const bodyField = fieldValue(n, ["body"]);
|
|
409
|
+
const body = (bodyField && typeof bodyField === "object" ? bodyField.value : bodyField) ?? "";
|
|
410
|
+
const meta = fieldValue(n, ["metaDescription", "field_meta_description", "metatag"]) ?? null;
|
|
411
|
+
const wordCount = String(body).replace(/<[^>]+>/g, " ").split(/\s+/).filter(Boolean).length;
|
|
412
|
+
if (!meta) issues.get("missingMetaDescription").push({ id: n.id, title });
|
|
413
|
+
if (title.length > 60) issues.get("titleTooLong").push({ id: n.id, title, length: title.length });
|
|
414
|
+
if (title.length < 20) issues.get("titleTooShort").push({ id: n.id, title, length: title.length });
|
|
415
|
+
if (wordCount < 300 && wordCount > 0) issues.get("thinContent").push({ id: n.id, title, wordCount });
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
contentType,
|
|
419
|
+
scanned: entities.length,
|
|
420
|
+
approximate: false,
|
|
421
|
+
issues: Object.fromEntries(issueKeys.map((k) => {
|
|
422
|
+
const list = issues.get(k);
|
|
423
|
+
return [k, { count: list.length, nodes: list }];
|
|
424
|
+
})),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Accessibility audit of body HTML: images missing alt text, inline H1s,
|
|
430
|
+
* non-descriptive link text, and tables without a caption. Detection is
|
|
431
|
+
* regex-based on the rendered body markup, so it is a heuristic pass, not a
|
|
432
|
+
* full DOM-aware a11y checker.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} args - { site?, type?, sampleSize? }.
|
|
435
|
+
* @returns {Promise<object>} Issue lists keyed by category, with counts.
|
|
436
|
+
* @throws {SecurityError} If reading the content type is not permitted.
|
|
437
|
+
*/
|
|
438
|
+
async function accessibilityAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
439
|
+
const site = getSiteConfig(siteName);
|
|
440
|
+
const sec = resolveSecurityConfig(site);
|
|
441
|
+
assertReadAllowed(sec, "node", type);
|
|
442
|
+
const backend = await resolveBackend(site);
|
|
443
|
+
const contentType = type || "article";
|
|
444
|
+
const issueKeys = ["imagesWithoutAlt", "inlineH1", "nonDescriptiveLinkText", "tablesWithoutCaption"];
|
|
445
|
+
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
446
|
+
const badLinkText = />\s*(click here|read more|learn more|here|more|link)\s*</i;
|
|
447
|
+
const entities = await collectEntities(
|
|
448
|
+
backend,
|
|
449
|
+
{ entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
|
|
450
|
+
sampleSize
|
|
451
|
+
);
|
|
452
|
+
for (const n of entities) {
|
|
453
|
+
const bodyField = fieldValue(n, ["body"]);
|
|
454
|
+
const body = (bodyField && typeof bodyField === "object" ? bodyField.value : bodyField) ?? "";
|
|
455
|
+
const title = n.title;
|
|
456
|
+
if (!body) continue;
|
|
457
|
+
if (/<img(?![^>]*alt=["'][^"']+["'])[^>]*>/i.test(body)) issues.get("imagesWithoutAlt").push({ id: n.id, title });
|
|
458
|
+
if (/<h1/i.test(body)) issues.get("inlineH1").push({ id: n.id, title });
|
|
459
|
+
if (badLinkText.test(body)) issues.get("nonDescriptiveLinkText").push({ id: n.id, title });
|
|
460
|
+
if (/<table/i.test(body) && !/<caption/i.test(body)) issues.get("tablesWithoutCaption").push({ id: n.id, title });
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
contentType,
|
|
464
|
+
scanned: entities.length,
|
|
465
|
+
approximate: false,
|
|
466
|
+
issues: Object.fromEntries(issueKeys.map((k) => {
|
|
467
|
+
const list = issues.get(k);
|
|
468
|
+
return [k, { count: list.length, nodes: list }];
|
|
469
|
+
})),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Definitions
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
export const definitions = [
|
|
478
|
+
{
|
|
479
|
+
name: "drupal_report_content_summary",
|
|
480
|
+
description: "High-level content inventory: total node counts by type and status (published/unpublished). Good first step for any site audit.",
|
|
481
|
+
inputSchema: { type: "object", properties: { site: { type: "string" } } },
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: "drupal_report_stale_content",
|
|
485
|
+
description: "Find content that hasn't been updated in N days. Returns a sorted list with titles, status, and days-since-update.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: {
|
|
489
|
+
site: { type: "string" },
|
|
490
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
491
|
+
days: { type: "number", default: 180, description: "Stale threshold in days" },
|
|
492
|
+
status: { type: "boolean", description: "Filter by publish status" },
|
|
493
|
+
limit: { type: "number", default: 50 },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "drupal_report_content_by_author",
|
|
499
|
+
description: "Count nodes per author for a given content type. Returns author UUIDs and counts sorted by most prolific.",
|
|
500
|
+
inputSchema: {
|
|
501
|
+
type: "object",
|
|
502
|
+
properties: {
|
|
503
|
+
site: { type: "string" },
|
|
504
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
505
|
+
limit: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "drupal_report_recently_published",
|
|
511
|
+
description: "List the most recently published content of a given type.",
|
|
512
|
+
inputSchema: {
|
|
513
|
+
type: "object",
|
|
514
|
+
properties: {
|
|
515
|
+
site: { type: "string" },
|
|
516
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
517
|
+
limit: { type: "number", default: 20 },
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "drupal_report_field_completeness",
|
|
523
|
+
description: "Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
type: "object", required: ["type"],
|
|
526
|
+
properties: {
|
|
527
|
+
site: { type: "string" },
|
|
528
|
+
type: { type: "string", description: "Content type machine name" },
|
|
529
|
+
fields: { type: "array", items: { type: "string" }, description: "Field machine names to check. Defaults to common SEO/editorial fields." },
|
|
530
|
+
sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
name: "drupal_report_taxonomy_usage",
|
|
536
|
+
description: "Count how many nodes use each term in a vocabulary. Identifies over-used, under-used, and orphaned terms.",
|
|
537
|
+
inputSchema: {
|
|
538
|
+
type: "object", required: ["vocabulary"],
|
|
539
|
+
properties: {
|
|
540
|
+
site: { type: "string" },
|
|
541
|
+
vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags', 'category'" },
|
|
542
|
+
contentType: { type: "string", description: "Content type to count references from (default: article)" },
|
|
543
|
+
referenceField: { type: "string", description: "Field referencing the vocabulary (default: field_{vocabulary})" },
|
|
544
|
+
limit: { type: "number", default: 100 },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
name: "drupal_report_revision_hotspots",
|
|
550
|
+
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.",
|
|
551
|
+
inputSchema: {
|
|
552
|
+
type: "object",
|
|
553
|
+
properties: {
|
|
554
|
+
site: { type: "string" },
|
|
555
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
556
|
+
limit: { type: "number", default: 20 },
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "drupal_report_user_activity",
|
|
562
|
+
description: "User activity summary: active vs blocked accounts, never-logged-in users, and users inactive beyond a threshold. Useful for security audits and account hygiene.",
|
|
563
|
+
inputSchema: {
|
|
564
|
+
type: "object",
|
|
565
|
+
properties: {
|
|
566
|
+
site: { type: "string" },
|
|
567
|
+
inactiveDays: { type: "number", default: 90, description: "Days without login to flag as inactive" },
|
|
568
|
+
limit: { type: "number", default: 50 },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
name: "drupal_report_seo_audit",
|
|
574
|
+
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.",
|
|
575
|
+
inputSchema: {
|
|
576
|
+
type: "object",
|
|
577
|
+
properties: {
|
|
578
|
+
site: { type: "string" },
|
|
579
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
580
|
+
sampleSize: { type: "number", default: 100 },
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
name: "drupal_report_accessibility_audit",
|
|
586
|
+
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.",
|
|
587
|
+
inputSchema: {
|
|
588
|
+
type: "object",
|
|
589
|
+
properties: {
|
|
590
|
+
site: { type: "string" },
|
|
591
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
592
|
+
sampleSize: { type: "number", default: 100 },
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
export const handlers = {
|
|
599
|
+
drupal_report_content_summary: contentSummary,
|
|
600
|
+
drupal_report_stale_content: staleContent,
|
|
601
|
+
drupal_report_content_by_author: contentByAuthor,
|
|
602
|
+
drupal_report_recently_published: recentlyPublished,
|
|
603
|
+
drupal_report_field_completeness: fieldCompleteness,
|
|
604
|
+
drupal_report_taxonomy_usage: taxonomyUsage,
|
|
605
|
+
drupal_report_revision_hotspots: revisionHotspots,
|
|
606
|
+
drupal_report_user_activity: userActivity,
|
|
607
|
+
drupal_report_seo_audit: seoAudit,
|
|
608
|
+
drupal_report_accessibility_audit: accessibilityAudit,
|
|
609
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Site.
|
|
3
|
+
*
|
|
4
|
+
* Site-level discovery: base URL and available resource/query types, content
|
|
5
|
+
* type listing, and enumeration of configured named sites. Backend-agnostic
|
|
6
|
+
* (works for both JSON:API and GraphQL backends).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getSiteConfig, listSiteNames } from "../lib/config.js";
|
|
10
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Implementations
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Report a site's base URL and the resource/query types its backend exposes.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} args - { site? }.
|
|
20
|
+
* @returns {Promise<{site: string, baseUrl: string, resourceTypes: object[]}>}
|
|
21
|
+
*/
|
|
22
|
+
async function getSiteInfo({ site: siteName }) {
|
|
23
|
+
const site = getSiteConfig(siteName);
|
|
24
|
+
const backend = await resolveBackend(site);
|
|
25
|
+
const info = await backend.introspect();
|
|
26
|
+
return {
|
|
27
|
+
site: site._name,
|
|
28
|
+
baseUrl: site.baseUrl,
|
|
29
|
+
resourceTypes: info.resourceTypes ?? [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List the content types defined on a site.
|
|
35
|
+
* @param {object} args - { site? }.
|
|
36
|
+
* @returns {Promise<object[]>} Content type descriptors (machine name + label).
|
|
37
|
+
*/
|
|
38
|
+
async function listContentTypes({ site: siteName }) {
|
|
39
|
+
const site = getSiteConfig(siteName);
|
|
40
|
+
const backend = await resolveBackend(site);
|
|
41
|
+
return backend.listContentTypes();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List all named sites from config.json. No backend call and no credentials.
|
|
46
|
+
* @returns {Promise<{sites: string[]}>}
|
|
47
|
+
*/
|
|
48
|
+
async function listConfiguredSites() {
|
|
49
|
+
return { sites: listSiteNames() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Definitions
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export const definitions = [
|
|
57
|
+
{
|
|
58
|
+
name: "drupal_site_info",
|
|
59
|
+
description: "Get the base URL and the list of available resource/query types for a configured site (works for JSON:API and GraphQL backends).",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: { site: { type: "string" } },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "drupal_list_content_types",
|
|
67
|
+
description: "List all content types defined on this Drupal site with their machine names and descriptions.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: { site: { type: "string" } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "drupal_list_sites",
|
|
75
|
+
description: "List all named Drupal sites configured in config.json. Useful for multi-site setups.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export const handlers = {
|
|
84
|
+
drupal_site_info: getSiteInfo,
|
|
85
|
+
drupal_list_content_types: listContentTypes,
|
|
86
|
+
drupal_list_sites: listConfiguredSites,
|
|
87
|
+
};
|