drupal-mcp-connector 2.4.0 → 2.5.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/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-update-media.md +4 -4
- package/CHANGELOG.md +35 -0
- package/package.json +1 -1
- package/src/index.js +3 -3
- package/src/lib/canonical.js +48 -0
- package/src/lib/dispatch.js +21 -1
- package/src/lib/entity-response.js +41 -0
- package/src/tools/entities.js +20 -4
- package/src/tools/media.js +34 -12
- package/src/tools/nodes.js +18 -5
|
@@ -17,6 +17,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
|
|
20
|
-
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
|
|
20
|
+
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...'). Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
|
|
21
21
|
|
|
22
22
|
If a required parameter is missing from `$ARGUMENTS`, 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,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update a media entity's name, status, or field values."
|
|
2
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched."
|
|
3
3
|
argument-hint: "<type> <id> [site] [name] [status] [fields]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_update_media
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_update_media` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update a media entity's name, status, or field values.
|
|
9
|
+
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -17,7 +17,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `name` (string)
|
|
20
|
-
- `status` (boolean (true/false))
|
|
21
|
-
- `fields` (object (pass as JSON))
|
|
20
|
+
- `status` (boolean (true/false)): Published flag. Only sent when provided; requires allowPublish when true.
|
|
21
|
+
- `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
|
|
22
22
|
|
|
23
23
|
If a required parameter is missing from `$ARGUMENTS`, 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,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.5.0] - 2026-08-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **An unrequested published-state change is no longer silent (#171).**
|
|
14
|
+
`status` stays strictly opt-in on updates — the connector never adds it to a
|
|
15
|
+
PATCH — but a server-side gate can still flip it (an unmoderated-entity
|
|
16
|
+
publish backstop, or a write landing as an unpublished forward revision).
|
|
17
|
+
`drupal_entity_update`, `drupal_update_node`, and `drupal_update_media` now
|
|
18
|
+
compare the written state against a pre-write read and, when the caller sent
|
|
19
|
+
neither `status` nor an explicit moderation state, report a flip via a
|
|
20
|
+
`_statusChanged` marker (`from`/`to` plus a verification note) instead of
|
|
21
|
+
returning a clean success. The marker survives `returning: "minimal"`.
|
|
22
|
+
Regression tests pin that relationships-only and field-only updates send
|
|
23
|
+
neither `status` nor `moderation_state` across the entity, media, node, and
|
|
24
|
+
bulk update tools.
|
|
25
|
+
- **Media tools route reference-shaped `fields` to relationships (#171).**
|
|
26
|
+
`drupal_update_media` and `drupal_create_media` forwarded entity-reference
|
|
27
|
+
values under `fields` as JSON:API attributes, which Drupal rejects with a 422
|
|
28
|
+
("relationship fields were provided as attributes"). Values in linkage shape
|
|
29
|
+
(`{ data: { type, id } }`, an array of those, or `{ data: null }` to clear)
|
|
30
|
+
are now sent as relationships, matching what `drupal_entity_update` accepts;
|
|
31
|
+
composite attribute values (`{ value, format }` and friends) are untouched.
|
|
32
|
+
The linkage-shape helpers live in `src/lib/canonical.js` for reuse.
|
|
33
|
+
|
|
34
|
+
## [2.4.1] - 2026-08-14
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- **One unresolvable site no longer kills tool discovery (#187).** 2.4.0's
|
|
38
|
+
discovery gate resolved every configured site eagerly, so a deliberately
|
|
39
|
+
credential-less site — the inert break-glass tier keeps its Keychain item
|
|
40
|
+
absent by design — threw `requireSecureAuth` during `tools/list` and took
|
|
41
|
+
the whole tool surface down. Discovery now skips sites whose resolution
|
|
42
|
+
throws; execution against such a site still surfaces its own descriptive
|
|
43
|
+
error at call time, exactly as in 2.3.0.
|
|
44
|
+
|
|
10
45
|
## [2.4.0] - 2026-08-14
|
|
11
46
|
|
|
12
47
|
### Added
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -31,12 +31,12 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
|
31
31
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
32
32
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
33
33
|
|
|
34
|
-
import {
|
|
34
|
+
import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
35
35
|
import { makeBearerCheck } from "./lib/http-auth.js";
|
|
36
36
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
37
37
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
38
38
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
39
|
-
import { callTool } from "./lib/dispatch.js";
|
|
39
|
+
import { callTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
40
40
|
import { filterDiscoverableTools } from "./lib/governance.js";
|
|
41
41
|
|
|
42
42
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
@@ -236,7 +236,7 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
236
236
|
serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
237
237
|
tools: {
|
|
238
238
|
definitions: allDefinitions,
|
|
239
|
-
list: () => filterDiscoverableTools(allDefinitions,
|
|
239
|
+
list: () => filterDiscoverableTools(allDefinitions, listResolvableSiteConfigs()),
|
|
240
240
|
call: callTool,
|
|
241
241
|
},
|
|
242
242
|
resources: { definitions: RESOURCES, read: readResource },
|
package/src/lib/canonical.js
CHANGED
|
@@ -66,3 +66,51 @@ export function normalizeRelationship(ref) {
|
|
|
66
66
|
const [entityType = null, bundle = null] = (ref.type || "").split("--");
|
|
67
67
|
return { id: ref.id, entityType, bundle };
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a field value is a JSON:API relationship linkage (`{ data: ... }`
|
|
72
|
+
* where data is null, one `{ type, id }` reference, or an array of them —
|
|
73
|
+
* an empty array clears a multi-value reference).
|
|
74
|
+
*
|
|
75
|
+
* Composite attribute values (e.g. `{ value, format }` text fields) have no
|
|
76
|
+
* `data` key and are never matched, so ordinary attributes pass through.
|
|
77
|
+
*
|
|
78
|
+
* @param {*} value A caller-supplied field value.
|
|
79
|
+
* @returns {boolean} True when the value is relationship-shaped.
|
|
80
|
+
*/
|
|
81
|
+
export function isRelationshipLinkage(value) {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
83
|
+
if (!Object.prototype.hasOwnProperty.call(value, "data")) return false;
|
|
84
|
+
const isRef = (d) => Boolean(d) && typeof d === "object" && !Array.isArray(d)
|
|
85
|
+
&& typeof d.type === "string" && typeof d.id === "string";
|
|
86
|
+
const { data } = value;
|
|
87
|
+
if (data === null) return true;
|
|
88
|
+
if (Array.isArray(data)) return data.every(isRef);
|
|
89
|
+
return isRef(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a caller field map into JSON:API attributes and relationships (#171).
|
|
94
|
+
*
|
|
95
|
+
* Entity-reference values passed under a `fields` map used to be forwarded as
|
|
96
|
+
* attributes, which Drupal rejects with a 422 ("relationship fields were
|
|
97
|
+
* provided as attributes"). Relationship-shaped values are routed to the
|
|
98
|
+
* `relationships` document member instead, so field-map tools accept the same
|
|
99
|
+
* reference shape as `drupal_entity_update`.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} fields Caller-supplied field map.
|
|
102
|
+
* @returns {{attributes: object, relationships: ?object}} Split maps;
|
|
103
|
+
* `relationships` is null when no value was relationship-shaped.
|
|
104
|
+
*/
|
|
105
|
+
export function splitReferenceFields(fields) {
|
|
106
|
+
const attrEntries = [];
|
|
107
|
+
const relEntries = [];
|
|
108
|
+
for (const entry of Object.entries(fields)) {
|
|
109
|
+
const [, value] = entry;
|
|
110
|
+
(isRelationshipLinkage(value) ? relEntries : attrEntries).push(entry);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
attributes: Object.fromEntries(attrEntries),
|
|
114
|
+
relationships: relEntries.length ? Object.fromEntries(relEntries) : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/lib/dispatch.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* this module is denial on every path, with no ungoverned fallback below it.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { getSiteConfig } from "./config.js";
|
|
11
|
+
import { getSiteConfig, listSiteNames } from "./config.js";
|
|
12
12
|
import { resolveSecurityConfig, assertNotReadOnly,
|
|
13
13
|
assertDestructiveAllowed, assertGraphqlMutationAllowed,
|
|
14
14
|
SecurityError } from "./security.js";
|
|
@@ -18,6 +18,26 @@ import { inferOperation } from "./operations.js";
|
|
|
18
18
|
import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
|
|
19
19
|
import { allHandlers } from "../tools/index.js";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolve every configured site that CAN resolve, skipping the ones that
|
|
23
|
+
* throw. A site can legitimately be unresolvable on purpose — the break-glass
|
|
24
|
+
* tier keeps its credential absent to stay inert — and discovery must treat
|
|
25
|
+
* such a site as simply unable to serve tools, never let it kill tools/list
|
|
26
|
+
* for everyone (the 2.4.0 regression). Execution against an unresolvable
|
|
27
|
+
* site still surfaces its own descriptive error at call time.
|
|
28
|
+
*
|
|
29
|
+
* @returns {Array<object>} Resolved site configs.
|
|
30
|
+
*/
|
|
31
|
+
export function listResolvableSiteConfigs() {
|
|
32
|
+
return listSiteNames().flatMap((name) => {
|
|
33
|
+
try {
|
|
34
|
+
return [getSiteConfig(name)];
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* Derive the entity type a tool acts on, for destructive-allow assertions.
|
|
23
43
|
*
|
|
@@ -34,6 +34,47 @@ export function shapeWriteResponse(entity, returning = "full") {
|
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Flag a published-state change the caller never requested (#171).
|
|
39
|
+
*
|
|
40
|
+
* `status` is strictly opt-in on updates: the connector never adds it to a
|
|
41
|
+
* PATCH. A server-side gate can still flip it (e.g. a governance backstop
|
|
42
|
+
* unpublishing an unmoderated entity, or a write landing as an unpublished
|
|
43
|
+
* forward revision), and a silent success that also changed live state is the
|
|
44
|
+
* failure class that surfaces only when content goes missing. When a
|
|
45
|
+
* pre-write read is available and the sent attributes carry neither `status`
|
|
46
|
+
* nor `moderation_state` (an explicit moderation transition legitimately
|
|
47
|
+
* changes the published state, as does the #131 injected draft default), a
|
|
48
|
+
* different `status` in the write result is surfaced as `_statusChanged` — an
|
|
49
|
+
* `_`-prefixed key, so it survives `returning: "minimal"`.
|
|
50
|
+
*
|
|
51
|
+
* Best-effort by design: it requires a readable pre-write entity, and the
|
|
52
|
+
* server-side gate stays authoritative either way.
|
|
53
|
+
*
|
|
54
|
+
* @param {?object} result The entity returned by (or re-read after) the write.
|
|
55
|
+
* @param {?object} existing The pre-write entity, when it could be read.
|
|
56
|
+
* @param {object} sentAttributes The attribute map that was sent.
|
|
57
|
+
* @returns {?object} The result, with `_statusChanged` attached when it applies.
|
|
58
|
+
*/
|
|
59
|
+
export function flagUnrequestedStatusChange(result, existing, sentAttributes) {
|
|
60
|
+
if (!result || !existing) return result;
|
|
61
|
+
const sent = (key) => Object.prototype.hasOwnProperty.call(sentAttributes, key);
|
|
62
|
+
if (sent("status") || sent("moderation_state")) return result;
|
|
63
|
+
if (typeof existing.status !== "boolean" || typeof result.status !== "boolean") return result;
|
|
64
|
+
if (existing.status === result.status) return result;
|
|
65
|
+
return {
|
|
66
|
+
...result,
|
|
67
|
+
_statusChanged: {
|
|
68
|
+
from: existing.status,
|
|
69
|
+
to: result.status,
|
|
70
|
+
note: "The returned published status differs from the pre-write state although the request did not " +
|
|
71
|
+
"include `status`. A server-side gate intervened — the write may have landed as an unpublished " +
|
|
72
|
+
"forward revision (live revision unchanged) or the entity may have been unpublished. Verify which " +
|
|
73
|
+
"revision is live before relying on this content's visibility.",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
/** JSON Schema fragment for the shared `returning` parameter. */
|
|
38
79
|
export const RETURNING_SCHEMA = {
|
|
39
80
|
type: "string",
|
package/src/tools/entities.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
|
-
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
|
-
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
13
|
+
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
|
+
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
15
15
|
import {
|
|
16
16
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
17
17
|
redactCanonicalEntity, getSecuritySummary,
|
|
@@ -75,6 +75,10 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
75
75
|
* `moderation_state` get `moderation_state: draft` so the write is a forward
|
|
76
76
|
* revision rather than a live default-revision mutation.
|
|
77
77
|
*
|
|
78
|
+
* Live-state mediation (#171): `status` is never added to the PATCH unless the
|
|
79
|
+
* caller passed it, and an unrequested published-state flip in the write result
|
|
80
|
+
* is reported via `_statusChanged` rather than returned silently.
|
|
81
|
+
*
|
|
78
82
|
* @param {object} args - { site?, entityType, bundle, id, attributes?, relationships? }.
|
|
79
83
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
80
84
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
@@ -84,12 +88,24 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
84
88
|
const sec = resolveSecurityConfig(site);
|
|
85
89
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
86
90
|
const backend = await resolveBackend(site);
|
|
91
|
+
// One pre-write read serves both the #131 draft default and the #171
|
|
92
|
+
// unrequested-status-change flag. Skipped when the caller pinned the
|
|
93
|
+
// moderation state explicitly (same condition the draft default uses).
|
|
94
|
+
let existing = null;
|
|
95
|
+
if (!hasExplicitModerationState(attributes)) {
|
|
96
|
+
try {
|
|
97
|
+
existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
|
|
98
|
+
} catch {
|
|
99
|
+
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
87
102
|
const safeAttributes = await applySafeDraftDefault({
|
|
88
|
-
backend, entityType, bundle, id, attributes,
|
|
103
|
+
backend, entityType, bundle, id, attributes, existingEntity: existing,
|
|
89
104
|
});
|
|
90
105
|
assertPublishAllowed(sec, safeAttributes);
|
|
91
106
|
if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
|
|
92
|
-
|
|
107
|
+
const result = await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships });
|
|
108
|
+
return shapeWriteResponse(flagUnrequestedStatusChange(result, existing, safeAttributes), returning);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
/**
|
package/src/tools/media.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { getSiteConfig } from "../lib/config.js";
|
|
10
10
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
+
import { splitReferenceFields } from "../lib/canonical.js";
|
|
12
|
+
import { flagUnrequestedStatusChange } from "../lib/entity-response.js";
|
|
11
13
|
import {
|
|
12
14
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -62,9 +64,10 @@ async function getMedia({ site: siteName, type, id }) {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/**
|
|
65
|
-
* Create a media entity. Caller `fields` are
|
|
66
|
-
* status are layered on top
|
|
67
|
-
*
|
|
67
|
+
* Create a media entity. Caller `fields` are split into attributes and
|
|
68
|
+
* relationship-shaped references (#171); name and status are layered on top of
|
|
69
|
+
* the attributes. Defaults to unpublished (`status: false`) so media is never
|
|
70
|
+
* auto-published under non-publishing presets (#139).
|
|
68
71
|
*
|
|
69
72
|
* @param {object} args - { site?, type, name, status?, fields? }.
|
|
70
73
|
* @returns {Promise<object>} The created media descriptor.
|
|
@@ -73,17 +76,23 @@ async function createMedia({ site: siteName, type, name, status = false, fields
|
|
|
73
76
|
const site = getSiteConfig(siteName);
|
|
74
77
|
const sec = resolveSecurityConfig(site);
|
|
75
78
|
assertWriteAllowed(sec, "create", "media", type);
|
|
76
|
-
const attributes
|
|
79
|
+
const { attributes, relationships } = splitReferenceFields(fields);
|
|
77
80
|
// Layer name/status after fields so they win, matching prior behaviour.
|
|
78
81
|
attributes.name = name;
|
|
79
82
|
attributes.status = status;
|
|
80
83
|
assertPublishAllowed(sec, attributes);
|
|
81
84
|
const backend = await resolveBackend(site);
|
|
82
|
-
return backend.createEntity({
|
|
85
|
+
return backend.createEntity({
|
|
86
|
+
entityType: "media", bundle: type, attributes,
|
|
87
|
+
...(relationships ? { relationships } : {}),
|
|
88
|
+
});
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/**
|
|
86
|
-
* Update a media entity (partial — omitted fields are left untouched
|
|
92
|
+
* Update a media entity (partial — omitted fields are left untouched; `status`
|
|
93
|
+
* is strictly opt-in, #171). Reference-shaped `fields` values are routed to
|
|
94
|
+
* relationships — see splitReferenceFields.
|
|
95
|
+
*
|
|
87
96
|
* @param {object} args - { site?, type, id, name?, status?, fields? }.
|
|
88
97
|
* @returns {Promise<object>} The updated media descriptor.
|
|
89
98
|
*/
|
|
@@ -91,12 +100,25 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
91
100
|
const site = getSiteConfig(siteName);
|
|
92
101
|
const sec = resolveSecurityConfig(site);
|
|
93
102
|
assertWriteAllowed(sec, "update", "media", type);
|
|
94
|
-
const attributes =
|
|
103
|
+
const { attributes, relationships } = splitReferenceFields(fields);
|
|
95
104
|
if (name !== undefined) attributes.name = name;
|
|
96
105
|
if (status !== undefined) attributes.status = status;
|
|
97
106
|
assertPublishAllowed(sec, attributes);
|
|
98
107
|
const backend = await resolveBackend(site);
|
|
99
|
-
|
|
108
|
+
// #171: pre-read so an unrequested published-state flip is reported, not silent.
|
|
109
|
+
let existing = null;
|
|
110
|
+
if (status === undefined) {
|
|
111
|
+
try {
|
|
112
|
+
existing = (await backend.getEntity({ entityType: "media", bundle: type, id })) ?? null;
|
|
113
|
+
} catch {
|
|
114
|
+
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const result = await backend.updateEntity({
|
|
118
|
+
entityType: "media", bundle: type, id, attributes,
|
|
119
|
+
...(relationships ? { relationships } : {}),
|
|
120
|
+
});
|
|
121
|
+
return flagUnrequestedStatusChange(result, existing, attributes);
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
/**
|
|
@@ -239,13 +261,13 @@ export const definitions = [
|
|
|
239
261
|
type: { type: "string", description: "Media type machine name" },
|
|
240
262
|
name: { type: "string", description: "Media entity name / label" },
|
|
241
263
|
status: { type: "boolean", default: false, description: "Published flag. Defaults to false (unpublished). Requires allowPublish when true." },
|
|
242
|
-
fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')" },
|
|
264
|
+
fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...'). Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
|
|
243
265
|
},
|
|
244
266
|
},
|
|
245
267
|
},
|
|
246
268
|
{
|
|
247
269
|
name: "drupal_update_media",
|
|
248
|
-
description: "Update a media entity's name, status, or field values.",
|
|
270
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.",
|
|
249
271
|
inputSchema: {
|
|
250
272
|
type: "object", required: ["type", "id"],
|
|
251
273
|
properties: {
|
|
@@ -253,8 +275,8 @@ export const definitions = [
|
|
|
253
275
|
type: { type: "string" },
|
|
254
276
|
id: { type: "string" },
|
|
255
277
|
name: { type: "string" },
|
|
256
|
-
status: { type: "boolean" },
|
|
257
|
-
fields: { type: "object" },
|
|
278
|
+
status: { type: "boolean", description: "Published flag. Only sent when provided; requires allowPublish when true." },
|
|
279
|
+
fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
|
|
258
280
|
},
|
|
259
281
|
},
|
|
260
282
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
14
14
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
15
15
|
} from "../lib/security.js";
|
|
16
|
-
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
17
|
-
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
16
|
+
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
|
+
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
18
18
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
19
19
|
|
|
20
20
|
/** Fallback language for an alias when the node exposes none. */
|
|
@@ -318,10 +318,20 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
318
318
|
else if (status !== undefined) attributes.status = status;
|
|
319
319
|
const bodyAttr = buildBodyAttribute(body, summary, format, site);
|
|
320
320
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
321
|
+
// One pre-read serves the #131 draft default and the #171 unrequested-
|
|
322
|
+
// status-change flag. Skipped when the caller pinned the moderation state.
|
|
323
|
+
let existing = null;
|
|
324
|
+
if (!hasExplicitModerationState(attributes)) {
|
|
325
|
+
try {
|
|
326
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
327
|
+
} catch {
|
|
328
|
+
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
321
331
|
// #131: published moderated nodes without an explicit state → draft forward revision.
|
|
322
332
|
// Runs before the publish gate and on dryRun so previews match the real write.
|
|
323
333
|
attributes = await applySafeDraftDefault({
|
|
324
|
-
backend, entityType: "node", bundle: type, id, attributes,
|
|
334
|
+
backend, entityType: "node", bundle: type, id, attributes, existingEntity: existing,
|
|
325
335
|
});
|
|
326
336
|
assertPublishAllowed(sec, attributes);
|
|
327
337
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
|
|
@@ -337,8 +347,11 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
337
347
|
// Honest response: re-read persisted state so the returned `url` is the alias
|
|
338
348
|
// that actually resolves, never the just-sent value.
|
|
339
349
|
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
|
|
340
|
-
|
|
341
|
-
|
|
350
|
+
// #171: an unrequested published-state flip in the persisted node is
|
|
351
|
+
// reported via _statusChanged rather than returned as a clean success.
|
|
352
|
+
const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
|
|
353
|
+
if (flagged && redirectResult) return shapeWriteResponse({ ...flagged, _redirect: redirectResult }, returning);
|
|
354
|
+
return shapeWriteResponse(flagged ?? { id }, returning);
|
|
342
355
|
}
|
|
343
356
|
|
|
344
357
|
/**
|