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,391 @@
1
+ /**
2
+ * Read-only GraphQL backend adapter over graphql_compose.
3
+ *
4
+ * Single responsibility: implement the Backend interface against a
5
+ * graphql_compose schema, returning the shared CanonicalEntity shape. This
6
+ * backend is read-only — create/update/delete are capability-gated and throw
7
+ * BackendCapabilityError. Because graphql_compose offers no server-side field
8
+ * filtering and only a fixed enum of sort keys, arbitrary filters and
9
+ * non-native sorts are evaluated client-side over a bounded page set, and the
10
+ * results are flagged `approximate`/`truncated` so callers know totals/paging
11
+ * are best-effort.
12
+ */
13
+
14
+ import { drupalGraphqlFetch } from "../drupal-fetch.js";
15
+ import { Backend } from "./backend-interface.js";
16
+ import { BackendCapabilityError } from "./errors.js";
17
+ import { loadSchemaMap } from "./graphql-schema.js";
18
+ import { buildCollectionQuery, buildSingleQuery } from "./graphql-query.js";
19
+ import { graphqlNodeToCanonical } from "./graphql-normalize.js";
20
+ import { applyClientFilters, applyClientSort } from "./graphql-filter.js";
21
+ import { graphqlTypeToEntity } from "./graphql-names.js";
22
+
23
+ // Upper bound on records pulled into memory for the client-side filter/sort
24
+ // path; caps cost and flags results `truncated` when exceeded.
25
+ const MAX_CLIENT_RECORDS = 1000;
26
+ // Page size used when the caller does not specify one.
27
+ const DEFAULT_PAGE = 50;
28
+ // Per-request fetch cap when collecting pages for the client-side path.
29
+ const CLIENT_FETCH_BATCH = 100;
30
+
31
+ // Canonical sort field -> graphql_compose ConnectionSortKeys enum value. Only
32
+ // these map to a native (server-side) sort; anything else falls back to client.
33
+ const SORT_KEY_MAP = new Map([
34
+ ["created", "CREATED_AT"],
35
+ ["changed", "UPDATED_AT"],
36
+ ["title", "TITLE"],
37
+ ]);
38
+
39
+ /**
40
+ * Safe dynamic-key lookup that avoids security/detect-object-injection.
41
+ * @param {object|null|undefined} obj Source object.
42
+ * @param {string} key Property name to read.
43
+ * @returns {*} The value, or undefined when obj is null/undefined or key absent.
44
+ */
45
+ function pick(obj, key) {
46
+ if (obj === null || obj === undefined) return undefined;
47
+ return new Map(Object.entries(obj)).get(key);
48
+ }
49
+
50
+ /**
51
+ * Read-only Backend adapter backed by a graphql_compose schema.
52
+ */
53
+ export class GraphqlBackend extends Backend {
54
+ /** @param {object} site Site config (must include `_name`). */
55
+ constructor(site) {
56
+ super();
57
+ this.site = site;
58
+ }
59
+
60
+ /**
61
+ * Report capabilities: read-only, no count/filter, enum-only sort.
62
+ * @returns {import("./backend-interface.js").Capabilities}
63
+ */
64
+ capabilities() {
65
+ return {
66
+ read: true, write: false, delete: false,
67
+ count: false, filter: false, sort: "enum", revisions: false,
68
+ fieldAvailability: (entityType, bundle) => this._fieldNames(entityType, bundle),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Load (cached) the site's SchemaMap.
74
+ * @returns {Promise<import("./graphql-schema.js").SchemaMap>}
75
+ */
76
+ async _map() {
77
+ return loadSchemaMap(this.site);
78
+ }
79
+
80
+ /**
81
+ * List the known field names for a bundle.
82
+ * @param {string} entityType
83
+ * @param {string} bundle
84
+ * @returns {Promise<string[]>} Field names, or [] when the bundle is unknown.
85
+ */
86
+ async _fieldNames(entityType, bundle) {
87
+ const map = await this._map();
88
+ const entry = map.forEntity(entityType, bundle);
89
+ return entry ? [...entry.fields.keys()] : [];
90
+ }
91
+
92
+ /**
93
+ * Resolve a request into native sort args, when possible.
94
+ * Only a single sort on a server-supported key qualifies; multi-key or
95
+ * unsupported sorts return null so the caller falls back to client-side sort.
96
+ * @param {import("./graphql-schema.js").SchemaMap} map
97
+ * @param {Array<{field: string, dir?: "asc"|"desc"}>} sort
98
+ * @returns {{sortKey: string, reverse: boolean}|null}
99
+ */
100
+ _nativeSortKey(map, sort) {
101
+ if (!sort || sort.length !== 1) return null;
102
+ const key = SORT_KEY_MAP.get(sort[0].field);
103
+ if (key && map.sortKeys.has(key)) return { sortKey: key, reverse: sort[0].dir === "desc" };
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Resolve a queryable schema entry for an entity/bundle, or throw.
109
+ * @param {string} entityType
110
+ * @param {string} bundle
111
+ * @returns {Promise<{map: import("./graphql-schema.js").SchemaMap, entry: import("./graphql-schema.js").SchemaEntry}>}
112
+ * @throws {BackendCapabilityError} When no queryable collection type exists.
113
+ */
114
+ async _requireEntry(entityType, bundle) {
115
+ const map = await this._map();
116
+ const entry = map.forEntity(entityType, bundle);
117
+ if (!entry || !entry.collection) {
118
+ throw new BackendCapabilityError(
119
+ `GraphQL backend has no queryable type for "${entityType}/${bundle}" on site "${this.site._name}".`
120
+ );
121
+ }
122
+ return { map, entry };
123
+ }
124
+
125
+ /**
126
+ * Run a GraphQL query and surface server errors as thrown errors.
127
+ * GraphQL reports query errors as HTTP 200 with an `errors` array; throw on
128
+ * them so a backend failure surfaces as an error, not a silently-empty result.
129
+ * @param {string} query GraphQL query document.
130
+ * @returns {Promise<object>} The raw `{ data, errors? }` response.
131
+ * @throws {Error} When the response contains GraphQL errors.
132
+ */
133
+ async _query(query) {
134
+ const json = await drupalGraphqlFetch(this.site, { query });
135
+ if (json?.errors?.length) {
136
+ throw new Error(
137
+ `GraphQL query failed on site "${this.site._name}": ` +
138
+ json.errors.map((e) => e.message).join("; ")
139
+ );
140
+ }
141
+ return json;
142
+ }
143
+
144
+ /**
145
+ * Fetch one page of a collection.
146
+ * @param {import("./graphql-schema.js").SchemaEntry} entry
147
+ * @param {{first?: number, after?: string, sortKey?: string, reverse?: boolean}} args
148
+ * @returns {Promise<{nodes: object[], hasNext: boolean, cursor: ?string}>}
149
+ */
150
+ async _fetchPage(entry, args) {
151
+ const query = buildCollectionQuery(entry, args);
152
+ const json = await this._query(query);
153
+ const conn = pick(json.data, entry.collection);
154
+ return {
155
+ nodes: conn?.nodes ?? [],
156
+ hasNext: Boolean(conn?.pageInfo?.hasNextPage),
157
+ cursor: conn?.pageInfo?.endCursor ?? null,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * List entities for a descriptor, using native paging/sort when the schema
163
+ * supports it and falling back to a bounded client-side filter/sort path
164
+ * otherwise.
165
+ * @param {import("../canonical.js").QueryDescriptor} descriptor
166
+ * @returns {Promise<import("./backend-interface.js").ListResult>} When the
167
+ * client-side path runs, `page.total` reflects the bounded set and the
168
+ * result is flagged `approximate` (and `truncated` if the cap was hit).
169
+ */
170
+ async listEntities(descriptor) {
171
+ const { entityType, bundle, filters = [], sort = [], page = {} } = descriptor;
172
+ const { map, entry } = await this._requireEntry(entityType, bundle);
173
+ const limit = page.limit ?? DEFAULT_PAGE;
174
+
175
+ const native = this._nativeSortKey(map, sort);
176
+ const needsClient = filters.length > 0 || (sort.length > 0 && !native);
177
+
178
+ if (!needsClient) {
179
+ const offset = page.offset ?? 0;
180
+ const pageData = await this._fetchPage(entry, { first: offset + limit, ...(native ? { sortKey: native.sortKey, reverse: native.reverse } : {}) });
181
+ const all = pageData.nodes.map(graphqlNodeToCanonical);
182
+ const window = all.slice(offset, offset + limit);
183
+ return {
184
+ entities: window,
185
+ page: { total: null, hasNext: pageData.hasNext || all.length > offset + limit, cursor: pageData.cursor },
186
+ approximate: false,
187
+ truncated: false,
188
+ };
189
+ }
190
+
191
+ // Client-side path: paginate up to the cap, then filter/sort/slice.
192
+ const collected = [];
193
+ let after = null;
194
+ let truncated = false;
195
+ const sortArgs = native ? { sortKey: native.sortKey, reverse: native.reverse } : {};
196
+ for (;;) {
197
+ const remaining = MAX_CLIENT_RECORDS - collected.length;
198
+ if (remaining <= 0) { truncated = true; break; }
199
+ const pageData = await this._fetchPage(entry, { first: Math.min(CLIENT_FETCH_BATCH, remaining), after, ...sortArgs });
200
+ collected.push(...pageData.nodes.map(graphqlNodeToCanonical));
201
+ if (!pageData.hasNext) break;
202
+ if (!pageData.cursor) break; // server bug guard: hasNext:true but no cursor — stop rather than spin
203
+ after = pageData.cursor;
204
+ }
205
+
206
+ let result = applyClientFilters(collected, filters);
207
+ if (sort.length && !native) result = applyClientSort(result, sort);
208
+ const offset = page.offset ?? 0;
209
+ const sliced = result.slice(offset, offset + limit);
210
+ return {
211
+ entities: sliced,
212
+ page: { total: result.length, hasNext: result.length > offset + limit, cursor: null },
213
+ approximate: true,
214
+ truncated,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Fetch a single entity by id.
220
+ * @param {{entityType: string, bundle: string, id: string}} ref
221
+ * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
222
+ * @throws {BackendCapabilityError} When the bundle is not queryable.
223
+ */
224
+ async getEntity({ entityType, bundle, id }) {
225
+ const { entry } = await this._requireEntry(entityType, bundle);
226
+ const query = buildSingleQuery(entry, id);
227
+ const json = await this._query(query);
228
+ const node = pick(json.data, entry.single);
229
+ return node ? graphqlNodeToCanonical(node) : null;
230
+ }
231
+
232
+ /**
233
+ * @returns {Promise<never>}
234
+ * @throws {BackendCapabilityError} Always — this backend is read-only.
235
+ */
236
+ async createEntity() { return this._noWrite("create"); }
237
+ /**
238
+ * @returns {Promise<never>}
239
+ * @throws {BackendCapabilityError} Always — this backend is read-only.
240
+ */
241
+ async updateEntity() { return this._noWrite("update"); }
242
+ /**
243
+ * @returns {Promise<never>}
244
+ * @throws {BackendCapabilityError} Always — this backend is read-only.
245
+ */
246
+ async deleteEntity() { return this._noWrite("delete"); }
247
+
248
+ /**
249
+ * Throw the uniform read-only error for a write operation.
250
+ * @param {string} op Operation name for the message.
251
+ * @returns {never}
252
+ * @throws {BackendCapabilityError} Always.
253
+ */
254
+ _noWrite(op) {
255
+ throw new BackendCapabilityError(
256
+ `The GraphQL backend for site "${this.site._name}" is read-only; "${op}" is not supported. ` +
257
+ "Use a JSON:API site for writes."
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Discover queryable node resource types.
263
+ * @returns {Promise<{resourceTypes: string[]}>} `node--<bundle>` identifiers.
264
+ */
265
+ async introspect() {
266
+ const map = await this._map();
267
+ const resourceTypes = [];
268
+ for (const bundle of map.nodeBundles()) resourceTypes.push(`node--${bundle}`);
269
+ return { resourceTypes };
270
+ }
271
+
272
+ /**
273
+ * List node content types. GraphQL exposes no human label/description, so
274
+ * `label` mirrors the machine id and `description` is null.
275
+ * @returns {Promise<Array<{id: string, label: string, description: null}>>}
276
+ */
277
+ async listContentTypes() {
278
+ const map = await this._map();
279
+ return map.nodeBundles().map((bundle) => ({ id: bundle, label: bundle, description: null }));
280
+ }
281
+
282
+ /**
283
+ * List the bundles of an entity type (label mirrors id; no description).
284
+ * @param {string} entityType
285
+ * @returns {Promise<Array<{id: string, label: string, description: null}>>}
286
+ */
287
+ async listBundles(entityType) {
288
+ const map = await this._map();
289
+ return map.bundlesOf(entityType).map((bundle) => ({ id: bundle, label: bundle, description: null }));
290
+ }
291
+
292
+ /**
293
+ * @returns {Promise<never>}
294
+ * @throws {BackendCapabilityError} Always — roles are not exposed over GraphQL.
295
+ */
296
+ async listRoles() {
297
+ throw new BackendCapabilityError(
298
+ `Listing user roles is not available over the GraphQL backend for site "${this.site._name}". ` +
299
+ "Use a JSON:API site to enumerate roles."
300
+ );
301
+ }
302
+
303
+ /**
304
+ * @returns {Promise<never>}
305
+ * @throws {BackendCapabilityError} Always — uploads are not supported over GraphQL.
306
+ */
307
+ async uploadFile() {
308
+ throw new BackendCapabilityError(
309
+ `File upload is not available over the GraphQL backend for site "${this.site._name}". ` +
310
+ "Use a JSON:API site to upload files."
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Approximate a count by listing up to the client-record cap.
316
+ * GraphQL has no exact server count, so the bounded result count is always
317
+ * flagged approximate.
318
+ * @param {import("../canonical.js").QueryDescriptor} descriptor
319
+ * @returns {Promise<{count: number, approximate: true}>}
320
+ */
321
+ async countEntities(descriptor) {
322
+ const res = await this.listEntities({ ...descriptor, page: { limit: MAX_CLIENT_RECORDS } });
323
+ return { count: res.entities.length, approximate: true };
324
+ }
325
+
326
+ /**
327
+ * List all entity/bundle pairs as resource types.
328
+ * @returns {Promise<Array<{resourceType: string, entityType: string, bundle: string}>>}
329
+ */
330
+ async listResourceTypes() {
331
+ const map = await this._map();
332
+ return map.allEntities().map(({ entityType, bundle }) => ({
333
+ resourceType: `${entityType}--${bundle}`, entityType, bundle,
334
+ }));
335
+ }
336
+
337
+ /**
338
+ * Describe a bundle's fields, splitting them into attributes vs.
339
+ * relationships. A field is a relationship when it resolves to another
340
+ * entity — i.e. a union/interface (single or list) or an OBJECT that maps to
341
+ * an entity type (excluding the known scalar-wrapper object types). All other
342
+ * fields are attributes, typed by their named/element type or kind.
343
+ * @param {string} entityType
344
+ * @param {string} bundle
345
+ * @returns {Promise<{entityType: string, bundle: string, resourceType: string, attributes: object, relationships: object}>}
346
+ * @throws {BackendCapabilityError} When the bundle is not queryable.
347
+ */
348
+ async getEntitySchema(entityType, bundle) {
349
+ const { entry } = await this._requireEntry(entityType, bundle);
350
+ const attrPairs = [];
351
+ const relPairs = [];
352
+ for (const [name, desc] of entry.fields) {
353
+ const isUnionList = desc.kind === "LIST" && (desc.ofTypeKind === "UNION" || desc.ofTypeKind === "INTERFACE");
354
+ const isEntityObj = desc.kind === "OBJECT" && graphqlTypeToEntity(desc.typeName) && !["DateTime", "Language", "TextSummary", "Text"].includes(desc.typeName);
355
+ const isUnion = desc.kind === "UNION" || desc.kind === "INTERFACE";
356
+ if (isUnionList || isEntityObj || isUnion) {
357
+ relPairs.push([name, "relationship"]);
358
+ } else {
359
+ attrPairs.push([name, desc.typeName || desc.ofTypeName || desc.kind]);
360
+ }
361
+ }
362
+ return {
363
+ entityType, bundle, resourceType: `${entityType}--${bundle}`,
364
+ attributes: Object.fromEntries(attrPairs),
365
+ relationships: Object.fromEntries(relPairs),
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Escape hatch: run a raw GraphQL request and return the raw response.
371
+ * @param {{query: string, variables?: object, operationName?: string}} input
372
+ * @returns {Promise<object>} The raw `{ data, errors? }` response.
373
+ */
374
+ async rawQuery({ query, variables, operationName }) {
375
+ return drupalGraphqlFetch(this.site, { query, variables, operationName });
376
+ }
377
+
378
+ /**
379
+ * Resolve the first candidate field name that exists on the bundle.
380
+ * @param {string} entityType
381
+ * @param {string} bundle
382
+ * @param {string[]} candidates Field names in preference order.
383
+ * @returns {Promise<?string>} Matching field name, or null when none match.
384
+ */
385
+ async resolveFieldName(entityType, bundle, candidates) {
386
+ const map = await this._map();
387
+ const entry = map.forEntity(entityType, bundle);
388
+ if (!entry) return null;
389
+ return candidates.find((c) => entry.fields.has(c)) ?? null;
390
+ }
391
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Backend resolution and registry.
3
+ *
4
+ * Single responsibility: choose the concrete backend adapter for a site —
5
+ * honoring an explicit `api` config first, otherwise running a one-time
6
+ * capability probe — and cache the verdict per site so resolution happens
7
+ * once. This is the only module that knows the protocol-name -> adapter-class
8
+ * mapping.
9
+ */
10
+
11
+ import { drupalFetch } from "../drupal-fetch.js";
12
+ import { drupalGraphqlFetch } from "../drupal-fetch.js";
13
+ import { JsonApiBackend } from "./jsonapi.js";
14
+ import { GraphqlBackend } from "./graphql.js";
15
+ import { BackendResolutionError } from "./errors.js";
16
+
17
+ // Protocol name -> adapter class. Probe order follows insertion order.
18
+ const REGISTRY = new Map([
19
+ ["jsonapi", JsonApiBackend],
20
+ ["graphql", GraphqlBackend],
21
+ ]);
22
+
23
+ // Per-site resolved-backend cache, keyed by site._name.
24
+ const cache = new Map();
25
+
26
+ /**
27
+ * Test helper: clear the per-site backend cache.
28
+ * @returns {void}
29
+ */
30
+ export function _clearBackendCache() {
31
+ cache.clear();
32
+ }
33
+
34
+ /**
35
+ * Resolve (and cache) the backend adapter for a site.
36
+ * @param {object} site Site config; must include `_name` and may include `api`.
37
+ * @returns {Promise<import("./backend-interface.js").Backend>} A backend instance.
38
+ * @throws {BackendResolutionError} When no configured/probed backend is usable.
39
+ */
40
+ export async function resolveBackend(site) {
41
+ if (cache.has(site._name)) return cache.get(site._name);
42
+
43
+ const order = normalizeApiOrder(site.api);
44
+ let backend;
45
+
46
+ if (order) {
47
+ backend = await firstUsable(site, order);
48
+ if (!backend) {
49
+ throw new BackendResolutionError(
50
+ `Site "${site._name}": none of the configured api backends [${order.join(", ")}] are usable. ` +
51
+ "Check the \"api\" setting and that the endpoint is reachable."
52
+ );
53
+ }
54
+ } else {
55
+ backend = await probe(site);
56
+ if (!backend) {
57
+ throw new BackendResolutionError(
58
+ `Site "${site._name}": could not auto-detect a usable API. ` +
59
+ "Set \"api\" in config (e.g. \"graphql\" or \"jsonapi\")."
60
+ );
61
+ }
62
+ }
63
+
64
+ cache.set(site._name, backend);
65
+ return backend;
66
+ }
67
+
68
+ /**
69
+ * Normalize the `api` config into an ordered list of protocol names.
70
+ * @param {string|string[]|undefined|null} api Config value.
71
+ * @returns {string[]|null} Ordered protocol names, or null when unset/invalid.
72
+ */
73
+ function normalizeApiOrder(api) {
74
+ if (!api) return null;
75
+ if (typeof api === "string") return [api];
76
+ if (Array.isArray(api)) return api;
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Return the first configured backend that is actually reachable.
82
+ * @param {object} site Site config.
83
+ * @param {string[]} order Protocol names in preference order.
84
+ * @returns {Promise<?import("./backend-interface.js").Backend>} Instance or null.
85
+ */
86
+ async function firstUsable(site, order) {
87
+ for (const name of order) {
88
+ const Cls = REGISTRY.get(name);
89
+ if (!Cls) continue;
90
+ if (await isReachable(name, site)) return new Cls(site);
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Auto-detect a backend by probing each registered protocol in order.
97
+ * @param {object} site Site config.
98
+ * @returns {Promise<?import("./backend-interface.js").Backend>} Instance or null.
99
+ */
100
+ async function probe(site) {
101
+ for (const [name, Cls] of REGISTRY) {
102
+ if (await isReachable(name, site)) return new Cls(site);
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Probe whether a given protocol responds for a site. Any error counts as
109
+ * unreachable so probing never throws.
110
+ * @param {string} name Protocol name ("jsonapi" | "graphql").
111
+ * @param {object} site Site config.
112
+ * @returns {Promise<boolean>} True when the endpoint answered successfully.
113
+ */
114
+ async function isReachable(name, site) {
115
+ try {
116
+ if (name === "jsonapi") {
117
+ await drupalFetch(site, "/jsonapi");
118
+ return true;
119
+ }
120
+ if (name === "graphql") {
121
+ const json = await drupalGraphqlFetch(site, { query: "{ __typename }" });
122
+ return Boolean(json && !json.errors);
123
+ }
124
+ return false;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }