drupal-mcp-connector 2.13.0 → 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +26 -12
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/draft-write.js +57 -0
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +8 -8
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +1 -2
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets
|
|
2
|
+
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
|
|
3
3
|
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_entity_update`.
|
|
7
7
|
|
|
8
|
-
Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets
|
|
8
|
+
Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
18
18
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
19
19
|
- `attributes` (object (pass as JSON))
|
|
20
20
|
- `relationships` (object (pass as JSON))
|
|
21
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
21
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
|
22
22
|
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
23
23
|
|
|
24
24
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
2
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_node`.
|
|
7
7
|
|
|
8
|
-
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
8
|
+
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -23,7 +23,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
23
23
|
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
|
|
24
24
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
|
|
25
25
|
- `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot.
|
|
26
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
26
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
|
27
27
|
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
28
28
|
|
|
29
29
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.13.1] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Existing node drafts use Sentinel's governed draft-continuation endpoint
|
|
14
|
+
instead of a revision-selected core PATCH, which Drupal rejects (#166).
|
|
15
|
+
Preflight sends the actual fields and revision preconditions without saving.
|
|
16
|
+
An unavailable server endpoint fails closed without discarding the draft.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Comments, test names, lab notes, and older changelog entries now reference
|
|
20
|
+
GitHub issues, release versions, or plain descriptions instead of internal
|
|
21
|
+
tracker keys (#258). No behaviour change.
|
|
22
|
+
|
|
10
23
|
## [2.13.0] - 2026-09-02
|
|
11
24
|
|
|
12
25
|
### Security
|
|
@@ -124,7 +137,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
124
137
|
credentials never appear in any frame crossing the tunnel. The lab
|
|
125
138
|
README now states plainly that the lab northbound does not authenticate
|
|
126
139
|
the caller (the lab credential authenticates the agent channel) — the
|
|
127
|
-
product edge's northbound OAuth is
|
|
140
|
+
product edge's northbound OAuth is #232 work, not this harness.
|
|
128
141
|
|
|
129
142
|
### Added
|
|
130
143
|
- **Deployment documentation for the relay edge and tenant-agent entry
|
|
@@ -149,13 +162,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
149
162
|
window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
|
|
150
163
|
teardown, and timeout bounds. These are entry points and libraries only;
|
|
151
164
|
nothing here is a hosted service.
|
|
152
|
-
- **Lab-only outbound-relay harness
|
|
165
|
+
- **Lab-only outbound-relay harness.** Isolated under
|
|
153
166
|
`lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
|
|
154
167
|
to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
|
|
155
168
|
no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
|
|
156
169
|
keeps the same tunnel identity; revocation is checked per request (next
|
|
157
170
|
request denied, no grace window). This is not a public surface, not a
|
|
158
|
-
hosted-service claim, and not
|
|
171
|
+
hosted-service claim, and not the product edge (#232). Hosted MCP is unstarted.
|
|
159
172
|
|
|
160
173
|
## [2.9.0] - 2026-08-26
|
|
161
174
|
|
|
@@ -935,7 +948,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
935
948
|
## [1.5.1] - 2026-06-29
|
|
936
949
|
|
|
937
950
|
### Fixed
|
|
938
|
-
- **Node URL aliases set via the connector now actually persist
|
|
951
|
+
- **Node URL aliases set via the connector now actually persist.** Setting an
|
|
939
952
|
alias with `drupal_update_node` (`fields.path = { alias, pathauto: 0 }`) returned
|
|
940
953
|
success but silently reverted, causing nav 404s. Root cause: JSON:API deserialized the
|
|
941
954
|
`path` field without the existing alias's **`pid`**, so Drupal's `PathItem::postSave`
|
|
@@ -943,7 +956,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
943
956
|
in place. The connector now reads the current alias's `pid` (new
|
|
944
957
|
`backend.getPathInfo`) and round-trips it, so the alias is **updated in place** — one
|
|
945
958
|
canonical alias, no duplicate. Verified end-to-end over JSON:API on Drupal 11.
|
|
946
|
-
- **Path-less updates no longer create duplicate aliases.** The
|
|
959
|
+
- **Path-less updates no longer create duplicate aliases.** The 1.5.0 "preserve" path
|
|
947
960
|
re-pinned the current alias *without* its `pid`, hitting the same duplicate bug; it now
|
|
948
961
|
round-trips the `pid` too.
|
|
949
962
|
- **Honest write responses.** `drupal_create_node` / `drupal_update_node` now **re-read**
|
|
@@ -973,29 +986,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
973
986
|
### Added
|
|
974
987
|
- **`drupal_update_paragraph`** — update an existing Paragraph entity's field values
|
|
975
988
|
in place (partial JSON:API PATCH) by bundle + UUID, so component / key-capability
|
|
976
|
-
paragraphs can be maintained end-to-end without re-embedding
|
|
989
|
+
paragraphs can be maintained end-to-end without re-embedding.
|
|
977
990
|
- **`drupal_update_menu_link`** — update a menu link by UUID (rename, re-weight,
|
|
978
991
|
re-target, re-parent, enable/disable). `enabled` is preserved across edits unless
|
|
979
|
-
passed explicitly
|
|
992
|
+
passed explicitly.
|
|
980
993
|
- **`drupal_create_menu_link`** now accepts **`parent`** (nest under a parent link
|
|
981
994
|
plugin id) and **`enabled`** on create, and creates links **enabled by default** so
|
|
982
995
|
they render immediately — closing the "menu links created disabled / no parent on
|
|
983
|
-
create" gap
|
|
996
|
+
create" gap.
|
|
984
997
|
|
|
985
998
|
### Fixed
|
|
986
999
|
- **Menu links no longer silently regress to disabled.** Every menu-link write now
|
|
987
1000
|
asserts `enabled` explicitly (default true on create; the current value re-pinned on
|
|
988
1001
|
update), so an unrelated edit can't drop a live link to disabled through the JSON:API
|
|
989
|
-
write path
|
|
1002
|
+
write path.
|
|
990
1003
|
- **Node updates preserve the existing URL alias.** When `drupal_update_node` is called
|
|
991
1004
|
without a `path`, the connector reads the current alias and re-pins it
|
|
992
1005
|
(`{ alias, pathauto: 0 }`) so a save can't let Pathauto revert the alias to a stale
|
|
993
|
-
value. Pass `fields.path` to set the alias explicitly
|
|
1006
|
+
value. Pass `fields.path` to set the alias explicitly.
|
|
994
1007
|
- **Intermittent `drupal_create_menu_link` 422 "path '/…' is inaccessible".** This is a
|
|
995
1008
|
transient path-validator/access-cache race in Drupal's `LinkAccessConstraint`; menu-link
|
|
996
1009
|
create/update now retries once after a short delay when it hits that specific error.
|
|
997
1010
|
Prefer an `entity:node/<id>` target over `internal:/<alias>` to avoid the alias
|
|
998
|
-
resolution step entirely
|
|
1011
|
+
resolution step entirely.
|
|
999
1012
|
|
|
1000
1013
|
## [1.4.0] - 2026-06-29
|
|
1001
1014
|
|
|
@@ -1011,7 +1024,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1011
1024
|
its status code via a partial update — the path to activate/fix a redirect that
|
|
1012
1025
|
isn't firing. Both are governed by the per-site security policy (redirect writes /
|
|
1013
1026
|
`administer redirects`). Resolves the gap where connector-created redirects were
|
|
1014
|
-
inactive and could not be enabled
|
|
1027
|
+
inactive and could not be enabled.
|
|
1015
1028
|
|
|
1016
1029
|
## [1.3.2] - 2026-06-27
|
|
1017
1030
|
|
|
@@ -1418,6 +1431,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1418
1431
|
- User tools gained explicit PII-access assertions.
|
|
1419
1432
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1420
1433
|
|
|
1434
|
+
[2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
|
|
1421
1435
|
[2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
|
|
1422
1436
|
[2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
|
|
1423
1437
|
[2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
|
package/package.json
CHANGED
|
@@ -78,7 +78,7 @@ export class Backend {
|
|
|
78
78
|
* Read the raw `path` field (alias/pid/langcode) and internal id of an entity,
|
|
79
79
|
* for callers that must round-trip the alias `pid` on an in-place update (the
|
|
80
80
|
* canonical entity only exposes `path.alias` as `url`). Optional capability:
|
|
81
|
-
* the default returns nulls so read-only/path-less backends are safe. See
|
|
81
|
+
* the default returns nulls so read-only/path-less backends are safe. See the 1.5.1 alias fix.
|
|
82
82
|
* @param {{entityType: string, bundle: string, id: string}} _ref
|
|
83
83
|
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
|
|
84
84
|
*/
|
|
@@ -137,7 +137,7 @@ function applyFilter(params, { field, op = "eq", value }) {
|
|
|
137
137
|
/**
|
|
138
138
|
* Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
|
|
139
139
|
* User entities are left unchanged. No actor claim (auth.actors not in effect
|
|
140
|
-
* for this principal) leaves relationships as-is — the
|
|
140
|
+
* for this principal) leaves relationships as-is — the path before actor mapping (#247).
|
|
141
141
|
* A *present* actor claim that fails UUID validation fails closed (throws)
|
|
142
142
|
* rather than silently keeping a caller-supplied uid: resolveActor()/
|
|
143
143
|
* normalizeActors() already validate the shape before stamping identity.actor,
|
|
@@ -309,7 +309,7 @@ export class JsonApiBackend extends Backend {
|
|
|
309
309
|
* correct in-place alias *update* must round-trip the existing alias's `pid`
|
|
310
310
|
* (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
|
|
311
311
|
* — so this method exposes it. Returns nulls for entities/backends without a
|
|
312
|
-
* path field. See
|
|
312
|
+
* path field. See the 1.5.1 alias fix.
|
|
313
313
|
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
314
314
|
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
|
|
315
315
|
*/
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel's governed draft-continuation contract (d.o #3621022).
|
|
3
|
+
* Core JSON:API revision selectors support reads, not PATCH requests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate or continue a draft, using the same payload and revision precondition.
|
|
8
|
+
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
9
|
+
* @param {object} backend JSON:API backend.
|
|
10
|
+
* @param {object} input Canonical update input plus draftRevision.
|
|
11
|
+
* @param {boolean} preflight Validate without saving.
|
|
12
|
+
* @returns {Promise<object>} Preflight metadata or the written canonical entity.
|
|
13
|
+
*/
|
|
14
|
+
export async function writeDraft(backend, input, preflight = false) {
|
|
15
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
16
|
+
const live = String(draftRevision?.liveVid ?? "");
|
|
17
|
+
const working = String(draftRevision?.workingVid ?? "");
|
|
18
|
+
if (entityType !== "node" || !/^[1-9]\d*$/.test(live)
|
|
19
|
+
|| !/^[1-9]\d*$/.test(working) || live === working) {
|
|
20
|
+
throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
|
|
21
|
+
}
|
|
22
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
23
|
+
throw new Error("This backend does not support governed draft continuation.");
|
|
24
|
+
}
|
|
25
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
26
|
+
if (relationships) data.relationships = relationships;
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await backend.rawQuery({
|
|
30
|
+
path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
|
|
31
|
+
options: {
|
|
32
|
+
method: "PATCH",
|
|
33
|
+
headers: {
|
|
34
|
+
"If-Match": `"${live}:${working}"`,
|
|
35
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({ data }),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (/Drupal (404|405)\b/.test(String(error?.message))) {
|
|
42
|
+
throw new Error("The site does not provide Sentinel's governed draft endpoint (d.o #3621022). Update the server-side module; the draft was not discarded and no canonical fallback was attempted.", { cause: error });
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (preflight) {
|
|
47
|
+
if (result?.meta?.draft_preflight !== true
|
|
48
|
+
|| String(result.meta.live) !== live || String(result.meta.working) !== working) {
|
|
49
|
+
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
54
|
+
throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
55
|
+
}
|
|
56
|
+
return backend.toCanonical(result.data);
|
|
57
|
+
}
|
|
@@ -12,19 +12,19 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Two distinct cases share that core 400:
|
|
14
14
|
*
|
|
15
|
-
* - **#166** — `rel:working-copy` resolves.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* - **#166** — `rel:working-copy` resolves. Both preflight and write use
|
|
16
|
+
* Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
|
|
17
|
+
* Core rejects resourceVersion on PATCH. Never retry the canonical URL.
|
|
18
18
|
* - **#201** — the working-copy alias does not resolve, but core still
|
|
19
19
|
* blocks. That is a stray revision row. Refuse with revision-surgery
|
|
20
20
|
* language. `workingCopy: null` is not proof the node is writable.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* concurrent write — refuse, do not fall back to discard.
|
|
22
|
+
* Unsupported endpoints and stale revisions fail closed, without discard.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
27
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
|
+
import { writeDraft } from "./draft-write.js";
|
|
28
28
|
|
|
29
29
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
30
30
|
export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
|
|
@@ -59,7 +59,7 @@ export const PATCH_WORKING_COPY_STALE_MESSAGE =
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Operator message for a core working-copy 400.
|
|
62
|
-
* A resolvable working copy
|
|
62
|
+
* A resolvable working copy gets a new draft revision (#166) — this message is only
|
|
63
63
|
* for the invisible-row case (#201).
|
|
64
64
|
* @returns {string}
|
|
65
65
|
*/
|
|
@@ -300,11 +300,18 @@ export async function preflightPatchWritable({
|
|
|
300
300
|
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
301
301
|
*/
|
|
302
302
|
export async function prepareGuardedPatch(backend, {
|
|
303
|
-
entityType, bundle, id, existing, attributes,
|
|
303
|
+
entityType, bundle, id, existing, attributes, relationships,
|
|
304
304
|
}) {
|
|
305
305
|
const target = shouldPreflightPatch({ existing, attributes })
|
|
306
306
|
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
307
307
|
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
308
|
+
if (target.resourceVersion) {
|
|
309
|
+
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
310
|
+
await writeDraft(backend, {
|
|
311
|
+
entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
|
|
312
|
+
}, true);
|
|
313
|
+
return target;
|
|
314
|
+
}
|
|
308
315
|
await preflightPatchWritable({
|
|
309
316
|
backend, entityType, bundle, id, existing, attributes,
|
|
310
317
|
resourceVersion: target.resourceVersion,
|
|
@@ -323,6 +330,10 @@ export async function prepareGuardedPatch(backend, {
|
|
|
323
330
|
*/
|
|
324
331
|
export async function updateEntityGuarded(backend, input) {
|
|
325
332
|
try {
|
|
333
|
+
if (input?.draftRevision) return await writeDraft(backend, input);
|
|
334
|
+
if (input?.resourceVersion) {
|
|
335
|
+
throw new Error("Core JSON:API does not support revision-selected PATCH. A governed draft preflight is required.");
|
|
336
|
+
}
|
|
326
337
|
return await backend.updateEntity(input);
|
|
327
338
|
} catch (err) {
|
|
328
339
|
if (!isWorkingCopyPatchError(err)) throw err;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tenant-side local policy enforcement (#253
|
|
2
|
+
* Tenant-side local policy enforcement (#253).
|
|
3
3
|
*
|
|
4
4
|
* Loopback stand-in for mcp_sentinel `McpPolicyBundleRegistry`: mint / verify
|
|
5
5
|
* / activate / simulate / revoke / rollback / emergency deny. The relay edge
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* W&L-operated dual-control promotion ledger (#253
|
|
2
|
+
* W&L-operated dual-control promotion ledger (#253).
|
|
3
3
|
*
|
|
4
4
|
* The edge never mints a Sentinel HMAC seal. A promotion is an already-sealed
|
|
5
5
|
* portable document plus two distinct operator ids. Eligibility is the gate
|
package/src/lib/relay/agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay tenant agent (#232) — the
|
|
2
|
+
* Relay tenant agent (#232) — the hosted-edge slice, tenant side.
|
|
3
3
|
*
|
|
4
4
|
* Dials OUT to the edge's agent channel and serves the real connector server
|
|
5
5
|
* (`createConnectorServerFactory`) over the framed tunnel. The agent never
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* attributable usage, quotas, and abuse signals.
|
|
2
|
+
* Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — the
|
|
3
|
+
* hosted-edge slice (#232), tenant isolation (#242), tenant routing (#244),
|
|
4
|
+
* actor mapping (#247), policy digest (#250) and W&L-operated bundle
|
|
5
|
+
* promotion (#253), attributable usage, quotas, and abuse signals (#256).
|
|
6
6
|
*
|
|
7
7
|
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
8
8
|
* down outbound tenant-agent channels. The edge proposes; the tenant-side
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* `agentId`; channel records may bind `sites`. A second unscoped agent
|
|
24
24
|
* or overlapping site claim is denied at hello. Fan-down selects the
|
|
25
25
|
* unique bound agent from server-owned grants. Single unscoped agent
|
|
26
|
-
* remains the
|
|
26
|
+
* remains the #232 single-agent compatibility path.
|
|
27
27
|
* - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
|
|
28
28
|
* no `Mcp-Session-Id` crosses in either direction.
|
|
29
29
|
* - Revocation is per-request with no grace window, for both credential
|
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
export const EDGE_MCP_PROTOCOL = "2026-07-28";
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Revocation bound restated from the
|
|
77
|
+
* Revocation bound restated from the outbound-relay lab for both credential kinds.
|
|
78
78
|
* The next request after revoke is denied; an in-flight request may finish.
|
|
79
79
|
*/
|
|
80
80
|
export const EDGE_REVOCATION_BOUND = Object.freeze({
|
|
@@ -122,7 +122,7 @@ const SHARED_SCOPES = new Set(["tenant", "config"]);
|
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* Normalize a channel-record `sites` list. Empty / missing means unscoped
|
|
125
|
-
* (legal only as the sole connected agent — the
|
|
125
|
+
* (legal only as the sole connected agent — the #232 compatibility path).
|
|
126
126
|
*
|
|
127
127
|
* @param {unknown} sites
|
|
128
128
|
* @returns {string[]}
|
|
@@ -228,7 +228,7 @@ export function selectTenantSession({
|
|
|
228
228
|
* Resolve tenant and target from server-owned grants. Caller `tenant` is a
|
|
229
229
|
* confirming hint inside the grant, never authority. When `tenantGrants` is
|
|
230
230
|
* omitted, tenant is derived from the unique agent covering the site grant
|
|
231
|
-
* (
|
|
231
|
+
* (#242 / #232 compatibility).
|
|
232
232
|
*
|
|
233
233
|
* @param {object} params
|
|
234
234
|
* @param {object|null} params.identity
|
package/src/lib/relay/frames.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Relay frame codec (#232).
|
|
3
3
|
*
|
|
4
4
|
* Length-prefixed JSON frames for the edge/agent channel, promoted from the
|
|
5
|
-
*
|
|
5
|
+
* outbound-relay lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
|
|
6
6
|
* authentication, entitlement, or header policy lives here.
|
|
7
7
|
*
|
|
8
8
|
* Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame
|
package/src/lib/usage.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Attributable usage, quotas, and abuse signals on the relay edge (#256
|
|
3
|
-
* DEV-126).
|
|
2
|
+
* Attributable usage, quotas, and abuse signals on the relay edge (#256).
|
|
4
3
|
*
|
|
5
4
|
* Metering at the seam, lab bounds. Every edge decision (allow or deny) and
|
|
6
5
|
* every fan-down receipt is a record keyed by the grant-resolved tenant and
|
package/src/tools/bulk.js
CHANGED
|
@@ -119,13 +119,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
119
119
|
assertPublishAllowed(sec, attributes);
|
|
120
120
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
121
121
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
122
|
-
entityType, bundle, id: item.id, existing, attributes,
|
|
122
|
+
entityType, bundle, id: item.id, existing, attributes, relationships: resolvedRelationships,
|
|
123
123
|
});
|
|
124
124
|
const entity = await updateEntityGuarded(backend, {
|
|
125
125
|
entityType, bundle, id: item.id,
|
|
126
126
|
attributes,
|
|
127
127
|
relationships: resolvedRelationships,
|
|
128
128
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
129
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
129
130
|
});
|
|
130
131
|
updated += 1;
|
|
131
132
|
results.push({ index, success: true, id: entity?.id ?? item.id });
|
package/src/tools/entities.js
CHANGED
|
@@ -118,7 +118,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
118
118
|
assertPublishAllowed(sec, safeAttributes);
|
|
119
119
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
120
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
121
|
-
entityType, bundle, id, existing, attributes: safeAttributes,
|
|
121
|
+
entityType, bundle, id, existing, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
122
122
|
});
|
|
123
123
|
if (dryRun) {
|
|
124
124
|
return {
|
|
@@ -129,6 +129,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
129
129
|
const result = await updateEntityGuarded(backend, {
|
|
130
130
|
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
131
131
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
132
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
132
133
|
});
|
|
133
134
|
const written = await readWrittenRevision({
|
|
134
135
|
backend, entityType, bundle, id,
|
|
@@ -280,7 +281,7 @@ export const definitions = [
|
|
|
280
281
|
},
|
|
281
282
|
{
|
|
282
283
|
name: "drupal_entity_update",
|
|
283
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets
|
|
284
|
+
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
|
|
284
285
|
inputSchema: {
|
|
285
286
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
286
287
|
properties: {
|
|
@@ -290,7 +291,7 @@ export const definitions = [
|
|
|
290
291
|
id: { type: "string" },
|
|
291
292
|
attributes: { type: "object" },
|
|
292
293
|
relationships: { type: "object" },
|
|
293
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
294
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
294
295
|
returning: RETURNING_SCHEMA,
|
|
295
296
|
},
|
|
296
297
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -44,11 +44,11 @@ function normalizeAlias(value) {
|
|
|
44
44
|
* Resolve the `path` attribute to send on an alias-aware node write so the alias
|
|
45
45
|
* actually persists, and decide whether a rename redirect is needed.
|
|
46
46
|
*
|
|
47
|
-
* The bug (
|
|
47
|
+
* The bug (fixed in 1.5.1): JSON:API deserializes `{ alias, pathauto }` onto the node's
|
|
48
48
|
* `path` field, dropping the existing alias's `pid`; Drupal's `PathItem::postSave`
|
|
49
49
|
* then *creates a duplicate* `path_alias` (the older one stays canonical) instead
|
|
50
50
|
* of updating in place. The fix is to round-trip the existing `pid` so the update
|
|
51
|
-
* is in place.
|
|
51
|
+
* is in place. The 1.5.0 path-omitted "preserve" had the same defect (no `pid`),
|
|
52
52
|
* so it is fixed here too.
|
|
53
53
|
*
|
|
54
54
|
* @param {object} args - { backend, type, id, providedPath, isCreate }.
|
|
@@ -274,7 +274,7 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
274
274
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
275
275
|
}
|
|
276
276
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
277
|
-
// `path` is omitted so pathauto generates the alias (
|
|
277
|
+
// `path` is omitted so pathauto generates the alias (see the 1.5.1 alias fix).
|
|
278
278
|
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
279
279
|
if (pathAttr === undefined) delete attributes.path;
|
|
280
280
|
else attributes.path = pathAttr;
|
|
@@ -344,11 +344,10 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
344
344
|
// #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
|
|
345
345
|
// list would persist empty — fail the whole write instead.
|
|
346
346
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
347
|
-
// #201 / #166: probe the
|
|
348
|
-
//
|
|
349
|
-
// fails the probe with revision-surgery language.
|
|
347
|
+
// #201 / #166: probe the write endpoint. Addressable drafts use Sentinel's
|
|
348
|
+
// governed continuation endpoint; stray revisions still fail the probe.
|
|
350
349
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
351
|
-
entityType: "node", bundle: type, id, existing, attributes,
|
|
350
|
+
entityType: "node", bundle: type, id, existing, attributes, relationships: resolvedRelationships,
|
|
352
351
|
});
|
|
353
352
|
if (dryRun) {
|
|
354
353
|
const preview = {
|
|
@@ -357,7 +356,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
357
356
|
};
|
|
358
357
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
359
358
|
}
|
|
360
|
-
// Alias handling (
|
|
359
|
+
// Alias handling (1.5.1 alias fix): an explicit `path.alias` is set in place by
|
|
361
360
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
362
361
|
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
363
362
|
// it. A rename (alias changed) also gets a 301 redirect from the old path.
|
|
@@ -367,6 +366,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
367
366
|
const patched = await updateEntityGuarded(backend, {
|
|
368
367
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
369
368
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
369
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
370
370
|
});
|
|
371
371
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
372
372
|
// #169: when relationships were sent, the canonical re-read is the published
|
|
@@ -477,7 +477,7 @@ export const definitions = [
|
|
|
477
477
|
},
|
|
478
478
|
{
|
|
479
479
|
name: "drupal_update_node",
|
|
480
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
480
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
481
481
|
inputSchema: {
|
|
482
482
|
type: "object", required: ["type", "id"],
|
|
483
483
|
properties: {
|
|
@@ -492,7 +492,7 @@ export const definitions = [
|
|
|
492
492
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
|
|
493
493
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
494
494
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot." },
|
|
495
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
495
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
496
496
|
returning: RETURNING_SCHEMA,
|
|
497
497
|
},
|
|
498
498
|
},
|