drupal-mcp-connector 2.17.0 → 2.18.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/CHANGELOG.md +33 -0
- package/README.md +5 -0
- package/package.json +3 -1
- package/src/index.js +16 -8
- package/src/lib/backends/backend-interface.js +3 -0
- package/src/lib/backends/graphql.js +1 -0
- package/src/lib/backends/jsonapi.js +1 -0
- package/src/lib/dispatch.js +10 -0
- package/src/lib/draft-write.js +18 -429
- package/src/lib/module-tools.js +241 -0
- package/src/lib/node-draft-inventory.js +4 -47
- package/src/lib/operations.js +5 -0
- package/src/lib/patch-preflight.js +7 -15
- package/src/lib/sentinel-draft.js +530 -0
- package/src/lib/server-tools.js +50 -16
- package/src/lib/verify.js +7 -1
- package/src/tools/config.js +14 -4
- package/src/tools/media.js +14 -18
- package/src/tools/moderation.js +21 -21
- package/src/tools/nodes.js +1 -1
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +11 -1
- package/src/tools/reports-content.js +7 -6
- package/src/tools/revisions.js +24 -23
- package/src/tools/structure.js +1 -1
- package/src/tools/taxonomy.js +1 -1
- package/src/tools/translations.js +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.18.0] - 2026-09-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Nullable module inputs (#320).** The generic registry accepts composed nullable
|
|
14
|
+
schemas while retaining runtime type, length, required and unknown-field checks.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Module compatibility bindings (#317).** Existing config commands and reports
|
|
18
|
+
can use locally approved module tools through generic discovery and schema
|
|
19
|
+
validation. Configured bindings never fall back after a refusal. The live
|
|
20
|
+
verifier probes the configured source tool directly.
|
|
21
|
+
- **Module-owned tools (#317).** An opt-in registry discovers typed Drupal MCP
|
|
22
|
+
tools and routes them through explicit site, scope and operation policy. Module
|
|
23
|
+
actions need no dedicated JavaScript handler. Catalog revisions, schema checks,
|
|
24
|
+
bounded results and refusal without a valid source prevent silent fallbacks.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Server-tool sessions are bounded and isolated by endpoint, credential and caller.
|
|
28
|
+
Module writes do not automatically retry after an authentication/session refusal.
|
|
29
|
+
- **Remaining writes use `prepareGuardedPatch`.** `langcode` always resolves
|
|
30
|
+
Sentinel inventory and draft-preflights, including unmoderated media.
|
|
31
|
+
`drupal_update_media` + `langcode`, unscoped `drupal_set_moderation_state`,
|
|
32
|
+
and `drupal_revert_revision` share the same preflight / `writeDraft` path
|
|
33
|
+
as node updates. `revisions.js` uses the shared `changedAheadOfRevision`.
|
|
34
|
+
- **One Sentinel HTTP client and fail-closed inventory policy.** Draft,
|
|
35
|
+
translation, and inventory requests share `src/lib/sentinel-draft.js`
|
|
36
|
+
(paths, If-Match, lang/state headers, missing-endpoint classifiers,
|
|
37
|
+
validated inventory). Missing 404/405 stays unavailable / no canonical
|
|
38
|
+
langcode PATCH. Permission, 5xx, and malformed inventory now throw from
|
|
39
|
+
`resolveNodeTranslationPair` instead of falling through to
|
|
40
|
+
`rel:working-copy`. JSON:API advertises `capabilities().sentinelDraft`;
|
|
41
|
+
GraphQL `rawQuery` does not count as Sentinel-capable.
|
|
42
|
+
|
|
10
43
|
## [2.17.0] - 2026-09-11
|
|
11
44
|
|
|
12
45
|
### Added
|
package/README.md
CHANGED
|
@@ -31,6 +31,11 @@ The connector speaks **two Drupal backends interchangeably** — Drupal core's *
|
|
|
31
31
|
|
|
32
32
|
## Dual-Protocol Backends
|
|
33
33
|
|
|
34
|
+
Drupal modules can also supply their own typed MCP actions through an opt-in
|
|
35
|
+
[module-tool registry](docs/module-tools.md). The module owns business rules and
|
|
36
|
+
schemas; the connector discovers and forwards approved tools under Sentinel and
|
|
37
|
+
connector policy. Existing built-in tools remain available during migration.
|
|
38
|
+
|
|
34
39
|
Each site declares which backend(s) it exposes via the `api` key:
|
|
35
40
|
|
|
36
41
|
```json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
70
70
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
71
|
+
"ajv": "^8.20.0",
|
|
72
|
+
"ajv-formats": "^3.0.1",
|
|
71
73
|
"graphql": "^17.0.0",
|
|
72
74
|
"jose": "^6.2.9",
|
|
73
75
|
"node-fetch": "^3.3.2",
|
package/src/index.js
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
|
|
59
59
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
60
60
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
61
|
+
import { createModuleToolRegistry, isModuleTool } from "./lib/module-tools.js";
|
|
61
62
|
import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
|
|
62
63
|
|
|
63
64
|
// Apply config/secrets.map (or the shipped example table) before any site
|
|
@@ -104,6 +105,18 @@ const RESOURCES = [
|
|
|
104
105
|
},
|
|
105
106
|
];
|
|
106
107
|
|
|
108
|
+
const moduleTools = createModuleToolRegistry();
|
|
109
|
+
|
|
110
|
+
async function discoverableTools() {
|
|
111
|
+
const sites = listResolvableSiteConfigs();
|
|
112
|
+
const identity = getRequestIdentity();
|
|
113
|
+
const governed = await filterDiscoverableTools(allDefinitions, sites);
|
|
114
|
+
return [
|
|
115
|
+
...filterToolsByPrincipal(governed, sites, identity),
|
|
116
|
+
...await moduleTools.list({ sites, identity }),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
/**
|
|
108
121
|
* Resolve a resource URI to its JSON payload. URIs are matched in order; the
|
|
109
122
|
* templated forms (content-types, security-policy) capture the site name and
|
|
@@ -114,13 +127,6 @@ const RESOURCES = [
|
|
|
114
127
|
* @returns {Promise<object>} The resource data (later JSON-serialized).
|
|
115
128
|
* @throws {Error} If the URI matches no known resource.
|
|
116
129
|
*/
|
|
117
|
-
async function discoverableTools() {
|
|
118
|
-
const sites = listResolvableSiteConfigs();
|
|
119
|
-
const identity = getRequestIdentity();
|
|
120
|
-
const governed = await filterDiscoverableTools(allDefinitions, sites);
|
|
121
|
-
return filterToolsByPrincipal(governed, sites, identity);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
130
|
async function readResource(uri) {
|
|
125
131
|
const identity = getRequestIdentity();
|
|
126
132
|
const sites = listResolvableSiteConfigs();
|
|
@@ -295,7 +301,9 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
295
301
|
tools: {
|
|
296
302
|
definitions: allDefinitions,
|
|
297
303
|
list: discoverableTools,
|
|
298
|
-
call:
|
|
304
|
+
call: (name, args, context) => isModuleTool(name)
|
|
305
|
+
? moduleTools.call(name, args, context)
|
|
306
|
+
: callTool(name, args, context),
|
|
299
307
|
},
|
|
300
308
|
resources: {
|
|
301
309
|
definitions: RESOURCES,
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* @property {"full"|"enum"|"none"} sort Server-side sort support: arbitrary
|
|
24
24
|
* fields ("full"), a fixed enum of keys ("enum"), or none ("none").
|
|
25
25
|
* @property {boolean} revisions Adapter exposes entity revisions.
|
|
26
|
+
* @property {boolean} [sentinelDraft] Adapter can issue Sentinel's JSON:API
|
|
27
|
+
* `/mcp-draft` and `/mcp-translations` routes (`rawQuery({ path, options })`
|
|
28
|
+
* plus `resourcePath`). GraphQL `rawQuery({ query })` must be false.
|
|
26
29
|
* @property {((entityType: string, bundle: string) => string[])|null} fieldAvailability
|
|
27
30
|
* Optional resolver returning the known field names for a bundle, or null
|
|
28
31
|
* when the adapter cannot cheaply enumerate fields.
|
|
@@ -65,6 +65,7 @@ export class GraphqlBackend extends Backend {
|
|
|
65
65
|
return {
|
|
66
66
|
read: true, write: false, delete: false,
|
|
67
67
|
count: false, filter: false, sort: "enum", revisions: false,
|
|
68
|
+
sentinelDraft: false,
|
|
68
69
|
fieldAvailability: (entityType, bundle) => this._fieldNames(entityType, bundle),
|
|
69
70
|
};
|
|
70
71
|
}
|
package/src/lib/dispatch.js
CHANGED
|
@@ -18,6 +18,7 @@ import { inferOperation } from "./operations.js";
|
|
|
18
18
|
import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
|
|
19
19
|
import {
|
|
20
20
|
assertPrincipalEntitlement, callerTargetHints, getRequestIdentity,
|
|
21
|
+
principalHasScope, resolveAuthoritativeTarget,
|
|
21
22
|
} from "./principal.js";
|
|
22
23
|
import { assertExplicitSiteForWrite, withResolvedTarget } from "./site-target.js";
|
|
23
24
|
import { buildDataFlowContext, consumeBudgetIfEnforced, runWithDataFlow } from "./data-flow.js";
|
|
@@ -62,6 +63,15 @@ export function resolveCallTarget(toolName, rawArgs, context = {}) {
|
|
|
62
63
|
|
|
63
64
|
const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
|
|
64
65
|
if (identity) {
|
|
66
|
+
if (context.moduleTool) {
|
|
67
|
+
if (!principalHasScope(identity, context.moduleTool.scope)) {
|
|
68
|
+
throw new SecurityError("Not entitled to invoke this module tool.");
|
|
69
|
+
}
|
|
70
|
+
return resolveAuthoritativeTarget(rawArgs, identity,
|
|
71
|
+
context.sites ?? listResolvableSiteConfigs(), {
|
|
72
|
+
grants: context.grants, defaultSite: context.defaultSite,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
65
75
|
return assertPrincipalEntitlement({
|
|
66
76
|
toolName,
|
|
67
77
|
args: rawArgs,
|
package/src/lib/draft-write.js
CHANGED
|
@@ -1,430 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* @returns {Error}
|
|
21
|
-
*/
|
|
22
|
-
function missingEndpointError(error, message) {
|
|
23
|
-
if (/Drupal (404|405)\b/.test(String(error?.message))) {
|
|
24
|
-
return new Error(message, { cause: error });
|
|
25
|
-
}
|
|
26
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {string} langcode
|
|
31
|
-
* @returns {string}
|
|
32
|
-
*/
|
|
33
|
-
export function assertDraftLangcode(langcode) {
|
|
34
|
-
const value = String(langcode || "").trim();
|
|
35
|
-
if (!LANGCODE_RE.test(value)) {
|
|
36
|
-
throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
|
|
37
|
-
}
|
|
38
|
-
return value;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* @param {object} [draftRevision]
|
|
43
|
-
* @returns {{live: string, working: string}}
|
|
44
|
-
*/
|
|
45
|
-
function requireWorkingPair(draftRevision) {
|
|
46
|
-
const live = String(draftRevision?.liveVid ?? "");
|
|
47
|
-
const working = String(draftRevision?.workingVid ?? "");
|
|
48
|
-
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
49
|
-
throw new Error("Draft continuation requires distinct, verified live and working revision IDs.");
|
|
50
|
-
}
|
|
51
|
-
return { live, working };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param {object} backend
|
|
56
|
-
* @param {string} entityType
|
|
57
|
-
* @param {string} bundle
|
|
58
|
-
* @param {string} id
|
|
59
|
-
* @returns {string}
|
|
60
|
-
*/
|
|
61
|
-
function draftResource(backend, entityType, bundle, id) {
|
|
62
|
-
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
63
|
-
throw new Error("Governed draft translation is implemented for nodes, paragraphs, and media.");
|
|
64
|
-
}
|
|
65
|
-
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
66
|
-
throw new Error("This backend does not support governed draft continuation.");
|
|
67
|
-
}
|
|
68
|
-
return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @param {object} [draftRevision]
|
|
73
|
-
* @returns {string}
|
|
74
|
-
*/
|
|
75
|
-
function requireParagraphRevisionId(draftRevision) {
|
|
76
|
-
const revisionId = String(draftRevision?.revisionId ?? draftRevision?.workingVid ?? "");
|
|
77
|
-
if (!/^[1-9]\d*$/.test(revisionId)) {
|
|
78
|
-
throw new Error("Paragraph translation requires a verified paragraph revision ID.");
|
|
79
|
-
}
|
|
80
|
-
return revisionId;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Validate or continue a draft, using the same payload and revision precondition.
|
|
85
|
-
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
86
|
-
* @param {object} backend JSON:API backend.
|
|
87
|
-
* @param {object} input Canonical update input plus draftRevision.
|
|
88
|
-
* @param {boolean} preflight Validate without saving.
|
|
89
|
-
* @returns {Promise<object>} Preflight metadata or the written canonical entity.
|
|
90
|
-
*/
|
|
91
|
-
export async function writeDraft(backend, input, preflight = false) {
|
|
92
|
-
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
93
|
-
if (entityType === "paragraph") {
|
|
94
|
-
return writeParagraphDraft(backend, input, preflight);
|
|
95
|
-
}
|
|
96
|
-
const { live, working } = requireWorkingPair(draftRevision);
|
|
97
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
98
|
-
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
99
|
-
if (relationships) data.relationships = relationships;
|
|
100
|
-
const headers = {
|
|
101
|
-
"If-Match": `"${live}:${working}"`,
|
|
102
|
-
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
103
|
-
};
|
|
104
|
-
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
105
|
-
if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
|
|
106
|
-
let result;
|
|
107
|
-
try {
|
|
108
|
-
result = await backend.rawQuery({
|
|
109
|
-
path: `${base}/mcp-draft`,
|
|
110
|
-
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
111
|
-
});
|
|
112
|
-
} catch (error) {
|
|
113
|
-
throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
|
|
114
|
-
}
|
|
115
|
-
if (preflight) {
|
|
116
|
-
if (result?.meta?.draft_preflight !== true
|
|
117
|
-
|| String(result.meta.live) !== live || String(result.meta.working) !== working) {
|
|
118
|
-
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
119
|
-
}
|
|
120
|
-
if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
|
|
121
|
-
throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
|
|
122
|
-
}
|
|
123
|
-
return result;
|
|
124
|
-
}
|
|
125
|
-
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
126
|
-
throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
127
|
-
}
|
|
128
|
-
return backend.toCanonical(result.data);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Create a target-language translation as an unpublished forward revision.
|
|
133
|
-
* If-Match is `"live"` when there is no working copy, or `"live:working"` when
|
|
134
|
-
* adding the language onto an existing unpublished English draft.
|
|
135
|
-
* @param {object} backend
|
|
136
|
-
* @param {object} input
|
|
137
|
-
* @param {boolean} [preflight]
|
|
138
|
-
* @returns {Promise<object>}
|
|
139
|
-
*/
|
|
140
|
-
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
141
|
-
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
142
|
-
const langcode = assertDraftLangcode(input.langcode);
|
|
143
|
-
if (entityType === "paragraph") {
|
|
144
|
-
return createParagraphTranslationDraft(backend, {
|
|
145
|
-
entityType, bundle, id, attributes, relationships, draftRevision, langcode,
|
|
146
|
-
}, preflight);
|
|
147
|
-
}
|
|
148
|
-
const live = String(draftRevision?.liveVid ?? "");
|
|
149
|
-
const workingRaw = draftRevision?.workingVid;
|
|
150
|
-
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
151
|
-
? ""
|
|
152
|
-
: String(workingRaw);
|
|
153
|
-
if ((entityType !== "node" && entityType !== "media") || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
-
throw new Error("Translation create requires a verified live revision ID.");
|
|
155
|
-
}
|
|
156
|
-
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
157
|
-
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
158
|
-
}
|
|
159
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
160
|
-
const safeAttributes = { ...attributes };
|
|
161
|
-
delete safeAttributes.langcode;
|
|
162
|
-
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
163
|
-
if (relationships) data.relationships = relationships;
|
|
164
|
-
const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
|
|
165
|
-
let result;
|
|
166
|
-
try {
|
|
167
|
-
result = await backend.rawQuery({
|
|
168
|
-
path: `${base}/mcp-draft/translations`,
|
|
169
|
-
options: {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: {
|
|
172
|
-
"If-Match": ifMatch,
|
|
173
|
-
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
174
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
175
|
-
},
|
|
176
|
-
body: JSON.stringify({ data }),
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
} catch (error) {
|
|
180
|
-
throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
|
|
181
|
-
}
|
|
182
|
-
if (preflight) {
|
|
183
|
-
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
184
|
-
throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
|
|
185
|
-
}
|
|
186
|
-
return result;
|
|
187
|
-
}
|
|
188
|
-
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
189
|
-
throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
190
|
-
}
|
|
191
|
-
return backend.toCanonical(result.data);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Live + working revision ids for a node translation create.
|
|
196
|
-
* Prefers Sentinel's mcp-translations inventory over JSON:API
|
|
197
|
-
* `rel:working-copy`, which 403s on some unpublished drafts even when a
|
|
198
|
-
* working revision exists (#282).
|
|
199
|
-
* @param {object} backend
|
|
200
|
-
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
201
|
-
* @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
|
|
202
|
-
*/
|
|
203
|
-
export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
|
|
204
|
-
const fromEntity = existing ? entityRevisionId(existing) : null;
|
|
205
|
-
try {
|
|
206
|
-
const meta = await readTranslationInventory(backend, { entityType, bundle, id });
|
|
207
|
-
const liveVid = meta.live?.vid ?? fromEntity;
|
|
208
|
-
const workingVid = meta.working?.vid;
|
|
209
|
-
const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
|
|
210
|
-
&& liveVid !== undefined && liveVid !== null
|
|
211
|
-
&& String(workingVid) !== String(liveVid);
|
|
212
|
-
return { liveVid, workingVid: distinct ? workingVid : undefined };
|
|
213
|
-
} catch {
|
|
214
|
-
if (typeof backend.getEntity !== "function") {
|
|
215
|
-
return { liveVid: fromEntity, workingVid: undefined };
|
|
216
|
-
}
|
|
217
|
-
let workingCopy = null;
|
|
218
|
-
try {
|
|
219
|
-
workingCopy = await backend.getEntity({
|
|
220
|
-
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
221
|
-
});
|
|
222
|
-
} catch {
|
|
223
|
-
workingCopy = null;
|
|
224
|
-
}
|
|
225
|
-
const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
|
|
226
|
-
const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
|
|
227
|
-
return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Rewrite Sentinel's live-only 409 into an actionable connector error.
|
|
233
|
-
* @param {unknown} error
|
|
234
|
-
* @returns {Error}
|
|
235
|
-
*/
|
|
236
|
-
export function rewriteTranslationWorkingRevisionError(error) {
|
|
237
|
-
if (/A working revision exists\. Reload and send both revision IDs/i.test(String(error?.message || ""))) {
|
|
238
|
-
return new Error(
|
|
239
|
-
"Translation create sent only the live revision, but a working draft exists. " +
|
|
240
|
-
"The connector should have sent both live and working revision IDs (If-Match). " +
|
|
241
|
-
"Reload with drupal_list_translations and retry. See connector #282.",
|
|
242
|
-
{ cause: error instanceof Error ? error : undefined },
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Read live/working translation inventory from Sentinel.
|
|
250
|
-
* @param {object} backend
|
|
251
|
-
* @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
|
|
252
|
-
* @returns {Promise<object>}
|
|
253
|
-
*/
|
|
254
|
-
export async function readTranslationInventory(backend, { entityType, bundle, id, revisionId }) {
|
|
255
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
256
|
-
const headers = {};
|
|
257
|
-
if (revisionId !== undefined && revisionId !== null && revisionId !== "") {
|
|
258
|
-
headers["If-Match"] = `"${requireParagraphRevisionId({ revisionId })}"`;
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
const result = await backend.rawQuery({
|
|
262
|
-
path: `${base}/mcp-translations`,
|
|
263
|
-
options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
|
|
264
|
-
});
|
|
265
|
-
if (!result?.meta?.live) {
|
|
266
|
-
throw new Error("The site did not return a translation inventory.");
|
|
267
|
-
}
|
|
268
|
-
return result.meta;
|
|
269
|
-
} catch (error) {
|
|
270
|
-
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Read one unpublished working translation.
|
|
276
|
-
* @param {object} backend
|
|
277
|
-
* @param {object} input
|
|
278
|
-
* @returns {Promise<object>}
|
|
279
|
-
*/
|
|
280
|
-
export async function readDraftTranslation(backend, input) {
|
|
281
|
-
const { entityType, bundle, id, draftRevision } = input;
|
|
282
|
-
const langcode = assertDraftLangcode(input.langcode);
|
|
283
|
-
if (entityType === "paragraph") {
|
|
284
|
-
return readParagraphDraftTranslation(backend, input);
|
|
285
|
-
}
|
|
286
|
-
const { live, working } = requireWorkingPair(draftRevision);
|
|
287
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
288
|
-
let result;
|
|
289
|
-
try {
|
|
290
|
-
result = await backend.rawQuery({
|
|
291
|
-
path: `${base}/mcp-draft`,
|
|
292
|
-
options: {
|
|
293
|
-
method: "GET",
|
|
294
|
-
headers: {
|
|
295
|
-
"If-Match": `"${live}:${working}"`,
|
|
296
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
} catch (error) {
|
|
301
|
-
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
302
|
-
}
|
|
303
|
-
if (!result?.data || result.data.id !== id) {
|
|
304
|
-
throw new Error("Draft translation read did not identify the requested entity.");
|
|
305
|
-
}
|
|
306
|
-
return backend.toCanonical(result.data);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Continue an unpublished paragraph translation on a pinned revision.
|
|
311
|
-
* @param {object} backend
|
|
312
|
-
* @param {object} input
|
|
313
|
-
* @param {boolean} [preflight]
|
|
314
|
-
* @returns {Promise<object>}
|
|
315
|
-
*/
|
|
316
|
-
async function writeParagraphDraft(backend, input, preflight = false) {
|
|
317
|
-
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
318
|
-
const langcode = assertDraftLangcode(input.langcode);
|
|
319
|
-
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
320
|
-
const draftState = input.draftState;
|
|
321
|
-
if (typeof draftState !== "string" || !/^[a-f0-9]{64}$/.test(draftState)) {
|
|
322
|
-
throw new Error("Paragraph translation update requires draftState from the previous draft read. Re-read the draft; do not retry old copy with a refreshed token.");
|
|
323
|
-
}
|
|
324
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
325
|
-
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
326
|
-
if (relationships) data.relationships = relationships;
|
|
327
|
-
const headers = {
|
|
328
|
-
"If-Match": `"${revisionId}"`,
|
|
329
|
-
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
330
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
331
|
-
"X-MCP-Draft-State": draftState,
|
|
332
|
-
};
|
|
333
|
-
let result;
|
|
334
|
-
try {
|
|
335
|
-
result = await backend.rawQuery({
|
|
336
|
-
path: `${base}/mcp-draft`,
|
|
337
|
-
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
338
|
-
});
|
|
339
|
-
} catch (error) {
|
|
340
|
-
throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
|
|
341
|
-
}
|
|
342
|
-
if (preflight) {
|
|
343
|
-
if (result?.meta?.draft_preflight !== true
|
|
344
|
-
|| String(result.meta.live) !== revisionId
|
|
345
|
-
|| (result.meta.langcode && String(result.meta.langcode) !== langcode)) {
|
|
346
|
-
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
347
|
-
}
|
|
348
|
-
return result;
|
|
349
|
-
}
|
|
350
|
-
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
351
|
-
throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
352
|
-
}
|
|
353
|
-
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Create an unpublished paragraph translation on a pinned revision.
|
|
358
|
-
* @param {object} backend
|
|
359
|
-
* @param {object} input
|
|
360
|
-
* @param {boolean} [preflight]
|
|
361
|
-
* @returns {Promise<object>}
|
|
362
|
-
*/
|
|
363
|
-
async function createParagraphTranslationDraft(backend, input, preflight = false) {
|
|
364
|
-
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
365
|
-
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
366
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
367
|
-
const safeAttributes = { ...attributes };
|
|
368
|
-
delete safeAttributes.langcode;
|
|
369
|
-
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
370
|
-
if (relationships) data.relationships = relationships;
|
|
371
|
-
let result;
|
|
372
|
-
try {
|
|
373
|
-
result = await backend.rawQuery({
|
|
374
|
-
path: `${base}/mcp-draft/translations`,
|
|
375
|
-
options: {
|
|
376
|
-
method: "POST",
|
|
377
|
-
headers: {
|
|
378
|
-
"If-Match": `"${revisionId}"`,
|
|
379
|
-
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
380
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
381
|
-
},
|
|
382
|
-
body: JSON.stringify({ data }),
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
} catch (error) {
|
|
386
|
-
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
387
|
-
}
|
|
388
|
-
if (preflight) {
|
|
389
|
-
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== revisionId) {
|
|
390
|
-
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
391
|
-
}
|
|
392
|
-
return result;
|
|
393
|
-
}
|
|
394
|
-
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
395
|
-
throw new Error("Paragraph translation create did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
396
|
-
}
|
|
397
|
-
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Read one unpublished paragraph translation of a pinned revision.
|
|
402
|
-
* @param {object} backend
|
|
403
|
-
* @param {object} input
|
|
404
|
-
* @returns {Promise<object>}
|
|
405
|
-
*/
|
|
406
|
-
async function readParagraphDraftTranslation(backend, input) {
|
|
407
|
-
const { entityType, bundle, id, draftRevision } = input;
|
|
408
|
-
const langcode = assertDraftLangcode(input.langcode);
|
|
409
|
-
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
410
|
-
const base = draftResource(backend, entityType, bundle, id);
|
|
411
|
-
let result;
|
|
412
|
-
try {
|
|
413
|
-
result = await backend.rawQuery({
|
|
414
|
-
path: `${base}/mcp-draft`,
|
|
415
|
-
options: {
|
|
416
|
-
method: "GET",
|
|
417
|
-
headers: {
|
|
418
|
-
"If-Match": `"${revisionId}"`,
|
|
419
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
420
|
-
},
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
|
-
} catch (error) {
|
|
424
|
-
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
425
|
-
}
|
|
426
|
-
if (!result?.data || result.data.id !== id) {
|
|
427
|
-
throw new Error("Paragraph draft translation read did not identify the requested entity.");
|
|
428
|
-
}
|
|
429
|
-
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
430
|
-
}
|
|
2
|
+
* Compatibility facade for the Sentinel HTTP client.
|
|
3
|
+
* New call sites should import from `./sentinel-draft.js`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
assertDraftLangcode,
|
|
8
|
+
assertInventoryDraftLanguage,
|
|
9
|
+
createTranslationDraft,
|
|
10
|
+
isMissingDraftEndpoint,
|
|
11
|
+
isMissingTranslationEndpoint,
|
|
12
|
+
readDraftTranslation,
|
|
13
|
+
readNodeDraftInventory,
|
|
14
|
+
readTranslationInventory,
|
|
15
|
+
resolveNodeTranslationPair,
|
|
16
|
+
rewriteTranslationWorkingRevisionError,
|
|
17
|
+
supportsSentinelDraft,
|
|
18
|
+
writeDraft,
|
|
19
|
+
} from "./sentinel-draft.js";
|