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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tool group: Taxonomy.
3
+ *
4
+ * Vocabulary discovery and term CRUD, backend-agnostic via resolveBackend. A
5
+ * vocabulary is modelled as a bundle of the `taxonomy_term` entity type; reads
6
+ * are redacted per the site security 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
+ // Implementations
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * List all taxonomy vocabularies (bundles of the taxonomy_term entity type).
19
+ *
20
+ * @param {object} args - { site? }.
21
+ * @returns {Promise<object[]>} Vocabulary bundle descriptors.
22
+ */
23
+ async function listVocabularies({ site: siteName }) {
24
+ const site = getSiteConfig(siteName);
25
+ const backend = await resolveBackend(site);
26
+ return backend.listBundles("taxonomy_term");
27
+ }
28
+
29
+ /**
30
+ * List terms in a vocabulary, sorted by name, redacted per policy.
31
+ *
32
+ * @param {object} args - { site?, vocabulary, limit?, offset? }.
33
+ * @returns {Promise<{total: number, approximate: boolean, terms: object[]}>}
34
+ */
35
+ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset = 0 }) {
36
+ const site = getSiteConfig(siteName);
37
+ const sec = resolveSecurityConfig(site);
38
+ const backend = await resolveBackend(site);
39
+ const res = await backend.listEntities({
40
+ entityType: "taxonomy_term", bundle: vocabulary,
41
+ sort: [{ field: "name", dir: "asc" }], page: { limit, offset },
42
+ });
43
+ return {
44
+ total: res.page?.total ?? res.entities.length,
45
+ approximate: res.approximate ?? false,
46
+ terms: res.entities.map((e) => redactCanonicalEntity(e, sec, "taxonomy_term")),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Fetch a single taxonomy term by UUID, redacted per policy.
52
+ *
53
+ * @param {object} args - { site?, vocabulary, id }.
54
+ * @returns {Promise<object|null>} The redacted term, or null if not found.
55
+ */
56
+ async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
57
+ const site = getSiteConfig(siteName);
58
+ const sec = resolveSecurityConfig(site);
59
+ const backend = await resolveBackend(site);
60
+ const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
61
+ return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
62
+ }
63
+
64
+ /**
65
+ * Create a taxonomy term. The description is wrapped as a plain_text formatted
66
+ * field; a parentId, if given, becomes a hierarchical `parent` relationship.
67
+ *
68
+ * @param {object} args - { site?, vocabulary, name, description?, weight?, parentId? }.
69
+ * @returns {Promise<object>} The created term descriptor.
70
+ */
71
+ async function createTaxonomyTerm({ site: siteName, vocabulary, name, description, weight = 0, parentId }) {
72
+ const site = getSiteConfig(siteName);
73
+ const backend = await resolveBackend(site);
74
+ const attributes = { name, weight };
75
+ if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
76
+ const relationships = parentId
77
+ ? { parent: { data: [{ type: `taxonomy_term--${vocabulary}`, id: parentId }] } }
78
+ : undefined;
79
+ return backend.createEntity({ entityType: "taxonomy_term", bundle: vocabulary, attributes, relationships });
80
+ }
81
+
82
+ /**
83
+ * Update a term's name, description, and/or weight (partial — omitted fields
84
+ * are left untouched).
85
+ *
86
+ * @param {object} args - { site?, vocabulary, id, name?, description?, weight? }.
87
+ * @returns {Promise<object>} The updated term descriptor.
88
+ */
89
+ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
90
+ const site = getSiteConfig(siteName);
91
+ const backend = await resolveBackend(site);
92
+ const attributes = {};
93
+ if (name !== undefined) attributes.name = name;
94
+ if (weight !== undefined) attributes.weight = weight;
95
+ if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
96
+ return backend.updateEntity({ entityType: "taxonomy_term", bundle: vocabulary, id, attributes });
97
+ }
98
+
99
+ /**
100
+ * Delete a taxonomy term. Destructive-allowed assertion is applied upstream by
101
+ * the security middleware.
102
+ *
103
+ * @param {object} args - { site?, vocabulary, id }.
104
+ * @returns {Promise<{success: boolean, deletedId: string}>}
105
+ */
106
+ async function deleteTaxonomyTerm({ site: siteName, vocabulary, id }) {
107
+ const site = getSiteConfig(siteName);
108
+ const backend = await resolveBackend(site);
109
+ await backend.deleteEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
110
+ return { success: true, deletedId: id };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Definitions
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export const definitions = [
118
+ {
119
+ name: "drupal_list_vocabularies",
120
+ description: "List all taxonomy vocabularies defined on this Drupal site.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: { site: { type: "string" } },
124
+ },
125
+ },
126
+ {
127
+ name: "drupal_get_taxonomy_terms",
128
+ description: "List all terms in a taxonomy vocabulary, sorted by name.",
129
+ inputSchema: {
130
+ type: "object", required: ["vocabulary"],
131
+ properties: {
132
+ site: { type: "string" },
133
+ vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags'" },
134
+ limit: { type: "number", default: 50 },
135
+ offset: { type: "number", default: 0 },
136
+ },
137
+ },
138
+ },
139
+ {
140
+ name: "drupal_get_taxonomy_term",
141
+ description: "Fetch a single taxonomy term by UUID.",
142
+ inputSchema: {
143
+ type: "object", required: ["vocabulary", "id"],
144
+ properties: {
145
+ site: { type: "string" },
146
+ vocabulary: { type: "string" },
147
+ id: { type: "string", description: "Term UUID" },
148
+ },
149
+ },
150
+ },
151
+ {
152
+ name: "drupal_create_taxonomy_term",
153
+ description: "Create a new taxonomy term in a vocabulary.",
154
+ inputSchema: {
155
+ type: "object", required: ["vocabulary", "name"],
156
+ properties: {
157
+ site: { type: "string" },
158
+ vocabulary: { type: "string" },
159
+ name: { type: "string" },
160
+ description: { type: "string" },
161
+ weight: { type: "number", default: 0 },
162
+ parentId: { type: "string", description: "UUID of parent term (for hierarchical vocabularies)" },
163
+ },
164
+ },
165
+ },
166
+ {
167
+ name: "drupal_update_taxonomy_term",
168
+ description: "Update an existing taxonomy term's name, description, or weight.",
169
+ inputSchema: {
170
+ type: "object", required: ["vocabulary", "id"],
171
+ properties: {
172
+ site: { type: "string" },
173
+ vocabulary: { type: "string" },
174
+ id: { type: "string" },
175
+ name: { type: "string" },
176
+ description: { type: "string" },
177
+ weight: { type: "number" },
178
+ },
179
+ },
180
+ },
181
+ {
182
+ name: "drupal_delete_taxonomy_term",
183
+ description: "Delete a taxonomy term. Confirm with the user before calling.",
184
+ inputSchema: {
185
+ type: "object", required: ["vocabulary", "id"],
186
+ properties: {
187
+ site: { type: "string" },
188
+ vocabulary: { type: "string" },
189
+ id: { type: "string" },
190
+ },
191
+ },
192
+ },
193
+ ];
194
+
195
+ export const handlers = {
196
+ drupal_list_vocabularies: listVocabularies,
197
+ drupal_get_taxonomy_terms: getTaxonomyTerms,
198
+ drupal_get_taxonomy_term: getTaxonomyTerm,
199
+ drupal_create_taxonomy_term: createTaxonomyTerm,
200
+ drupal_update_taxonomy_term: updateTaxonomyTerm,
201
+ drupal_delete_taxonomy_term: deleteTaxonomyTerm,
202
+ };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Tool group: User accounts.
3
+ *
4
+ * User CRUD plus role listing, backend-agnostic. Because user records are PII,
5
+ * each handler asserts read/write access against the per-site policy in-handler
6
+ * (in addition to the name-prefix gating in index.js), and reads are redacted
7
+ * via redactCanonicalEntity.
8
+ */
9
+
10
+ import { getSiteConfig } from "../lib/config.js";
11
+ import { resolveBackend } from "../lib/backends/index.js";
12
+ import { resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
13
+
14
+ /**
15
+ * Build a JSON:API relationship payload assigning the given role IDs.
16
+ * @param {string[]} roles - Role UUIDs.
17
+ * @returns {{data: Array<{type: string, id: string}>}}
18
+ */
19
+ const ROLE_REL = (roles) => ({ data: roles.map((id) => ({ type: "user_role--user_role", id })) });
20
+
21
+ /**
22
+ * List user accounts, optionally filtered by status and/or role machine name.
23
+ *
24
+ * @param {object} args - { site?, status?, role?, limit?, offset? }.
25
+ * A `role` is matched on the internal role id relationship path.
26
+ * @returns {Promise<{total: number, approximate: boolean, offset: number,
27
+ * nextOffset: number, users: object[]}>} Paged, redacted user list.
28
+ * @throws {SecurityError} If reading users is not permitted by policy.
29
+ */
30
+ async function listUsers({ site: siteName, status, role, limit = 20, offset = 0 }) {
31
+ const site = getSiteConfig(siteName);
32
+ const sec = resolveSecurityConfig(site);
33
+ assertReadAllowed(sec, "user", "user");
34
+ const backend = await resolveBackend(site);
35
+ const filters = [];
36
+ if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
37
+ if (role) filters.push({ field: "roles.meta.drupal_internal__id", op: "eq", value: role });
38
+ const res = await backend.listEntities({ entityType: "user", bundle: "user", filters, sort: [{ field: "name", dir: "asc" }], page: { limit, offset } });
39
+ const users = res.entities.map((e) => redactCanonicalEntity(e, sec, "user"));
40
+ return { total: res.page?.total ?? users.length, approximate: res.approximate ?? false, offset, nextOffset: offset + users.length, users };
41
+ }
42
+
43
+ /**
44
+ * Fetch a single user by UUID, with roles sideloaded, redacted per policy.
45
+ *
46
+ * @param {object} args - { site?, id }.
47
+ * @returns {Promise<object|null>} The redacted user, or null if not found.
48
+ * @throws {SecurityError} If reading users is not permitted.
49
+ */
50
+ async function getUser({ site: siteName, id }) {
51
+ const site = getSiteConfig(siteName);
52
+ const sec = resolveSecurityConfig(site);
53
+ assertReadAllowed(sec, "user", "user");
54
+ const backend = await resolveBackend(site);
55
+ const entity = await backend.getEntity({ entityType: "user", bundle: "user", id, include: ["roles"] });
56
+ return entity ? redactCanonicalEntity(entity, sec, "user") : null;
57
+ }
58
+
59
+ /**
60
+ * Look up a user by exact username.
61
+ *
62
+ * @param {object} args - { site?, name }.
63
+ * @returns {Promise<object>} The redacted matching user.
64
+ * @throws {Error} If no user matches the name.
65
+ * @throws {SecurityError} If reading users is not permitted.
66
+ */
67
+ async function getUserByName({ site: siteName, name }) {
68
+ const site = getSiteConfig(siteName);
69
+ const sec = resolveSecurityConfig(site);
70
+ assertReadAllowed(sec, "user", "user");
71
+ const backend = await resolveBackend(site);
72
+ const res = await backend.listEntities({ entityType: "user", bundle: "user", filters: [{ field: "name", op: "eq", value: name }], page: { limit: 1 } });
73
+ if (!res.entities.length) throw new Error(`No user found with name: "${name}"`);
74
+ return redactCanonicalEntity(res.entities[0], sec, "user");
75
+ }
76
+
77
+ /**
78
+ * Create a user account. The password is mapped to Drupal's `pass` attribute
79
+ * shape ([{ value }]); roles, if any, become a role relationship.
80
+ *
81
+ * @param {object} args - { site?, name, mail, password?, status?, roles?, timezone? }.
82
+ * @returns {Promise<object>} The created user descriptor.
83
+ * @throws {SecurityError} If creating users is not permitted.
84
+ */
85
+ async function createUser({ site: siteName, name, mail, password, status = true, roles = [], timezone = "UTC" }) {
86
+ const site = getSiteConfig(siteName);
87
+ const sec = resolveSecurityConfig(site);
88
+ assertWriteAllowed(sec, "create", "user", "user");
89
+ const backend = await resolveBackend(site);
90
+ const attributes = { name, mail, status, timezone };
91
+ if (password) attributes.pass = [{ value: password }];
92
+ const relationships = roles.length ? { roles: ROLE_REL(roles) } : undefined;
93
+ return backend.createEntity({ entityType: "user", bundle: "user", attributes, relationships });
94
+ }
95
+
96
+ /**
97
+ * Update a user account (partial). Supplying `roles` replaces the full role
98
+ * set; omitting it leaves roles untouched.
99
+ *
100
+ * @param {object} args - { site?, id, name?, mail?, password?, status?, roles?, timezone? }.
101
+ * @returns {Promise<object>} The updated user descriptor.
102
+ * @throws {SecurityError} If updating users is not permitted.
103
+ */
104
+ async function updateUser({ site: siteName, id, name, mail, password, status, roles, timezone }) {
105
+ const site = getSiteConfig(siteName);
106
+ const sec = resolveSecurityConfig(site);
107
+ assertWriteAllowed(sec, "update", "user", "user");
108
+ const backend = await resolveBackend(site);
109
+ const attributes = {};
110
+ if (name !== undefined) attributes.name = name;
111
+ if (mail !== undefined) attributes.mail = mail;
112
+ if (status !== undefined) attributes.status = status;
113
+ if (timezone !== undefined) attributes.timezone = timezone;
114
+ if (typeof password !== "undefined") attributes.pass = [{ value: password }];
115
+ const relationships = roles !== undefined ? { roles: ROLE_REL(roles) } : undefined;
116
+ return backend.updateEntity({ entityType: "user", bundle: "user", id, attributes, relationships });
117
+ }
118
+
119
+ /**
120
+ * Block or unblock a user by toggling its status (no deletion).
121
+ *
122
+ * @param {object} args - { site?, id, block? }. block=true sets status=false.
123
+ * @returns {Promise<object>} The updated user descriptor.
124
+ * @throws {SecurityError} If updating users is not permitted.
125
+ */
126
+ async function blockUser({ site: siteName, id, block = true }) {
127
+ const site = getSiteConfig(siteName);
128
+ const sec = resolveSecurityConfig(site);
129
+ assertWriteAllowed(sec, "update", "user", "user");
130
+ const backend = await resolveBackend(site);
131
+ return backend.updateEntity({ entityType: "user", bundle: "user", id, attributes: { status: !block } });
132
+ }
133
+
134
+ /**
135
+ * List all user roles defined on the site.
136
+ *
137
+ * @param {object} args - { site? }.
138
+ * @returns {Promise<object[]>} Role descriptors.
139
+ */
140
+ async function listRoles({ site: siteName }) {
141
+ const site = getSiteConfig(siteName);
142
+ const backend = await resolveBackend(site);
143
+ return backend.listRoles();
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Definitions
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export const definitions = [
151
+ {
152
+ name: "drupal_list_users",
153
+ description: "List Drupal user accounts. Filter by active/blocked status or by role machine name.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ site: { type: "string" },
158
+ status: { type: "boolean", description: "true = active only, false = blocked only" },
159
+ role: { type: "string", description: "Filter by role machine name, e.g. 'editor'" },
160
+ limit: { type: "number", default: 20 },
161
+ offset: { type: "number", default: 0 },
162
+ },
163
+ },
164
+ },
165
+ {
166
+ name: "drupal_get_user",
167
+ description: "Fetch a single Drupal user account by UUID, including their assigned roles.",
168
+ inputSchema: {
169
+ type: "object", required: ["id"],
170
+ properties: {
171
+ site: { type: "string" },
172
+ id: { type: "string", description: "User UUID" },
173
+ },
174
+ },
175
+ },
176
+ {
177
+ name: "drupal_get_user_by_name",
178
+ description: "Look up a Drupal user by their exact username.",
179
+ inputSchema: {
180
+ type: "object", required: ["name"],
181
+ properties: {
182
+ site: { type: "string" },
183
+ name: { type: "string", description: "Drupal username (exact match)" },
184
+ },
185
+ },
186
+ },
187
+ {
188
+ name: "drupal_create_user",
189
+ description: "Create a new Drupal user account with optional roles and password.",
190
+ inputSchema: {
191
+ type: "object", required: ["name", "mail"],
192
+ properties: {
193
+ site: { type: "string" },
194
+ name: { type: "string", description: "Username" },
195
+ mail: { type: "string", description: "Email address" },
196
+ password: { type: "string", description: "Initial password (plaintext — sent over HTTPS)" },
197
+ status: { type: "boolean", default: true, description: "true = active (default)" },
198
+ roles: { type: "array", items: { type: "string" }, description: "Role UUIDs to assign" },
199
+ timezone: { type: "string", default: "UTC" },
200
+ },
201
+ },
202
+ },
203
+ {
204
+ name: "drupal_update_user",
205
+ description: "Update a Drupal user account. Only include fields you want to change. Can reassign roles by providing a full replacement role list.",
206
+ inputSchema: {
207
+ type: "object", required: ["id"],
208
+ properties: {
209
+ site: { type: "string" },
210
+ id: { type: "string", description: "User UUID" },
211
+ name: { type: "string" },
212
+ mail: { type: "string" },
213
+ password: { type: "string" },
214
+ status: { type: "boolean" },
215
+ roles: { type: "array", items: { type: "string" }, description: "Full replacement role UUID list" },
216
+ timezone: { type: "string" },
217
+ },
218
+ },
219
+ },
220
+ {
221
+ name: "drupal_block_user",
222
+ description: "Block or unblock a Drupal user account without deleting it.",
223
+ inputSchema: {
224
+ type: "object", required: ["id"],
225
+ properties: {
226
+ site: { type: "string" },
227
+ id: { type: "string", description: "User UUID" },
228
+ block: { type: "boolean", default: true, description: "true = block, false = unblock" },
229
+ },
230
+ },
231
+ },
232
+ {
233
+ name: "drupal_list_roles",
234
+ description: "List all user roles defined on this Drupal site.",
235
+ inputSchema: {
236
+ type: "object",
237
+ properties: { site: { type: "string" } },
238
+ },
239
+ },
240
+ ];
241
+
242
+ export const handlers = {
243
+ drupal_list_users: listUsers,
244
+ drupal_get_user: getUser,
245
+ drupal_get_user_by_name: getUserByName,
246
+ drupal_create_user: createUser,
247
+ drupal_update_user: updateUser,
248
+ drupal_block_user: blockUser,
249
+ drupal_list_roles: listRoles,
250
+ };