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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-06-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`dryRun` option** on the node + generic-entity write tools (`drupal_create_node`,
|
|
14
|
+
`drupal_update_node`, `drupal_delete_node`, `drupal_entity_create`,
|
|
15
|
+
`drupal_entity_update`, `drupal_entity_delete`) (#42). When `true`, the tool runs
|
|
16
|
+
the security checks and builds the final payload, then returns a preview
|
|
17
|
+
(`{ dryRun: true, operation, entityType, bundle, id?, attributes }`) **without
|
|
18
|
+
writing** — no backend call is made. Lets an agent confirm intent safely.
|
|
19
|
+
- **23 new tools across 11 modules** (66 → 89 tools), toward 1.0 feature coverage:
|
|
20
|
+
- **Revisions** (#37): `drupal_list_revisions`, `drupal_get_revision`, `drupal_revert_revision` (governed revert; JSON:API addresses revisions by id / latest-version / working-copy — full history enumeration needs the Drush bridge).
|
|
21
|
+
- **Moderation** (#38): `drupal_set_moderation_state`, `drupal_content_by_moderation_state`, `drupal_list_moderation_states` (content_moderation).
|
|
22
|
+
- **Scheduler** (#39): `drupal_schedule_publish` (publish_on / unpublish_on).
|
|
23
|
+
- **Fields** (#40): `drupal_describe_fields` (bundle field schema; best-effort, Drush-enhanced).
|
|
24
|
+
- **References** (#41): `drupal_resolve_reference` (name/title → UUID).
|
|
25
|
+
- **Bulk** (#43): `drupal_bulk_create`, `drupal_bulk_update` (per-item partial-failure reporting).
|
|
26
|
+
- **Translations** (#45): `drupal_list_translations`, `drupal_create_translation`.
|
|
27
|
+
- **Paragraphs** (#44): `drupal_create_paragraph`, `drupal_get_paragraph`.
|
|
28
|
+
- **Structure** (#46): `drupal_list_menu_links`, `drupal_create_menu_link`, `drupal_list_blocks`, `drupal_create_block`.
|
|
29
|
+
- **Search** (#47): `drupal_search` (best-effort title match; Search API/Solr-ready).
|
|
30
|
+
- **Reports (extra)** (#48): `drupal_report_orphaned_references`, `drupal_report_unpublished`, `drupal_report_missing_field`.
|
|
31
|
+
- All reads are policy-redacted; all writes assert the security policy. New write verbs (`bulk_`/`revert_`/`schedule_`/`set_`) added to the middleware write-gating prefixes.
|
|
32
|
+
|
|
33
|
+
## [0.9.1] - 2026-06-15
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
- Validate and URL-encode JSON:API path segments. The entity `id` is now checked
|
|
37
|
+
with `validateUuid` and `entityType`/`bundle` with `validateMachineName` (both
|
|
38
|
+
previously unused), and every segment is `encodeURIComponent`'d. This closes a
|
|
39
|
+
path-traversal vector where a crafted `id` (e.g. `../../user/user/<uuid>`) could
|
|
40
|
+
reach a different resource type and bypass the connector's entity-type/PII
|
|
41
|
+
policy. (Drupal core permissions were always still enforced.)
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- A [Threat Model](docs/threat-model.md) documenting trust boundaries, threats &
|
|
45
|
+
mitigations, residual risks (drush SQL bridge; why filter-field names are not
|
|
46
|
+
machine-name-validated), and the 1.0 security-pass results (`npm audit` clean,
|
|
47
|
+
adversarial review).
|
|
48
|
+
|
|
10
49
|
## [0.9.0] - 2026-06-15
|
|
11
50
|
|
|
12
51
|
### Added
|
|
@@ -213,6 +252,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
213
252
|
- User tools gained explicit PII-access assertions.
|
|
214
253
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
215
254
|
|
|
255
|
+
[0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
|
|
256
|
+
[0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
|
|
216
257
|
[0.9.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.0
|
|
217
258
|
[0.8.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.8.0
|
|
218
259
|
[0.7.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.1
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
51
51
|
|
|
52
52
|
## Features
|
|
53
53
|
|
|
54
|
-
###
|
|
54
|
+
### 89 Tools Across 20 Modules
|
|
55
55
|
|
|
56
56
|
| Module | Tools |
|
|
57
57
|
|--------|-------|
|
|
@@ -64,6 +64,17 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
64
64
|
| **Site** | Site info, content-type discovery, configured-site listing |
|
|
65
65
|
| **Reports** | Content summary, stale content, field completeness, SEO/accessibility audits, taxonomy usage, user activity, revision hotspots (10 read-only reports) |
|
|
66
66
|
| **Drush** | Cache rebuild, cron, config sync, module management, DB updates via SSH |
|
|
67
|
+
| **Revisions** | List/get entity revisions; governed revert to a prior revision |
|
|
68
|
+
| **Moderation** | Set moderation state; list content by state; observed-state discovery (content_moderation) |
|
|
69
|
+
| **Scheduler** | Set publish-on / unpublish-on dates (Scheduler module) |
|
|
70
|
+
| **Fields** | Describe a bundle's fields (type/required/cardinality, best-effort) |
|
|
71
|
+
| **References** | Resolve a human name/title to an entity UUID for relationship fields |
|
|
72
|
+
| **Bulk** | Bulk create/update with per-item partial-failure reporting |
|
|
73
|
+
| **Translations** | List + create entity translations |
|
|
74
|
+
| **Paragraphs** | Create/get Paragraph components for embedding in host fields |
|
|
75
|
+
| **Structure** | Menu links + custom blocks (list/create) |
|
|
76
|
+
| **Search** | Best-effort content search (title match; Search API/Solr-ready) |
|
|
77
|
+
| **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
|
|
67
78
|
|
|
68
79
|
### MCP Resources
|
|
69
80
|
Browsable, always-fresh context the client can read without calling a tool:
|
|
@@ -174,9 +185,10 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
|
|
|
174
185
|
| [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
|
|
175
186
|
| [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
|
|
176
187
|
| [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
|
|
177
|
-
| [Tools Reference](docs/tools-reference.md) | Full reference for all
|
|
188
|
+
| [Tools Reference](docs/tools-reference.md) | Full reference for all 89 tools |
|
|
178
189
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
179
190
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
191
|
+
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
180
192
|
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
181
193
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
182
194
|
| [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -56,12 +56,24 @@ import * as site from "./tools/site.js";
|
|
|
56
56
|
import * as entities from "./tools/entities.js";
|
|
57
57
|
import * as reports from "./tools/reports.js";
|
|
58
58
|
import * as drush from "./tools/drush.js";
|
|
59
|
+
import * as revisions from "./tools/revisions.js";
|
|
60
|
+
import * as moderation from "./tools/moderation.js";
|
|
61
|
+
import * as scheduler from "./tools/scheduler.js";
|
|
62
|
+
import * as fields from "./tools/fields.js";
|
|
63
|
+
import * as references from "./tools/references.js";
|
|
64
|
+
import * as bulk from "./tools/bulk.js";
|
|
65
|
+
import * as translations from "./tools/translations.js";
|
|
66
|
+
import * as paragraphs from "./tools/paragraphs.js";
|
|
67
|
+
import * as structure from "./tools/structure.js";
|
|
68
|
+
import * as search from "./tools/search.js";
|
|
69
|
+
import * as reportsExtra from "./tools/reports-extra.js";
|
|
59
70
|
|
|
60
71
|
// ---------------------------------------------------------------------------
|
|
61
72
|
// Aggregate tools
|
|
62
73
|
// ---------------------------------------------------------------------------
|
|
63
74
|
|
|
64
|
-
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush
|
|
75
|
+
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
76
|
+
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, search, reportsExtra];
|
|
65
77
|
|
|
66
78
|
// Flatten every module's tool definitions into one ListTools payload, and merge
|
|
67
79
|
// their handler maps into a single closed dispatch table keyed by tool name.
|
|
@@ -81,7 +93,9 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload
|
|
|
81
93
|
"drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
|
|
82
94
|
"drupal_drush_config_export", "drupal_drush_config_import",
|
|
83
95
|
"drupal_drush_updatedb", "drupal_drush_module_enable",
|
|
84
|
-
"drupal_drush_module_disable", "drupal_drush_user_create"
|
|
96
|
+
"drupal_drush_module_disable", "drupal_drush_user_create",
|
|
97
|
+
// v1.0 feature tools that perform writes but don't start with create_/update_:
|
|
98
|
+
"drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_"];
|
|
85
99
|
const DESTRUCTIVE_PREFIXES = ["drupal_delete_", "drupal_drush_module_disable"];
|
|
86
100
|
|
|
87
101
|
/**
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
|
+
import { validateUuid, validateMachineName } from "../validate.js";
|
|
11
12
|
import { Backend } from "./backend-interface.js";
|
|
12
13
|
import {
|
|
13
14
|
makeCanonicalEntity,
|
|
@@ -132,7 +133,12 @@ export class JsonApiBackend extends Backend {
|
|
|
132
133
|
* @returns {string} e.g. "/jsonapi/node/article".
|
|
133
134
|
*/
|
|
134
135
|
resourcePath(entityType, bundle) {
|
|
135
|
-
|
|
136
|
+
// Validate before interpolating into the URL: machine names cannot contain
|
|
137
|
+
// path separators or `..`, so this blocks path-traversal to other resources
|
|
138
|
+
// (e.g. id="../../user/user/…"). Encoding is belt-and-suspenders.
|
|
139
|
+
validateMachineName(entityType, "entityType");
|
|
140
|
+
validateMachineName(bundle, "bundle");
|
|
141
|
+
return `/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}`;
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
/**
|
|
@@ -219,7 +225,8 @@ export class JsonApiBackend extends Backend {
|
|
|
219
225
|
* @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
|
|
220
226
|
*/
|
|
221
227
|
async getEntity({ entityType, bundle, id }) {
|
|
222
|
-
|
|
228
|
+
validateUuid(id);
|
|
229
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
|
|
223
230
|
return data?.data ? this.toCanonical(data.data) : null;
|
|
224
231
|
}
|
|
225
232
|
|
|
@@ -275,12 +282,13 @@ export class JsonApiBackend extends Backend {
|
|
|
275
282
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
276
283
|
*/
|
|
277
284
|
async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
|
|
285
|
+
validateUuid(id);
|
|
278
286
|
const buildPayload = (attrs) => {
|
|
279
287
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
280
288
|
if (relationships) payload.data.relationships = relationships;
|
|
281
289
|
return payload;
|
|
282
290
|
};
|
|
283
|
-
const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${id}`, "PATCH", buildPayload, attributes);
|
|
291
|
+
const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, "PATCH", buildPayload, attributes);
|
|
284
292
|
return this.toCanonical(data.data);
|
|
285
293
|
}
|
|
286
294
|
|
|
@@ -290,7 +298,8 @@ export class JsonApiBackend extends Backend {
|
|
|
290
298
|
* @returns {Promise<void>}
|
|
291
299
|
*/
|
|
292
300
|
async deleteEntity({ entityType, bundle, id }) {
|
|
293
|
-
|
|
301
|
+
validateUuid(id);
|
|
302
|
+
await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
294
303
|
}
|
|
295
304
|
|
|
296
305
|
/**
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Bulk operations.
|
|
3
|
+
*
|
|
4
|
+
* Create or update many entities of a single type + bundle in one call. The
|
|
5
|
+
* write permission is asserted ONCE up front (the whole batch targets one
|
|
6
|
+
* type/bundle), then each item is processed in its own try/catch so a single
|
|
7
|
+
* failure does not abort the batch — callers get partial success with a
|
|
8
|
+
* per-item result and a roll-up summary.
|
|
9
|
+
*
|
|
10
|
+
* The hardened JSON:API backend is reused as-is (path validation, the
|
|
11
|
+
* content_moderation status-on-403 fallback, etc. all apply per item).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
15
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
|
+
import { resolveSecurityConfig, assertWriteAllowed } from "../lib/security.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize an unknown thrown value into a human-readable message.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} err - The caught error.
|
|
22
|
+
* @returns {string} A message string.
|
|
23
|
+
*/
|
|
24
|
+
function errorMessage(err) {
|
|
25
|
+
return err instanceof Error ? err.message : String(err);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bulk-create entities of one type + bundle. Write permission is asserted once;
|
|
30
|
+
* each item is created independently so the batch continues past failures.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} args - { site?, entityType, bundle, items: [{ attributes?, relationships? }] }.
|
|
33
|
+
* @returns {Promise<{results: object[], summary: {created: number, failed: number}}>}
|
|
34
|
+
* Per-item { index, success, id? | error } plus a roll-up summary.
|
|
35
|
+
* @throws {SecurityError} If creating the type/bundle is not permitted.
|
|
36
|
+
*/
|
|
37
|
+
async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
38
|
+
const site = getSiteConfig(siteName);
|
|
39
|
+
const sec = resolveSecurityConfig(site);
|
|
40
|
+
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
41
|
+
const backend = await resolveBackend(site);
|
|
42
|
+
|
|
43
|
+
const results = [];
|
|
44
|
+
let created = 0;
|
|
45
|
+
let failed = 0;
|
|
46
|
+
for (const [index, rawItem] of items.entries()) {
|
|
47
|
+
const item = rawItem || {};
|
|
48
|
+
try {
|
|
49
|
+
const entity = await backend.createEntity({
|
|
50
|
+
entityType, bundle,
|
|
51
|
+
attributes: item.attributes ?? {},
|
|
52
|
+
relationships: item.relationships ?? {},
|
|
53
|
+
});
|
|
54
|
+
created += 1;
|
|
55
|
+
results.push({ index, success: true, id: entity?.id });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
failed += 1;
|
|
58
|
+
results.push({ index, success: false, error: errorMessage(err) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { results, summary: { created, failed } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bulk-update entities of one type + bundle. Write permission is asserted once;
|
|
66
|
+
* each item is updated independently so the batch continues past failures. An
|
|
67
|
+
* item missing an id is reported as a per-item failure rather than aborting.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} args - { site?, entityType, bundle, items: [{ id, attributes?, relationships? }] }.
|
|
70
|
+
* @returns {Promise<{results: object[], summary: {updated: number, failed: number}}>}
|
|
71
|
+
* Per-item { index, success, id? | error } plus a roll-up summary.
|
|
72
|
+
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
73
|
+
*/
|
|
74
|
+
async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
75
|
+
const site = getSiteConfig(siteName);
|
|
76
|
+
const sec = resolveSecurityConfig(site);
|
|
77
|
+
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
78
|
+
const backend = await resolveBackend(site);
|
|
79
|
+
|
|
80
|
+
const results = [];
|
|
81
|
+
let updated = 0;
|
|
82
|
+
let failed = 0;
|
|
83
|
+
for (const [index, rawItem] of items.entries()) {
|
|
84
|
+
const item = rawItem || {};
|
|
85
|
+
try {
|
|
86
|
+
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
87
|
+
const entity = await backend.updateEntity({
|
|
88
|
+
entityType, bundle, id: item.id,
|
|
89
|
+
attributes: item.attributes ?? {},
|
|
90
|
+
relationships: item.relationships ?? {},
|
|
91
|
+
});
|
|
92
|
+
updated += 1;
|
|
93
|
+
results.push({ index, success: true, id: entity?.id ?? item.id });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
failed += 1;
|
|
96
|
+
results.push({ index, success: false, error: errorMessage(err) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { results, summary: { updated, failed } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Definitions
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const itemAttributesSchema = {
|
|
107
|
+
attributes: { type: "object", description: "Field values keyed by Drupal machine name" },
|
|
108
|
+
relationships: { type: "object", description: "Relationship data keyed by field name" },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const definitions = [
|
|
112
|
+
{
|
|
113
|
+
name: "drupal_bulk_create",
|
|
114
|
+
description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object", required: ["entityType", "bundle", "items"],
|
|
117
|
+
properties: {
|
|
118
|
+
site: { type: "string" },
|
|
119
|
+
entityType: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term'" },
|
|
120
|
+
bundle: { type: "string", description: "Bundle machine name, e.g. 'article'" },
|
|
121
|
+
items: {
|
|
122
|
+
type: "array",
|
|
123
|
+
description: "Entities to create. Each is { attributes?, relationships? }.",
|
|
124
|
+
items: { type: "object", properties: { ...itemAttributesSchema } },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "drupal_bulk_update",
|
|
131
|
+
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object", required: ["entityType", "bundle", "items"],
|
|
134
|
+
properties: {
|
|
135
|
+
site: { type: "string" },
|
|
136
|
+
entityType: { type: "string", description: "Entity type machine name" },
|
|
137
|
+
bundle: { type: "string", description: "Bundle machine name" },
|
|
138
|
+
items: {
|
|
139
|
+
type: "array",
|
|
140
|
+
description: "Entities to update. Each is { id, attributes?, relationships? }.",
|
|
141
|
+
items: {
|
|
142
|
+
type: "object", required: ["id"],
|
|
143
|
+
properties: { id: { type: "string", description: "Entity UUID" }, ...itemAttributesSchema },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
export const handlers = {
|
|
152
|
+
drupal_bulk_create: bulkCreate,
|
|
153
|
+
drupal_bulk_update: bulkUpdate,
|
|
154
|
+
};
|
package/src/tools/entities.js
CHANGED
|
@@ -56,10 +56,11 @@ async function getEntity({ site: siteName, entityType, bundle, id, include = []
|
|
|
56
56
|
* @returns {Promise<object>} The created entity descriptor.
|
|
57
57
|
* @throws {SecurityError} If creating the type/bundle is not permitted.
|
|
58
58
|
*/
|
|
59
|
-
async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {} }) {
|
|
59
|
+
async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {}, dryRun = false }) {
|
|
60
60
|
const site = getSiteConfig(siteName);
|
|
61
61
|
const sec = resolveSecurityConfig(site);
|
|
62
62
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
63
|
+
if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships };
|
|
63
64
|
const backend = await resolveBackend(site);
|
|
64
65
|
return backend.createEntity({ entityType, bundle, attributes, relationships });
|
|
65
66
|
}
|
|
@@ -71,10 +72,11 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
71
72
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
72
73
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
73
74
|
*/
|
|
74
|
-
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {} }) {
|
|
75
|
+
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, dryRun = false }) {
|
|
75
76
|
const site = getSiteConfig(siteName);
|
|
76
77
|
const sec = resolveSecurityConfig(site);
|
|
77
78
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
79
|
+
if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes, relationships };
|
|
78
80
|
const backend = await resolveBackend(site);
|
|
79
81
|
return backend.updateEntity({ entityType, bundle, id, attributes, relationships });
|
|
80
82
|
}
|
|
@@ -86,10 +88,11 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
86
88
|
* @returns {Promise<{success: boolean, deletedId: string, entityType: string, bundle: string}>}
|
|
87
89
|
* @throws {SecurityError} If deleting the type/bundle is not permitted.
|
|
88
90
|
*/
|
|
89
|
-
async function deleteEntity({ site: siteName, entityType, bundle, id }) {
|
|
91
|
+
async function deleteEntity({ site: siteName, entityType, bundle, id, dryRun = false }) {
|
|
90
92
|
const site = getSiteConfig(siteName);
|
|
91
93
|
const sec = resolveSecurityConfig(site);
|
|
92
94
|
assertDeleteAllowed(sec, entityType, bundle, id);
|
|
95
|
+
if (dryRun) return { dryRun: true, operation: "delete", entityType, bundle, id };
|
|
93
96
|
const backend = await resolveBackend(site);
|
|
94
97
|
await backend.deleteEntity({ entityType, bundle, id });
|
|
95
98
|
return { success: true, deletedId: id, entityType, bundle };
|
|
@@ -209,6 +212,7 @@ export const definitions = [
|
|
|
209
212
|
bundle: { type: "string" },
|
|
210
213
|
attributes: { type: "object", description: "Field values keyed by Drupal machine name" },
|
|
211
214
|
relationships: { type: "object", description: "Relationship data keyed by field name" },
|
|
215
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the create without committing." },
|
|
212
216
|
},
|
|
213
217
|
},
|
|
214
218
|
},
|
|
@@ -224,6 +228,7 @@ export const definitions = [
|
|
|
224
228
|
id: { type: "string" },
|
|
225
229
|
attributes: { type: "object" },
|
|
226
230
|
relationships: { type: "object" },
|
|
231
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
|
227
232
|
},
|
|
228
233
|
},
|
|
229
234
|
},
|
|
@@ -237,6 +242,7 @@ export const definitions = [
|
|
|
237
242
|
entityType: { type: "string" },
|
|
238
243
|
bundle: { type: "string" },
|
|
239
244
|
id: { type: "string" },
|
|
245
|
+
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the delete without committing." },
|
|
240
246
|
},
|
|
241
247
|
},
|
|
242
248
|
},
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Field introspection.
|
|
3
|
+
*
|
|
4
|
+
* Read-only discovery of the fields available on a Drupal entity type + bundle.
|
|
5
|
+
*
|
|
6
|
+
* The always-available source is `backend.getEntitySchema(entityType, bundle)`,
|
|
7
|
+
* which derives a schema by SAMPLING an existing entity over JSON:API/GraphQL.
|
|
8
|
+
* Sampling can only observe the fields a sampled entity happens to populate and
|
|
9
|
+
* the runtime shape of their values — it CANNOT see authoritative Field API
|
|
10
|
+
* metadata. So every result is flagged `approximate: true`, and per-field
|
|
11
|
+
* `required` / `cardinality` / `allowedValues` are best-effort hints derived
|
|
12
|
+
* from the sampled value shape (e.g. an `array<…>` sampled type implies a
|
|
13
|
+
* multi-valued field). Authoritative metadata comes from the Drush bridge
|
|
14
|
+
* (Field API: `field_config` / `base_field_definitions`), which these tools do
|
|
15
|
+
* not require — see the `note` on the response.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
19
|
+
import { resolveBackend } from "../lib/backends/index.js";
|
|
20
|
+
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
21
|
+
|
|
22
|
+
const SAMPLING_NOTE =
|
|
23
|
+
"Schema derived by sampling an existing entity, so it is APPROXIMATE: only " +
|
|
24
|
+
"fields populated on the sampled entity are visible, and required/cardinality/" +
|
|
25
|
+
"allowedValues are inferred from value shape rather than read from Drupal's " +
|
|
26
|
+
"Field API. For authoritative field definitions (required flags, exact " +
|
|
27
|
+
"cardinality, allowed values, widget/storage settings) use the Drush bridge " +
|
|
28
|
+
"(field config), which reads the Field API directly.";
|
|
29
|
+
|
|
30
|
+
const EMPTY_SCHEMA_NOTE =
|
|
31
|
+
"No entities of this type/bundle exist yet, so sampling found no fields. " +
|
|
32
|
+
"Create one entity, or use the Drush bridge (Field API), to introspect fields.";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Infer a cardinality hint from a sampled attribute type string.
|
|
36
|
+
* `getEntitySchema` reports list-valued fields as `array<...>`; a leading
|
|
37
|
+
* `array<` is the only cheap multi-value signal sampling can give us.
|
|
38
|
+
* @param {string} type Sampled type string from the backend schema.
|
|
39
|
+
* @returns {{cardinality: number}|{}} `{cardinality: -1}` for arrays, else `{}`.
|
|
40
|
+
*/
|
|
41
|
+
function cardinalityHint(type) {
|
|
42
|
+
return typeof type === "string" && type.startsWith("array<") ? { cardinality: -1 } : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a per-field descriptor from a sampled attribute entry.
|
|
47
|
+
* @param {string} name Field machine name.
|
|
48
|
+
* @param {string} type Sampled type string.
|
|
49
|
+
* @returns {object} `{ name, type, kind:'attribute', approximate, [cardinality] }`.
|
|
50
|
+
*/
|
|
51
|
+
function attributeField(name, type) {
|
|
52
|
+
return { name, type, kind: "attribute", ...cardinalityHint(type), approximate: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a per-field descriptor from a sampled relationship entry.
|
|
57
|
+
* @param {string} name Relationship field machine name.
|
|
58
|
+
* @returns {object} `{ name, type:'relationship', kind:'relationship', approximate }`.
|
|
59
|
+
*/
|
|
60
|
+
function relationshipField(name) {
|
|
61
|
+
return { name, type: "relationship", kind: "relationship", approximate: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Describe the fields of a Drupal entity type + bundle.
|
|
66
|
+
*
|
|
67
|
+
* Read-only. Builds on the always-available sampling schema and normalizes it
|
|
68
|
+
* into a flat, per-field list with inferred hints. Always `approximate: true`.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} args - { site?, type, bundle? }. `bundle` defaults to `type`
|
|
71
|
+
* (matching Drupal's single-bundle entity types, e.g. `user`).
|
|
72
|
+
* @returns {Promise<{entityType: string, bundle: string, resourceType?: string,
|
|
73
|
+
* approximate: true, fieldCount: number, fields: object[], note: string,
|
|
74
|
+
* authoritativeSource: string}>}
|
|
75
|
+
* @throws {SecurityError} If reading the type/bundle is not permitted.
|
|
76
|
+
*/
|
|
77
|
+
async function describeFields({ site: siteName, type, bundle }) {
|
|
78
|
+
const entityType = type;
|
|
79
|
+
const resolvedBundle = bundle || type;
|
|
80
|
+
|
|
81
|
+
const site = getSiteConfig(siteName);
|
|
82
|
+
const sec = resolveSecurityConfig(site);
|
|
83
|
+
assertReadAllowed(sec, entityType, resolvedBundle);
|
|
84
|
+
|
|
85
|
+
const backend = await resolveBackend(site);
|
|
86
|
+
const schema = await backend.getEntitySchema(entityType, resolvedBundle);
|
|
87
|
+
|
|
88
|
+
const fields = [
|
|
89
|
+
...Object.entries(schema.attributes ?? {}).map(([name, t]) => attributeField(name, t)),
|
|
90
|
+
...Object.keys(schema.relationships ?? {}).map((name) => relationshipField(name)),
|
|
91
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
92
|
+
|
|
93
|
+
const sampledEmpty = fields.length === 0;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
entityType: schema.entityType ?? entityType,
|
|
97
|
+
bundle: schema.bundle ?? resolvedBundle,
|
|
98
|
+
...(schema.resourceType ? { resourceType: schema.resourceType } : {}),
|
|
99
|
+
approximate: true,
|
|
100
|
+
fieldCount: fields.length,
|
|
101
|
+
fields,
|
|
102
|
+
note: sampledEmpty ? EMPTY_SCHEMA_NOTE : SAMPLING_NOTE,
|
|
103
|
+
authoritativeSource: "drush-bridge (Drupal Field API)",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Definitions
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export const definitions = [
|
|
112
|
+
{
|
|
113
|
+
name: "drupal_describe_fields",
|
|
114
|
+
description:
|
|
115
|
+
"Introspect the fields of a Drupal entity type + bundle: returns a per-field " +
|
|
116
|
+
"list of { name, type, kind, cardinality?, approximate }. Read-only. Built on " +
|
|
117
|
+
"schema SAMPLING (an existing entity), so results are approximate — only " +
|
|
118
|
+
"populated fields are visible and required/cardinality/allowedValues are " +
|
|
119
|
+
"inferred from value shape. Authoritative field metadata comes from the Drush " +
|
|
120
|
+
"bridge (Field API). Use this before creating/updating entities to learn field names.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
required: ["site", "type"],
|
|
124
|
+
properties: {
|
|
125
|
+
site: { type: "string", description: "Configured site name." },
|
|
126
|
+
type: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term', 'user', 'media'." },
|
|
127
|
+
bundle: { type: "string", description: "Bundle machine name, e.g. 'article'. Defaults to the entity type for single-bundle types (e.g. 'user')." },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export const handlers = {
|
|
134
|
+
drupal_describe_fields: describeFields,
|
|
135
|
+
};
|