drupal-mcp-connector 2.0.0 → 2.1.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.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }."
2
+ description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }."
3
3
  argument-hint: "<entityType> <bundle> <items> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_bulk_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_bulk_update` MCP tool.
8
8
 
9
- Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.
9
+ Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
16
16
 
17
17
  **Optional:**
18
18
  - `site` (string): omit for the default site
19
- - `status` (boolean (true/false))
19
+ - `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
20
20
  - `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
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 an existing entity of any Drupal entity type. Only include attributes/relationships you want to change."
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)."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_entity_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_entity_update` MCP tool.
8
8
 
9
- Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change.
9
+ 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).
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
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. Entity-reference fields go in `relationships`, not `fields`."
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`."
3
3
  argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_update_node
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_node` MCP tool.
8
8
 
9
- Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.
9
+ 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`.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -20,7 +20,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
20
20
  - `body` (string)
21
21
  - `summary` (string)
22
22
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
23
- - `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status.
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. 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 }, …] }.
26
26
  - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
@@ -1,17 +1,17 @@
1
1
  ---
2
- description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow."
2
+ description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished."
3
3
  argument-hint: "<filePath> <mediaType> <fieldName> [site] [mediaName] [altText] [status]"
4
4
  allowed-tools: mcp__drupal__drupal_upload_file_and_create_media
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_upload_file_and_create_media` MCP tool.
8
8
 
9
- Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.
9
+ Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
13
13
  **Required:**
14
- - `filePath` (string): Absolute local path to the file
14
+ - `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
15
15
  - `mediaType` (string): Media type machine name, e.g. 'image'
16
16
  - `fieldName` (string): Source field machine name, e.g. 'field_media_image'
17
17
 
@@ -19,6 +19,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
19
19
  - `site` (string): omit for the default site
20
20
  - `mediaName` (string): Name for the media entity (defaults to filename)
21
21
  - `altText` (string): Alt text for image media
22
- - `status` (boolean (true/false))
22
+ - `status` (boolean (true/false)): Published flag. Defaults to false. Requires allowPublish when true.
23
23
 
24
24
  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.
@@ -11,7 +11,7 @@ Upload a local file to Drupal and create a File entity. Returns the file UUID to
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
13
13
  **Required:**
14
- - `filePath` (string): Absolute local path to the file to upload
14
+ - `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
15
15
  - `bundle` (string): Bundle machine name, e.g. 'image', 'article'
16
16
  - `fieldName` (string): Field machine name, e.g. 'field_media_image', 'field_image'
17
17
 
package/CHANGELOG.md CHANGED
@@ -5,8 +5,67 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [2.1.0] - 2026-07-31
11
+
12
+ ### Security
13
+ - **Specialized tools honor entity allowlists/denylists (#138).**
14
+ `drupal_*_node`, media, and taxonomy tools now call the same
15
+ `assertReadAllowed` / `assertWriteAllowed` / `assertDeleteAllowed` gates as
16
+ `drupal_entity_*`, so a denied type (e.g. `media`) cannot be reached by
17
+ switching tool names.
18
+ - **Upload path allowlist and path segment hardening (#137).** File uploads
19
+ must resolve under `MCP_UPLOAD_ROOT` (or the connector cwd by default);
20
+ entity/bundle/field path segments are machine-name validated and encoded;
21
+ Content-Disposition filenames are sanitized. Sensitive paths (`.env*`,
22
+ `.ssh`, connector `config.json`) are refused even under an allowed root.
23
+ - **Media no longer publishes by default (#139).** `drupal_create_media` and
24
+ upload-and-create default to `status: false` and honor `assertPublishAllowed`.
25
+ `moderation_state: published` and `drupal_set_moderation_state` to published
26
+ are treated as publish-bearing.
27
+ - **HTTPS fails closed without auth when non-loopback (#141).** Binding beyond
28
+ loopback requires `MCP_AUTH_TOKEN` unless `MCP_ALLOW_UNAUTHENTICATED=1`.
29
+ Non-loopback HTTPS defaults to 120 req/min rate limiting when
30
+ `MCP_RATE_LIMIT` is unset.
31
+ - **Link-checker does not follow redirects (#143).** Live checks use
32
+ `redirect: "manual"` to avoid SSRF via 302 to private/metadata targets.
33
+ - **auditor / production-strict apply SENSITIVE_DENY** (partial #140). Secrets,
34
+ governance, and account entity types stay denied on those presets. Default
35
+ when `security` is omitted remains `development` (tracked in #140).
36
+
37
+ ### Fixed
38
+ - **Published moderated updates default to a draft forward revision (#131).**
39
+ `drupal_update_node`, `drupal_bulk_update`, and `drupal_entity_update` now
40
+ sniff the target entity: when it is published under content_moderation and
41
+ the caller omitted `moderation_state` / `moderationState`, the write is sent
42
+ as `moderation_state: draft` instead of omitting the field. That keeps bulk
43
+ relationship-wiring and field edits as reviewable forward revisions rather
44
+ than live default-revision mutations if a server-side publish gate
45
+ mis-classifies the write. Callers that want a same-state save must pass an
46
+ explicit moderation state.
47
+
48
+ ### Added
49
+ - **Provider-agnostic agent instructions.** Root `AGENTS.md` is the only
50
+ committed agent rule file (any coding agent). No vendor-specific
51
+ `.grok/` / `CLAUDE.md` / parallel rule trees. Generated slash stubs under
52
+ `.claude/commands/` remain client ergonomics for MCP operators, not project
53
+ development rules.
54
+
55
+ ### Documentation
56
+ - Security, hardening, deployment, tools-reference, threat-model, and
57
+ architecture docs updated for the 2.1 controls (fail-closed HTTPS, upload
58
+ roots, publish/draft defaults, GraphQL policy caveat).
59
+
8
60
  ## [2.0.0] - 2026-07-29
9
61
 
62
+ ### Fixed
63
+ - **Re-pushing a release tag no longer fails the publish job.** The workflow
64
+ now skips publishing a version already on the registry. Moving or recreating
65
+ a tag is a normal recovery operation, and npm's E403 "cannot publish over the
66
+ previously published version" reads as a broken release when the registry is
67
+ in exactly the state you wanted.
68
+
10
69
  ### Changed
11
70
  - **BREAKING: `drupal_drush_sql_query` no longer runs ungoverned SQL, and is off
12
71
  by default.** It called `drush sql:query`, which executes below Drupal's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
@@ -8,6 +8,10 @@
8
8
  * `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
9
9
  * tool's parameters before making a single call.
10
10
  *
11
+ * These files are *client ergonomics* for operators using Claude Code as an MCP
12
+ * client against this server — not provider-specific project rules. Development
13
+ * instructions for any agent live only in AGENTS.md.
14
+ *
11
15
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
12
16
  * command set never drifts from the tools. Run: `npm run generate:commands`.
13
17
  *
package/src/index.js CHANGED
@@ -384,6 +384,7 @@ if (transport === "stdio") {
384
384
 
385
385
  const authToken = process.env.MCP_AUTH_TOKEN || "";
386
386
  const checkAuth = makeBearerCheck(authToken);
387
+ const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
387
388
  if (!authToken) {
388
389
  console.error(
389
390
  "[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
@@ -450,11 +451,33 @@ if (transport === "stdio") {
450
451
  return mcpTransport;
451
452
  }
452
453
 
453
- // Optional fixed-window rate limiting on /mcp, keyed by client IP. Off unless
454
- // MCP_RATE_LIMIT > 0. Counts are per-process; for multi-replica deployments
455
- // prefer rate limiting at the reverse proxy.
456
- const rateLimit = Number(process.env.MCP_RATE_LIMIT || 0);
454
+ const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
455
+ // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
456
+ // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
457
+ const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
458
+ const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
459
+
460
+ // #141: fail closed when HTTPS is network-facing without a bearer token.
461
+ // Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
462
+ if (!authToken && !isLoopbackBind && !allowUnauth) {
463
+ console.error(
464
+ "[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
465
+ " Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
466
+ " MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
467
+ );
468
+ process.exit(1);
469
+ }
470
+
471
+ // Optional fixed-window rate limiting on /mcp, keyed by client IP.
472
+ // HTTPS non-loopback defaults to 120 req/min when MCP_RATE_LIMIT is unset (#141).
473
+ // Set MCP_RATE_LIMIT=0 to disable. Counts are per-process; multi-replica should
474
+ // also rate-limit at the reverse proxy.
457
475
  const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
476
+ const rateLimitEnv = process.env.MCP_RATE_LIMIT;
477
+ const rateLimitDefault = (hasTls && !isLoopbackBind) ? 120 : 0;
478
+ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
479
+ ? rateLimitDefault
480
+ : Number(rateLimitEnv);
458
481
  const rateLimiter = rateLimit > 0
459
482
  ? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
460
483
  : null;
@@ -474,11 +497,6 @@ if (transport === "stdio") {
474
497
 
475
498
  const nodeServer = createNodeServer(requestHandler);
476
499
 
477
- const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
478
- // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
479
- // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
480
- const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
481
-
482
500
  nodeServer.listen(port, bindHost, () => {
483
501
  const proto = hasTls ? "https" : "http";
484
502
  console.error(
@@ -7,6 +7,11 @@ import { createReadStream, statSync } from "fs";
7
7
  import { basename } from "path";
8
8
  import { authHeadersAsync, clientHeaders } from "./config.js";
9
9
  import { clearToken } from "./oauth.js";
10
+ import {
11
+ assertUploadPathAllowed,
12
+ sanitizeUploadFilename,
13
+ validateMachineName,
14
+ } from "./validate.js";
10
15
 
11
16
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
12
17
 
@@ -115,10 +120,15 @@ export async function drupalGraphqlFetch(site, body) {
115
120
  * @throws {Error} on any non-2xx response.
116
121
  */
117
122
  export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
118
- const filename = basename(filePath);
119
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
120
- const stat = statSync(filePath);
121
- const url = `${site.baseUrl}/jsonapi/${entityType}/${bundle}/${fieldName}`;
123
+ // #137: machine-name segments + path allowlist before any FS or network I/O.
124
+ validateMachineName(entityType, "entityType");
125
+ validateMachineName(bundle, "bundle");
126
+ validateMachineName(fieldName, "fieldName");
127
+ const safePath = assertUploadPathAllowed(filePath);
128
+ const filename = sanitizeUploadFilename(basename(safePath));
129
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
130
+ const stat = statSync(safePath);
131
+ const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
122
132
 
123
133
  const res = await fetch(url, {
124
134
  method: "POST",
@@ -129,8 +139,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
129
139
  ...clientHeaders(),
130
140
  ...(await authHeadersAsync(site)),
131
141
  },
132
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
133
- body: createReadStream(filePath),
142
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
143
+ body: createReadStream(safePath),
134
144
  // node-fetch requires explicit size for streams to set Content-Length
135
145
  size: stat.size,
136
146
  });
@@ -190,7 +190,9 @@ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
190
190
  const controller = new AbortController();
191
191
  const timer = setTimeout(() => controller.abort(), timeoutMs);
192
192
  try {
193
- const res = await fetchImpl(url, { method, redirect: "follow", signal: controller.signal });
193
+ // #143: never auto-follow redirects a public allowlisted host can 302 to
194
+ // private/metadata. Report the redirect status; do not chase Location.
195
+ const res = await fetchImpl(url, { method, redirect: "manual", signal: controller.signal });
194
196
  return { status: res.status, error: null };
195
197
  } catch (err) {
196
198
  const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Safe draft default for updates of published, moderated entities (#131).
3
+ *
4
+ * When a PATCH targets a *published* entity under content_moderation and the
5
+ * caller did not pass an explicit `moderation_state`, the write can land on the
6
+ * live default revision if a server-side publish gate mis-classifies it as "no
7
+ * transition". Defaulting to `moderation_state: draft` forces a forward
8
+ * (reviewable) revision instead.
9
+ *
10
+ * Callers that genuinely want a same-state save on a published node must opt in
11
+ * by passing `moderation_state` (or the tool-level `moderationState`) explicitly.
12
+ *
13
+ * Detection is best-effort from the existing entity payload: the entity is
14
+ * treated as moderated when a `moderation_state` field is present on it, and as
15
+ * published when `status === true`. If the entity cannot be read, attributes
16
+ * are left unchanged (fail open for non-moderated bundles; server-side gate
17
+ * remains authoritative).
18
+ */
19
+
20
+ /** Default moderation state applied when none was supplied for a published moderated entity. */
21
+ export const SAFE_DRAFT_STATE = "draft";
22
+
23
+ /**
24
+ * Whether a canonical entity exposes a content_moderation state field.
25
+ * @param {?object} entity Canonical entity (or null).
26
+ * @returns {boolean}
27
+ */
28
+ export function entityLooksModerated(entity) {
29
+ if (!entity) return false;
30
+ if (entity.fields && Object.prototype.hasOwnProperty.call(entity.fields, "moderation_state")) {
31
+ return true;
32
+ }
33
+ // Tolerate a top-level promotion if a backend ever surfaces it that way.
34
+ return Object.prototype.hasOwnProperty.call(entity, "moderation_state");
35
+ }
36
+
37
+ /**
38
+ * Whether a canonical entity is currently published.
39
+ * @param {?object} entity Canonical entity (or null).
40
+ * @returns {boolean}
41
+ */
42
+ export function isPublishedEntity(entity) {
43
+ return entity?.status === true;
44
+ }
45
+
46
+ /**
47
+ * Whether the write attributes already carry an explicit moderation state.
48
+ * @param {?object} attributes Attribute map about to be sent.
49
+ * @returns {boolean}
50
+ */
51
+ export function hasExplicitModerationState(attributes) {
52
+ return Boolean(attributes && Object.prototype.hasOwnProperty.call(attributes, "moderation_state"));
53
+ }
54
+
55
+ /**
56
+ * Decide whether a draft default should be injected for this update.
57
+ * Pure helper — no I/O.
58
+ *
59
+ * @param {{attributes?: object, entity?: ?object}} input
60
+ * @returns {boolean}
61
+ */
62
+ export function shouldDefaultPublishedUpdateToDraft({ attributes, entity }) {
63
+ if (hasExplicitModerationState(attributes)) return false;
64
+ if (!isPublishedEntity(entity)) return false;
65
+ if (!entityLooksModerated(entity)) return false;
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Apply the safe draft default when updating a published moderated entity.
71
+ * Returns a new attributes object when a default is applied; otherwise returns
72
+ * the original attributes reference unchanged.
73
+ *
74
+ * @param {object} args
75
+ * @param {object} args.backend Backend with `getEntity`.
76
+ * @param {string} args.entityType
77
+ * @param {string} args.bundle
78
+ * @param {string} args.id Entity UUID.
79
+ * @param {object} [args.attributes={}] Attribute map for the write.
80
+ * @param {?object} [args.existingEntity] Pre-fetched entity; when omitted a get is issued.
81
+ * @returns {Promise<object>} Attributes to send (possibly with moderation_state: draft).
82
+ */
83
+ export async function applySafeDraftDefault({
84
+ backend,
85
+ entityType,
86
+ bundle,
87
+ id,
88
+ attributes = {},
89
+ existingEntity,
90
+ }) {
91
+ if (hasExplicitModerationState(attributes)) return attributes;
92
+
93
+ let entity = existingEntity;
94
+ if (entity === undefined) {
95
+ try {
96
+ entity = await backend.getEntity({ entityType, bundle, id });
97
+ } catch {
98
+ // Fail open: without a readable target we cannot sniff moderation.
99
+ return attributes;
100
+ }
101
+ }
102
+ if (!shouldDefaultPublishedUpdateToDraft({ attributes, entity })) {
103
+ return attributes;
104
+ }
105
+ return { ...attributes, moderation_state: SAFE_DRAFT_STATE };
106
+ }
@@ -175,7 +175,8 @@ const PRESETS = {
175
175
  allowConfigRead: true, // read-only inspection of config
176
176
  allowConfigWrite: false,
177
177
  allowedEntityTypes: null, // read any entity type
178
- deniedEntityTypes: [],
178
+ // Secrets / governance / account entities stay denied even on broad read (#140).
179
+ deniedEntityTypes: [...SENSITIVE_DENY],
179
180
  entityRules: {
180
181
  user: {
181
182
  allowedOperations: ["read"],
@@ -192,7 +193,7 @@ const PRESETS = {
192
193
  allowConfigRead: false, // nothing implicit; opt in per site
193
194
  allowConfigWrite: false,
194
195
  allowedEntityTypes: null, // set an explicit allowlist in your config
195
- deniedEntityTypes: ["user"], // no user data at all
196
+ deniedEntityTypes: [...SENSITIVE_DENY], // includes user + secrets/governance
196
197
  entityRules: {},
197
198
  globalRedactedFields: ["pass", "mail", "field_private", "field_api_key", "field_token"],
198
199
  },
@@ -379,23 +380,33 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
379
380
  }
380
381
 
381
382
  /**
382
- * Whether a set of write attributes carries a publish action. Deliberately
383
- * limited to the unambiguous, entity-agnostic signal `status === true`; a site's
384
- * moderation-workflow state names are not knowable from a site-agnostic
385
- * connector, so publishing via `moderation_state` stays gated server-side.
383
+ * Whether a set of write attributes carries a publish action.
384
+ *
385
+ * Recognized signals:
386
+ * - `status === true` (non-moderated publish)
387
+ * - `moderation_state` equal to `"published"` (case-insensitive) — the common
388
+ * core content_moderation publish state (#139)
389
+ *
390
+ * Other workflow state names are site-specific and remain server-gated.
386
391
  * @param {object} [attributes] Attribute map for the write.
387
392
  * @returns {boolean}
388
393
  */
389
394
  export function isPublishBearing(attributes = {}) {
390
- return attributes?.status === true;
395
+ if (attributes?.status === true) return true;
396
+ // Common core content_moderation publish state. Site-specific publish state
397
+ // names remain server-gated; this closes the obvious connector-layer hole (#139).
398
+ const mod = attributes?.moderation_state;
399
+ if (typeof mod === "string" && mod.toLowerCase() === "published") return true;
400
+ return false;
391
401
  }
392
402
 
393
403
  /**
394
404
  * Local, fail-fast publish gate, symmetric with assertDestructiveAllowed. When
395
405
  * the connector is not permitted to publish (allowPublish false — the default in
396
- * every preset except `development`), a write carrying `status: true` is refused
397
- * before the round-trip, rather than being silently dropped by a moderated-bundle
398
- * retry or a server-side gate (see #111/#114). Client-side convenience only — the
406
+ * every preset except `development`), a write that is publish-bearing
407
+ * (`status: true` or `moderation_state: "published"`) is refused before the
408
+ * round-trip, rather than being silently dropped by a moderated-bundle retry or
409
+ * a server-side gate (see #111/#114/#139). Client-side convenience only — the
399
410
  * remote Drupal's own permissions remain the real authority.
400
411
  * @param {object} secConfig Resolved security config.
401
412
  * @param {object} [attributes] Attribute map for the write.
@@ -407,9 +418,8 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
407
418
  if (isPublishBearing(attributes)) {
408
419
  throw new SecurityError(
409
420
  "Publishing is disabled for this connector (allowPublish: false). " +
410
- "Blocked: a write carrying status:true. " +
411
- "To enable, set security.allowPublish = true in your config; otherwise publish " +
412
- "via an operator/server-gated path (e.g. a moderation transition)."
421
+ "Blocked: a write carrying status:true or moderation_state:published. " +
422
+ "To enable, set security.allowPublish = true in your config."
413
423
  );
414
424
  }
415
425
  }
@@ -8,6 +8,8 @@
8
8
  * coerce values in ways that could hide injection attempts.
9
9
  */
10
10
 
11
+ import { existsSync, realpathSync } from "fs";
12
+ import { basename, resolve, sep } from "path";
11
13
  import { SecurityError } from "./security.js";
12
14
 
13
15
  // ---------------------------------------------------------------------------
@@ -214,6 +216,120 @@ export function clampLimit(value, defaultVal = 20) {
214
216
  return Math.min(n, MAX_PAGE_LIMIT);
215
217
  }
216
218
 
219
+ // ---------------------------------------------------------------------------
220
+ // Local file upload path allowlist (#137 / audit C1)
221
+ // ---------------------------------------------------------------------------
222
+ //
223
+ // Upload tools must not be able to read arbitrary process-readable files
224
+ // (SSH keys, .env, connector config). Paths must resolve under an allowed
225
+ // root. Default root is process.cwd(); operators expand with MCP_UPLOAD_ROOT
226
+ // (colon- or semicolon-separated absolute paths).
227
+
228
+ /**
229
+ * Resolve an absolute path, following symlinks when the target exists.
230
+ * @param {string} p Path to resolve.
231
+ * @returns {string} Canonical absolute path.
232
+ */
233
+ function resolveExistingPath(p) {
234
+ const abs = resolve(p);
235
+ try {
236
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator-configured upload root or cwd
237
+ return realpathSync(abs);
238
+ } catch {
239
+ return abs;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Allowed upload roots. From MCP_UPLOAD_ROOT when set; otherwise process.cwd().
245
+ * @returns {string[]} Absolute root paths (realpath when possible).
246
+ */
247
+ export function getUploadRoots() {
248
+ const raw = process.env.MCP_UPLOAD_ROOT;
249
+ if (raw && String(raw).trim()) {
250
+ return String(raw)
251
+ .split(/[:;]/)
252
+ .map((s) => s.trim())
253
+ .filter(Boolean)
254
+ .map(resolveExistingPath);
255
+ }
256
+ return [resolveExistingPath(process.cwd())];
257
+ }
258
+
259
+ /**
260
+ * Sanitize a filename for Content-Disposition (no CR/LF/quotes; bounded length).
261
+ * @param {string} name Raw filename (often basename of a path).
262
+ * @returns {string} Safe filename for the header.
263
+ */
264
+ export function sanitizeUploadFilename(name) {
265
+ const base = basename(String(name || "upload"));
266
+ const cleaned = base
267
+ .replace(/[\r\n"\\]/g, "")
268
+ .replace(/[^\w.\- ()[\]]+/g, "_")
269
+ .replace(/^\.+/, "")
270
+ .slice(0, 200);
271
+ return cleaned || "upload.bin";
272
+ }
273
+
274
+ /**
275
+ * Whether a resolved path sits under a sensitive location that must never
276
+ * be uploaded even when it is inside an allowed root.
277
+ * @param {string} real Absolute real path.
278
+ * @returns {boolean}
279
+ */
280
+ function isSensitiveUploadPath(real) {
281
+ const n = real.split(sep).join("/");
282
+ if (n.includes("/.ssh/") || n.endsWith("/.ssh") || n.includes("/.gnupg/")) return true;
283
+ const base = basename(real);
284
+ if (base === "id_rsa" || base === "id_ed25519" || base === "id_ecdsa" || base === "id_dsa") return true;
285
+ if (base.startsWith(".env")) return true;
286
+ if (n.endsWith("/config/config.json")) return true;
287
+ return false;
288
+ }
289
+
290
+ /**
291
+ * Validate that a local file path may be uploaded: exists, under an allowed
292
+ * root, and not a known secret path. Returns the resolved real path for use
293
+ * with fs streams.
294
+ *
295
+ * @param {string} filePath Caller-supplied path.
296
+ * @returns {string} Real absolute path safe to read for upload.
297
+ * @throws {Error} if the path is missing or not a string.
298
+ * @throws {SecurityError} if the path is outside roots or sensitive.
299
+ */
300
+ export function assertUploadPathAllowed(filePath) {
301
+ if (typeof filePath !== "string" || !filePath.trim()) {
302
+ throw new Error("filePath must be a non-empty string.");
303
+ }
304
+ const abs = resolve(filePath);
305
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is caller-supplied; allowlist enforced after realpath
306
+ if (!existsSync(abs)) {
307
+ throw new Error(`Upload path does not exist: ${filePath}`);
308
+ }
309
+ let real;
310
+ try {
311
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is caller-supplied; allowlist enforced next
312
+ real = realpathSync(abs);
313
+ } catch {
314
+ throw new Error(`Upload path is not readable: ${filePath}`);
315
+ }
316
+ const roots = getUploadRoots();
317
+ const allowed = roots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep));
318
+ if (!allowed) {
319
+ throw new SecurityError(
320
+ "Upload path is outside allowed roots. Set MCP_UPLOAD_ROOT to an absolute " +
321
+ "directory (or several, colon-separated), or place the file under the " +
322
+ `connector working directory. Denied: ${filePath}`
323
+ );
324
+ }
325
+ if (isSensitiveUploadPath(real)) {
326
+ throw new SecurityError(
327
+ "Upload of sensitive paths (SSH keys, .env*, connector config.json) is not allowed."
328
+ );
329
+ }
330
+ return real;
331
+ }
332
+
217
333
  // ---------------------------------------------------------------------------
218
334
  // Field name sanitization (prevent crafted field names in JSON:API filters)
219
335
  // ---------------------------------------------------------------------------
package/src/tools/bulk.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
16
  import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
17
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
17
18
 
18
19
  /**
19
20
  * Normalize an unknown thrown value into a human-readable message.
@@ -67,6 +68,11 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
67
68
  * each item is updated independently so the batch continues past failures. An
68
69
  * item missing an id is reported as a per-item failure rather than aborting.
69
70
  *
71
+ * Safe default (#131): each published moderated target without an explicit
72
+ * `moderation_state` is rewritten to `moderation_state: draft` before the
73
+ * PATCH, so bulk relationship-wiring and field edits become forward revisions
74
+ * rather than live default-revision mutations.
75
+ *
70
76
  * @param {object} args - { site?, entityType, bundle, items: [{ id, attributes?, relationships? }] }.
71
77
  * @returns {Promise<{results: object[], summary: {updated: number, failed: number}}>}
72
78
  * Per-item { index, success, id? | error } plus a roll-up summary.
@@ -85,10 +91,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
85
91
  const item = rawItem || {};
86
92
  try {
87
93
  if (!item.id) throw new Error("Missing 'id' for update item");
88
- assertPublishAllowed(sec, item.attributes ?? {});
94
+ const attributes = await applySafeDraftDefault({
95
+ backend, entityType, bundle, id: item.id,
96
+ attributes: item.attributes ?? {},
97
+ });
98
+ assertPublishAllowed(sec, attributes);
89
99
  const entity = await backend.updateEntity({
90
100
  entityType, bundle, id: item.id,
91
- attributes: item.attributes ?? {},
101
+ attributes,
92
102
  relationships: item.relationships ?? {},
93
103
  });
94
104
  updated += 1;
@@ -130,7 +140,7 @@ export const definitions = [
130
140
  },
131
141
  {
132
142
  name: "drupal_bulk_update",
133
- description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.",
143
+ description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.",
134
144
  inputSchema: {
135
145
  type: "object", required: ["entityType", "bundle", "items"],
136
146
  properties: {
@@ -11,6 +11,7 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
14
15
  import {
15
16
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
16
17
  redactCanonicalEntity, getSecuritySummary,
@@ -70,6 +71,10 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
70
71
  /**
71
72
  * Update an entity of any type/bundle (partial — only supplied fields are sent).
72
73
  *
74
+ * Safe default (#131): published moderated targets without an explicit
75
+ * `moderation_state` get `moderation_state: draft` so the write is a forward
76
+ * revision rather than a live default-revision mutation.
77
+ *
73
78
  * @param {object} args - { site?, entityType, bundle, id, attributes?, relationships? }.
74
79
  * @returns {Promise<object>} The updated entity descriptor.
75
80
  * @throws {SecurityError} If updating the type/bundle is not permitted.
@@ -78,10 +83,13 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
78
83
  const site = getSiteConfig(siteName);
79
84
  const sec = resolveSecurityConfig(site);
80
85
  assertWriteAllowed(sec, "update", entityType, bundle);
81
- assertPublishAllowed(sec, attributes);
82
- if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes, relationships };
83
86
  const backend = await resolveBackend(site);
84
- return shapeWriteResponse(await backend.updateEntity({ entityType, bundle, id, attributes, relationships }), returning);
87
+ const safeAttributes = await applySafeDraftDefault({
88
+ backend, entityType, bundle, id, attributes,
89
+ });
90
+ assertPublishAllowed(sec, safeAttributes);
91
+ if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
92
+ return shapeWriteResponse(await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships }), returning);
85
93
  }
86
94
 
87
95
  /**
@@ -222,7 +230,7 @@ export const definitions = [
222
230
  },
223
231
  {
224
232
  name: "drupal_entity_update",
225
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change.",
233
+ 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).",
226
234
  inputSchema: {
227
235
  type: "object", required: ["entityType", "bundle", "id"],
228
236
  properties: {
@@ -8,7 +8,10 @@
8
8
 
9
9
  import { getSiteConfig } from "../lib/config.js";
10
10
  import { resolveBackend } from "../lib/backends/index.js";
11
- import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
11
+ import {
12
+ resolveSecurityConfig, redactCanonicalEntity,
13
+ assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
14
+ } from "../lib/security.js";
12
15
 
13
16
  /**
14
17
  * List all media types (bundles of the media entity type).
@@ -17,6 +20,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
17
20
  */
18
21
  async function listMediaTypes({ site: siteName }) {
19
22
  const site = getSiteConfig(siteName);
23
+ assertReadAllowed(resolveSecurityConfig(site), "media");
20
24
  const backend = await resolveBackend(site);
21
25
  return backend.listBundles("media");
22
26
  }
@@ -32,11 +36,13 @@ async function listMediaTypes({ site: siteName }) {
32
36
  async function listMedia({ site: siteName, type, status, name, limit = 20, offset = 0 }) {
33
37
  const site = getSiteConfig(siteName);
34
38
  const sec = resolveSecurityConfig(site);
39
+ const bundle = type || "image";
40
+ assertReadAllowed(sec, "media", bundle);
35
41
  const backend = await resolveBackend(site);
36
42
  const filters = [];
37
43
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
38
44
  if (name) filters.push({ field: "name", op: "contains", value: name });
39
- const res = await backend.listEntities({ entityType: "media", bundle: type || "image", filters, sort: [{ field: "changed", dir: "desc" }], page: { limit, offset } });
45
+ const res = await backend.listEntities({ entityType: "media", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit, offset } });
40
46
  const items = res.entities.map((e) => redactCanonicalEntity(e, sec, "media"));
41
47
  return { total: res.page?.total ?? items.length, approximate: res.approximate ?? false, offset, nextOffset: offset + items.length, media: items };
42
48
  }
@@ -49,6 +55,7 @@ async function listMedia({ site: siteName, type, status, name, limit = 20, offse
49
55
  async function getMedia({ site: siteName, type, id }) {
50
56
  const site = getSiteConfig(siteName);
51
57
  const sec = resolveSecurityConfig(site);
58
+ assertReadAllowed(sec, "media", type);
52
59
  const backend = await resolveBackend(site);
53
60
  const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
54
61
  return entity ? redactCanonicalEntity(entity, sec, "media") : null;
@@ -56,15 +63,23 @@ async function getMedia({ site: siteName, type, id }) {
56
63
 
57
64
  /**
58
65
  * Create a media entity. Caller `fields` are spread into attributes; name and
59
- * status are layered on top.
66
+ * status are layered on top. Defaults to unpublished (`status: false`) so
67
+ * media is never auto-published under non-publishing presets (#139).
60
68
  *
61
69
  * @param {object} args - { site?, type, name, status?, fields? }.
62
70
  * @returns {Promise<object>} The created media descriptor.
63
71
  */
64
- async function createMedia({ site: siteName, type, name, status = true, fields = {} }) {
72
+ async function createMedia({ site: siteName, type, name, status = false, fields = {} }) {
65
73
  const site = getSiteConfig(siteName);
74
+ const sec = resolveSecurityConfig(site);
75
+ assertWriteAllowed(sec, "create", "media", type);
76
+ const attributes = { name, status, ...fields };
77
+ // Layer name/status after fields so they win, matching prior behaviour.
78
+ attributes.name = name;
79
+ attributes.status = status;
80
+ assertPublishAllowed(sec, attributes);
66
81
  const backend = await resolveBackend(site);
67
- return backend.createEntity({ entityType: "media", bundle: type, attributes: { name, status, ...fields } });
82
+ return backend.createEntity({ entityType: "media", bundle: type, attributes });
68
83
  }
69
84
 
70
85
  /**
@@ -74,10 +89,13 @@ async function createMedia({ site: siteName, type, name, status = true, fields =
74
89
  */
75
90
  async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
76
91
  const site = getSiteConfig(siteName);
77
- const backend = await resolveBackend(site);
92
+ const sec = resolveSecurityConfig(site);
93
+ assertWriteAllowed(sec, "update", "media", type);
78
94
  const attributes = { ...fields };
79
95
  if (name !== undefined) attributes.name = name;
80
96
  if (status !== undefined) attributes.status = status;
97
+ assertPublishAllowed(sec, attributes);
98
+ const backend = await resolveBackend(site);
81
99
  return backend.updateEntity({ entityType: "media", bundle: type, id, attributes });
82
100
  }
83
101
 
@@ -90,6 +108,7 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
90
108
  */
91
109
  async function deleteMedia({ site: siteName, type, id }) {
92
110
  const site = getSiteConfig(siteName);
111
+ assertDeleteAllowed(resolveSecurityConfig(site), "media", type, id);
93
112
  const backend = await resolveBackend(site);
94
113
  await backend.deleteEntity({ entityType: "media", bundle: type, id });
95
114
  return { success: true, deletedId: id };
@@ -104,6 +123,10 @@ async function deleteMedia({ site: siteName, type, id }) {
104
123
  */
105
124
  async function uploadFile({ site: siteName, filePath, entityType = "media", bundle, fieldName }) {
106
125
  const site = getSiteConfig(siteName);
126
+ const sec = resolveSecurityConfig(site);
127
+ // Host entity field write + file create (both required for JSON:API upload).
128
+ assertWriteAllowed(sec, "create", entityType, bundle);
129
+ assertWriteAllowed(sec, "create", "file");
107
130
  const backend = await resolveBackend(site);
108
131
  return backend.uploadFile({ entityType, bundle, fieldName, filePath });
109
132
  }
@@ -118,8 +141,14 @@ async function uploadFile({ site: siteName, filePath, entityType = "media", bund
118
141
  * @returns {Promise<{file: {id: string, filename: string}, media: object}>}
119
142
  * @throws {BackendCapabilityError} If the backend cannot upload files.
120
143
  */
121
- async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, mediaName, fieldName, altText, status = true }) {
144
+ async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, mediaName, fieldName, altText, status = false }) {
122
145
  const site = getSiteConfig(siteName);
146
+ const sec = resolveSecurityConfig(site);
147
+ assertWriteAllowed(sec, "create", "media", mediaType);
148
+ assertWriteAllowed(sec, "create", "file");
149
+ const attributes = { name: mediaName || undefined, status };
150
+ // Gate publish before any upload I/O so a blocked publish does not leave an orphan file.
151
+ assertPublishAllowed(sec, attributes);
123
152
  const backend = await resolveBackend(site);
124
153
  const file = await backend.uploadFile({ entityType: "media", bundle: mediaType, fieldName, filePath });
125
154
  const fileData = altText
@@ -144,8 +173,9 @@ async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, m
144
173
  async function findOrphanedMedia({ site: siteName, type, limit = 50 }) {
145
174
  const site = getSiteConfig(siteName);
146
175
  const sec = resolveSecurityConfig(site);
147
- const backend = await resolveBackend(site);
148
176
  const bundle = type || "image";
177
+ assertReadAllowed(sec, "media", bundle);
178
+ const backend = await resolveBackend(site);
149
179
  const map = (res, method, note) => ({
150
180
  method, ...(note ? { note } : {}), count: res.entities.length,
151
181
  media: res.entities.map((e) => redactCanonicalEntity(e, sec, "media")),
@@ -208,7 +238,7 @@ export const definitions = [
208
238
  site: { type: "string" },
209
239
  type: { type: "string", description: "Media type machine name" },
210
240
  name: { type: "string", description: "Media entity name / label" },
211
- status: { type: "boolean", default: true },
241
+ status: { type: "boolean", default: false, description: "Published flag. Defaults to false (unpublished). Requires allowPublish when true." },
212
242
  fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')" },
213
243
  },
214
244
  },
@@ -247,7 +277,7 @@ export const definitions = [
247
277
  type: "object", required: ["filePath", "bundle", "fieldName"],
248
278
  properties: {
249
279
  site: { type: "string" },
250
- filePath: { type: "string", description: "Absolute local path to the file to upload" },
280
+ filePath: { type: "string", description: "Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)" },
251
281
  entityType: { type: "string", default: "media", description: "Drupal entity type (usually 'media' or 'node')" },
252
282
  bundle: { type: "string", description: "Bundle machine name, e.g. 'image', 'article'" },
253
283
  fieldName: { type: "string", description: "Field machine name, e.g. 'field_media_image', 'field_image'" },
@@ -256,17 +286,17 @@ export const definitions = [
256
286
  },
257
287
  {
258
288
  name: "drupal_upload_file_and_create_media",
259
- description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.",
289
+ description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.",
260
290
  inputSchema: {
261
291
  type: "object", required: ["filePath", "mediaType", "fieldName"],
262
292
  properties: {
263
293
  site: { type: "string" },
264
- filePath: { type: "string", description: "Absolute local path to the file" },
294
+ filePath: { type: "string", description: "Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)" },
265
295
  mediaType: { type: "string", description: "Media type machine name, e.g. 'image'" },
266
296
  mediaName: { type: "string", description: "Name for the media entity (defaults to filename)" },
267
297
  fieldName: { type: "string", description: "Source field machine name, e.g. 'field_media_image'" },
268
298
  altText: { type: "string", description: "Alt text for image media" },
269
- status: { type: "boolean", default: true },
299
+ status: { type: "boolean", default: false, description: "Published flag. Defaults to false. Requires allowPublish when true." },
270
300
  },
271
301
  },
272
302
  },
@@ -16,7 +16,10 @@
16
16
 
17
17
  import { getSiteConfig } from "../lib/config.js";
18
18
  import { resolveBackend } from "../lib/backends/index.js";
19
- import { resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
19
+ import {
20
+ resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertPublishAllowed,
21
+ redactCanonicalEntity,
22
+ } from "../lib/security.js";
20
23
 
21
24
  /** Read a node's moderation_state from a canonical entity, tolerating shapes. */
22
25
  function moderationStateOf(entity) {
@@ -37,8 +40,10 @@ async function setModerationState({ site: siteName, type, id, state }) {
37
40
  const site = getSiteConfig(siteName);
38
41
  const sec = resolveSecurityConfig(site);
39
42
  assertWriteAllowed(sec, "update", "node", type);
43
+ const attributes = { moderation_state: state };
44
+ assertPublishAllowed(sec, attributes);
40
45
  const backend = await resolveBackend(site);
41
- const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes: { moderation_state: state } });
46
+ const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
42
47
  return redactCanonicalEntity(entity, sec, "node");
43
48
  }
44
49
 
@@ -9,7 +9,11 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
- import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
12
+ import {
13
+ resolveSecurityConfig, redactCanonicalEntity,
14
+ assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
15
+ } from "../lib/security.js";
16
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
13
17
  import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
18
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
15
19
 
@@ -156,6 +160,7 @@ function pageOf({ limit = 20, offset = 0 }) {
156
160
  async function getNode({ site: siteName, type, id }) {
157
161
  const site = getSiteConfig(siteName);
158
162
  const sec = resolveSecurityConfig(site);
163
+ assertReadAllowed(sec, "node", type);
159
164
  const backend = await resolveBackend(site);
160
165
  const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
161
166
  return entity ? redactCanonicalEntity(entity, sec, "node") : null;
@@ -172,6 +177,7 @@ async function getNode({ site: siteName, type, id }) {
172
177
  async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
173
178
  const site = getSiteConfig(siteName);
174
179
  const sec = resolveSecurityConfig(site);
180
+ assertReadAllowed(sec, "node", type);
175
181
  const backend = await resolveBackend(site);
176
182
  const allFilters = [...filters];
177
183
  if (status !== undefined) allFilters.push({ field: "status", op: "eq", value: status });
@@ -195,10 +201,12 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
195
201
  async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
196
202
  const site = getSiteConfig(siteName);
197
203
  const sec = resolveSecurityConfig(site);
204
+ const bundle = type || "article";
205
+ assertReadAllowed(sec, "node", bundle);
198
206
  const backend = await resolveBackend(site);
199
207
  const filters = [{ field: "title", op: "contains", value: query }];
200
208
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
201
- const res = await backend.listEntities({ entityType: "node", bundle: type || "article", filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
209
+ const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
202
210
  return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
203
211
  }
204
212
 
@@ -225,6 +233,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
225
233
  */
226
234
  async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
227
235
  const site = getSiteConfig(siteName);
236
+ const sec = resolveSecurityConfig(site);
237
+ assertWriteAllowed(sec, "create", "node", type);
228
238
  const attributes = { title, ...fields };
229
239
  if (moderationState !== undefined) {
230
240
  attributes.moderation_state = moderationState;
@@ -233,7 +243,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
233
243
  }
234
244
  const bodyAttr = buildBodyAttribute(body, summary);
235
245
  if (bodyAttr) attributes.body = bodyAttr;
236
- assertPublishAllowed(resolveSecurityConfig(site), attributes);
246
+ assertPublishAllowed(sec, attributes);
237
247
  if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
238
248
  const backend = await resolveBackend(site);
239
249
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
@@ -256,6 +266,11 @@ async function createNode({ site: siteName, type, title, body, summary, status,
256
266
  * bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
257
267
  * types. `moderationState` takes precedence; both are optional on update.
258
268
  *
269
+ * Safe default (#131): when the target is a *published* moderated node and the
270
+ * caller did not pass `moderationState` / `moderation_state`, the write is
271
+ * forced to `moderation_state: draft` so it becomes a forward revision rather
272
+ * than mutating the live default revision. Opt out by passing an explicit state.
273
+ *
259
274
  * Alias hardening: a partial update that doesn't touch `path` can still lose the
260
275
  * node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
261
276
  * it on save. To preserve the existing alias, when the caller supplies no `path`
@@ -269,16 +284,22 @@ async function createNode({ site: siteName, type, title, body, summary, status,
269
284
  */
270
285
  async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
271
286
  const site = getSiteConfig(siteName);
272
- const attributes = { ...fields };
287
+ const sec = resolveSecurityConfig(site);
288
+ assertWriteAllowed(sec, "update", "node", type);
289
+ const backend = await resolveBackend(site);
290
+ let attributes = { ...fields };
273
291
  if (title !== undefined) attributes.title = title;
274
292
  if (moderationState !== undefined) attributes.moderation_state = moderationState;
275
293
  else if (status !== undefined) attributes.status = status;
276
294
  const bodyAttr = buildBodyAttribute(body, summary);
277
295
  if (bodyAttr) attributes.body = bodyAttr;
278
- assertPublishAllowed(resolveSecurityConfig(site), attributes);
296
+ // #131: published moderated nodes without an explicit state → draft forward revision.
297
+ // Runs before the publish gate and on dryRun so previews match the real write.
298
+ attributes = await applySafeDraftDefault({
299
+ backend, entityType: "node", bundle: type, id, attributes,
300
+ });
301
+ assertPublishAllowed(sec, attributes);
279
302
  if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
280
- const backend = await resolveBackend(site);
281
- const sec = resolveSecurityConfig(site);
282
303
  // Alias handling (DEV-116): an explicit `path.alias` is set in place by
283
304
  // round-tripping the existing alias's pid (no duplicate); a path-less update
284
305
  // re-pins the current alias *with its pid* so the save can't revert/duplicate
@@ -304,6 +325,8 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
304
325
  */
305
326
  async function deleteNode({ site: siteName, type, id, dryRun = false }) {
306
327
  const site = getSiteConfig(siteName);
328
+ const sec = resolveSecurityConfig(site);
329
+ assertDeleteAllowed(sec, "node", type, id);
307
330
  if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
308
331
  const backend = await resolveBackend(site);
309
332
  await backend.deleteEntity({ entityType: "node", bundle: type, id });
@@ -379,7 +402,7 @@ export const definitions = [
379
402
  },
380
403
  {
381
404
  name: "drupal_update_node",
382
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.",
405
+ 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`.",
383
406
  inputSchema: {
384
407
  type: "object", required: ["type", "id"],
385
408
  properties: {
@@ -390,7 +413,7 @@ export const definitions = [
390
413
  body: { type: "string" },
391
414
  summary: { type: "string" },
392
415
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
393
- moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
416
+ 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'." },
394
417
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
395
418
  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 }, …] }." },
396
419
  dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
@@ -8,7 +8,10 @@
8
8
 
9
9
  import { getSiteConfig } from "../lib/config.js";
10
10
  import { resolveBackend } from "../lib/backends/index.js";
11
- import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
11
+ import {
12
+ resolveSecurityConfig, redactCanonicalEntity,
13
+ assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
14
+ } from "../lib/security.js";
12
15
 
13
16
  // ---------------------------------------------------------------------------
14
17
  // Implementations
@@ -22,6 +25,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
22
25
  */
23
26
  async function listVocabularies({ site: siteName }) {
24
27
  const site = getSiteConfig(siteName);
28
+ assertReadAllowed(resolveSecurityConfig(site), "taxonomy_term");
25
29
  const backend = await resolveBackend(site);
26
30
  return backend.listBundles("taxonomy_term");
27
31
  }
@@ -35,6 +39,7 @@ async function listVocabularies({ site: siteName }) {
35
39
  async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset = 0 }) {
36
40
  const site = getSiteConfig(siteName);
37
41
  const sec = resolveSecurityConfig(site);
42
+ assertReadAllowed(sec, "taxonomy_term", vocabulary);
38
43
  const backend = await resolveBackend(site);
39
44
  const res = await backend.listEntities({
40
45
  entityType: "taxonomy_term", bundle: vocabulary,
@@ -56,6 +61,7 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
56
61
  async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
57
62
  const site = getSiteConfig(siteName);
58
63
  const sec = resolveSecurityConfig(site);
64
+ assertReadAllowed(sec, "taxonomy_term", vocabulary);
59
65
  const backend = await resolveBackend(site);
60
66
  const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
61
67
  return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
@@ -70,6 +76,7 @@ async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
70
76
  */
71
77
  async function createTaxonomyTerm({ site: siteName, vocabulary, name, description, weight = 0, parentId }) {
72
78
  const site = getSiteConfig(siteName);
79
+ assertWriteAllowed(resolveSecurityConfig(site), "create", "taxonomy_term", vocabulary);
73
80
  const backend = await resolveBackend(site);
74
81
  const attributes = { name, weight };
75
82
  if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
@@ -88,6 +95,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
88
95
  */
89
96
  async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
90
97
  const site = getSiteConfig(siteName);
98
+ assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
91
99
  const backend = await resolveBackend(site);
92
100
  const attributes = {};
93
101
  if (name !== undefined) attributes.name = name;
@@ -105,6 +113,7 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
105
113
  */
106
114
  async function deleteTaxonomyTerm({ site: siteName, vocabulary, id }) {
107
115
  const site = getSiteConfig(siteName);
116
+ assertDeleteAllowed(resolveSecurityConfig(site), "taxonomy_term", vocabulary, id);
108
117
  const backend = await resolveBackend(site);
109
118
  await backend.deleteEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
110
119
  return { success: true, deletedId: id };