drupal-mcp-connector 2.6.1 → 2.7.1
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-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-list-sites.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +80 -0
- package/README.md +2 -2
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +91 -21
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/config.js +23 -0
- package/src/lib/dispatch.js +36 -10
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/patch-preflight.js +176 -0
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/config.js +6 -0
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/revisions.js +42 -7
- package/src/tools/site.js +19 -5
package/src/lib/dispatch.js
CHANGED
|
@@ -16,6 +16,7 @@ import { toolError, toolResult } from "./errors.js";
|
|
|
16
16
|
import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
18
|
import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
|
|
19
|
+
import { assertPrincipalEntitlement, getRequestIdentity } from "./principal.js";
|
|
19
20
|
import { allHandlers } from "../tools/index.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -59,17 +60,41 @@ function extractEntityType(toolName, args) {
|
|
|
59
60
|
* @param {string} toolName - The MCP tool name.
|
|
60
61
|
* @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
|
|
61
62
|
* @param {Function} handler - The resolved tool handler.
|
|
63
|
+
* @param {object} [context] Optional inbound identity / grant overrides (tests).
|
|
62
64
|
* @returns {Promise<*>} The handler's result.
|
|
63
65
|
* @throws {GovernanceError} If the site requires source governance and the
|
|
64
66
|
* contract is not verified — checked FIRST, so no assertion below can be
|
|
65
67
|
* read as an ungoverned fallback verdict.
|
|
66
68
|
* @throws {SecurityError} If the resolved policy forbids the inferred operation.
|
|
67
69
|
*/
|
|
68
|
-
export async function securityMiddleware(toolName, args, handler) {
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
export async function securityMiddleware(toolName, args, handler, context = {}) {
|
|
71
|
+
const rawArgs = args ?? {};
|
|
72
|
+
const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
|
|
73
|
+
let nextArgs = rawArgs;
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
if (identity) {
|
|
76
|
+
const resolved = assertPrincipalEntitlement({
|
|
77
|
+
toolName,
|
|
78
|
+
args: rawArgs,
|
|
79
|
+
identity,
|
|
80
|
+
sites: context.sites ?? listResolvableSiteConfigs(),
|
|
81
|
+
grants: context.grants,
|
|
82
|
+
defaultSite: context.defaultSite,
|
|
83
|
+
});
|
|
84
|
+
if (resolved) {
|
|
85
|
+
nextArgs = { ...rawArgs, site: resolved.name };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Tools with no site context skip per-site checks. governance_status
|
|
90
|
+
// without a hint reports every granted/configured site and must not
|
|
91
|
+
// resolve (or fail on) the configured default first.
|
|
92
|
+
if (toolName === "drupal_list_sites") return handler(nextArgs);
|
|
93
|
+
if (toolName === "drupal_governance_status" && !nextArgs.site) {
|
|
94
|
+
return handler(nextArgs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const site = getSiteConfig(nextArgs.site);
|
|
73
98
|
|
|
74
99
|
// Source-governance gate (#176). The diagnostic tools stay callable while
|
|
75
100
|
// governance fails — they are how an operator learns which condition failed.
|
|
@@ -81,15 +106,15 @@ export async function securityMiddleware(toolName, args, handler) {
|
|
|
81
106
|
const op = inferOperation(toolName);
|
|
82
107
|
|
|
83
108
|
if (op === "delete") {
|
|
84
|
-
assertDestructiveAllowed(sec, extractEntityType(toolName,
|
|
109
|
+
assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
|
|
85
110
|
assertNotReadOnly(sec, toolName);
|
|
86
111
|
} else if (op === "write") {
|
|
87
112
|
assertNotReadOnly(sec, toolName);
|
|
88
|
-
} else if (op === "graphql" &&
|
|
89
|
-
assertGraphqlMutationAllowed(sec,
|
|
113
|
+
} else if (op === "graphql" && nextArgs?.query) {
|
|
114
|
+
assertGraphqlMutationAllowed(sec, nextArgs.query);
|
|
90
115
|
}
|
|
91
116
|
|
|
92
|
-
return handler(
|
|
117
|
+
return handler(nextArgs);
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
/**
|
|
@@ -98,9 +123,10 @@ export async function securityMiddleware(toolName, args, handler) {
|
|
|
98
123
|
*
|
|
99
124
|
* @param {string} name - The MCP tool name.
|
|
100
125
|
* @param {object} args - The tool arguments.
|
|
126
|
+
* @param {object} [context] Optional inbound identity / grant overrides.
|
|
101
127
|
* @returns {Promise<object>} An MCP tool result payload.
|
|
102
128
|
*/
|
|
103
|
-
export async function callTool(name, args) {
|
|
129
|
+
export async function callTool(name, args, context = {}) {
|
|
104
130
|
// eslint-disable-next-line security/detect-object-injection -- name is an MCP tool name from validated schema; allHandlers is a closed dispatch table built at startup
|
|
105
131
|
const handler = allHandlers[name];
|
|
106
132
|
|
|
@@ -111,7 +137,7 @@ export async function callTool(name, args) {
|
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
try {
|
|
114
|
-
const result = await securityMiddleware(name, args ?? {}, handler);
|
|
140
|
+
const result = await securityMiddleware(name, args ?? {}, handler, context);
|
|
115
141
|
return toolResult(result);
|
|
116
142
|
} catch (err) {
|
|
117
143
|
// Translate known error classes into clear, non-leaky isError responses;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve Entity Reference Revisions identifiers before a host write (#192).
|
|
3
|
+
*
|
|
4
|
+
* Drupal's ERR item is empty unless both `target_id` and `target_revision_id`
|
|
5
|
+
* are set. JSON:API only receives the revision id when the resource identifier
|
|
6
|
+
* carries `meta.target_revision_id`. Sending `{ type, id }` persists an empty
|
|
7
|
+
* field — not a no-op — so a draft forked from a published node with N refs
|
|
8
|
+
* lands with 0.
|
|
9
|
+
*
|
|
10
|
+
* Ordinary entity-reference fields (taxonomy, media, nodes, users) must not
|
|
11
|
+
* require a revision id. Heuristic: resource types starting with `paragraph--`
|
|
12
|
+
* are ERR targets and must resolve; anything else is left unchanged. An empty
|
|
13
|
+
* `data` array is an explicit clear and is sent as-is.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when one or more paragraph identifiers cannot be given a
|
|
18
|
+
* `target_revision_id`. The host write must not proceed.
|
|
19
|
+
*/
|
|
20
|
+
export class ErrRelationshipError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} message Human-readable reason.
|
|
23
|
+
* @param {{unresolved?: Array<{id: ?string, reason: string}>}} [details]
|
|
24
|
+
*/
|
|
25
|
+
constructor(message, details = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "ErrRelationshipError";
|
|
28
|
+
this.details = details;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a JSON:API resource type is a paragraph bundle.
|
|
34
|
+
* @param {*} type Resource type string, e.g. "paragraph--capability".
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isParagraphResourceType(type) {
|
|
38
|
+
return typeof type === "string" && type.startsWith("paragraph--");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split a JSON:API resource type into entity type + bundle.
|
|
43
|
+
* @param {string} type e.g. "paragraph--capability".
|
|
44
|
+
* @returns {?{entityType: string, bundle: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function parseResourceType(type) {
|
|
47
|
+
if (typeof type !== "string" || !type.includes("--")) return null;
|
|
48
|
+
const [entityType, ...rest] = type.split("--");
|
|
49
|
+
return { entityType, bundle: rest.join("--") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Prefer the vid on a just-created/updated paragraph (before a follow-up GET).
|
|
54
|
+
* Fall back to an un-redacted GET when the write result did not carry it.
|
|
55
|
+
* @param {object} backend Backend with `getEntity`.
|
|
56
|
+
* @param {?object} paragraph Create/update result.
|
|
57
|
+
* @param {string} bundle Paragraph type machine name.
|
|
58
|
+
* @returns {Promise<?number>}
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveParagraphRevisionId(backend, paragraph, bundle) {
|
|
61
|
+
const fromWrite = paragraphRevisionId(paragraph);
|
|
62
|
+
if (fromWrite !== null) return fromWrite;
|
|
63
|
+
if (!paragraph?.id || typeof backend?.getEntity !== "function") return null;
|
|
64
|
+
const fresh = await backend.getEntity({
|
|
65
|
+
entityType: "paragraph",
|
|
66
|
+
bundle: paragraph.bundle || bundle,
|
|
67
|
+
id: paragraph.id,
|
|
68
|
+
}).catch(() => null);
|
|
69
|
+
return paragraphRevisionId(fresh);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Error when a paragraph write succeeded but no revision id is readable.
|
|
74
|
+
* Returning `{type, id}` would persist an empty ERR field (#192).
|
|
75
|
+
* @param {string} id Paragraph UUID.
|
|
76
|
+
* @param {"Created"|"Updated"} [operation="Created"] The write that already landed.
|
|
77
|
+
* @returns {Error}
|
|
78
|
+
*/
|
|
79
|
+
export function missingParagraphRevisionError(id, operation = "Created") {
|
|
80
|
+
const verb = operation === "Updated" ? "Updated" : "Created";
|
|
81
|
+
return new Error(
|
|
82
|
+
`${verb} paragraph ${id} but could not read drupal_internal__revision_id; ` +
|
|
83
|
+
"refusing to return a relationship identifier Drupal would persist as empty (#192)."
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read `drupal_internal__revision_id` from a canonical entity (or a raw-ish
|
|
89
|
+
* object that still carries the attribute at the top level).
|
|
90
|
+
* @param {?object} entity
|
|
91
|
+
* @returns {?number}
|
|
92
|
+
*/
|
|
93
|
+
export function paragraphRevisionId(entity) {
|
|
94
|
+
if (!entity || typeof entity !== "object") return null;
|
|
95
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
96
|
+
const raw = Object.prototype.hasOwnProperty.call(fields, "drupal_internal__revision_id")
|
|
97
|
+
? fields.drupal_internal__revision_id
|
|
98
|
+
: entity.drupal_internal__revision_id;
|
|
99
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
100
|
+
const n = Number(raw);
|
|
101
|
+
return Number.isFinite(n) ? n : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the JSON:API resource identifier used to embed a paragraph in a host
|
|
106
|
+
* ERR field. Includes `meta.target_revision_id` when a vid is known.
|
|
107
|
+
* @param {string} bundle Paragraph type machine name.
|
|
108
|
+
* @param {string} id Paragraph UUID.
|
|
109
|
+
* @param {number|string|null|undefined} revisionId Current revision id.
|
|
110
|
+
* @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
|
|
111
|
+
*/
|
|
112
|
+
export function embedParagraphRef(bundle, id, revisionId) {
|
|
113
|
+
const ref = { type: `paragraph--${bundle}`, id };
|
|
114
|
+
if (revisionId === undefined || revisionId === null || revisionId === "") return ref;
|
|
115
|
+
const n = Number(revisionId);
|
|
116
|
+
if (!Number.isFinite(n)) return ref;
|
|
117
|
+
ref.meta = { target_revision_id: n };
|
|
118
|
+
return ref;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether a resource identifier already carries a usable target revision id.
|
|
123
|
+
* @param {?object} item
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
export function linkageHasRevisionMeta(item) {
|
|
127
|
+
const vid = item?.meta?.target_revision_id;
|
|
128
|
+
return vid !== undefined && vid !== null && vid !== "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether the caller supplied any relationship fields (including an explicit
|
|
133
|
+
* empty-array clear). An omitted / empty object is "not sent".
|
|
134
|
+
* @param {?object} relationships
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function relationshipsWereSent(relationships) {
|
|
138
|
+
if (!relationships || typeof relationships !== "object" || Array.isArray(relationships)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return Object.keys(relationships).length > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Clone a resource identifier, preserving `meta` when present.
|
|
146
|
+
* @param {object} item
|
|
147
|
+
* @returns {{type: *, id: *, meta?: object}}
|
|
148
|
+
*/
|
|
149
|
+
function cloneIdentifier(item) {
|
|
150
|
+
const out = { type: item.type, id: item.id };
|
|
151
|
+
if (item.meta && typeof item.meta === "object") out.meta = { ...item.meta };
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve one resource identifier. Paragraph refs without a vid are loaded
|
|
157
|
+
* un-redacted and stamped with `meta.target_revision_id`. Failures are
|
|
158
|
+
* recorded on `unresolved` rather than thrown so a mixed list is never
|
|
159
|
+
* half-applied.
|
|
160
|
+
* @param {object} backend Backend with `getEntity`.
|
|
161
|
+
* @param {object} item Resource identifier.
|
|
162
|
+
* @param {Map<string, number>} cache uuid → revision id (create-response vids).
|
|
163
|
+
* @param {Array<{id: ?string, reason: string}>} unresolved
|
|
164
|
+
* @returns {Promise<object>}
|
|
165
|
+
*/
|
|
166
|
+
async function resolveOneIdentifier(backend, item, cache, unresolved) {
|
|
167
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
168
|
+
unresolved.push({ id: null, reason: "malformed resource identifier" });
|
|
169
|
+
return item;
|
|
170
|
+
}
|
|
171
|
+
if (typeof item.type !== "string" || typeof item.id !== "string") {
|
|
172
|
+
unresolved.push({ id: item.id ?? null, reason: "resource identifier must include type and id" });
|
|
173
|
+
return item;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const out = cloneIdentifier(item);
|
|
177
|
+
|
|
178
|
+
// Ordinary entity-reference (taxonomy, media, node, user, file): leave alone.
|
|
179
|
+
if (!isParagraphResourceType(item.type)) return out;
|
|
180
|
+
|
|
181
|
+
if (linkageHasRevisionMeta(out)) {
|
|
182
|
+
const n = Number(out.meta.target_revision_id);
|
|
183
|
+
if (Number.isFinite(n)) {
|
|
184
|
+
out.meta = { ...out.meta, target_revision_id: n };
|
|
185
|
+
cache.set(item.id, n);
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cache.has(item.id)) {
|
|
191
|
+
out.meta = { ...(out.meta || {}), target_revision_id: cache.get(item.id) };
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const parsed = parseResourceType(item.type);
|
|
196
|
+
if (!parsed) {
|
|
197
|
+
unresolved.push({ id: item.id, reason: `unparseable type "${item.type}"` });
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let entity = null;
|
|
202
|
+
try {
|
|
203
|
+
entity = await backend.getEntity({
|
|
204
|
+
entityType: parsed.entityType,
|
|
205
|
+
bundle: parsed.bundle,
|
|
206
|
+
id: item.id,
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
210
|
+
unresolved.push({ id: item.id, reason: `GET failed: ${reason}` });
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
if (!entity) {
|
|
214
|
+
unresolved.push({ id: item.id, reason: "GET returned 404 / null" });
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const vid = paragraphRevisionId(entity);
|
|
219
|
+
if (vid === null) {
|
|
220
|
+
// Paragraphs are revisionable. A missing vid is a connector/backend gap,
|
|
221
|
+
// not "not revisionable" — sending {type,id} would persist empty.
|
|
222
|
+
unresolved.push({ id: item.id, reason: "paragraph has no drupal_internal__revision_id" });
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
cache.set(item.id, vid);
|
|
226
|
+
out.meta = { ...(out.meta || {}), target_revision_id: vid };
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Inject `meta.target_revision_id` on every paragraph identifier in a
|
|
232
|
+
* JSON:API relationships map. Throws before the caller PATCHes if any
|
|
233
|
+
* paragraph ref cannot be resolved. Empty arrays and `data: null` pass
|
|
234
|
+
* through (explicit clear). Non-paragraph refs are unchanged.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} backend Backend with un-redacted `getEntity`.
|
|
237
|
+
* @param {?object} relationships JSON:API relationships map.
|
|
238
|
+
* @param {{revisionCache?: Map<string, number>}} [options]
|
|
239
|
+
* @returns {Promise<?object>} A new relationships map, or the input when empty.
|
|
240
|
+
* @throws {ErrRelationshipError} If any paragraph identifier cannot be resolved.
|
|
241
|
+
*/
|
|
242
|
+
export async function resolveErrRelationships(backend, relationships, options = {}) {
|
|
243
|
+
if (relationships === null || relationships === undefined) return relationships;
|
|
244
|
+
if (typeof relationships !== "object" || Array.isArray(relationships)) return relationships;
|
|
245
|
+
|
|
246
|
+
const cache = options.revisionCache instanceof Map ? options.revisionCache : new Map();
|
|
247
|
+
const unresolved = [];
|
|
248
|
+
const entries = [];
|
|
249
|
+
|
|
250
|
+
for (const [field, rel] of Object.entries(relationships)) {
|
|
251
|
+
if (!rel || typeof rel !== "object" || !Object.prototype.hasOwnProperty.call(rel, "data")) {
|
|
252
|
+
entries.push([field, rel]);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const { data } = rel;
|
|
256
|
+
if (data === null) {
|
|
257
|
+
entries.push([field, { ...rel, data: null }]);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (Array.isArray(data)) {
|
|
261
|
+
// Empty array is an explicit clear — do not resolve, do not fail.
|
|
262
|
+
if (data.length === 0) {
|
|
263
|
+
entries.push([field, { ...rel, data: [] }]);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const items = [];
|
|
267
|
+
for (const item of data) {
|
|
268
|
+
items.push(await resolveOneIdentifier(backend, item, cache, unresolved));
|
|
269
|
+
}
|
|
270
|
+
entries.push([field, { ...rel, data: items }]);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
entries.push([field, { ...rel, data: await resolveOneIdentifier(backend, data, cache, unresolved) }]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (unresolved.length) {
|
|
277
|
+
const listed = unresolved.map((u) => `${u.id ?? "(missing id)"}: ${u.reason}`).join("; ");
|
|
278
|
+
throw new ErrRelationshipError(
|
|
279
|
+
"Cannot attach paragraph relationship: failed to resolve target_revision_id " +
|
|
280
|
+
`for ${unresolved.length} identifier(s). The write was not sent — an unresolved ` +
|
|
281
|
+
`ERR identifier would persist an empty field. ${listed}`,
|
|
282
|
+
{ unresolved }
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return Object.fromEntries(entries);
|
|
286
|
+
}
|