drupal-mcp-connector 0.6.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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Media.
|
|
3
|
+
*
|
|
4
|
+
* Media entity CRUD, file upload, and an orphaned-media audit. Backend-agnostic
|
|
5
|
+
* for reads/writes, but file upload is JSON:API-only and is capability-gated on
|
|
6
|
+
* read-only/GraphQL backends. Reads are redacted per the site policy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
10
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
+
import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* List all media types (bundles of the media entity type).
|
|
15
|
+
* @param {object} args - { site? }.
|
|
16
|
+
* @returns {Promise<object[]>} Media bundle descriptors.
|
|
17
|
+
*/
|
|
18
|
+
async function listMediaTypes({ site: siteName }) {
|
|
19
|
+
const site = getSiteConfig(siteName);
|
|
20
|
+
const backend = await resolveBackend(site);
|
|
21
|
+
return backend.listBundles("media");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List media entities of a type (default: image), filtered by status and/or
|
|
26
|
+
* name substring.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} args - { site?, type?, status?, name?, limit?, offset? }.
|
|
29
|
+
* @returns {Promise<{total: number, approximate: boolean, offset: number,
|
|
30
|
+
* nextOffset: number, media: object[]}>} Paged, redacted media list.
|
|
31
|
+
*/
|
|
32
|
+
async function listMedia({ site: siteName, type, status, name, limit = 20, offset = 0 }) {
|
|
33
|
+
const site = getSiteConfig(siteName);
|
|
34
|
+
const sec = resolveSecurityConfig(site);
|
|
35
|
+
const backend = await resolveBackend(site);
|
|
36
|
+
const filters = [];
|
|
37
|
+
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
38
|
+
if (name) filters.push({ field: "name", op: "contains", value: name });
|
|
39
|
+
const res = await backend.listEntities({ entityType: "media", bundle: type || "image", filters, sort: [{ field: "changed", dir: "desc" }], page: { limit, offset } });
|
|
40
|
+
const items = res.entities.map((e) => redactCanonicalEntity(e, sec, "media"));
|
|
41
|
+
return { total: res.page?.total ?? items.length, approximate: res.approximate ?? false, offset, nextOffset: offset + items.length, media: items };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch a single media entity by UUID, redacted per policy.
|
|
46
|
+
* @param {object} args - { site?, type, id }.
|
|
47
|
+
* @returns {Promise<object|null>} The redacted media entity, or null.
|
|
48
|
+
*/
|
|
49
|
+
async function getMedia({ site: siteName, type, id }) {
|
|
50
|
+
const site = getSiteConfig(siteName);
|
|
51
|
+
const sec = resolveSecurityConfig(site);
|
|
52
|
+
const backend = await resolveBackend(site);
|
|
53
|
+
const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
|
|
54
|
+
return entity ? redactCanonicalEntity(entity, sec, "media") : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a media entity. Caller `fields` are spread into attributes; name and
|
|
59
|
+
* status are layered on top.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} args - { site?, type, name, status?, fields? }.
|
|
62
|
+
* @returns {Promise<object>} The created media descriptor.
|
|
63
|
+
*/
|
|
64
|
+
async function createMedia({ site: siteName, type, name, status = true, fields = {} }) {
|
|
65
|
+
const site = getSiteConfig(siteName);
|
|
66
|
+
const backend = await resolveBackend(site);
|
|
67
|
+
return backend.createEntity({ entityType: "media", bundle: type, attributes: { name, status, ...fields } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Update a media entity (partial — omitted fields are left untouched).
|
|
72
|
+
* @param {object} args - { site?, type, id, name?, status?, fields? }.
|
|
73
|
+
* @returns {Promise<object>} The updated media descriptor.
|
|
74
|
+
*/
|
|
75
|
+
async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
|
|
76
|
+
const site = getSiteConfig(siteName);
|
|
77
|
+
const backend = await resolveBackend(site);
|
|
78
|
+
const attributes = { ...fields };
|
|
79
|
+
if (name !== undefined) attributes.name = name;
|
|
80
|
+
if (status !== undefined) attributes.status = status;
|
|
81
|
+
return backend.updateEntity({ entityType: "media", bundle: type, id, attributes });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete a media entity (the underlying File entity is left intact).
|
|
86
|
+
* Destructive-allowed assertion is applied upstream by the security middleware.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} args - { site?, type, id }.
|
|
89
|
+
* @returns {Promise<{success: boolean, deletedId: string}>}
|
|
90
|
+
*/
|
|
91
|
+
async function deleteMedia({ site: siteName, type, id }) {
|
|
92
|
+
const site = getSiteConfig(siteName);
|
|
93
|
+
const backend = await resolveBackend(site);
|
|
94
|
+
await backend.deleteEntity({ entityType: "media", bundle: type, id });
|
|
95
|
+
return { success: true, deletedId: id };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Upload a local file and create a Drupal File entity (JSON:API-only).
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args - { site?, filePath, entityType?, bundle, fieldName }.
|
|
102
|
+
* @returns {Promise<object>} The created file descriptor (includes id/filename).
|
|
103
|
+
* @throws {BackendCapabilityError} If the backend cannot upload files.
|
|
104
|
+
*/
|
|
105
|
+
async function uploadFile({ site: siteName, filePath, entityType = "media", bundle, fieldName }) {
|
|
106
|
+
const site = getSiteConfig(siteName);
|
|
107
|
+
const backend = await resolveBackend(site);
|
|
108
|
+
return backend.uploadFile({ entityType, bundle, fieldName, filePath });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convenience flow: upload a file, then create a media entity referencing it in
|
|
113
|
+
* one step. The file is attached via the `fieldName` relationship; alt text, if
|
|
114
|
+
* provided, is carried in the relationship meta.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} args - { site?, filePath, mediaType, mediaName?, fieldName, altText?, status? }.
|
|
117
|
+
* mediaName defaults to the uploaded filename.
|
|
118
|
+
* @returns {Promise<{file: {id: string, filename: string}, media: object}>}
|
|
119
|
+
* @throws {BackendCapabilityError} If the backend cannot upload files.
|
|
120
|
+
*/
|
|
121
|
+
async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, mediaName, fieldName, altText, status = true }) {
|
|
122
|
+
const site = getSiteConfig(siteName);
|
|
123
|
+
const backend = await resolveBackend(site);
|
|
124
|
+
const file = await backend.uploadFile({ entityType: "media", bundle: mediaType, fieldName, filePath });
|
|
125
|
+
const fileData = altText
|
|
126
|
+
? { type: "file--file", id: file.id, meta: { alt: altText } }
|
|
127
|
+
: { type: "file--file", id: file.id };
|
|
128
|
+
const media = await backend.createEntity({
|
|
129
|
+
entityType: "media", bundle: mediaType,
|
|
130
|
+
attributes: { name: mediaName || file.filename, status },
|
|
131
|
+
relationships: Object.fromEntries([[fieldName, { data: fileData }]]),
|
|
132
|
+
});
|
|
133
|
+
return { file: { id: file.id, filename: file.filename }, media };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find media not referenced by any content. Prefers a field_usage_count filter
|
|
138
|
+
* when the site tracks usage; falls back to listing all media (annotated with a
|
|
139
|
+
* note) when usage tracking is unavailable.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} args - { site?, type?, limit? }. type defaults to "image".
|
|
142
|
+
* @returns {Promise<{method: string, note?: string, count: number, media: object[]}>}
|
|
143
|
+
*/
|
|
144
|
+
async function findOrphanedMedia({ site: siteName, type, limit = 50 }) {
|
|
145
|
+
const site = getSiteConfig(siteName);
|
|
146
|
+
const sec = resolveSecurityConfig(site);
|
|
147
|
+
const backend = await resolveBackend(site);
|
|
148
|
+
const bundle = type || "image";
|
|
149
|
+
const map = (res, method, note) => ({
|
|
150
|
+
method, ...(note ? { note } : {}), count: res.entities.length,
|
|
151
|
+
media: res.entities.map((e) => redactCanonicalEntity(e, sec, "media")),
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
const res = await backend.listEntities({ entityType: "media", bundle, filters: [{ field: "field_usage_count", op: "eq", value: 0 }], sort: [{ field: "changed", dir: "desc" }], page: { limit } });
|
|
155
|
+
return map(res, "usage_count_filter");
|
|
156
|
+
} catch {
|
|
157
|
+
const res = await backend.listEntities({ entityType: "media", bundle, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
|
|
158
|
+
return map(res, "all_media_no_usage_tracking", "Usage count tracking unavailable. Review manually or enable the Media module's usage tracking.");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Definitions
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export const definitions = [
|
|
167
|
+
{
|
|
168
|
+
name: "drupal_list_media_types",
|
|
169
|
+
description: "List all media types defined on this Drupal site (image, document, remote_video, audio, etc.).",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: { site: { type: "string" } },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "drupal_list_media",
|
|
177
|
+
description: "List media entities by type. Supports filtering by name substring and publish status.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
type: "object",
|
|
180
|
+
properties: {
|
|
181
|
+
site: { type: "string" },
|
|
182
|
+
type: { type: "string", description: "Media type machine name, e.g. 'image', 'document', 'remote_video'" },
|
|
183
|
+
status: { type: "boolean" },
|
|
184
|
+
name: { type: "string", description: "Filter by name substring" },
|
|
185
|
+
limit: { type: "number", default: 20 },
|
|
186
|
+
offset: { type: "number", default: 0 },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "drupal_get_media",
|
|
192
|
+
description: "Fetch a single media entity by UUID and media type.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object", required: ["type", "id"],
|
|
195
|
+
properties: {
|
|
196
|
+
site: { type: "string" },
|
|
197
|
+
type: { type: "string" },
|
|
198
|
+
id: { type: "string", description: "Media entity UUID" },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "drupal_create_media",
|
|
204
|
+
description: "Create a media entity. For remote video (YouTube/Vimeo), pass the URL via fields.field_media_oembed_video. For file-based media, use drupal_upload_file first to get a file UUID, then pass it in fields.",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: "object", required: ["type", "name"],
|
|
207
|
+
properties: {
|
|
208
|
+
site: { type: "string" },
|
|
209
|
+
type: { type: "string", description: "Media type machine name" },
|
|
210
|
+
name: { type: "string", description: "Media entity name / label" },
|
|
211
|
+
status: { type: "boolean", default: true },
|
|
212
|
+
fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')" },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "drupal_update_media",
|
|
218
|
+
description: "Update a media entity's name, status, or field values.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: "object", required: ["type", "id"],
|
|
221
|
+
properties: {
|
|
222
|
+
site: { type: "string" },
|
|
223
|
+
type: { type: "string" },
|
|
224
|
+
id: { type: "string" },
|
|
225
|
+
name: { type: "string" },
|
|
226
|
+
status: { type: "boolean" },
|
|
227
|
+
fields: { type: "object" },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "drupal_delete_media",
|
|
233
|
+
description: "Delete a media entity. Does not delete the underlying File entity. Confirm with the user before calling.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: "object", required: ["type", "id"],
|
|
236
|
+
properties: {
|
|
237
|
+
site: { type: "string" },
|
|
238
|
+
type: { type: "string" },
|
|
239
|
+
id: { type: "string" },
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "drupal_upload_file",
|
|
245
|
+
description: "Upload a local file to Drupal and create a File entity. Returns the file UUID to use when creating a Media entity. For images, the typical flow is: drupal_upload_file → drupal_create_media.",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
type: "object", required: ["filePath", "bundle", "fieldName"],
|
|
248
|
+
properties: {
|
|
249
|
+
site: { type: "string" },
|
|
250
|
+
filePath: { type: "string", description: "Absolute local path to the file to upload" },
|
|
251
|
+
entityType: { type: "string", default: "media", description: "Drupal entity type (usually 'media' or 'node')" },
|
|
252
|
+
bundle: { type: "string", description: "Bundle machine name, e.g. 'image', 'article'" },
|
|
253
|
+
fieldName: { type: "string", description: "Field machine name, e.g. 'field_media_image', 'field_image'" },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
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.",
|
|
260
|
+
inputSchema: {
|
|
261
|
+
type: "object", required: ["filePath", "mediaType", "fieldName"],
|
|
262
|
+
properties: {
|
|
263
|
+
site: { type: "string" },
|
|
264
|
+
filePath: { type: "string", description: "Absolute local path to the file" },
|
|
265
|
+
mediaType: { type: "string", description: "Media type machine name, e.g. 'image'" },
|
|
266
|
+
mediaName: { type: "string", description: "Name for the media entity (defaults to filename)" },
|
|
267
|
+
fieldName: { type: "string", description: "Source field machine name, e.g. 'field_media_image'" },
|
|
268
|
+
altText: { type: "string", description: "Alt text for image media" },
|
|
269
|
+
status: { type: "boolean", default: true },
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "drupal_find_orphaned_media",
|
|
275
|
+
description: "Find media entities not referenced by any content. Useful for storage cleanup audits.",
|
|
276
|
+
inputSchema: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
site: { type: "string" },
|
|
280
|
+
type: { type: "string", description: "Media type to check (default: image)" },
|
|
281
|
+
limit: { type: "number", default: 50 },
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
export const handlers = {
|
|
288
|
+
drupal_list_media_types: listMediaTypes,
|
|
289
|
+
drupal_list_media: listMedia,
|
|
290
|
+
drupal_get_media: getMedia,
|
|
291
|
+
drupal_create_media: createMedia,
|
|
292
|
+
drupal_update_media: updateMedia,
|
|
293
|
+
drupal_delete_media: deleteMedia,
|
|
294
|
+
drupal_upload_file: uploadFile,
|
|
295
|
+
drupal_upload_file_and_create_media: uploadFileAndCreateMedia,
|
|
296
|
+
drupal_find_orphaned_media: findOrphanedMedia,
|
|
297
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Node CRUD.
|
|
3
|
+
*
|
|
4
|
+
* Backend-agnostic content node operations (get/list/search/create/update/
|
|
5
|
+
* delete). Every handler resolves the active backend (JSON:API or GraphQL) via
|
|
6
|
+
* resolveBackend, and read results pass through redactCanonicalEntity so the
|
|
7
|
+
* per-site security policy can strip protected fields before returning.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
11
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
+
import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} [body] - Body HTML; when undefined the field is omitted.
|
|
18
|
+
* @param {string} [summary] - Teaser/summary text.
|
|
19
|
+
* @returns {{value: string, format: string, summary: string}|undefined}
|
|
20
|
+
* A body attribute object, or undefined when no body was supplied (so callers
|
|
21
|
+
* can skip the field on update rather than blanking it).
|
|
22
|
+
*/
|
|
23
|
+
function buildBodyAttribute(body, summary) {
|
|
24
|
+
if (body === undefined) return undefined;
|
|
25
|
+
return { value: body, format: "full_html", summary: summary ?? "" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize limit/offset args into the backend's page descriptor.
|
|
30
|
+
*
|
|
31
|
+
* @param {{limit?: number, offset?: number}} args
|
|
32
|
+
* @returns {{limit: number, offset: number}}
|
|
33
|
+
*/
|
|
34
|
+
function pageOf({ limit = 20, offset = 0 }) {
|
|
35
|
+
return { limit, offset };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch a single node by type + UUID, redacted per the site policy.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} args - { site?, type, id }.
|
|
42
|
+
* @returns {Promise<object|null>} The redacted node, or null if not found.
|
|
43
|
+
*/
|
|
44
|
+
async function getNode({ site: siteName, type, id }) {
|
|
45
|
+
const site = getSiteConfig(siteName);
|
|
46
|
+
const sec = resolveSecurityConfig(site);
|
|
47
|
+
const backend = await resolveBackend(site);
|
|
48
|
+
const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
|
|
49
|
+
return entity ? redactCanonicalEntity(entity, sec, "node") : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* List nodes of a content type with optional status filter, paging and sorting.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} args - { site?, type, status?, filters?, limit?, offset?, sort? }.
|
|
56
|
+
* A `status` boolean is appended to `filters` as a status equality descriptor.
|
|
57
|
+
* @returns {Promise<{total: number, approximate: boolean, offset: number,
|
|
58
|
+
* nextOffset: number, nodes: object[]}>} Paged, redacted node list.
|
|
59
|
+
*/
|
|
60
|
+
async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
|
|
61
|
+
const site = getSiteConfig(siteName);
|
|
62
|
+
const sec = resolveSecurityConfig(site);
|
|
63
|
+
const backend = await resolveBackend(site);
|
|
64
|
+
const allFilters = [...filters];
|
|
65
|
+
if (status !== undefined) allFilters.push({ field: "status", op: "eq", value: status });
|
|
66
|
+
const res = await backend.listEntities({ entityType: "node", bundle: type, filters: allFilters, sort, page: pageOf({ limit, offset }) });
|
|
67
|
+
const nodes = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
68
|
+
return {
|
|
69
|
+
total: res.page?.total ?? nodes.length,
|
|
70
|
+
approximate: res.approximate ?? false,
|
|
71
|
+
offset,
|
|
72
|
+
nextOffset: offset + nodes.length,
|
|
73
|
+
nodes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Search nodes by title substring (defaults to the article bundle).
|
|
79
|
+
*
|
|
80
|
+
* @param {object} args - { site?, query, type?, status?, limit? }.
|
|
81
|
+
* @returns {Promise<object[]>} Redacted matching nodes.
|
|
82
|
+
*/
|
|
83
|
+
async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
|
|
84
|
+
const site = getSiteConfig(siteName);
|
|
85
|
+
const sec = resolveSecurityConfig(site);
|
|
86
|
+
const backend = await resolveBackend(site);
|
|
87
|
+
const filters = [{ field: "title", op: "contains", value: query }];
|
|
88
|
+
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
89
|
+
const res = await backend.listEntities({ entityType: "node", bundle: type || "article", filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
|
|
90
|
+
return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a node. Caller-supplied `fields` are spread into the attribute map;
|
|
95
|
+
* title/status/body are layered on top so they win over any same-named field.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} args - { site?, type, title, body?, summary?, status?, fields? }.
|
|
98
|
+
* Defaults to status=false (draft) so content is never auto-published.
|
|
99
|
+
* @returns {Promise<object>} The created node descriptor from the backend.
|
|
100
|
+
*/
|
|
101
|
+
async function createNode({ site: siteName, type, title, body, summary, status = false, fields = {} }) {
|
|
102
|
+
const site = getSiteConfig(siteName);
|
|
103
|
+
const backend = await resolveBackend(site);
|
|
104
|
+
const attributes = { title, status, ...fields };
|
|
105
|
+
const bodyAttr = buildBodyAttribute(body, summary);
|
|
106
|
+
if (bodyAttr) attributes.body = bodyAttr;
|
|
107
|
+
return backend.createEntity({ entityType: "node", bundle: type, attributes });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Update a node. Only supplied attributes are sent, so omitted fields are left
|
|
112
|
+
* untouched (partial update). `fields` is spread first, then known scalars.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, fields? }.
|
|
115
|
+
* @returns {Promise<object>} The updated node descriptor.
|
|
116
|
+
*/
|
|
117
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, status, fields = {} }) {
|
|
118
|
+
const site = getSiteConfig(siteName);
|
|
119
|
+
const backend = await resolveBackend(site);
|
|
120
|
+
const attributes = { ...fields };
|
|
121
|
+
if (title !== undefined) attributes.title = title;
|
|
122
|
+
if (status !== undefined) attributes.status = status;
|
|
123
|
+
const bodyAttr = buildBodyAttribute(body, summary);
|
|
124
|
+
if (bodyAttr) attributes.body = bodyAttr;
|
|
125
|
+
return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Permanently delete a node. The destructive-allowed assertion is applied
|
|
130
|
+
* upstream by the security middleware in index.js.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} args - { site?, type, id }.
|
|
133
|
+
* @returns {Promise<{success: boolean, deletedId: string}>}
|
|
134
|
+
*/
|
|
135
|
+
async function deleteNode({ site: siteName, type, id }) {
|
|
136
|
+
const site = getSiteConfig(siteName);
|
|
137
|
+
const backend = await resolveBackend(site);
|
|
138
|
+
await backend.deleteEntity({ entityType: "node", bundle: type, id });
|
|
139
|
+
return { success: true, deletedId: id };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Tool definitions
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export const definitions = [
|
|
147
|
+
{
|
|
148
|
+
name: "drupal_get_node",
|
|
149
|
+
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
type: "object", required: ["type", "id"],
|
|
152
|
+
properties: {
|
|
153
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
154
|
+
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
155
|
+
id: { type: "string", description: "Node UUID" },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "drupal_list_nodes",
|
|
161
|
+
description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: "object", required: ["type"],
|
|
164
|
+
properties: {
|
|
165
|
+
site: { type: "string" },
|
|
166
|
+
type: { type: "string", description: "Content type machine name" },
|
|
167
|
+
status: { type: "boolean", description: "true = published only, false = unpublished only, omit = all" },
|
|
168
|
+
limit: { type: "number", default: 20 },
|
|
169
|
+
offset: { type: "number", default: 0 },
|
|
170
|
+
filters: { type: "array", description: "Structured filters: [{ field, op, value }]. op: eq|neq|gt|gte|lt|lte|contains|in|isNull", items: { type: "object" } },
|
|
171
|
+
sort: { type: "array", description: "Sort specs: [{ field, dir }] where dir is 'asc'|'desc'", items: { type: "object" } },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "drupal_search_content",
|
|
177
|
+
description: "Search nodes by title substring. Returns title, path alias, and body summary.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
type: "object", required: ["query"],
|
|
180
|
+
properties: {
|
|
181
|
+
site: { type: "string" },
|
|
182
|
+
query: { type: "string", description: "Search term to match against node titles" },
|
|
183
|
+
type: { type: "string", description: "Limit to this content type (default: article)" },
|
|
184
|
+
status: { type: "boolean", description: "Filter by publish status" },
|
|
185
|
+
limit: { type: "number", default: 10 },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "drupal_create_node",
|
|
191
|
+
description: "Create a new content node. Returns the new node UUID, integer ID, and URL.",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: "object", required: ["type", "title"],
|
|
194
|
+
properties: {
|
|
195
|
+
site: { type: "string" },
|
|
196
|
+
type: { type: "string", description: "Content type machine name" },
|
|
197
|
+
title: { type: "string" },
|
|
198
|
+
body: { type: "string", description: "Body field HTML" },
|
|
199
|
+
summary: { type: "string", description: "Body summary / teaser" },
|
|
200
|
+
status: { type: "boolean", default: false, description: "true to publish immediately" },
|
|
201
|
+
fields: { type: "object", description: "Additional field values keyed by Drupal machine name" },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "drupal_update_node",
|
|
207
|
+
description: "Update an existing node. Only include fields you want to change.",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: "object", required: ["type", "id"],
|
|
210
|
+
properties: {
|
|
211
|
+
site: { type: "string" },
|
|
212
|
+
type: { type: "string" },
|
|
213
|
+
id: { type: "string", description: "Node UUID" },
|
|
214
|
+
title: { type: "string" },
|
|
215
|
+
body: { type: "string" },
|
|
216
|
+
summary: { type: "string" },
|
|
217
|
+
status: { type: "boolean", description: "true = publish, false = unpublish" },
|
|
218
|
+
fields: { type: "object" },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "drupal_delete_node",
|
|
224
|
+
description: "Permanently delete a node. Irreversible — confirm with the user before calling.",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
type: "object", required: ["type", "id"],
|
|
227
|
+
properties: {
|
|
228
|
+
site: { type: "string" },
|
|
229
|
+
type: { type: "string" },
|
|
230
|
+
id: { type: "string", description: "Node UUID" },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Handler map
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export const handlers = {
|
|
241
|
+
drupal_get_node: getNode,
|
|
242
|
+
drupal_list_nodes: listNodes,
|
|
243
|
+
drupal_search_content: searchContent,
|
|
244
|
+
drupal_create_node: createNode,
|
|
245
|
+
drupal_update_node: updateNode,
|
|
246
|
+
drupal_delete_node: deleteNode,
|
|
247
|
+
};
|