drupal-mcp-connector 2.25.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +5 -3
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +4 -2
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +97 -10
- package/src/tools/translations.js +7 -1
package/src/lib/operations.js
CHANGED
|
@@ -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/
|
|
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.
|
|
106
|
-
*
|
|
107
|
-
* the
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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).
|
|
114
|
-
"
|
|
115
|
-
"
|
|
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
|
|
124
|
-
*
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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) {
|
package/src/lib/principal.js
CHANGED
|
@@ -39,15 +39,18 @@ export const DIAGNOSTIC_TOOLS = new Set([
|
|
|
39
39
|
"drupal_governance_status",
|
|
40
40
|
]);
|
|
41
41
|
|
|
42
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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) {
|
package/src/lib/security.js
CHANGED
|
@@ -512,22 +512,53 @@ function isGovernedSetup(site) {
|
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
/**
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
544
|
+
* @throws {SecurityError} if the required config scope is missing.
|
|
525
545
|
*/
|
|
526
|
-
export function assertConfigScope(site, operationLabel) {
|
|
527
|
-
if (
|
|
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
|
|
530
|
-
"Developer tier
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -444,7 +444,7 @@ export async function listServerTools(site, cursor) {
|
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
/** Fetch one page of Drupal `McpPromptConfig` prompts. */
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|