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.
Files changed (64) hide show
  1. package/.agents/commands/drupal-audit-site-health.md +1 -1
  2. package/.agents/commands/drupal-entity-update.md +1 -1
  3. package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
  4. package/.agents/commands/drupal-report-alias-coverage.md +4 -2
  5. package/.agents/commands/drupal-report-broken-embeds.md +4 -2
  6. package/.agents/commands/drupal-report-broken-links.md +4 -2
  7. package/.agents/commands/drupal-report-content-by-author.md +4 -2
  8. package/.agents/commands/drupal-report-duplicate-content.md +4 -2
  9. package/.agents/commands/drupal-report-missing-field.md +2 -2
  10. package/.agents/commands/drupal-report-orphan-pages.md +4 -2
  11. package/.agents/commands/drupal-report-orphaned-references.md +4 -2
  12. package/.agents/commands/drupal-report-pii-exposure.md +4 -2
  13. package/.agents/commands/drupal-report-readability.md +4 -2
  14. package/.agents/commands/drupal-report-recently-published.md +1 -1
  15. package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
  16. package/.agents/commands/drupal-report-scheduled-content.md +4 -4
  17. package/.agents/commands/drupal-report-seo-audit.md +4 -2
  18. package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
  19. package/.agents/commands/drupal-report-stale-content.md +4 -2
  20. package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
  21. package/.agents/commands/drupal-report-translation-coverage.md +4 -2
  22. package/.agents/commands/drupal-report-unpublished.md +1 -1
  23. package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
  24. package/.agents/commands/drupal-schedule-publish.md +5 -3
  25. package/.agents/commands/drupal-search-content.md +1 -1
  26. package/.agents/commands/drupal-search.md +1 -1
  27. package/.agents/commands/drupal-update-node.md +1 -1
  28. package/CHANGELOG.md +114 -0
  29. package/README.md +57 -1
  30. package/bin/drupal-mcp-doctor.js +16 -0
  31. package/bin/drupal-mcp-wizard.js +17 -0
  32. package/package.json +10 -5
  33. package/src/index.js +19 -19
  34. package/src/lib/backends/jsonapi.js +19 -7
  35. package/src/lib/dispatch.js +22 -0
  36. package/src/lib/doctor.js +645 -0
  37. package/src/lib/module-tools.js +33 -2
  38. package/src/lib/operations.js +1 -1
  39. package/src/lib/patch-preflight.js +32 -32
  40. package/src/lib/principal.js +13 -5
  41. package/src/lib/reports-support.js +68 -0
  42. package/src/lib/security.js +42 -44
  43. package/src/lib/sentinel-draft.js +59 -3
  44. package/src/lib/server-tools.js +3 -3
  45. package/src/lib/transports.js +153 -0
  46. package/src/lib/version-skew.js +193 -0
  47. package/src/lib/wizard.js +902 -0
  48. package/src/lib/workflow-prompts.js +5 -5
  49. package/src/tools/audit-composite.js +21 -11
  50. package/src/tools/codegen.js +2 -2
  51. package/src/tools/config.js +11 -6
  52. package/src/tools/entities.js +1 -1
  53. package/src/tools/index.js +1 -1
  54. package/src/tools/media.js +2 -2
  55. package/src/tools/nodes.js +17 -9
  56. package/src/tools/reports-config.js +3 -3
  57. package/src/tools/reports-content.js +109 -39
  58. package/src/tools/reports-extra.js +56 -32
  59. package/src/tools/reports-links.js +13 -13
  60. package/src/tools/reports.js +65 -42
  61. package/src/tools/revisions.js +2 -1
  62. package/src/tools/scheduler.js +142 -27
  63. package/src/tools/search.js +16 -12
  64. package/src/tools/translations.js +7 -1
@@ -110,7 +110,7 @@ export function normalizeWorkflow(raw) {
110
110
  * @param {string} namespace
111
111
  * @returns {string}
112
112
  */
113
- export function resolvedName(def, namespace) {
113
+ function resolvedName(def, namespace) {
114
114
  return def.name || workflowPromptName(namespace, def.id);
115
115
  }
116
116
 
@@ -141,7 +141,7 @@ export function publicToolName(namespace, alias, tools) {
141
141
  * @param {{builtin?: boolean}} [opts]
142
142
  * @returns {string[]|null} Public names, or null when any tool is missing.
143
143
  */
144
- export function resolveToolNames(def, namespace, tools, opts = {}) {
144
+ function resolveToolNames(def, namespace, tools, opts = {}) {
145
145
  const visible = new Set((tools ?? []).map((tool) => tool.name));
146
146
  const names = [];
147
147
  for (const alias of def.tools) {
@@ -270,7 +270,7 @@ export function renderWorkflowMessages(workflow, args = {}) {
270
270
  * @param {string} instructions
271
271
  * @returns {string[]}
272
272
  */
273
- export function toolAliasesInInstructions(instructions) {
273
+ function toolAliasesInInstructions(instructions) {
274
274
  const aliases = [];
275
275
  const seen = new Set();
276
276
  for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
@@ -287,7 +287,7 @@ export function toolAliasesInInstructions(instructions) {
287
287
  * @param {object} got
288
288
  * @returns {string}
289
289
  */
290
- export function instructionTextFromDrupalPrompt(got) {
290
+ function instructionTextFromDrupalPrompt(got) {
291
291
  const messages = Array.isArray(got?.messages) ? got.messages : [];
292
292
  const parts = [];
293
293
  for (const message of messages) {
@@ -313,7 +313,7 @@ export function instructionTextFromDrupalPrompt(got) {
313
313
  * @param {object} toolsMap `serverTools.modules.tools`
314
314
  * @returns {boolean}
315
315
  */
316
- export function inferReadOnly(aliases, toolsMap) {
316
+ function inferReadOnly(aliases, toolsMap) {
317
317
  for (const alias of aliases) {
318
318
  const operation = toolsMap?.[alias]?.operation;
319
319
  if (operation === "write" || operation === "delete") return false;
@@ -26,16 +26,16 @@ import * as reportsContent from "./reports-content.js";
26
26
  const SECTIONS = [
27
27
  // Content
28
28
  { key: "content_summary", group: "content", run: (o) => reports.handlers.drupal_report_content_summary({ site: o.site }), score: () => ({ issues: 0, high: 0 }) },
29
- { key: "stale_content", group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
30
- { key: "seo_audit", group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
31
- { key: "accessibility", group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
32
- { key: "duplicate_content", group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
33
- { key: "readability", group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
34
- { key: "pii_exposure", group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
29
+ { key: "stale_content", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
30
+ { key: "seo_audit", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
31
+ { key: "accessibility", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
32
+ { key: "duplicate_content", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
33
+ { key: "readability", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
34
+ { key: "pii_exposure", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
35
35
  // Links
36
- { key: "broken_links", group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
36
+ { key: "broken_links", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
37
37
  { key: "redirect_health", group: "links", run: (o) => reportsLinks.handlers.drupal_report_redirect_health({ site: o.site }), score: (r) => ({ issues: (r.summary?.duplicateSources || 0) + (r.summary?.chains || 0) + (r.summary?.loops || 0), high: r.summary?.loops || 0 }) },
38
- { key: "alias_coverage", group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
38
+ { key: "alias_coverage", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
39
39
  { key: "log_404", group: "links", run: (o) => reportsLinks.handlers.drupal_report_404_log({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: 0 }) },
40
40
  // Config
41
41
  { key: "config_best_practices", group: "config", run: (o) => reportsConfig.handlers.drupal_audit_config_best_practices({ site: o.site }), score: (r) => ({ issues: (r.counts?.high || 0) + (r.counts?.medium || 0) + (r.counts?.low || 0), high: r.counts?.high || 0 }) },
@@ -81,10 +81,13 @@ function grade(high, issues) {
81
81
  /**
82
82
  * Run a battery of audits and roll the results into a scored dashboard.
83
83
  *
84
+ * Sections that audit one content type (`needsType`) are reported unavailable
85
+ * when `type` is omitted; there is no default bundle (#403).
86
+ *
84
87
  * @param {object} args - { site?, type?, sampleSize?, sections? }.
85
88
  * @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
86
89
  */
87
- async function siteHealth({ site: siteName, type = "article", sampleSize = 50, sections }) {
90
+ async function siteHealth({ site: siteName, type, sampleSize = 50, sections }) {
88
91
  const site = getSiteConfig(siteName);
89
92
  const selected = sections && sections.length
90
93
  ? SECTIONS.filter((s) => sections.includes(s.key))
@@ -95,6 +98,13 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
95
98
  let totalHigh = 0;
96
99
  let totalIssues = 0;
97
100
  for (const section of selected) {
101
+ if (section.needsType && !type) {
102
+ results.push({
103
+ key: section.key, group: section.group, status: "unavailable",
104
+ reason: "This section audits one content type: pass type. Call drupal_list_content_types to see the bundles on this site.",
105
+ });
106
+ continue;
107
+ }
98
108
  try {
99
109
  const result = await section.run(opts);
100
110
  if (isGated(result)) {
@@ -113,7 +123,7 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
113
123
  const byStatus = (s) => results.filter((r) => r.status === s).length;
114
124
  return {
115
125
  site: site._name,
116
- type,
126
+ type: type || null,
117
127
  grade: grade(totalHigh, totalIssues),
118
128
  summary: {
119
129
  sectionsRun: results.length,
@@ -139,7 +149,7 @@ export const definitions = [
139
149
  type: "object",
140
150
  properties: {
141
151
  site: { type: "string" },
142
- type: { type: "string", description: "Primary content type for content/link sections (default: article)" },
152
+ type: { type: "string", description: "Content type for the content/link sections. Omit it and those sections are reported unavailable; site-wide sections still run." },
143
153
  sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
144
154
  sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
145
155
  },
@@ -18,7 +18,7 @@
18
18
  import { getSiteConfig } from "../lib/config.js";
19
19
  import { sshDrush } from "./drush.js";
20
20
  import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
21
- import { SecurityError } from "../lib/security.js";
21
+ import { SecurityError, configReadBindingScope } from "../lib/security.js";
22
22
  import { validateMachineName } from "../lib/validate.js";
23
23
 
24
24
  const INSPECT = "graphql-compose-codegen:inspect";
@@ -83,7 +83,7 @@ async function runGqcc(
83
83
  ]).get(subcommand);
84
84
  const result = toolResultData(await callBoundModuleTool(site, binding, {
85
85
  bundles: names, skip_fields: skip,
86
- }, { operation: "read", scope: "mcp_config", capabilities: ["configRead"] }));
86
+ }, { operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"] }));
87
87
  if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
88
88
  throw new SecurityError("Codegen module returned an invalid result. No SSH fallback was attempted.");
89
89
  }
@@ -24,6 +24,8 @@ import {
24
24
  assertCoreExtensionValueKeepsProtected,
25
25
  isCoreExtensionConfig,
26
26
  hasScope,
27
+ hasConfigReadScope,
28
+ configReadBindingScope,
27
29
  CORE_EXTENSION_CONFIG,
28
30
  SecurityError,
29
31
  } from "../lib/security.js";
@@ -36,8 +38,9 @@ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../
36
38
  */
37
39
  async function configTool(site, binding, args, operation, capability) {
38
40
  if (site.serverTools?.bindings !== undefined) {
41
+ const scope = operation === "read" ? configReadBindingScope(site) : "mcp_config";
39
42
  return callBoundModuleTool(site, binding, args, {
40
- operation, scope: "mcp_config", capabilities: [capability],
43
+ operation, scope, capabilities: [capability],
41
44
  });
42
45
  }
43
46
  return callGovernedServerTool(site, binding, args);
@@ -144,7 +147,7 @@ async function assertCoreExtensionWriteAllowed(site, sec, value) {
144
147
  async function configSet({ site: siteName, name, value }) {
145
148
  const site = getSiteConfig(siteName);
146
149
  const sec = resolveSecurityConfig(site);
147
- assertConfigScope(site, `config:set ${name}`);
150
+ assertConfigScope(site, `config:set ${name}`, { write: true });
148
151
  assertNotReadOnly(sec, `config:set ${name}`);
149
152
  assertConfigWriteAllowed(sec);
150
153
  if (isCoreExtensionConfig(name)) await assertCoreExtensionWriteAllowed(site, sec, value);
@@ -188,11 +191,13 @@ async function whoami({ site: siteName, _resolvedSource }) {
188
191
  const summary = getSecuritySummary(site);
189
192
  // Effective capability = connector preset AND the scope the server demands.
190
193
  // Reporting the preset alone over-states what the token can do — e.g. the
191
- // content-editor preset allows config reads locally, but every config_* tool
192
- // is gated server-side on mcp_config, which the content tier does not hold.
193
- // When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
194
+ // content-editor preset allows config reads locally, but config_* tools are
195
+ // gated server-side on mcp_config (writes) or mcp_config / mcp_config_read
196
+ // (reads). The content tier holds neither. When no OAuth scopes are
197
+ // configured, hasScope() is a no-op (preset-only).
194
198
  const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
195
199
  const canConfig = hasScope(site, "mcp_config");
200
+ const canConfigRead = hasConfigReadScope(site);
196
201
  const identity = getRequestIdentity();
197
202
  // Dispatch may inject `site` after a default/grant resolution. Prefer the
198
203
  // authoritative source so `target` and `_target` cannot disagree (#167).
@@ -212,7 +217,7 @@ async function whoami({ site: siteName, _resolvedSource }) {
212
217
  read: hasScope(site, "mcp_read"),
213
218
  write: canWrite,
214
219
  delete: sec.allowDestructive && canWrite,
215
- configRead: sec.allowConfigRead && canConfig,
220
+ configRead: sec.allowConfigRead && canConfigRead,
216
221
  configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
217
222
  // Local publish policy (allowPublish), symmetric with delete/config caps.
218
223
  // Defaults false in every preset except `development`; the remote Drupal's
@@ -308,7 +308,7 @@ export const definitions = [
308
308
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
309
309
  attributes: { type: "object" },
310
310
  relationships: { type: "object" },
311
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
311
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
312
312
  returning: RETURNING_SCHEMA,
313
313
  },
314
314
  },
@@ -41,7 +41,7 @@ import * as config from "./config.js";
41
41
  import * as codegen from "./codegen.js";
42
42
  import { SITE_PARAM } from "../lib/site-target.js";
43
43
 
44
- export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
44
+ const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
45
45
  revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
46
46
  reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
47
47
 
@@ -15,7 +15,7 @@ import {
15
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
16
16
  } from "../lib/security.js";
17
17
  import {
18
- assertDraftLangcode, readDraftTranslation, readTranslationInventory,
18
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
19
19
  } from "../lib/sentinel-draft.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { entityRevisionId } from "../lib/write-revision.js";
@@ -69,7 +69,7 @@ async function getMedia({ site: siteName, type, id, langcode }) {
69
69
  const targetLang = assertDraftLangcode(langcode);
70
70
  const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
71
71
  const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
72
- if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
72
+ if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
73
73
  const entity = await readDraftTranslation(backend, {
74
74
  entityType: "media", bundle: type, id, langcode: targetLang,
75
75
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
+ import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
12
13
  import {
13
14
  resolveSecurityConfig, redactCanonicalEntity,
14
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -19,7 +20,9 @@ import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity
19
20
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
21
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
22
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
22
- import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/sentinel-draft.js";
23
+ import {
24
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
25
+ } from "../lib/sentinel-draft.js";
23
26
  import { paragraphResourceVersion } from "./paragraphs.js";
24
27
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
25
28
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
@@ -294,7 +297,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion, in
294
297
  const targetLang = assertDraftLangcode(langcode);
295
298
  const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
296
299
  const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
297
- if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
300
+ if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
298
301
  const entity = await readDraftTranslation(backend, {
299
302
  entityType: "node", bundle: type, id, langcode: targetLang,
300
303
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
@@ -355,7 +358,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
355
358
  }
356
359
 
357
360
  /**
358
- * Search nodes by title substring (defaults to the article bundle).
361
+ * Search nodes by title substring. With `type`, searches that bundle. Without
362
+ * it, searches every node bundle the connector may read and merges by
363
+ * `changed` (newest first). A bundle that fails to load fails the search with
364
+ * the bundle named, so a partial result is never returned as complete (#403).
359
365
  *
360
366
  * @param {object} args - { site?, query, type?, status?, limit? }.
361
367
  * @returns {Promise<object[]>} Redacted matching nodes.
@@ -363,13 +369,15 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
363
369
  async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
364
370
  const site = getSiteConfig(siteName);
365
371
  const sec = resolveSecurityConfig(site);
366
- const bundle = type || "article";
367
- assertReadAllowed(sec, "node", bundle);
372
+ assertReadAllowed(sec, "node", type);
368
373
  const backend = await resolveBackend(site);
369
374
  const filters = [{ field: "title", op: "contains", value: query }];
370
375
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
371
- const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
372
- return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
376
+ const descriptor = { filters, sort: [{ field: "changed", dir: "desc" }] };
377
+ const found = type
378
+ ? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
379
+ : await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
380
+ return found.map((e) => redactCanonicalEntity(e, sec, "node"));
373
381
  }
374
382
 
375
383
  /**
@@ -619,7 +627,7 @@ export const definitions = [
619
627
  properties: {
620
628
  site: { type: "string" },
621
629
  query: { type: "string", description: "Search term to match against node titles" },
622
- type: { type: "string", description: "Limit to this content type (default: article)" },
630
+ 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." },
623
631
  status: { type: "boolean", description: "Filter by publish status" },
624
632
  limit: { type: "number", default: 10 },
625
633
  },
@@ -664,7 +672,7 @@ export const definitions = [
664
672
  langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
665
673
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
666
674
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
667
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
675
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
668
676
  returning: RETURNING_SCHEMA,
669
677
  },
670
678
  },
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { getSiteConfig } from "../lib/config.js";
21
- import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
21
+ import { resolveSecurityConfig, assertConfigReadAllowed, configReadBindingScope } from "../lib/security.js";
22
22
  import { gatedReport } from "../lib/reports-support.js";
23
23
  import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
24
24
  import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
@@ -67,7 +67,7 @@ function configSourceAvailable(site) {
67
67
  async function readConfig(site, name) {
68
68
  if (site.serverTools?.bindings !== undefined) {
69
69
  return toolResultData(await callBoundModuleTool(site, "configGet", { name }, {
70
- operation: "read", scope: "mcp_config", capabilities: ["configRead"],
70
+ operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
71
71
  }));
72
72
  }
73
73
  if (serverToolsConfigured(site)) {
@@ -89,7 +89,7 @@ async function readConfig(site, name) {
89
89
  async function listConfigNames(site, prefix) {
90
90
  if (site.serverTools?.bindings !== undefined) {
91
91
  return pickConfigNames(toolResultData(await callBoundModuleTool(site, "configList", { prefix }, {
92
- operation: "read", scope: "mcp_config", capabilities: ["configRead"],
92
+ operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
93
93
  })));
94
94
  }
95
95
  if (serverToolsConfigured(site)) {