drupal-mcp-connector 0.7.0 → 0.8.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 CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-06-15
11
+
12
+ ### Added
13
+ - Docs: an OAuth2 `client_credentials` deployment guide
14
+ (`docs/oauth-client-credentials.md`) covering scope→role mapping, JSON:API
15
+ write enablement, config persistence across deploys, and secret handling, plus
16
+ a reusable `examples/launch-with-secret.sh` secret-manager launcher. Linked
17
+ from the README and getting-started.
18
+ - `drupal_create_node` and `drupal_update_node` accept a `moderationState`
19
+ argument (e.g. `"draft"`/`"published"`) for content types under a
20
+ content_moderation workflow. When set, `moderation_state` is sent and `status`
21
+ is omitted (moderated entities own their published state).
22
+ - CI: a `Changelog` workflow blocks any pull request that doesn't update
23
+ `CHANGELOG.md`. Trivial PRs that genuinely need no entry can carry the
24
+ `no-changelog` label to bypass the check.
25
+ - CI: a `dependabot.yml` enabling weekly version updates for npm (dev
26
+ dependencies grouped) and GitHub Actions.
27
+ - CI: a `changelog-autoupdate` workflow (org reusable) that writes a CHANGELOG
28
+ entry on Dependabot PRs and pushes it via a GitHub App token, so the required
29
+ `CHANGELOG updated` check passes without manual edits. No-ops until the
30
+ `CHANGELOG_APP_*` Dependabot secrets are configured.
31
+ - CI: Dependabot patch/minor PRs now auto-merge once checks pass (majors still
32
+ reviewed), via the org reusable workflow.
33
+
34
+ ### Changed
35
+ - CI: bumped `actions/checkout` and `actions/setup-node` to `v6` (Node 24
36
+ runtime) ahead of GitHub's June 2026 deprecation of Node 20 actions.
37
+ - CI: added a `concurrency` group so superseded in-progress runs are cancelled,
38
+ matching the sibling repos' CI hygiene.
39
+ - Dependency: bumped `graphql` 16.14.0 → 16.14.1 (patch).
40
+ - Dev dependencies: bumped `eslint` `^9`→`^10`, `eslint-plugin-security` `^3`→`^4`,
41
+ `eslint-plugin-n` `^17`→`^18`, and `globals` `^15`→`^17` (Dependabot
42
+ dev-dependencies group). Lint and the full test suite pass on the new majors.
43
+
44
+ ### Fixed
45
+ - Create/update no longer fail on content_moderation bundles. The JSON:API
46
+ backend now transparently retries a write without the `status` attribute when
47
+ Drupal rejects it as a moderated entity's published field (HTTP 403), so the
48
+ safe default `status:false` works on moderated types (Drupal applies the
49
+ workflow's default state). Affects all entity create/update paths (nodes,
50
+ entities, media), not just nodes. (#23)
51
+ - Docs: replaced a personal email with the `opensource@wilkesliberty.com` role
52
+ address (README, `package.json`); corrected whitepaper tool counts (Drush
53
+ ~10→15, Nodes ~12→6).
54
+
55
+ ## [0.7.1] - 2026-06-08
56
+
57
+ ### Fixed
58
+ - The connector now reports its real version — sourced from `package.json` at
59
+ runtime — in the MCP handshake, the `X-MCP-Client` identity header, and the
60
+ startup logs. A hardcoded version literal had drifted and under-reported it
61
+ (0.7.0 still announced itself as `0.6.0`).
62
+
63
+ ### Documentation
64
+ - Corrected Node version references (18 → 20) in the README and getting-started
65
+ guide to match `engines.node >=20.0.0`, and updated the example startup banner
66
+ to the current version.
67
+ - Rewrote CONTRIBUTING.md: prerequisites, full dev-script list, a tests section,
68
+ accurate PR/CI gates, and the PR-then-tag release flow for protected `master`.
69
+
70
+ ### Changed
71
+ - Restored the column-aligned `package.json` `scripts` formatting that
72
+ `npm version` re-flattened while cutting 0.7.0.
73
+
10
74
  ## [0.7.0] - 2026-06-08
11
75
 
12
76
  ### Added
@@ -16,6 +80,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
80
  (GitHub Actions OIDC — no token/secret), gated on a tag↔`package.json` version
17
81
  match. Provenance is attached automatically. One-time trusted-publisher setup
18
82
  on npmjs.com (see CONTRIBUTING.md → Releasing).
83
+ - Branch protection on `master`: merges require a pull request with passing CI
84
+ (lint, unit tests on Node 20/22, Drupal integration, CodeQL) and resolved
85
+ review conversations; force-pushes and branch deletion are blocked.
86
+
87
+ ### Fixed
88
+ - CI now runs on the `master` default branch. The workflow had been configured
89
+ for a nonexistent `main` branch, so lint/syntax/unit and integration never
90
+ executed on pushes or PRs.
19
91
 
20
92
  ### Removed
21
93
  - **BREAKING:** dropped support for Node 18 (`engines.node` is now `>=20.0.0`).
@@ -108,6 +180,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
108
180
  - User tools gained explicit PII-access assertions.
109
181
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
110
182
 
183
+ [0.8.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.8.0
184
+ [0.7.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.1
111
185
  [0.7.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.0
112
186
  [0.6.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.6.1
113
187
  [0.6.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.6.0
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  > A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed content tools, audit reports, and an SSH Drush bridge.
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org)
7
7
  [![Drupal](https://img.shields.io/badge/drupal-10%20%7C%2011-blue)](https://drupal.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-2025--11--25-purple)](https://modelcontextprotocol.io)
9
9
 
10
- Built by **Jeremy Michael Cerda** (jmcerda@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
10
+ Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
12
  ---
13
13
 
@@ -98,7 +98,7 @@ Presets layer with entity allow/deny lists, per-bundle operation rules, and fiel
98
98
 
99
99
  ## Requirements
100
100
 
101
- - **Node.js** 18+
101
+ - **Node.js** 20+
102
102
  - **Drupal** 10 or 11 (JSON:API ships in core)
103
103
  - For the **GraphQL backend**: [GraphQL Compose](https://www.drupal.org/project/graphql_compose)
104
104
  - For **token auth** (recommended): [Simple OAuth](https://www.drupal.org/project/simple_oauth)
@@ -170,6 +170,7 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
170
170
  | Doc | Description |
171
171
  |-----|-------------|
172
172
  | [Getting Started](docs/getting-started.md) | Full setup: DDEV/Lando, Simple OAuth, multi-site, transports |
173
+ | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
173
174
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
174
175
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
175
176
  | [Tools Reference](docs/tools-reference.md) | Full reference for all 66 tools |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -35,7 +35,7 @@
35
35
  "url": "https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues"
36
36
  },
37
37
  "license": "MIT",
38
- "author": "Jeremy Michael Cerda <jmcerda@wilkesliberty.com> (https://wilkesliberty.com)",
38
+ "author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://wilkesliberty.com)",
39
39
  "contributors": [
40
40
  "Wilkes & Liberty, LLC <opensource@wilkesliberty.com> (https://wilkesliberty.com)"
41
41
  ],
@@ -61,10 +61,10 @@
61
61
  "ssh2": "^1.16.0"
62
62
  },
63
63
  "devDependencies": {
64
- "eslint": "^9.0.0",
65
- "eslint-plugin-security": "^3.0.0",
66
- "eslint-plugin-n": "^17.0.0",
67
- "globals": "^15.0.0",
64
+ "eslint": "^10.4.1",
65
+ "eslint-plugin-security": "^4.0.0",
66
+ "eslint-plugin-n": "^18.1.0",
67
+ "globals": "^17.6.0",
68
68
  "vitest": "^4.1.8"
69
69
  }
70
70
  }
package/src/index.js CHANGED
@@ -34,7 +34,7 @@ import { CallToolRequestSchema,
34
34
  ListPromptsRequestSchema,
35
35
  GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
36
36
 
37
- import { getSiteConfig, listSiteNames, getTlsConfig } from "./lib/config.js";
37
+ import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
38
38
  import { makeBearerCheck } from "./lib/http-auth.js";
39
39
  import { resolveSecurityConfig, assertNotReadOnly,
40
40
  assertDestructiveAllowed, assertGraphqlMutationAllowed,
@@ -298,7 +298,7 @@ function getPromptMessages(name, args) {
298
298
  // ---------------------------------------------------------------------------
299
299
 
300
300
  const server = new Server(
301
- { name: "drupal-mcp-connector", version: "0.6.0" },
301
+ { name: "drupal-mcp-connector", version: CLIENT_VERSION },
302
302
  { capabilities: { tools: {}, resources: {}, prompts: {} } }
303
303
  );
304
304
 
@@ -368,7 +368,7 @@ if (transport === "stdio") {
368
368
  const stdioTransport = new StdioServerTransport();
369
369
  await server.connect(stdioTransport);
370
370
  console.error(
371
- "[drupal-mcp-connector v0.6.0] stdio transport active. " +
371
+ `[drupal-mcp-connector v${CLIENT_VERSION}] stdio transport active. ` +
372
372
  `${allDefinitions.length} tools · ${RESOURCES.length} resources · ${PROMPTS.length} prompts`
373
373
  );
374
374
 
@@ -488,7 +488,7 @@ if (transport === "stdio") {
488
488
  nodeServer.listen(port, bindHost, () => {
489
489
  const proto = hasTls ? "https" : "http";
490
490
  console.error(
491
- `[drupal-mcp-connector v0.6.0] Listening on ${proto}://${bindHost}:${port}/mcp\n` +
491
+ `[drupal-mcp-connector v${CLIENT_VERSION}] Listening on ${proto}://${bindHost}:${port}/mcp\n` +
492
492
  ` ${allDefinitions.length} tools · ${RESOURCES.length} resources · ${PROMPTS.length} prompts`
493
493
  );
494
494
  });
@@ -19,6 +19,21 @@ import {
19
19
  // these are dropped from canonical `fields` (the canonical id is the UUID).
20
20
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
21
21
 
22
+ /**
23
+ * Detect the JSON:API error Drupal returns when a write attempts to set the
24
+ * `status` (published) field on a content_moderation-governed entity. Such
25
+ * entities own their published state via `moderation_state`, so a direct
26
+ * `status` write is refused with a 403 ("Cannot edit the published field of
27
+ * moderated entities" / "not allowed to … field (status)"). Used to decide
28
+ * whether to retry the write without `status`.
29
+ * @param {unknown} err
30
+ * @returns {boolean}
31
+ */
32
+ export function isModeratedStatusError(err) {
33
+ const msg = String(err?.message || "");
34
+ return /published field of moderated/i.test(msg) || /field \(status\)/i.test(msg);
35
+ }
36
+
22
37
  // Canonical filter op -> JSON:API condition operator.
23
38
  const OP_MAP = new Map([
24
39
  ["neq", "<>"], ["gt", ">"], ["gte", ">="], ["lt", "<"], ["lte", "<="],
@@ -209,32 +224,63 @@ export class JsonApiBackend extends Backend {
209
224
  }
210
225
 
211
226
  /**
212
- * Create an entity via JSON:API POST.
227
+ * Issue a JSON:API write, transparently retrying once without the `status`
228
+ * attribute if the target bundle is under a content_moderation workflow.
229
+ *
230
+ * Moderated entities derive their published state from `moderation_state` and
231
+ * reject a direct `status` write with a 403. This lets create/update "just
232
+ * work" on moderated bundles using the connector's safe default `status:false`:
233
+ * the retry drops `status` and Drupal applies the workflow's default state
234
+ * (typically draft — i.e. still unpublished, preserving the no-auto-publish
235
+ * guarantee). Callers that need a specific state pass `moderation_state`
236
+ * explicitly, in which case `status` is absent and no retry occurs.
237
+ *
238
+ * @param {string} path JSON:API resource path.
239
+ * @param {"POST"|"PATCH"} method HTTP method.
240
+ * @param {(attrs: object) => object} buildPayload Builds the request body from an attribute map.
241
+ * @param {object} attributes Entity attributes (may include `status`).
242
+ * @returns {Promise<object>} The JSON:API response body.
243
+ */
244
+ async writeWithModerationFallback(path, method, buildPayload, attributes) {
245
+ try {
246
+ return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)) });
247
+ } catch (err) {
248
+ if (!isModeratedStatusError(err) || !("status" in attributes)) throw err;
249
+ const withoutStatus = { ...attributes };
250
+ delete withoutStatus.status;
251
+ return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)) });
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Create an entity via JSON:API POST. Retries without `status` on moderated
257
+ * bundles — see writeWithModerationFallback.
213
258
  * @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} input
214
259
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The created entity.
215
260
  */
216
261
  async createEntity({ entityType, bundle, attributes = {}, relationships }) {
217
- const payload = { data: { type: `${entityType}--${bundle}`, attributes } };
218
- if (relationships) payload.data.relationships = relationships;
219
- const data = await drupalFetch(this.site, this.resourcePath(entityType, bundle), {
220
- method: "POST",
221
- body: JSON.stringify(payload),
222
- });
262
+ const buildPayload = (attrs) => {
263
+ const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
264
+ if (relationships) payload.data.relationships = relationships;
265
+ return payload;
266
+ };
267
+ const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
223
268
  return this.toCanonical(data.data);
224
269
  }
225
270
 
226
271
  /**
227
- * Update an entity via JSON:API PATCH.
272
+ * Update an entity via JSON:API PATCH. Retries without `status` on moderated
273
+ * bundles — see writeWithModerationFallback.
228
274
  * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
229
275
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
230
276
  */
231
277
  async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
232
- const payload = { data: { type: `${entityType}--${bundle}`, id, attributes } };
233
- if (relationships) payload.data.relationships = relationships;
234
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${id}`, {
235
- method: "PATCH",
236
- body: JSON.stringify(payload),
237
- });
278
+ const buildPayload = (attrs) => {
279
+ const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
280
+ if (relationships) payload.data.relationships = relationships;
281
+ return payload;
282
+ };
283
+ const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${id}`, "PATCH", buildPayload, attributes);
238
284
  return this.toCanonical(data.data);
239
285
  }
240
286
 
package/src/lib/config.js CHANGED
@@ -13,8 +13,11 @@ import { validateBaseUrl } from "./validate.js";
13
13
  import { SecurityError } from "./security.js";
14
14
  import { getAccessToken } from "./oauth.js";
15
15
 
16
- /** Connector version for the X-MCP-Client identity label. Keep in sync with package.json. */
17
- export const CLIENT_VERSION = "0.6.0";
16
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixed path relative to this module (the package's own package.json), not user input
17
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
18
+
19
+ /** Connector version, sourced from package.json so it never drifts out of sync. */
20
+ export const CLIENT_VERSION = pkg.version;
18
21
 
19
22
  /**
20
23
  * Identity headers sent on every outbound Drupal request. Lets governance layers
@@ -92,16 +92,30 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
92
92
 
93
93
  /**
94
94
  * Create a node. Caller-supplied `fields` are spread into the attribute map;
95
- * title/status/body are layered on top so they win over any same-named field.
95
+ * title/status/moderation_state/body are layered on top so they win over any
96
+ * same-named field.
96
97
  *
97
- * @param {object} args - { site?, type, title, body?, summary?, status?, fields? }.
98
- * Defaults to status=false (draft) so content is never auto-published.
98
+ * Publish state two mutually exclusive paths:
99
+ * - `moderationState` (e.g. "draft"/"published"): for content types under a
100
+ * content_moderation workflow. When given, `moderation_state` is sent and
101
+ * `status` is omitted (moderated entities reject a direct `status` write).
102
+ * - `status` (boolean): for non-moderated types. Defaults to false (draft) so
103
+ * content is never auto-published. `moderationState` takes precedence.
104
+ * If a moderated bundle still receives `status` (the safe default), the JSON:API
105
+ * backend transparently retries without it — see jsonapi.js.
106
+ *
107
+ * @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields? }.
99
108
  * @returns {Promise<object>} The created node descriptor from the backend.
100
109
  */
101
- async function createNode({ site: siteName, type, title, body, summary, status = false, fields = {} }) {
110
+ async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {} }) {
102
111
  const site = getSiteConfig(siteName);
103
112
  const backend = await resolveBackend(site);
104
- const attributes = { title, status, ...fields };
113
+ const attributes = { title, ...fields };
114
+ if (moderationState !== undefined) {
115
+ attributes.moderation_state = moderationState;
116
+ } else {
117
+ attributes.status = status === undefined ? false : status;
118
+ }
105
119
  const bodyAttr = buildBodyAttribute(body, summary);
106
120
  if (bodyAttr) attributes.body = bodyAttr;
107
121
  return backend.createEntity({ entityType: "node", bundle: type, attributes });
@@ -111,15 +125,20 @@ async function createNode({ site: siteName, type, title, body, summary, status =
111
125
  * Update a node. Only supplied attributes are sent, so omitted fields are left
112
126
  * untouched (partial update). `fields` is spread first, then known scalars.
113
127
  *
114
- * @param {object} args - { site?, type, id, title?, body?, summary?, status?, fields? }.
128
+ * Publish state mirrors createNode: pass `moderationState` for content_moderation
129
+ * bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
130
+ * types. `moderationState` takes precedence; both are optional on update.
131
+ *
132
+ * @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
115
133
  * @returns {Promise<object>} The updated node descriptor.
116
134
  */
117
- async function updateNode({ site: siteName, type, id, title, body, summary, status, fields = {} }) {
135
+ async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {} }) {
118
136
  const site = getSiteConfig(siteName);
119
137
  const backend = await resolveBackend(site);
120
138
  const attributes = { ...fields };
121
139
  if (title !== undefined) attributes.title = title;
122
- if (status !== undefined) attributes.status = status;
140
+ if (moderationState !== undefined) attributes.moderation_state = moderationState;
141
+ else if (status !== undefined) attributes.status = status;
123
142
  const bodyAttr = buildBodyAttribute(body, summary);
124
143
  if (bodyAttr) attributes.body = bodyAttr;
125
144
  return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
@@ -188,7 +207,7 @@ export const definitions = [
188
207
  },
189
208
  {
190
209
  name: "drupal_create_node",
191
- description: "Create a new content node. Returns the new node UUID, integer ID, and URL.",
210
+ description: "Create a new content node. Returns the new node UUID, integer ID, and URL. For content types under an editorial (content_moderation) workflow, set moderationState (e.g. 'draft'/'published') instead of status.",
192
211
  inputSchema: {
193
212
  type: "object", required: ["type", "title"],
194
213
  properties: {
@@ -197,14 +216,15 @@ export const definitions = [
197
216
  title: { type: "string" },
198
217
  body: { type: "string", description: "Body field HTML" },
199
218
  summary: { type: "string", description: "Body summary / teaser" },
200
- status: { type: "boolean", default: false, description: "true to publish immediately" },
219
+ status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
220
+ moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
201
221
  fields: { type: "object", description: "Additional field values keyed by Drupal machine name" },
202
222
  },
203
223
  },
204
224
  },
205
225
  {
206
226
  name: "drupal_update_node",
207
- description: "Update an existing node. Only include fields you want to change.",
227
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status.",
208
228
  inputSchema: {
209
229
  type: "object", required: ["type", "id"],
210
230
  properties: {
@@ -214,7 +234,8 @@ export const definitions = [
214
234
  title: { type: "string" },
215
235
  body: { type: "string" },
216
236
  summary: { type: "string" },
217
- status: { type: "boolean", description: "true = publish, false = unpublish" },
237
+ status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
238
+ moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
218
239
  fields: { type: "object" },
219
240
  },
220
241
  },