drupal-mcp-connector 1.8.1 → 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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -66,7 +68,13 @@ export function validateUuid(value, fieldName = "id") {
66
68
  // SQL query validation (read-only enforcement for Drush bridge)
67
69
  // ---------------------------------------------------------------------------
68
70
 
69
- const SAFE_SQL_PREFIXES = ["select ", "show ", "describe ", "explain ", "desc "];
71
+ // SELECT only. SHOW / DESCRIBE / EXPLAIN used to be accepted here, but raw SQL
72
+ // now runs through mcp_sentinel's governed command, which accepts SELECT alone
73
+ // — schema introspection has a governed home on the /drupal-mcp/context
74
+ // endpoint and the entity-schema tools. Keeping the wider list here would mean
75
+ // this check passed statements the server then refused, i.e. two policies
76
+ // disagreeing, which is the failure mode this whole change exists to remove.
77
+ const SAFE_SQL_PREFIXES = ["select "];
70
78
 
71
79
  // Patterns that indicate write operations even within SELECT contexts
72
80
  const DANGEROUS_SQL_PATTERNS = [
@@ -80,7 +88,14 @@ const DANGEROUS_SQL_PATTERNS = [
80
88
 
81
89
  /**
82
90
  * Validate that a SQL query is read-only.
83
- * Checks both the query prefix AND secondary injection patterns.
91
+ *
92
+ * This is a fast local reject, NOT the authority. The authority is
93
+ * mcp_sentinel's McpRawSqlGuard, which runs inside Drupal where the policy
94
+ * profile lives and can resolve denied entity types and redacted fields down
95
+ * to real tables and columns — something no client-side check can do. This
96
+ * check exists only to fail obvious cases without an SSH round trip, so it is
97
+ * deliberately kept coarser than, and never wider than, the server's.
98
+ *
84
99
  * @param {string} query The SQL query to validate.
85
100
  * @returns {string} The validated query.
86
101
  * @throws {Error} if the query is empty or exceeds the length cap.
@@ -95,8 +110,9 @@ export function validateSqlQuery(query) {
95
110
 
96
111
  if (!SAFE_SQL_PREFIXES.some((prefix) => normalised.startsWith(prefix))) {
97
112
  throw new SecurityError(
98
- "drupal_drush_sql_query only permits SELECT, SHOW, DESCRIBE, and EXPLAIN statements. " +
99
- "Use the JSON:API tools for write operations."
113
+ "drupal_drush_sql_query only permits SELECT statements. " +
114
+ "Use the JSON:API tools for write operations, and the site-context or " +
115
+ "entity-schema tools for schema introspection."
100
116
  );
101
117
  }
102
118
 
@@ -200,6 +216,120 @@ export function clampLimit(value, defaultVal = 20) {
200
216
  return Math.min(n, MAX_PAGE_LIMIT);
201
217
  }
202
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
+
203
333
  // ---------------------------------------------------------------------------
204
334
  // Field name sanitization (prevent crafted field names in JSON:API filters)
205
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: {
@@ -7,7 +7,11 @@
7
7
  * 1. SSH key auth only — password-based SSH is deliberately unsupported.
8
8
  * 2. All command arguments are validated before being passed to SSH.
9
9
  * 3. Module names are validated as machine names (a-z, 0-9, _) only.
10
- * 4. SQL tool enforces SELECT-only allowlist — no DDL/DML permitted.
10
+ * 4. Raw SQL runs ONLY through mcp_sentinel's governed command, and only for
11
+ * a site that opts in with drushSsh.rawSql="governed". The SELECT-only
12
+ * check here is a fast local reject; the site's policy profile is the
13
+ * authority, because this process holds the SSH key and cannot police
14
+ * itself.
11
15
  * 5. Key path is validated against path traversal.
12
16
  * 6. All operations are logged to stderr with site name and command.
13
17
  * 7. Write operations assert non-readOnly via the security layer.
@@ -58,6 +62,32 @@ function assertCommandAllowed(sshCfg, subcommand) {
58
62
  }
59
63
  }
60
64
 
65
+ /**
66
+ * Enforce the per-site opt-in for raw SQL.
67
+ *
68
+ * Raw SQL is off unless a site sets `drushSsh.rawSql: "governed"`. There is no
69
+ * ungoverned mode: the previous behaviour — running the statement through
70
+ * `drush sql:query` — bypassed the site's entire policy and left no audit
71
+ * record, so it was removed rather than kept behind a flag. A flag would have
72
+ * meant the bypass was one config key away and still invisible when used.
73
+ *
74
+ * @param {object} sshCfg Resolved drushSsh config block.
75
+ * @param {string} siteName Site name, for the error message.
76
+ * @returns {void}
77
+ * @throws {SecurityError} if the site has not opted in.
78
+ */
79
+ function assertGovernedRawSql(sshCfg, siteName) {
80
+ if (sshCfg.rawSql !== "governed") {
81
+ throw new SecurityError(
82
+ `Raw SQL is disabled for site "${siteName}". Raw SQL reads underneath Drupal's ` +
83
+ "entity API, so it is only available through mcp_sentinel's governed command. " +
84
+ "To enable it: install mcp_sentinel on the site, set `allow_raw_sql` on the policy " +
85
+ "profile that governs the agent, and set `rawSql: \"governed\"` in this site's " +
86
+ "drushSsh config (plus `mcp-sentinel:sql-query` in allowedCommands if that list is set)."
87
+ );
88
+ }
89
+ }
90
+
61
91
  /**
62
92
  * Resolve and validate the SSH key path. Prevents path traversal.
63
93
  */
@@ -400,18 +430,45 @@ async function drushCreateUser({ site: siteName, name, mail, password, roles = [
400
430
  }
401
431
 
402
432
  /**
403
- * Run a read-only SQL query (`drush sql:query`). The query is validated against
404
- * a SELECT-only allowlist before execution.
433
+ * Run a read-only SQL query through mcp_sentinel's governed command.
434
+ *
435
+ * This used to call `drush sql:query`, which was a hole straight through the
436
+ * site's policy: `sql:query` runs below Drupal's entity API, so no policy
437
+ * profile, no denied_entity_types, no redacted_fields and no audit entry ever
438
+ * applied to it. That is not a fixable property of the command — Drush caps
439
+ * its bootstrap below the level at which Drupal discovers module command
440
+ * files, so no module code can run on its path at all.
441
+ *
442
+ * `mcp-sentinel:sql-query` is a module-provided command, so Drupal is fully
443
+ * bootstrapped: the policy profile applies, the statement is checked against
444
+ * the same deny and redaction lists that govern JSON:API, and every attempt —
445
+ * permitted or refused — is written to the tamper-evident audit chain with its
446
+ * statement text. Enforcement is therefore server-side, which matters because
447
+ * this process holds the SSH key and cannot be trusted to police itself.
448
+ *
405
449
  * @param {object} args - { site?, query }.
406
- * @returns {Promise<{rows: *}>}
407
- * @throws {SecurityError} If the query is not read-only.
450
+ * @returns {Promise<object>} The server's payload: { rows, row_count, truncated, profile }.
451
+ * @throws {SecurityError} If the site has not opted in, or the query is not read-only.
408
452
  */
409
453
  async function sqlQuery({ site: siteName, query }) {
410
- const site = getSiteConfig(siteName);
411
- // Throws SecurityError if query is not read-only
454
+ const site = getSiteConfig(siteName);
455
+ const sshCfg = getDrushConfig(site);
456
+ assertGovernedRawSql(sshCfg, site._name);
457
+ // Fast local reject only; mcp_sentinel's guard is the authority.
412
458
  validateSqlQuery(query);
413
- const out = await sshDrush(site, ["sql:query", query]);
414
- return { rows: parseDrush(out) };
459
+ const out = await sshDrush(site, ["mcp-sentinel:sql-query", query]);
460
+ // The command emits a JSON object; a non-JSON reply means the command was
461
+ // not found (mcp_sentinel absent or too old), which must not be reported as
462
+ // an empty result set.
463
+ const payload = parseDrush(out);
464
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || !Array.isArray(payload.rows)) {
465
+ throw new Error(
466
+ `Raw SQL on site "${site._name}" did not return a governed result. ` +
467
+ "Confirm mcp_sentinel >= 1.14 is installed and enabled on the target site — " +
468
+ "`drush mcp-sentinel:sql-query` must exist there."
469
+ );
470
+ }
471
+ return payload;
415
472
  }
416
473
 
417
474
  /**
@@ -477,7 +534,7 @@ export const definitions = [
477
534
  },
478
535
  {
479
536
  name: "drupal_drush_sql_query",
480
- description: "Run a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer.",
537
+ description: "Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql=\"governed\" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection.",
481
538
  inputSchema: { type: "object", required: ["query"], properties: { site: { type: "string" }, query: { type: "string" } } },
482
539
  },
483
540
  {
@@ -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