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.
- package/.claude/commands/drupal-bulk-update.md +2 -2
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-drush-sql-query.md +2 -2
- package/.claude/commands/drupal-entity-update.md +2 -2
- package/.claude/commands/drupal-update-node.md +3 -3
- package/.claude/commands/drupal-upload-file-and-create-media.md +4 -4
- package/.claude/commands/drupal-upload-file.md +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +31 -0
- package/config/config.example.json +1 -1
- package/package.json +1 -1
- package/scripts/generate-commands.js +4 -0
- package/src/index.js +27 -9
- package/src/lib/drupal-fetch.js +16 -6
- package/src/lib/link-checker.js +3 -1
- package/src/lib/moderation-default.js +106 -0
- package/src/lib/security.js +23 -13
- package/src/lib/validate.js +134 -4
- package/src/tools/bulk.js +13 -3
- package/src/tools/drush.js +67 -10
- package/src/tools/entities.js +12 -4
- package/src/tools/media.js +43 -13
- package/src/tools/moderation.js +7 -2
- package/src/tools/nodes.js +32 -9
- package/src/tools/redirects.js +9 -5
- package/src/tools/taxonomy.js +10 -1
package/src/lib/validate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
package/src/tools/drush.js
CHANGED
|
@@ -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
|
|
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
|
|
404
|
-
*
|
|
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
|
|
411
|
-
|
|
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
|
|
414
|
-
|
|
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
|
|
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
|
{
|
package/src/tools/entities.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
package/src/tools/media.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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: "
|
|
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: "
|
|
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
|
},
|
package/src/tools/moderation.js
CHANGED
|
@@ -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 {
|
|
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
|
|
46
|
+
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
42
47
|
return redactCanonicalEntity(entity, sec, "node");
|
|
43
48
|
}
|
|
44
49
|
|