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
@@ -4,7 +4,7 @@
4
4
  * A tool's operation (read / write / delete / graphql) is inferred from its name
5
5
  * prefix rather than trusting per-tool metadata, so a new tool that follows the
6
6
  * naming convention is classified automatically. This single definition is shared
7
- * by the security middleware (src/index.js), the per-tool MCP prompts
7
+ * by the security middleware (src/lib/dispatch.js), the per-tool MCP prompts
8
8
  * (src/lib/tool-prompts.js), and the slash-command generator
9
9
  * (scripts/generate-commands.js) so authorization gating and the "destructive"
10
10
  * warnings surfaced to users can never drift apart.
@@ -30,6 +30,8 @@ import {
30
30
  readNodeDraftInventory,
31
31
  assertInventoryDraftLanguage,
32
32
  inferDefaultDraftLangcode,
33
+ isCarriedPublishedLanguage,
34
+ carriedPublishedLanguageError,
33
35
  MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION,
34
36
  } from "./sentinel-draft.js";
35
37
  import { PREFLIGHT_NONE, PREFLIGHT_CORE_GUARD, PREFLIGHT_SENTINEL_DRAFT } from "./dry-run-checks.js";
@@ -102,26 +104,24 @@ export class WorkingCopyStaleError extends Error {
102
104
  /**
103
105
  * Sentinel's save-time stale-default check (McpWritePreconditions) is not
104
106
  * exercised by the id-mismatch PATCH probe — that probe fails before
105
- * entity validation / presave. A published node with no distinct working
106
- * copy and a changed timestamp later than its own revision_timestamp is
107
- * the readable fingerprint (`possiblyPatchBlocked`). dryRun and the real
108
- * write must refuse the same way; reloading and retrying does not help.
109
- * Do not bypass the draft or publish gate. See connector #273.
107
+ * entity validation / presave. #273 used the `changed` >
108
+ * `revision_timestamp` fingerprint (`possiblyPatchBlocked`) as a local
109
+ * stand-in, but a gap on the default revision itself is not proof of a
110
+ * hidden row (#405). list_revisions still reports the warning. A draft
111
+ * write proceeds to the core probe / Sentinel; this message is used only
112
+ * when Sentinel actually refuses. Do not bypass the draft or publish gate.
110
113
  */
111
114
  export const STALE_COPY_MESSAGE =
112
115
  "This entity cannot be updated: MCP Sentinel refused a stale default-revision " +
113
- "write (the content changed after this copy was loaded). dryRun and the real " +
114
- "write share this check. rel:latest-version and rel:working-copy report the " +
115
- "same vid, but the default revision's changed timestamp is later than its " +
116
- "revision_timestamp (possiblyPatchBlocked). Reloading and retrying the same " +
117
- "canonical PATCH will not help. Do not bypass the draft or publish gate. " +
118
- "See connector #273 / #201.";
116
+ "write (the content changed after this copy was loaded). Reloading and " +
117
+ "retrying the same canonical PATCH will not help. Do not bypass the draft " +
118
+ "or publish gate. See connector #273 / #201 / #405.";
119
119
 
120
120
  const STALE_COPY_RE = /changed after this copy was loaded/i;
121
121
 
122
122
  /**
123
- * Thrown when a canonical write (or its dryRun) would hit Sentinel's
124
- * stale-default-revision check (#273).
123
+ * Thrown when Sentinel's save-time stale-default-revision check refuses
124
+ * a write (#273). Not thrown from the local fingerprint alone (#405).
125
125
  */
126
126
  export class StaleCopyError extends Error {
127
127
  /**
@@ -417,28 +417,28 @@ export async function prepareGuardedPatch(backend, {
417
417
  : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
418
418
  // Set only after a check has run and passed; a refusal throws first.
419
419
  target.preflight = PREFLIGHT_NONE;
420
- if (needsPreflight && !target.resourceVersion) {
421
- // Canonical path (no distinct working copy). The id-mismatch probe never
422
- // reaches Sentinel's save-time stale-default check; refuse here when the
423
- // possiblyPatchBlocked fingerprint is already readable (#273).
424
- let fingerprint = existing;
425
- const fields = fingerprint?.fields && typeof fingerprint.fields === "object" ? fingerprint.fields : {};
426
- const hasTimestamps = Boolean(
427
- fingerprint?.changed && (fingerprint.revisionTimestamp || fields.revision_timestamp),
428
- );
429
- if (!hasTimestamps && typeof backend?.getEntity === "function") {
430
- const latest = await backend.getEntity({
431
- entityType, bundle, id, resourceVersion: "rel:latest-version",
432
- }).catch(() => null);
433
- if (latest) fingerprint = latest;
434
- }
435
- if (changedAheadOfRevision(fingerprint)) {
436
- throw new StaleCopyError();
437
- }
438
- }
420
+ // Canonical path (no distinct working copy): do not treat
421
+ // possiblyPatchBlocked as local proof of a hidden revision (#405).
422
+ // A changed/revision_timestamp gap on the default revision is a
423
+ // warning on list_revisions. Proceed to Sentinel / the core probe;
424
+ // rewrite an actual Sentinel stale-copy refusal below.
439
425
  // An inventory already in hand names the default-language draft for free.
426
+ // When langcode is set and the working-copy alias skipped discovery, load
427
+ // inventory so a carried published language is refused locally (#400).
428
+ if (langcode && !target.inventory) {
429
+ const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
430
+ if (inventory) target.inventory = inventory;
431
+ }
440
432
  let inferredLangcode = langcode ? undefined : inferDefaultDraftLangcode(target.inventory);
441
433
  if (!langcode && !inferredLangcode && target.inventory) {
434
+ const defaultLang = typeof target.inventory.defaultLangcode === "string"
435
+ ? target.inventory.defaultLangcode
436
+ : undefined;
437
+ const carried = (target.inventory.working?.translations ?? [])
438
+ .find((row) => defaultLang && row?.langcode === defaultLang);
439
+ if (isCarriedPublishedLanguage(carried)) {
440
+ throw carriedPublishedLanguageError(defaultLang);
441
+ }
442
442
  assertInventoryDraftLanguage(target.inventory, langcode);
443
443
  }
444
444
  if (langcode) {
@@ -39,15 +39,18 @@ export const DIAGNOSTIC_TOOLS = new Set([
39
39
  "drupal_governance_status",
40
40
  ]);
41
41
 
42
- const CONFIG_TOOLS = new Set([
42
+ const CONFIG_READ_TOOLS = new Set([
43
43
  "drupal_config_get",
44
44
  "drupal_config_list",
45
- "drupal_config_set",
46
45
  "drupal_drush_config_export",
47
- "drupal_drush_config_import",
48
46
  "drupal_drush_config_status",
49
47
  ]);
50
48
 
49
+ const CONFIG_WRITE_TOOLS = new Set([
50
+ "drupal_config_set",
51
+ "drupal_drush_config_import",
52
+ ]);
53
+
51
54
  /** inferOperation() leaves these as "read"; they self-gate in-handler. */
52
55
  const WRITE_BY_NAME = new Set([
53
56
  "drupal_entity_create",
@@ -98,7 +101,8 @@ export function getRequestIdentity() {
98
101
  export function requiredScopeForTool(toolName) {
99
102
  if (DIAGNOSTIC_TOOLS.has(toolName)) return null;
100
103
  if (toolName === "drupal_drush_sql_query") return "mcp_admin";
101
- if (CONFIG_TOOLS.has(toolName)) return "mcp_config";
104
+ if (CONFIG_WRITE_TOOLS.has(toolName)) return "mcp_config";
105
+ if (CONFIG_READ_TOOLS.has(toolName)) return "mcp_config_read";
102
106
  if (WRITE_BY_NAME.has(toolName)) return "mcp_write";
103
107
  const op = inferOperation(toolName);
104
108
  if (op === "write" || op === "delete") return "mcp_write";
@@ -112,7 +116,11 @@ export function requiredScopeForTool(toolName) {
112
116
  */
113
117
  export function principalHasScope(identity, scope) {
114
118
  if (!scope) return true;
115
- return (identity?.scopes ?? []).includes(scope);
119
+ const scopes = identity?.scopes ?? [];
120
+ if (scopes.includes(scope)) return true;
121
+ // Write-capable mcp_config includes the read-only auditor scope (#397).
122
+ if (scope === "mcp_config_read" && scopes.includes("mcp_config")) return true;
123
+ return false;
116
124
  }
117
125
 
118
126
  function grantNameList(values) {
@@ -2,6 +2,8 @@
2
2
  * Shared helpers for the reporting tools — backend-neutral.
3
3
  */
4
4
 
5
+ import { assertReadAllowed } from "./security.js";
6
+
5
7
  /** Canonical base property names (read directly off the entity, not from `fields`). */
6
8
  const BASE_KEYS = new Set(["id", "title", "status", "langcode", "created", "changed", "url"]);
7
9
 
@@ -148,3 +150,69 @@ export function requestedFieldNames(fields) {
148
150
  const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
149
151
  return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
150
152
  }
153
+
154
+ /**
155
+ * Return the content type a bundle-scoped tool reads, or fail clearly.
156
+ * Node tools used to default to `article`, which 404s on a site without that
157
+ * bundle (#403). There is no bundle every site has, so the caller names one.
158
+ * @param {?string} type Content type machine name from the caller.
159
+ * @param {string} tool Tool name, for the message.
160
+ * @param {string} [argName] Argument name, for the message.
161
+ * @returns {string} The content type.
162
+ * @throws {Error} When no type was given.
163
+ */
164
+ export function requireContentType(type, tool, argName = "type") {
165
+ if (typeof type === "string" && type.trim()) return type;
166
+ throw new Error(
167
+ `${tool} reads one content type: pass ${argName} (a node bundle machine name). ` +
168
+ "Call drupal_list_content_types to see the bundles on this site.",
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Node bundles a tool may read when the caller named none. Bundles that
174
+ * connector policy denies are left out; they are not the caller's to see.
175
+ * @param {{listContentTypes: Function}} backend Resolved backend.
176
+ * @param {object} sec Resolved security config.
177
+ * @returns {Promise<string[]>} Bundle machine names.
178
+ */
179
+ export async function readableNodeBundles(backend, sec) {
180
+ const types = await backend.listContentTypes();
181
+ return types.map((ct) => ct?.id).filter((id) => {
182
+ if (!id) return false;
183
+ try {
184
+ assertReadAllowed(sec, "node", id);
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ });
190
+ }
191
+
192
+ /**
193
+ * List nodes from several bundles with one descriptor, merged newest first by
194
+ * `sortField`. A bundle that fails to load fails the whole call with the
195
+ * bundle named, so a partial list is never returned as complete (#403).
196
+ * @param {{listEntities: Function}} backend Resolved backend.
197
+ * @param {string[]} bundles Node bundles to read.
198
+ * @param {object} descriptor listEntities descriptor without `bundle`.
199
+ * @param {number} limit Max nodes returned.
200
+ * @param {string} [sortField] Canonical date property to merge by.
201
+ * @returns {Promise<object[]>} Canonical entities.
202
+ * @throws {Error} When any bundle fails to load.
203
+ */
204
+ export async function listNodesAcrossBundles(backend, bundles, descriptor, limit, sortField = "changed") {
205
+ const found = [];
206
+ for (const bundle of bundles) {
207
+ let res;
208
+ try {
209
+ res = await backend.listEntities({ ...descriptor, entityType: "node", bundle, page: { limit } });
210
+ } catch (err) {
211
+ throw new Error(`Could not read content type '${bundle}': ${err?.message || err}. Pass type to read one content type.`, { cause: err });
212
+ }
213
+ found.push(...(res.entities ?? []));
214
+ }
215
+ const key = (e) => String(new Map(Object.entries(e ?? {})).get(sortField) ?? "");
216
+ found.sort((x, y) => key(y).localeCompare(key(x)));
217
+ return found.slice(0, limit);
218
+ }
@@ -512,22 +512,53 @@ function isGovernedSetup(site) {
512
512
  }
513
513
 
514
514
  /**
515
- * Gate the config tools (get/list/set) on the dedicated `mcp_config` OAuth
516
- * scope, which the governed server requires for every config_* tool. When OAuth
517
- * scopes are configured but `mcp_config` is absent, fail fast with a clear
518
- * message instead of dispatching a call the server will deny — keeping the
519
- * connector's behaviour and its drupal_mcp_whoami report consistent with what
520
- * the token can actually exercise.
515
+ * Whether the site's token can read config: `mcp_config` (read+write) or
516
+ * the read-only `mcp_config_read` auditor scope (#397).
517
+ * @param {object} site Resolved site config.
518
+ * @returns {boolean}
519
+ */
520
+ export function hasConfigReadScope(site) {
521
+ return hasScope(site, "mcp_config") || hasScope(site, "mcp_config_read");
522
+ }
523
+
524
+ /**
525
+ * Inbound / binding scope to send for a config *read* (get/list/reports/codegen).
526
+ * Prefer `mcp_config` so existing write-capable bindings keep matching; a
527
+ * read-only token sends `mcp_config_read`.
528
+ * @param {object} site Resolved site config.
529
+ * @returns {string}
530
+ */
531
+ export function configReadBindingScope(site) {
532
+ return hasScope(site, "mcp_config") ? "mcp_config" : "mcp_config_read";
533
+ }
534
+
535
+ /**
536
+ * Gate the config tools on OAuth scope. Writes require `mcp_config`.
537
+ * Reads accept `mcp_config` or the read-only `mcp_config_read` (#397).
538
+ * When OAuth scopes are configured and neither is present, fail fast rather
539
+ * than dispatching a call the server will deny.
521
540
  * @param {object} site Resolved site config.
522
541
  * @param {string} operationLabel Label used in the error message.
542
+ * @param {{write?: boolean}} [opts] `write: true` requires `mcp_config`.
523
543
  * @returns {void}
524
- * @throws {SecurityError} if scopes are configured and `mcp_config` is missing.
544
+ * @throws {SecurityError} if the required config scope is missing.
525
545
  */
526
- export function assertConfigScope(site, operationLabel) {
527
- if (!hasScope(site, "mcp_config")) {
546
+ export function assertConfigScope(site, operationLabel, { write = false } = {}) {
547
+ if (write) {
548
+ if (!hasScope(site, "mcp_config")) {
549
+ throw new SecurityError(
550
+ "Config writes require the 'mcp_config' OAuth scope (config-editor / " +
551
+ "Developer tier); this token does not carry it. " +
552
+ `Operation blocked: ${operationLabel}.`
553
+ );
554
+ }
555
+ return;
556
+ }
557
+ if (!hasConfigReadScope(site)) {
528
558
  throw new SecurityError(
529
- "Config tools require the 'mcp_config' OAuth scope (config-editor / " +
530
- "Developer tier); this token does not carry it. " +
559
+ "Config reads require the 'mcp_config' or 'mcp_config_read' OAuth scope " +
560
+ "(config-editor / Developer tier, or a read-only auditor token); " +
561
+ "this token does not carry either. " +
531
562
  `Operation blocked: ${operationLabel}.`
532
563
  );
533
564
  }
@@ -922,41 +953,8 @@ export function assertDeleteAllowed(secConfig, entityType, bundle, id) {
922
953
  // Field redaction
923
954
  // ---------------------------------------------------------------------------
924
955
 
925
- /**
926
- * Redact sensitive fields from a JSON:API resource object (or array of them),
927
- * replacing their `attributes` values with "[REDACTED]".
928
- * @param {?(object|object[])} resource JSON:API resource(s) to redact.
929
- * @param {object} secConfig Resolved security config (supplies the field lists).
930
- * @param {string} entityType Entity type, used to pick per-type redacted fields.
931
- * @returns {?(object|object[])} New resource object(s); originals are not mutated.
932
- */
933
- export function redactResource(resource, secConfig, entityType) {
934
- if (!resource) return resource;
935
-
936
- // Collect fields to redact for this entity type
937
- const entityRules = new Map(Object.entries(secConfig.entityRules)).get(entityType) ?? {};
938
- const fieldsToRedact = new Set([
939
- ...(secConfig.globalRedactedFields ?? []),
940
- ...(entityRules.redactedFields ?? []),
941
- ]);
942
-
943
- if (fieldsToRedact.size === 0) return resource;
944
-
945
- function redactAttrs(obj) {
946
- if (!obj?.attributes) return obj;
947
- const attrs = Object.fromEntries(
948
- Object.entries(obj.attributes).map(([k, v]) => [k, fieldsToRedact.has(k) ? "[REDACTED]" : v])
949
- );
950
- return { ...obj, attributes: attrs };
951
- }
952
-
953
- if (Array.isArray(resource)) return resource.map(redactAttrs);
954
- return redactAttrs(resource);
955
- }
956
-
957
956
  /**
958
957
  * Redact sensitive fields from a CANONICAL entity (base props + `fields`).
959
- * Mirrors redactResource but for the API-neutral canonical shape.
960
958
  * @param {?object} entity Canonical entity to redact.
961
959
  * @param {object} secConfig Resolved security config (supplies the field lists).
962
960
  * @param {string} entityType Entity type, used to pick per-type redacted fields.
@@ -419,7 +419,7 @@ export function explainExistingTranslation(error, { revise = false, inventory =
419
419
  { cause: current },
420
420
  );
421
421
  }
422
- if (workingDistinct && workingRow && workingRow.status === false) {
422
+ if (workingDistinct && workingRow && isUnpublishedWorkingDraft(workingRow)) {
423
423
  return new Error(
424
424
  "An unpublished working translation for this language already exists. " +
425
425
  `Continue it with ${continuer} and langcode. ` +
@@ -427,6 +427,9 @@ export function explainExistingTranslation(error, { revise = false, inventory =
427
427
  { cause: current },
428
428
  );
429
429
  }
430
+ if (workingDistinct && workingRow && isCarriedPublishedLanguage(workingRow)) {
431
+ return carriedPublishedLanguageError(langcode);
432
+ }
430
433
  return current;
431
434
  }
432
435
 
@@ -497,6 +500,54 @@ export async function readNodeDraftInventory(backend, ref) {
497
500
  return inventory;
498
501
  }
499
502
 
503
+ /**
504
+ * Whether an inventory row is an unpublished working draft (#400).
505
+ *
506
+ * Decide from `moderation_state` when present: `status: false` alone is not
507
+ * a draft. Sentinel copies the live published moderation record onto a
508
+ * carried default language (drupal.org #3626610), so that row is unpublished
509
+ * in status and still `published` in workflow.
510
+ * @param {?object} row Inventory translation row.
511
+ * @returns {boolean}
512
+ */
513
+ export function isUnpublishedWorkingDraft(row) {
514
+ if (!row || typeof row !== "object") return false;
515
+ const state = typeof row.moderation_state === "string" ? row.moderation_state.trim().toLowerCase() : "";
516
+ if (state) return state !== "published";
517
+ return row.status === false;
518
+ }
519
+
520
+ /**
521
+ * Whether a working-copy row is a carried published language, not a draft.
522
+ * `status: false` + `moderation_state: published` — Sentinel copied live
523
+ * workflow onto the language. Neither continue nor revise can open a draft
524
+ * of it while another translation draft exists (drupal.org #3626919 / #400).
525
+ * @param {?object} row Inventory translation row.
526
+ * @returns {boolean}
527
+ */
528
+ export function isCarriedPublishedLanguage(row) {
529
+ if (!row || typeof row !== "object") return false;
530
+ const state = typeof row.moderation_state === "string" ? row.moderation_state.trim().toLowerCase() : "";
531
+ return row.status === false && state === "published";
532
+ }
533
+
534
+ /**
535
+ * Refusal when neither update_node nor revise can draft a carried language.
536
+ * @param {string} [langcode]
537
+ * @returns {Error}
538
+ */
539
+ export function carriedPublishedLanguageError(langcode) {
540
+ const named = langcode ? ` (${langcode})` : "";
541
+ return new Error(
542
+ `This language${named} is on the working copy with published moderation and unpublished status ` +
543
+ "(Sentinel copied the live moderation record; it is not a working draft). " +
544
+ "Neither drupal_update_node nor drupal_create_translation revise can open a draft of it " +
545
+ "while another translation draft exists (drupal.org #3626919). " +
546
+ "Publish or discard the translation draft first, or edit in the Drupal UI. " +
547
+ "See connector #400."
548
+ );
549
+ }
550
+
500
551
  /**
501
552
  * The language to name when continuing a multilingual working revision
502
553
  * without an explicit langcode (#379).
@@ -505,6 +556,8 @@ export async function readNodeDraftInventory(backend, ref) {
505
556
  * more than one language, even for the default language. Infer it only when
506
557
  * the default language is the unpublished draft in that revision; a
507
558
  * translation-only draft over published default copy stays explicit.
559
+ * A carried published language (`status: false` + published moderation) is
560
+ * not a draft (#400).
508
561
  * @param {object|null|undefined} inventory Sentinel translation inventory.
509
562
  * @returns {string|undefined}
510
563
  */
@@ -515,7 +568,7 @@ export function inferDefaultDraftLangcode(inventory) {
515
568
  const defaultLang = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : fallback;
516
569
  if (typeof defaultLang !== "string" || defaultLang === "") return undefined;
517
570
  const row = rows.find((item) => item?.langcode === defaultLang);
518
- return row && row.status === false ? defaultLang : undefined;
571
+ return row && isUnpublishedWorkingDraft(row) ? defaultLang : undefined;
519
572
  }
520
573
 
521
574
  /**
@@ -530,13 +583,16 @@ export function assertInventoryDraftLanguage(inventory, langcode) {
530
583
  throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
531
584
  }
532
585
  const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
586
+ if (row && isCarriedPublishedLanguage(row)) {
587
+ throw carriedPublishedLanguageError(langcode || row.langcode);
588
+ }
533
589
  if (row && row.status === true && langcode && langcode !== inventory.defaultLangcode && row.default !== true) {
534
590
  throw new Error(
535
591
  "This language is still published on the working copy, so it cannot be continued. " +
536
592
  "Open a draft of it with drupal_create_translation and revise: true. Published languages and other drafts were left unchanged.",
537
593
  );
538
594
  }
539
- if (!row || row.status !== false) {
595
+ if (!row || !isUnpublishedWorkingDraft(row)) {
540
596
  throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
541
597
  }
542
598
  }
@@ -444,7 +444,7 @@ export async function listServerTools(site, cursor) {
444
444
  }
445
445
 
446
446
  /** Fetch one page of Drupal `McpPromptConfig` prompts. */
447
- export async function listServerPrompts(site, cursor) {
447
+ async function listServerPrompts(site, cursor) {
448
448
  return requestServerTool(site, "prompts/list", cursor === undefined ? {} : { cursor }, {
449
449
  maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
450
450
  });
@@ -454,7 +454,7 @@ export async function listServerPrompts(site, cursor) {
454
454
  * Fetch one Drupal prompt body. Pass no arguments so `{{token}}` placeholders
455
455
  * stay intact for the connector's workflow renderer.
456
456
  */
457
- export async function getServerPrompt(site, name) {
457
+ async function getServerPrompt(site, name) {
458
458
  return requestServerTool(site, "prompts/get", { name, arguments: {} }, {
459
459
  maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
460
460
  });
@@ -466,7 +466,7 @@ export async function getServerPrompt(site, name) {
466
466
  * @param {Function} [list] Catalog page reader.
467
467
  * @returns {Promise<object[]>} Prompt descriptors.
468
468
  */
469
- export async function advertisedServerPrompts(site, list = listServerPrompts) {
469
+ async function advertisedServerPrompts(site, list = listServerPrompts) {
470
470
  const prompts = [];
471
471
  const seen = new Set();
472
472
  let cursor;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Honest MCP transport presets (DEV-763).
3
+ *
4
+ * Wizard `--transport` stays `stdio` | `https`. These named presets are the
5
+ * operator-facing labels so nobody invents a public SaaS URL.
6
+ */
7
+
8
+ /** @typedef {"local-stdio" | "tailscale" | "public-https"} TransportPresetId */
9
+
10
+ /**
11
+ * @typedef {object} TransportPreset
12
+ * @property {TransportPresetId} id
13
+ * @property {string} label
14
+ * @property {"stdio" | "https"} transport Connector MCP transport.
15
+ * @property {string} exampleHost Example URL shape.
16
+ * @property {string} notes Operator-facing constraints. No secrets.
17
+ * @property {boolean} gated True when this path is not a supported product claim.
18
+ */
19
+
20
+ /** Named presets in display order. */
21
+ export const TRANSPORT_PRESETS = {
22
+ "local-stdio": {
23
+ id: "local-stdio",
24
+ label: "Local stdio",
25
+ transport: "stdio",
26
+ exampleHost: "https://drupal.ddev.site",
27
+ notes:
28
+ "The MCP client launches this connector as a local subprocess. Drupal must " +
29
+ "be reachable from this machine (localhost, DDEV/Lando, or a routable LAN).",
30
+ gated: false,
31
+ },
32
+ tailscale: {
33
+ id: "tailscale",
34
+ label: "Tailscale VPN",
35
+ transport: "stdio",
36
+ exampleHost: "https://drupal.tailnet-name.ts.net",
37
+ notes:
38
+ "VPN-required. The machine running Cursor / Claude Code must be on the same " +
39
+ "Tailscale tailnet as Drupal (typical host: https://<machine>.<tailnet>.ts.net). " +
40
+ "This is not a public internet path and not a hosted SaaS endpoint.",
41
+ gated: false,
42
+ },
43
+ "public-https": {
44
+ id: "public-https",
45
+ label: "Public HTTPS",
46
+ transport: "https",
47
+ exampleHost: "https://mcp.example.com/mcp",
48
+ notes:
49
+ "Gated / later. An operator-run connector with MCP_TRANSPORT=https, TLS, and " +
50
+ "inbound OAuth (auth.issuer + auth.audience). There is no public hosted " +
51
+ "Wilkes & Liberty MCP URL. Do not invent a marketplace remote until Path B exists.",
52
+ gated: true,
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Look up a transport preset by id.
58
+ * @param {string} id
59
+ * @returns {TransportPreset|null}
60
+ */
61
+ export function getTransportPreset(id) {
62
+ switch (id) {
63
+ case "local-stdio":
64
+ return TRANSPORT_PRESETS["local-stdio"];
65
+ case "tailscale":
66
+ return TRANSPORT_PRESETS.tailscale;
67
+ case "public-https":
68
+ return TRANSPORT_PRESETS["public-https"];
69
+ default:
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Preset ids in display order.
76
+ * @returns {TransportPresetId[]}
77
+ */
78
+ export function listTransportPresetIds() {
79
+ return ["local-stdio", "tailscale", "public-https"];
80
+ }
81
+
82
+ /**
83
+ * Help text for wizard / doctor --help.
84
+ * @returns {string}
85
+ */
86
+ export function formatTransportPresetHelp() {
87
+ return listTransportPresetIds().map((id) => {
88
+ const preset = getTransportPreset(id);
89
+ const gate = preset.gated ? " [gated/later]" : "";
90
+ return ` ${preset.id}${gate} ${preset.label} — ${preset.exampleHost}\n ${preset.notes}`;
91
+ }).join("\n");
92
+ }
93
+
94
+ /**
95
+ * Map a wizard `--preset` onto the existing `--transport` pair.
96
+ * @param {string} id
97
+ * @returns {{preset: TransportPreset, transport: "stdio"|"https"}}
98
+ * @throws {Error} when the id is unknown.
99
+ */
100
+ export function resolveTransportPreset(id) {
101
+ const preset = getTransportPreset(String(id || "").trim().toLowerCase());
102
+ if (!preset) {
103
+ throw new Error("--preset must be local-stdio|tailscale|public-https.");
104
+ }
105
+ return { preset, transport: preset.transport };
106
+ }
107
+
108
+ /**
109
+ * RFC 2606 / 6761 documentation and reserved names — not a live Drupal.
110
+ * Shared by the wizard (DEV-758) and doctor (DEV-759).
111
+ * @param {string} url
112
+ * @returns {boolean}
113
+ */
114
+ export function isReservedDocumentationHost(url) {
115
+ let host;
116
+ try {
117
+ host = new URL(url).hostname;
118
+ } catch {
119
+ host = url;
120
+ }
121
+ host = String(host).toLowerCase();
122
+ return (
123
+ host === "example.com"
124
+ || host === "example.net"
125
+ || host === "example.org"
126
+ || host.endsWith(".example.com")
127
+ || host.endsWith(".example.net")
128
+ || host.endsWith(".example.org")
129
+ || host.endsWith(".example.test")
130
+ || host.endsWith(".invalid")
131
+ || host.endsWith(".test")
132
+ || host.endsWith(".example")
133
+ );
134
+ }
135
+
136
+ /**
137
+ * True when the URL hostname is MagicDNS (*.ts.net), not a substring match.
138
+ * Shared by doctor messaging (DEV-759) so evil.example.com/.ts.net/path is not treated as Tailscale.
139
+ * @param {string} url
140
+ * @returns {boolean}
141
+ */
142
+ export function isTailscaleMagicDnsHost(url) {
143
+ let host;
144
+ try {
145
+ host = new URL(url).hostname;
146
+ } catch {
147
+ host = String(url || "");
148
+ // Strip path/query/port if a bare host-ish string was passed.
149
+ host = host.split("/")[0].split("?")[0].split(":")[0];
150
+ }
151
+ host = String(host).toLowerCase();
152
+ return host === "ts.net" || host.endsWith(".ts.net");
153
+ }