drupal-mcp-connector 2.15.0 → 2.15.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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
2
+ description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> <id> [site] [langcode] [revisionId]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_get_paragraph`.
7
7
 
8
- Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
8
+ Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -16,6 +16,6 @@ Parse the arguments supplied with this command into this tool's parameters:
16
16
  **Optional:**
17
17
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
18
18
  - `langcode` (string): Target language for the unpublished working translation (e.g. 'es').
19
- - `revisionId` (string): Paragraph revision id the host already pins.
19
+ - `revisionId` (string): Paragraph revision id the host already pins (ERR meta.target_revision_id). Honored on the read; a different served vid is an error.
20
20
 
21
21
  If a required parameter is missing, 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,11 +1,11 @@
1
1
  ---
2
- description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors."
2
+ description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set."
3
3
  argument-hint: "<type> [site] [status] [limit] [offset] [filters] [sort]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_list_nodes`.
7
7
 
8
- List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.
8
+ List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.15.1] - 2026-09-10
11
+
12
+ ### Fixed
13
+ - **`drupal_get_paragraph` honors `revisionId` and refuses a default-revision fallback (#292).**
14
+ Passing the host pin without `langcode` now reads JSON:API
15
+ `?resourceVersion=id:<vid>`. If Drupal serves a different
16
+ `drupal_internal__revision_id` (or nothing), the tool errors instead of
17
+ returning the live default revision. `ref.meta.target_revision_id` is the
18
+ served vid. `langcode` still uses Sentinel's draft read.
19
+ - **`drupal_list_nodes` no longer reports a Drupal-capped page as the exact collection total (#291).**
20
+ Core JSON:API silently caps `page[limit]` at 50 and does not send `meta.count`.
21
+ The connector was treating that page length as `total` with `approximate: false`,
22
+ so a `limit: 100` call on a 54-item collection returned 50 and looked complete.
23
+ `listEntities` now follows `links.next` to fill a larger requested limit (up to
24
+ 1000) and only reports an exact `total` when `meta.count` is present or the
25
+ window reached the end. Otherwise `approximate` is true and `hasNext` is
26
+ returned on the tool result.
27
+
10
28
  ## [2.15.0] - 2026-09-10
11
29
 
12
30
  ### Fixed
@@ -1553,6 +1571,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1553
1571
  - User tools gained explicit PII-access assertions.
1554
1572
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1555
1573
 
1574
+ [2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
1575
+ [2.15.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.0
1556
1576
  [2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
1557
1577
  [2.14.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.1
1558
1578
  [2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # drupal-mcp-connector
2
2
 
3
- > A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed content tools, audit reports, and an SSH Drush bridge.
3
+ > A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.15.0",
4
- "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
3
+ "version": "2.15.1",
4
+ "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -24,14 +24,30 @@ import { isPositiveNid, normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../path-a
24
24
  // governed read/write workflows explicitly need.
25
25
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
26
26
 
27
- // countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
28
- // so an exact count is obtained by walking pages until `links.next` is gone.
29
- // COUNT_PAGE_SIZE is Drupal's default max page size; COUNT_MAX_RECORDS bounds
30
- // the walk so a huge collection can't issue unbounded requests (mirrors the
31
- // GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
27
+ // countEntities() / listEntities() pagination. Drupal core JSON:API returns no
28
+ // total in `meta` and silently caps `page[limit]` at OffsetPage::SIZE_MAX
29
+ // (50 by default). COUNT_PAGE_SIZE matches that default; COUNT_MAX_RECORDS
30
+ // bounds a walk so a huge collection can't issue unbounded requests (mirrors
31
+ // the GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
32
32
  const COUNT_PAGE_SIZE = 50;
33
33
  const COUNT_MAX_RECORDS = 1000;
34
34
 
35
+ /**
36
+ * Whether a JSON:API collection document advertises another page.
37
+ * `links.next` may be a string href or a `{ href }` link object.
38
+ * @param {?object} data JSON:API document.
39
+ * @returns {boolean}
40
+ */
41
+ function jsonApiHasNext(data) {
42
+ const next = data?.links?.next;
43
+ if (next === undefined || next === null || next === false) return false;
44
+ if (typeof next === "string") return next.length > 0;
45
+ if (typeof next === "object" && next.href !== undefined && next.href !== null) {
46
+ return String(next.href).length > 0;
47
+ }
48
+ return Boolean(next);
49
+ }
50
+
35
51
  /**
36
52
  * Detect the JSON:API error Drupal returns when a write attempts to set the
37
53
  * `status` (published) field on a content_moderation-governed entity. Such
@@ -268,24 +284,72 @@ export class JsonApiBackend extends Backend {
268
284
  }
269
285
 
270
286
  /**
271
- * List entities for a descriptor. Server-side filter/sort/paging means the
272
- * result is always exact (`approximate`/`truncated` are false).
287
+ * List entities for a descriptor.
288
+ *
289
+ * Drupal core JSON:API has no `meta.count` (jsonapi_extras can add it) and
290
+ * silently caps `page[limit]` at OffsetPage::SIZE_MAX (50 by default). When
291
+ * the caller asks for more rows than one Drupal page returns and
292
+ * `links.next` is present, this method follows that link until the requested
293
+ * window is filled, the collection ends, or COUNT_MAX_RECORDS is hit.
294
+ * `page.total` is exact when `meta.count` is present or this window reached
295
+ * the end; otherwise it is the number of rows seen so far and `approximate`
296
+ * is true. Never report a single page's length as an exact collection total.
297
+ *
273
298
  * @param {import("../canonical.js").QueryDescriptor} descriptor
274
299
  * @returns {Promise<import("./backend-interface.js").ListResult>}
275
300
  */
276
301
  async listEntities(descriptor) {
277
- const params = this.compileQuery(descriptor);
278
- const qs = params.toString();
279
- const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
280
- const path = qs ? `${base}?${qs}` : base;
281
- const data = await drupalFetch(this.site, path);
282
- const entities = (data.data || []).map((r) => this.toCanonical(r));
283
- const total = data.meta?.count ?? entities.length;
302
+ const requestedLimit = descriptor.page?.limit;
303
+ const startOffset = descriptor.page?.offset ?? 0;
304
+ const fillTo = typeof requestedLimit === "number"
305
+ ? Math.min(Math.max(0, requestedLimit), COUNT_MAX_RECORDS)
306
+ : null;
307
+
308
+ const entities = [];
309
+ let offset = startOffset;
310
+ let hasNext = false;
311
+ let metaCount = null;
312
+
313
+ for (;;) {
314
+ const remaining = fillTo === null ? requestedLimit : fillTo - entities.length;
315
+ const page = {
316
+ ...descriptor.page,
317
+ offset,
318
+ ...(typeof remaining === "number" ? { limit: remaining } : {}),
319
+ };
320
+ const params = this.compileQuery({ ...descriptor, page });
321
+ const qs = params.toString();
322
+ const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
323
+ const path = qs ? `${base}?${qs}` : base;
324
+ const data = await drupalFetch(this.site, path);
325
+ if (metaCount === null && typeof data?.meta?.count === "number") {
326
+ metaCount = data.meta.count;
327
+ }
328
+ const pageEntities = (data.data || []).map((r) => this.toCanonical(r));
329
+ entities.push(...pageEntities);
330
+ hasNext = jsonApiHasNext(data);
331
+
332
+ if (fillTo === null) break;
333
+ if (!hasNext || pageEntities.length === 0) break;
334
+ if (entities.length >= fillTo) break;
335
+ offset += pageEntities.length;
336
+ }
337
+
338
+ const truncated = fillTo !== null
339
+ && typeof requestedLimit === "number"
340
+ && entities.length < requestedLimit
341
+ && hasNext;
342
+ const seen = startOffset + entities.length;
343
+ const exact = typeof metaCount === "number" || !hasNext;
284
344
  return {
285
345
  entities,
286
- page: { total, hasNext: Boolean(data.links?.next), cursor: null },
287
- approximate: false,
288
- truncated: false,
346
+ page: {
347
+ total: typeof metaCount === "number" ? metaCount : seen,
348
+ hasNext,
349
+ cursor: null,
350
+ },
351
+ approximate: !exact,
352
+ truncated,
289
353
  };
290
354
  }
291
355
 
@@ -280,8 +280,9 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
280
280
  *
281
281
  * @param {object} args - { site?, type, status?, filters?, limit?, offset?, sort? }.
282
282
  * A `status` boolean is appended to `filters` as a status equality descriptor.
283
- * @returns {Promise<{total: number, approximate: boolean, offset: number,
284
- * nextOffset: number, nodes: object[]}>} Paged, redacted node list.
283
+ * @returns {Promise<{total: number, approximate: boolean, truncated: boolean,
284
+ * offset: number, nextOffset: number, hasNext: boolean, nodes: object[]}>}
285
+ * Paged, redacted node list. `hasNext` is the backend's `links.next` signal.
285
286
  */
286
287
  async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
287
288
  const site = getSiteConfig(siteName);
@@ -295,8 +296,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
295
296
  return {
296
297
  total: res.page?.total ?? nodes.length,
297
298
  approximate: res.approximate ?? false,
299
+ truncated: res.truncated ?? false,
298
300
  offset,
299
301
  nextOffset: offset + nodes.length,
302
+ hasNext: Boolean(res.page?.hasNext),
300
303
  nodes,
301
304
  };
302
305
  }
@@ -528,7 +531,7 @@ export const definitions = [
528
531
  },
529
532
  {
530
533
  name: "drupal_list_nodes",
531
- description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.",
534
+ description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set.",
532
535
  inputSchema: {
533
536
  type: "object", required: ["type"],
534
537
  properties: {
@@ -145,24 +145,85 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
145
145
  return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
146
146
  }
147
147
 
148
+ /**
149
+ * JSON:API `resourceVersion` selector for a paragraph host pin.
150
+ * Accepts a numeric vid, a numeric string, or an explicit `id:<vid>`.
151
+ * @param {string|number} revisionId Host pin / paragraph revision id.
152
+ * @returns {string} e.g. `id:6654`.
153
+ * @throws {Error} If the value is not a usable revision id.
154
+ */
155
+ export function paragraphResourceVersion(revisionId) {
156
+ if (typeof revisionId === "number" && Number.isFinite(revisionId) && revisionId > 0) {
157
+ return `id:${revisionId}`;
158
+ }
159
+ if (typeof revisionId === "string" && revisionId.length) {
160
+ if (/^id:[1-9]\d*$/.test(revisionId)) return revisionId;
161
+ if (/^[1-9]\d*$/.test(revisionId)) return `id:${revisionId}`;
162
+ }
163
+ throw new Error(
164
+ `Invalid paragraph revisionId "${revisionId}". Use a numeric vid (the host pin).`,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Numeric vid from a host pin (`6654` or `id:6654`).
170
+ * @param {string|number} revisionId
171
+ * @returns {?number}
172
+ */
173
+ function requestedParagraphVid(revisionId) {
174
+ const raw = String(revisionId).replace(/^id:/, "");
175
+ const n = Number(raw);
176
+ return Number.isFinite(n) && n > 0 ? n : null;
177
+ }
178
+
179
+ /**
180
+ * Fail closed when a requested paragraph revision was not the one served.
181
+ * Silent substitution of the default revision is the defect this guards (#292).
182
+ * @param {?object} entity Canonical paragraph, or null if the read missed.
183
+ * @param {string|number} requested Host pin that was asked for.
184
+ * @param {string} id Paragraph UUID.
185
+ * @returns {object} `entity` when the served vid matches.
186
+ * @throws {Error} On a miss or a mismatched vid.
187
+ */
188
+ function requireServedParagraphRevision(entity, requested, id) {
189
+ const wanted = requestedParagraphVid(requested);
190
+ const served = paragraphRevisionId(entity);
191
+ if (!entity || wanted === null || served === null || served !== wanted) {
192
+ const got = !entity || served === null ? "no revision" : String(served);
193
+ throw new Error(
194
+ `Requested paragraph revision ${wanted ?? requested} for ${id} but the backend served ${got}. ` +
195
+ "Refusing to substitute a different revision.",
196
+ );
197
+ }
198
+ return entity;
199
+ }
200
+
148
201
  /**
149
202
  * Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
150
203
  * annotate it with the embedding ref (including `meta.target_revision_id`).
151
204
  *
152
- * @param {object} args - { site?, paragraphType, id }.
205
+ * When `revisionId` is set (the host's `meta.target_revision_id`), the read
206
+ * addresses that revision via JSON:API `?resourceVersion=id:<vid>` and errors
207
+ * if Drupal serves a different vid. Omitting `revisionId` still reads the
208
+ * default revision.
209
+ *
210
+ * @param {object} args - { site?, paragraphType, id, langcode?, revisionId? }.
153
211
  * @returns {Promise<(object & {ref: object})|null>}
154
- * The redacted paragraph with an embedding `ref`, or null if not found.
212
+ * The redacted paragraph with an embedding `ref`, or null if not found and
213
+ * no `revisionId` was requested.
155
214
  * @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
215
+ * @throws {Error} If a requested `revisionId` cannot be served.
156
216
  */
157
217
  async function getParagraph({ site: siteName, paragraphType, id, langcode, revisionId }) {
158
218
  const site = getSiteConfig(siteName);
159
219
  const sec = resolveSecurityConfig(site);
160
220
  assertReadAllowed(sec, "paragraph", paragraphType);
161
221
  const backend = await resolveBackend(site);
222
+ const pinRequested = revisionId !== undefined && revisionId !== null && revisionId !== "";
162
223
  let entity;
163
224
  if (langcode) {
164
225
  const targetLang = assertDraftLangcode(langcode);
165
- const pinned = revisionId ?? paragraphRevisionId(
226
+ const pinned = pinRequested ? revisionId : paragraphRevisionId(
166
227
  await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
167
228
  );
168
229
  if (pinned === null || pinned === undefined || pinned === "") {
@@ -172,12 +233,18 @@ async function getParagraph({ site: siteName, paragraphType, id, langcode, revis
172
233
  entityType: "paragraph", bundle: paragraphType, id, langcode: targetLang,
173
234
  draftRevision: { revisionId: pinned },
174
235
  });
236
+ } else if (pinRequested) {
237
+ const resourceVersion = paragraphResourceVersion(revisionId);
238
+ entity = await backend.getEntity({
239
+ entityType: "paragraph", bundle: paragraphType, id, resourceVersion,
240
+ });
241
+ requireServedParagraphRevision(entity, revisionId, id);
175
242
  } else {
176
243
  entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
177
244
  }
178
245
  if (!entity) return null;
179
246
  const redacted = redactCanonicalEntity(entity, sec, "paragraph");
180
- const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted) ?? revisionId;
247
+ const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
181
248
  return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, resolvedRevisionId) };
182
249
  }
183
250
 
@@ -219,7 +286,7 @@ export const definitions = [
219
286
  {
220
287
  name: "drupal_get_paragraph",
221
288
  description:
222
- "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
289
+ "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
223
290
  inputSchema: {
224
291
  type: "object", required: ["paragraphType", "id"],
225
292
  properties: {
@@ -227,7 +294,7 @@ export const definitions = [
227
294
  paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
228
295
  id: { type: "string", description: "Paragraph UUID" },
229
296
  langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
230
- revisionId: { type: "string", description: "Paragraph revision id the host already pins." },
297
+ revisionId: { type: "string", description: "Paragraph revision id the host already pins (ERR meta.target_revision_id). Honored on the read; a different served vid is an error." },
231
298
  },
232
299
  },
233
300
  },