drupal-mcp-connector 0.9.1 → 1.0.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 +42 -0
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/index.js +16 -2
- 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,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.0] - 2026-06-15
|
|
11
|
+
|
|
12
|
+
First stable release. The tool surface, security model, and configuration
|
|
13
|
+
schema are now considered stable and will follow semantic versioning.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Stable **1.0** milestone: 89 tools across 20 modules with full read +
|
|
17
|
+
governed-write coverage (node/entity CRUD, revisions, moderation, scheduler,
|
|
18
|
+
fields, references, bulk operations, translations, paragraphs, structure,
|
|
19
|
+
search, and reports), `dryRun` preview on every write tool, the JSON:API and
|
|
20
|
+
GraphQL backends, the `write-plane` security preset, and multi-client launch
|
|
21
|
+
support (Claude Code, Claude Desktop, Grok Build).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- No functional changes since 0.10.0 — this release promotes the 0.10.x feature
|
|
25
|
+
set to a stable 1.0 line.
|
|
26
|
+
|
|
27
|
+
## [0.10.0] - 2026-06-15
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`dryRun` option** on the node + generic-entity write tools (`drupal_create_node`,
|
|
31
|
+
`drupal_update_node`, `drupal_delete_node`, `drupal_entity_create`,
|
|
32
|
+
`drupal_entity_update`, `drupal_entity_delete`) (#42). When `true`, the tool runs
|
|
33
|
+
the security checks and builds the final payload, then returns a preview
|
|
34
|
+
(`{ dryRun: true, operation, entityType, bundle, id?, attributes }`) **without
|
|
35
|
+
writing** — no backend call is made. Lets an agent confirm intent safely.
|
|
36
|
+
- **23 new tools across 11 modules** (66 → 89 tools), toward 1.0 feature coverage:
|
|
37
|
+
- **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).
|
|
38
|
+
- **Moderation** (#38): `drupal_set_moderation_state`, `drupal_content_by_moderation_state`, `drupal_list_moderation_states` (content_moderation).
|
|
39
|
+
- **Scheduler** (#39): `drupal_schedule_publish` (publish_on / unpublish_on).
|
|
40
|
+
- **Fields** (#40): `drupal_describe_fields` (bundle field schema; best-effort, Drush-enhanced).
|
|
41
|
+
- **References** (#41): `drupal_resolve_reference` (name/title → UUID).
|
|
42
|
+
- **Bulk** (#43): `drupal_bulk_create`, `drupal_bulk_update` (per-item partial-failure reporting).
|
|
43
|
+
- **Translations** (#45): `drupal_list_translations`, `drupal_create_translation`.
|
|
44
|
+
- **Paragraphs** (#44): `drupal_create_paragraph`, `drupal_get_paragraph`.
|
|
45
|
+
- **Structure** (#46): `drupal_list_menu_links`, `drupal_create_menu_link`, `drupal_list_blocks`, `drupal_create_block`.
|
|
46
|
+
- **Search** (#47): `drupal_search` (best-effort title match; Search API/Solr-ready).
|
|
47
|
+
- **Reports (extra)** (#48): `drupal_report_orphaned_references`, `drupal_report_unpublished`, `drupal_report_missing_field`.
|
|
48
|
+
- 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.
|
|
49
|
+
|
|
10
50
|
## [0.9.1] - 2026-06-15
|
|
11
51
|
|
|
12
52
|
### Security
|
|
@@ -229,6 +269,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
229
269
|
- User tools gained explicit PII-access assertions.
|
|
230
270
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
231
271
|
|
|
272
|
+
[1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
|
|
273
|
+
[0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
|
|
232
274
|
[0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
|
|
233
275
|
[0.9.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.0
|
|
234
276
|
[0.8.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.8.0
|
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,7 +185,7 @@ 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 |
|
|
180
191
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
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
|
/**
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|