drupal-mcp-connector 0.9.0 → 0.10.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/CHANGELOG.md +41 -0
- package/README.md +14 -2
- package/package.json +1 -1
- package/src/index.js +16 -2
- package/src/lib/backends/jsonapi.js +13 -4
- package/src/tools/bulk.js +154 -0
- package/src/tools/entities.js +9 -3
- package/src/tools/fields.js +135 -0
- package/src/tools/moderation.js +129 -0
- package/src/tools/nodes.js +11 -5
- package/src/tools/paragraphs.js +143 -0
- package/src/tools/references.js +133 -0
- package/src/tools/reports-extra.js +293 -0
- package/src/tools/revisions.js +323 -0
- package/src/tools/scheduler.js +111 -0
- package/src/tools/search.js +60 -0
- package/src/tools/structure.js +218 -0
- package/src/tools/translations.js +176 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: content_moderation workflow.
|
|
3
|
+
*
|
|
4
|
+
* Thin, governed operations over the canonical backend for sites using Drupal's
|
|
5
|
+
* content_moderation (editorial) workflow:
|
|
6
|
+
* - set a node's moderation_state (the governed write; draft -> needs_review -> published -> archived)
|
|
7
|
+
* - list content filtered by moderation_state (e.g. "what's awaiting review")
|
|
8
|
+
* - list the moderation states observed on a bundle's content
|
|
9
|
+
*
|
|
10
|
+
* Capability note: the authoritative set of states and the *valid transitions*
|
|
11
|
+
* from a given state live in workflow config and are not exposed over JSON:API.
|
|
12
|
+
* drupal_list_moderation_states therefore degrades to the DISTINCT states
|
|
13
|
+
* observed on existing content (authoritative:false); a full transition map
|
|
14
|
+
* requires the Drush bridge.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
18
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
19
|
+
import { resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
20
|
+
|
|
21
|
+
/** Read a node's moderation_state from a canonical entity, tolerating shapes. */
|
|
22
|
+
function moderationStateOf(entity) {
|
|
23
|
+
const v = entity?.fields?.moderation_state;
|
|
24
|
+
if (Array.isArray(v)) return v[0]?.value ?? v[0] ?? null;
|
|
25
|
+
if (v && typeof v === "object") return v.value ?? null;
|
|
26
|
+
return v ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Transition a node to a moderation state (governed write).
|
|
31
|
+
* @param {object} args - { site?, type, id, state }.
|
|
32
|
+
* @returns {Promise<object>} The updated, redacted node.
|
|
33
|
+
* @throws {SecurityError} If writing node/type is not permitted.
|
|
34
|
+
*/
|
|
35
|
+
async function setModerationState({ site: siteName, type, id, state }) {
|
|
36
|
+
if (!state) throw new Error("A moderation 'state' is required (e.g. 'draft', 'published').");
|
|
37
|
+
const site = getSiteConfig(siteName);
|
|
38
|
+
const sec = resolveSecurityConfig(site);
|
|
39
|
+
assertWriteAllowed(sec, "update", "node", type);
|
|
40
|
+
const backend = await resolveBackend(site);
|
|
41
|
+
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes: { moderation_state: state } });
|
|
42
|
+
return redactCanonicalEntity(entity, sec, "node");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List nodes of a type in a given moderation state, paged + redacted.
|
|
47
|
+
* @param {object} args - { site?, type, state, limit?, offset? }.
|
|
48
|
+
*/
|
|
49
|
+
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
|
|
50
|
+
const site = getSiteConfig(siteName);
|
|
51
|
+
const sec = resolveSecurityConfig(site);
|
|
52
|
+
assertReadAllowed(sec, "node", type);
|
|
53
|
+
const backend = await resolveBackend(site);
|
|
54
|
+
const res = await backend.listEntities({
|
|
55
|
+
entityType: "node", bundle: type,
|
|
56
|
+
filters: [{ field: "moderation_state", op: "eq", value: state }],
|
|
57
|
+
sort: [{ field: "changed", dir: "desc" }],
|
|
58
|
+
page: { limit, offset },
|
|
59
|
+
});
|
|
60
|
+
const nodes = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
61
|
+
return { type, state, total: res.page?.total ?? nodes.length, approximate: res.approximate ?? false, offset, nextOffset: offset + nodes.length, nodes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* List the moderation states observed on a bundle's content (best-effort).
|
|
66
|
+
* @param {object} args - { site?, type, sample? }.
|
|
67
|
+
*/
|
|
68
|
+
async function listModerationStates({ site: siteName, type, sample = 50 }) {
|
|
69
|
+
const site = getSiteConfig(siteName);
|
|
70
|
+
const sec = resolveSecurityConfig(site);
|
|
71
|
+
assertReadAllowed(sec, "node", type);
|
|
72
|
+
const backend = await resolveBackend(site);
|
|
73
|
+
const res = await backend.listEntities({ entityType: "node", bundle: type, page: { limit: sample } });
|
|
74
|
+
const states = [...new Set(res.entities.map(moderationStateOf).filter(Boolean))].sort();
|
|
75
|
+
return {
|
|
76
|
+
type,
|
|
77
|
+
states,
|
|
78
|
+
authoritative: false,
|
|
79
|
+
note: "Derived from observed content (sampled). The authoritative state set and valid transitions live in workflow config and require the Drush bridge.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const definitions = [
|
|
84
|
+
{
|
|
85
|
+
name: "drupal_set_moderation_state",
|
|
86
|
+
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object", required: ["type", "id", "state"],
|
|
89
|
+
properties: {
|
|
90
|
+
site: { type: "string" },
|
|
91
|
+
type: { type: "string", description: "Content type machine name" },
|
|
92
|
+
id: { type: "string", description: "Node UUID" },
|
|
93
|
+
state: { type: "string", description: "Target moderation state machine name" },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "drupal_content_by_moderation_state",
|
|
99
|
+
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review').",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object", required: ["type", "state"],
|
|
102
|
+
properties: {
|
|
103
|
+
site: { type: "string" },
|
|
104
|
+
type: { type: "string", description: "Content type machine name" },
|
|
105
|
+
state: { type: "string", description: "Moderation state machine name" },
|
|
106
|
+
limit: { type: "number", default: 20 },
|
|
107
|
+
offset: { type: "number", default: 0 },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "drupal_list_moderation_states",
|
|
113
|
+
description: "List the moderation states observed on a content type's content (best-effort; authoritative transitions require the Drush bridge).",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: "object", required: ["type"],
|
|
116
|
+
properties: {
|
|
117
|
+
site: { type: "string" },
|
|
118
|
+
type: { type: "string", description: "Content type machine name" },
|
|
119
|
+
sample: { type: "number", default: 50, description: "How many recent items to sample" },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
export const handlers = {
|
|
126
|
+
drupal_set_moderation_state: setModerationState,
|
|
127
|
+
drupal_content_by_moderation_state: contentByModerationState,
|
|
128
|
+
drupal_list_moderation_states: listModerationStates,
|
|
129
|
+
};
|
package/src/tools/nodes.js
CHANGED
|
@@ -107,9 +107,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
107
107
|
* @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields? }.
|
|
108
108
|
* @returns {Promise<object>} The created node descriptor from the backend.
|
|
109
109
|
*/
|
|
110
|
-
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {} }) {
|
|
110
|
+
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
|
|
111
111
|
const site = getSiteConfig(siteName);
|
|
112
|
-
const backend = await resolveBackend(site);
|
|
113
112
|
const attributes = { title, ...fields };
|
|
114
113
|
if (moderationState !== undefined) {
|
|
115
114
|
attributes.moderation_state = moderationState;
|
|
@@ -118,6 +117,8 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
118
117
|
}
|
|
119
118
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
120
119
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
120
|
+
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes };
|
|
121
|
+
const backend = await resolveBackend(site);
|
|
121
122
|
return backend.createEntity({ entityType: "node", bundle: type, attributes });
|
|
122
123
|
}
|
|
123
124
|
|
|
@@ -132,15 +133,16 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
132
133
|
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
|
|
133
134
|
* @returns {Promise<object>} The updated node descriptor.
|
|
134
135
|
*/
|
|
135
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {} }) {
|
|
136
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
|
|
136
137
|
const site = getSiteConfig(siteName);
|
|
137
|
-
const backend = await resolveBackend(site);
|
|
138
138
|
const attributes = { ...fields };
|
|
139
139
|
if (title !== undefined) attributes.title = title;
|
|
140
140
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
141
141
|
else if (status !== undefined) attributes.status = status;
|
|
142
142
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
143
143
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
144
|
+
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
|
|
145
|
+
const backend = await resolveBackend(site);
|
|
144
146
|
return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -151,8 +153,9 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
151
153
|
* @param {object} args - { site?, type, id }.
|
|
152
154
|
* @returns {Promise<{success: boolean, deletedId: string}>}
|
|
153
155
|
*/
|
|
154
|
-
async function deleteNode({ site: siteName, type, id }) {
|
|
156
|
+
async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
155
157
|
const site = getSiteConfig(siteName);
|
|
158
|
+
if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
|
|
156
159
|
const backend = await resolveBackend(site);
|
|
157
160
|
await backend.deleteEntity({ entityType: "node", bundle: type, id });
|
|
158
161
|
return { success: true, deletedId: id };
|
|
@@ -219,6 +222,7 @@ export const definitions = [
|
|
|
219
222
|
status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
|
|
220
223
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
221
224
|
fields: { type: "object", description: "Additional field values keyed by Drupal machine name" },
|
|
225
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
|
|
222
226
|
},
|
|
223
227
|
},
|
|
224
228
|
},
|
|
@@ -237,6 +241,7 @@ export const definitions = [
|
|
|
237
241
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
238
242
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
|
|
239
243
|
fields: { type: "object" },
|
|
244
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
|
240
245
|
},
|
|
241
246
|
},
|
|
242
247
|
},
|
|
@@ -249,6 +254,7 @@ export const definitions = [
|
|
|
249
254
|
site: { type: "string" },
|
|
250
255
|
type: { type: "string" },
|
|
251
256
|
id: { type: "string", description: "Node UUID" },
|
|
257
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the delete without committing." },
|
|
252
258
|
},
|
|
253
259
|
},
|
|
254
260
|
},
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Paragraphs authoring helper.
|
|
3
|
+
*
|
|
4
|
+
* Paragraphs (the contrib Paragraphs module) are content-fragment entities of
|
|
5
|
+
* the `paragraph` entity type, one bundle per paragraph type (e.g. `text`,
|
|
6
|
+
* `image`, `cta`). They are NOT standalone content: a paragraph only appears on
|
|
7
|
+
* a site when a *host* entity (usually a node) references it from an
|
|
8
|
+
* Entity Reference Revisions (ERR) field. This module gives an authoring agent a
|
|
9
|
+
* focused way to mint a paragraph and fetch it back, plus the relationship data
|
|
10
|
+
* needed to embed it into a host field.
|
|
11
|
+
*
|
|
12
|
+
* Embedding model — IMPORTANT:
|
|
13
|
+
* - Over JSON:API (this connector's default backend) a host references a
|
|
14
|
+
* paragraph from its ERR field by a resource identifier object
|
|
15
|
+
* `{ type: "paragraph--<bundle>", id: "<paragraph-uuid>" }`. Drupal resolves
|
|
16
|
+
* the correct target_id + target_revision_id server-side from the UUID. Drop
|
|
17
|
+
* `relationshipData` (returned by drupal_create_paragraph) into the host's
|
|
18
|
+
* relationships map and call drupal_entity_update / drupal_update_node.
|
|
19
|
+
* - The classic entity-API pair `{ target_id, target_revision_id }` (integer
|
|
20
|
+
* ids) is the REST/Form-API shape, not the JSON:API shape. Those numeric ids
|
|
21
|
+
* are not surfaced by the canonical entity here; prefer the UUID relationship
|
|
22
|
+
* form above when writing through this connector.
|
|
23
|
+
*
|
|
24
|
+
* Both tools are governed: writes assert create permission for the `paragraph`
|
|
25
|
+
* entity type + bundle, reads assert read permission and are redacted per the
|
|
26
|
+
* site security policy. Writes default to whatever the backend default is for
|
|
27
|
+
* the bundle (paragraphs have no independent publish status of their own).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
31
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
32
|
+
import {
|
|
33
|
+
resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
|
|
34
|
+
} from "../lib/security.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the JSON:API resource type string for a paragraph bundle.
|
|
38
|
+
* @param {string} bundle Paragraph type machine name.
|
|
39
|
+
* @returns {string} e.g. "paragraph--text".
|
|
40
|
+
*/
|
|
41
|
+
function resourceType(bundle) {
|
|
42
|
+
return `paragraph--${bundle}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the resource-identifier ref used to embed a paragraph in a host ERR /
|
|
47
|
+
* paragraph reference field over JSON:API.
|
|
48
|
+
* @param {string} bundle Paragraph type machine name.
|
|
49
|
+
* @param {string} id Paragraph UUID.
|
|
50
|
+
* @returns {{type: string, id: string}}
|
|
51
|
+
*/
|
|
52
|
+
function embedRef(bundle, id) {
|
|
53
|
+
return { type: resourceType(bundle), id };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const EMBED_NOTE =
|
|
57
|
+
"Paragraphs are not standalone content: embed this paragraph in a host entity's " +
|
|
58
|
+
"Entity Reference Revisions (paragraph) field. Over JSON:API, add `relationshipData` " +
|
|
59
|
+
"to the host field's relationship (e.g. drupal_entity_update / drupal_update_node with " +
|
|
60
|
+
"relationships: { field_paragraphs: { data: [ relationshipData ] } }). Drupal resolves " +
|
|
61
|
+
"target_id + target_revision_id from the UUID server-side.";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a paragraph entity of the given type and return a ref suitable for
|
|
65
|
+
* embedding it in a host entity's paragraph/ERR field.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} args - { site?, paragraphType, attributes? }.
|
|
68
|
+
* `attributes` are paragraph field values keyed by Drupal machine name
|
|
69
|
+
* (e.g. { field_body: { value, format } }). Use drupal_get_entity_schema for
|
|
70
|
+
* entityType "paragraph" + the bundle to discover available fields.
|
|
71
|
+
* @returns {Promise<{paragraph: object, ref: {id: string, type: string},
|
|
72
|
+
* relationshipData: {type: string, id: string}, note: string}>}
|
|
73
|
+
* The created paragraph descriptor plus the embedding ref/relationship data.
|
|
74
|
+
* @throws {SecurityError} If creating paragraphs of this bundle is not permitted.
|
|
75
|
+
*/
|
|
76
|
+
async function createParagraph({ site: siteName, paragraphType, attributes = {} }) {
|
|
77
|
+
const site = getSiteConfig(siteName);
|
|
78
|
+
const sec = resolveSecurityConfig(site);
|
|
79
|
+
assertWriteAllowed(sec, "create", "paragraph", paragraphType);
|
|
80
|
+
const backend = await resolveBackend(site);
|
|
81
|
+
const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
|
|
82
|
+
const bundle = paragraph.bundle || paragraphType;
|
|
83
|
+
const ref = embedRef(bundle, paragraph.id);
|
|
84
|
+
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
|
|
89
|
+
* annotate it with the embedding ref.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} args - { site?, paragraphType, id }.
|
|
92
|
+
* @returns {Promise<(object & {ref: {id: string, type: string}})|null>}
|
|
93
|
+
* The redacted paragraph with an embedding `ref`, or null if not found.
|
|
94
|
+
* @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
|
|
95
|
+
*/
|
|
96
|
+
async function getParagraph({ site: siteName, paragraphType, id }) {
|
|
97
|
+
const site = getSiteConfig(siteName);
|
|
98
|
+
const sec = resolveSecurityConfig(site);
|
|
99
|
+
assertReadAllowed(sec, "paragraph", paragraphType);
|
|
100
|
+
const backend = await resolveBackend(site);
|
|
101
|
+
const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
|
|
102
|
+
if (!entity) return null;
|
|
103
|
+
const redacted = redactCanonicalEntity(entity, sec, "paragraph");
|
|
104
|
+
return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Definitions
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export const definitions = [
|
|
112
|
+
{
|
|
113
|
+
name: "drupal_create_paragraph",
|
|
114
|
+
description:
|
|
115
|
+
"Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object", required: ["paragraphType"],
|
|
118
|
+
properties: {
|
|
119
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
120
|
+
paragraphType: { type: "string", description: "Paragraph type / bundle machine name, e.g. 'text', 'image', 'cta'" },
|
|
121
|
+
attributes: { type: "object", description: "Paragraph field values keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }" },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "drupal_get_paragraph",
|
|
127
|
+
description:
|
|
128
|
+
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object", required: ["paragraphType", "id"],
|
|
131
|
+
properties: {
|
|
132
|
+
site: { type: "string" },
|
|
133
|
+
paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
|
|
134
|
+
id: { type: "string", description: "Paragraph UUID" },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export const handlers = {
|
|
141
|
+
drupal_create_paragraph: createParagraph,
|
|
142
|
+
drupal_get_paragraph: getParagraph,
|
|
143
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Reference resolution.
|
|
3
|
+
*
|
|
4
|
+
* A standalone, read-only helper that turns a human-friendly name or title into
|
|
5
|
+
* a stable Drupal UUID. Editors and agents rarely know UUIDs; they know labels
|
|
6
|
+
* ("the News term", "the About page"). This tool runs a single label filter via
|
|
7
|
+
* the backend's listEntities, ranks the results, and returns the best match plus
|
|
8
|
+
* any ambiguous candidates so the caller (or a human) can disambiguate before
|
|
9
|
+
* wiring the UUID into a create/update relationship.
|
|
10
|
+
*
|
|
11
|
+
* Scope: this NEVER writes. It only reads, and every read is gated by the site
|
|
12
|
+
* security policy (assertReadAllowed) and redacted (redactCanonicalEntity) before
|
|
13
|
+
* a label is derived — so a redacted label surfaces as "[REDACTED]" rather than
|
|
14
|
+
* leaking a protected value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
18
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
19
|
+
import { resolveSecurityConfig, assertReadAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pick the label field to filter on for a given entity type.
|
|
23
|
+
*
|
|
24
|
+
* Nodes are keyed by `title`; taxonomy terms and users (and other label-as-name
|
|
25
|
+
* entities) are keyed by `name`. Anything else defaults to `name`, which is the
|
|
26
|
+
* most common Drupal label field outside of content nodes.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} entityType Entity type machine name.
|
|
29
|
+
* @returns {"title"|"name"} The JSON:API field to filter against.
|
|
30
|
+
*/
|
|
31
|
+
function labelFieldFor(entityType) {
|
|
32
|
+
return entityType === "node" ? "title" : "name";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive a human label from a (already redacted) canonical entity.
|
|
37
|
+
*
|
|
38
|
+
* Nodes promote their label to the canonical `title`; taxonomy terms / users
|
|
39
|
+
* keep it in `fields.name`. We fall back across both so the resolver returns a
|
|
40
|
+
* meaningful label regardless of entity type. Redaction runs before this, so a
|
|
41
|
+
* protected label is already "[REDACTED]" here.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} entity Redacted canonical entity.
|
|
44
|
+
* @param {string} labelField The field used for filtering ("title"|"name").
|
|
45
|
+
* @returns {?string} The best available label, or null.
|
|
46
|
+
*/
|
|
47
|
+
function labelOf(entity, labelField) {
|
|
48
|
+
return (
|
|
49
|
+
entity.title ??
|
|
50
|
+
// eslint-disable-next-line security/detect-object-injection -- labelField is a fixed literal ("title"|"name") from labelFieldFor, not user input
|
|
51
|
+
entity.fields?.[labelField] ??
|
|
52
|
+
entity.fields?.name ??
|
|
53
|
+
entity.fields?.title ??
|
|
54
|
+
null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a human name/title to a Drupal UUID.
|
|
60
|
+
*
|
|
61
|
+
* Strategy: filter the entity type/bundle by a label-contains query, redact the
|
|
62
|
+
* results, then rank — an exact (case-insensitive, trimmed) label match wins;
|
|
63
|
+
* otherwise the first contains-match is the best guess and the result is flagged
|
|
64
|
+
* ambiguous when more than one candidate came back without an exact hit.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} args - { site?, entityType, bundle, name, limit? }.
|
|
67
|
+
* @returns {Promise<{resolved: boolean, ambiguous: boolean,
|
|
68
|
+
* match: {id: string, title: ?string}|null, candidates: {id: string, title: ?string}[]}>}
|
|
69
|
+
* The best match and any ambiguous candidates.
|
|
70
|
+
* @throws {SecurityError} If reading the entity type/bundle is not permitted.
|
|
71
|
+
*/
|
|
72
|
+
async function resolveReference({ site: siteName, entityType, bundle, name, limit = 10 }) {
|
|
73
|
+
const site = getSiteConfig(siteName);
|
|
74
|
+
const sec = resolveSecurityConfig(site);
|
|
75
|
+
assertReadAllowed(sec, entityType, bundle);
|
|
76
|
+
const backend = await resolveBackend(site);
|
|
77
|
+
|
|
78
|
+
const field = labelFieldFor(entityType);
|
|
79
|
+
const res = await backend.listEntities({
|
|
80
|
+
entityType,
|
|
81
|
+
bundle,
|
|
82
|
+
filters: [{ field, op: "contains", value: name }],
|
|
83
|
+
page: { limit },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const candidates = res.entities
|
|
87
|
+
.map((e) => redactCanonicalEntity(e, sec, entityType))
|
|
88
|
+
.map((e) => ({ id: e.id, title: labelOf(e, field) }));
|
|
89
|
+
|
|
90
|
+
if (candidates.length === 0) {
|
|
91
|
+
return { resolved: false, ambiguous: false, match: null, candidates: [] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const needle = String(name).trim().toLowerCase();
|
|
95
|
+
const exact = candidates.find(
|
|
96
|
+
(c) => typeof c.title === "string" && c.title.trim().toLowerCase() === needle
|
|
97
|
+
);
|
|
98
|
+
const match = exact ?? candidates[0];
|
|
99
|
+
const ambiguous = !exact && candidates.length > 1;
|
|
100
|
+
|
|
101
|
+
return { resolved: true, ambiguous, match, candidates };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Definitions
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export const definitions = [
|
|
109
|
+
{
|
|
110
|
+
name: "drupal_resolve_reference",
|
|
111
|
+
description:
|
|
112
|
+
"Resolve a human name or title to a Drupal entity UUID. Use this before " +
|
|
113
|
+
"creating or updating an entity reference when you only know the label " +
|
|
114
|
+
"(e.g. a taxonomy term name, a user name, or a node title) and not its UUID. " +
|
|
115
|
+
"Read-only: returns the best match { id, title } plus any ambiguous candidates. " +
|
|
116
|
+
"Filters on 'title' for nodes and 'name' for taxonomy_term / user.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
required: ["entityType", "bundle", "name"],
|
|
120
|
+
properties: {
|
|
121
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
122
|
+
entityType: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term', 'user'" },
|
|
123
|
+
bundle: { type: "string", description: "Bundle machine name, e.g. 'article', 'tags', 'user'" },
|
|
124
|
+
name: { type: "string", description: "Human name/title to resolve (matched as a substring)" },
|
|
125
|
+
limit: { type: "number", default: 10, description: "Maximum candidates to consider" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
export const handlers = {
|
|
132
|
+
drupal_resolve_reference: resolveReference,
|
|
133
|
+
};
|