drupal-mcp-connector 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-connector — entry point
4
+ *
5
+ * Transports:
6
+ * stdio (default) Local subprocess mode for MCP clients
7
+ * https Multi-client remote mode. HTTPS always; HTTP refused on
8
+ * non-localhost unless MCP_ALLOW_HTTP=1 is explicitly set.
9
+ *
10
+ * Environment variables:
11
+ * MCP_TRANSPORT "stdio" (default) | "https"
12
+ * MCP_PORT Port for HTTPS mode (default: 3443)
13
+ * TLS_CERT_PATH Path to TLS certificate (PEM)
14
+ * TLS_KEY_PATH Path to TLS private key (PEM)
15
+ * DRUPAL_BASE_URL Single-site fallback baseUrl
16
+ * DRUPAL_API_TOKEN Single-site fallback Bearer token
17
+ * MCP_ALLOW_HTTP Set to "1" to allow plain HTTP on localhost only (dev)
18
+ * MCP_AUTH_TOKEN Bearer token required on /mcp in https mode (warns if unset)
19
+ * MCP_BIND_HOST Bind address for https mode when TLS is present
20
+ * (default: "0.0.0.0"; ignored without TLS, which forces loopback)
21
+ */
22
+
23
+ import { createServer as createHttpsServer } from "https";
24
+ import { createServer as createHttpServer } from "http";
25
+ import { readFileSync } from "fs";
26
+ import { randomUUID } from "node:crypto";
27
+
28
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
+ import { CallToolRequestSchema,
31
+ ListToolsRequestSchema,
32
+ ListResourcesRequestSchema,
33
+ ReadResourceRequestSchema,
34
+ ListPromptsRequestSchema,
35
+ GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
36
+
37
+ import { getSiteConfig, listSiteNames, getTlsConfig } from "./lib/config.js";
38
+ import { makeBearerCheck } from "./lib/http-auth.js";
39
+ import { resolveSecurityConfig, assertNotReadOnly,
40
+ assertDestructiveAllowed, assertGraphqlMutationAllowed,
41
+ SecurityError } from "./lib/security.js";
42
+ import { toolError, toolResult } from "./lib/errors.js";
43
+ import { BackendCapabilityError, BackendResolutionError } from "./lib/backends/errors.js";
44
+
45
+ // Tool modules
46
+ import * as nodes from "./tools/nodes.js";
47
+ import * as taxonomy from "./tools/taxonomy.js";
48
+ import * as users from "./tools/users.js";
49
+ import * as media from "./tools/media.js";
50
+ import * as graphql from "./tools/graphql.js";
51
+ import * as site from "./tools/site.js";
52
+ import * as entities from "./tools/entities.js";
53
+ import * as reports from "./tools/reports.js";
54
+ import * as drush from "./tools/drush.js";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Aggregate tools
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush];
61
+
62
+ // Flatten every module's tool definitions into one ListTools payload, and merge
63
+ // their handler maps into a single closed dispatch table keyed by tool name.
64
+ const allDefinitions = allModules.flatMap((m) => m.definitions);
65
+ const allHandlers = Object.assign({}, ...allModules.map((m) => m.handlers));
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Security middleware — runs BEFORE every tool handler
69
+ //
70
+ // Operation intent (read/write/delete/graphql) is inferred from the tool name
71
+ // prefix rather than trusting per-tool metadata, so a new tool that follows the
72
+ // naming convention is gated automatically. The matched operation drives which
73
+ // assertions from lib/security.js run against the resolved per-site policy.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
77
+ "drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
78
+ "drupal_drush_config_export", "drupal_drush_config_import",
79
+ "drupal_drush_updatedb", "drupal_drush_module_enable",
80
+ "drupal_drush_module_disable", "drupal_drush_user_create"];
81
+ const DESTRUCTIVE_PREFIXES = ["drupal_delete_", "drupal_drush_module_disable"];
82
+
83
+ /**
84
+ * Classify a tool's operation intent from its name prefix.
85
+ *
86
+ * @param {string} toolName - The MCP tool name being invoked.
87
+ * @returns {"delete"|"write"|"graphql"|"read"} Inferred operation. Destructive
88
+ * prefixes are checked first so they take precedence over plain write.
89
+ */
90
+ function inferOperation(toolName) {
91
+ if (DESTRUCTIVE_PREFIXES.some((p) => toolName.startsWith(p))) return "delete";
92
+ if (WRITE_PREFIXES.some((p) => toolName.startsWith(p))) return "write";
93
+ if (toolName === "drupal_graphql") return "graphql";
94
+ return "read";
95
+ }
96
+
97
+ /**
98
+ * Derive the entity type a tool acts on, for destructive-allow assertions.
99
+ *
100
+ * @param {string} toolName - The MCP tool name.
101
+ * @param {object} args - The tool arguments.
102
+ * @returns {string} Explicit args.entityType when present, else the suffix
103
+ * parsed from the tool name (e.g. "node" from "drupal_delete_node"),
104
+ * falling back to "entity".
105
+ */
106
+ function extractEntityType(toolName, args) {
107
+ if (args?.entityType) return args.entityType;
108
+ const m = toolName.match(/^drupal_(?:delete|create|update|get|list)_(.+)$/);
109
+ return m ? m[1] : "entity";
110
+ }
111
+
112
+ /**
113
+ * Apply per-site security assertions before dispatching to a tool handler.
114
+ *
115
+ * @param {string} toolName - The MCP tool name.
116
+ * @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
117
+ * @param {Function} handler - The resolved tool handler.
118
+ * @returns {Promise<*>} The handler's result.
119
+ * @throws {SecurityError} If the resolved policy forbids the inferred operation.
120
+ */
121
+ async function securityMiddleware(toolName, args, handler) {
122
+ // Tools with no site context skip per-site checks
123
+ if (toolName === "drupal_list_sites") return handler(args);
124
+
125
+ const site = getSiteConfig(args?.site);
126
+ const sec = resolveSecurityConfig(site);
127
+ const op = inferOperation(toolName);
128
+
129
+ if (op === "delete") {
130
+ assertDestructiveAllowed(sec, extractEntityType(toolName, args), args?.id ?? "?");
131
+ assertNotReadOnly(sec, toolName);
132
+ } else if (op === "write") {
133
+ assertNotReadOnly(sec, toolName);
134
+ } else if (op === "graphql" && args?.query) {
135
+ assertGraphqlMutationAllowed(sec, args.query);
136
+ }
137
+
138
+ return handler(args);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // MCP Resources — browsable, always-fresh site context
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const RESOURCES = [
146
+ {
147
+ uri: "drupal://sites",
148
+ name: "Configured Drupal Sites",
149
+ description: "All named Drupal site profiles (no credentials).",
150
+ mimeType: "application/json",
151
+ },
152
+ {
153
+ uri: "drupal://{site}/content-types",
154
+ name: "Content Types",
155
+ description: "All content types with machine names and descriptions.",
156
+ mimeType: "application/json",
157
+ },
158
+ {
159
+ uri: "drupal://{site}/security-policy",
160
+ name: "Security Policy",
161
+ description: "Active security configuration for this site.",
162
+ mimeType: "application/json",
163
+ },
164
+ ];
165
+
166
+ /**
167
+ * Resolve a resource URI to its JSON payload. URIs are matched in order; the
168
+ * templated forms (content-types, security-policy) capture the site name and
169
+ * delegate to the corresponding read-only tool handler so resources and tools
170
+ * always return the same shape.
171
+ *
172
+ * @param {string} uri - A drupal:// resource URI.
173
+ * @returns {Promise<object>} The resource data (later JSON-serialized).
174
+ * @throws {Error} If the URI matches no known resource.
175
+ */
176
+ async function readResource(uri) {
177
+ // drupal://sites
178
+ if (uri === "drupal://sites") {
179
+ return { sites: listSiteNames() };
180
+ }
181
+
182
+ // drupal://{site}/content-types
183
+ const ctMatch = uri.match(/^drupal:\/\/([^/]+)\/content-types$/);
184
+ if (ctMatch) {
185
+ return allHandlers.drupal_list_content_types({ site: ctMatch[1] });
186
+ }
187
+
188
+ // drupal://{site}/security-policy
189
+ const spMatch = uri.match(/^drupal:\/\/([^/]+)\/security-policy$/);
190
+ if (spMatch) {
191
+ return allHandlers.drupal_security_info({ site: spMatch[1] });
192
+ }
193
+
194
+ throw new Error(`Unknown resource URI: ${uri}`);
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // MCP Prompts — common Drupal workflow templates
199
+ // ---------------------------------------------------------------------------
200
+
201
+ const PROMPTS = [
202
+ {
203
+ name: "drupal-content-audit",
204
+ description: "Walk through a full content audit: inventory, staleness, SEO gaps, accessibility issues, and recommendations.",
205
+ arguments: [{ name: "site", description: "Named site to audit (omit for default)", required: false }],
206
+ },
207
+ {
208
+ name: "drupal-create-article",
209
+ description: "Guided workflow to research, draft, and publish an article node with all fields, tags, and metadata.",
210
+ arguments: [
211
+ { name: "site", description: "Target site", required: false },
212
+ { name: "topic", description: "Article topic/brief", required: true },
213
+ ],
214
+ },
215
+ {
216
+ name: "drupal-seo-fix",
217
+ description: "Find SEO gaps in content (missing meta descriptions, thin content, title issues) and fix them interactively.",
218
+ arguments: [
219
+ { name: "site", description: "Target site", required: false },
220
+ { name: "type", description: "Content type to scan", required: false },
221
+ ],
222
+ },
223
+ {
224
+ name: "drupal-user-cleanup",
225
+ description: "Identify inactive, never-logged-in, or overly permissioned user accounts and take action.",
226
+ arguments: [{ name: "site", description: "Target site", required: false }],
227
+ },
228
+ ];
229
+
230
+ /**
231
+ * Build the message list for a named prompt, interpolating site/type/topic
232
+ * args into a pre-authored multi-step workflow. Unknown prompt names fall back
233
+ * to a generic one-line instruction so the call never fails.
234
+ *
235
+ * @param {string} name - The prompt name.
236
+ * @param {object} args - Prompt arguments (site, type, topic — all optional).
237
+ * @returns {Array<object>} MCP prompt messages.
238
+ */
239
+ function getPromptMessages(name, args) {
240
+ const site = args?.site ? `on the "${args.site}" site` : "on the default site";
241
+ const type = args?.type || "article";
242
+ const topic = args?.topic || "the requested topic";
243
+
244
+ const prompts = {
245
+ "drupal-content-audit": [
246
+ { role: "user", content: { type: "text", text:
247
+ `Please run a comprehensive content audit ${site}. Follow these steps:\n` +
248
+ "1. Call drupal_report_content_summary to get the full inventory.\n" +
249
+ "2. Call drupal_report_stale_content (days: 180) to find stale content.\n" +
250
+ "3. Call drupal_report_field_completeness for each major content type.\n" +
251
+ "4. Call drupal_report_seo_audit for the article content type.\n" +
252
+ "5. Call drupal_report_accessibility_audit for the article content type.\n" +
253
+ "6. Synthesize findings into: (a) immediate actions, (b) medium-term improvements, (c) process recommendations.\n" +
254
+ "Present results as a structured report with counts, severity, and specific node links where possible."
255
+ }},
256
+ ],
257
+ "drupal-create-article": [
258
+ { role: "user", content: { type: "text", text:
259
+ `I need to create a new article ${site} about: ${topic}\n\n` +
260
+ "Please:\n" +
261
+ "1. Call drupal_list_content_types to confirm \"article\" exists and check its fields.\n" +
262
+ "2. Call drupal_get_entity_schema for node/article to see all available fields.\n" +
263
+ "3. Call drupal_list_vocabularies and drupal_get_taxonomy_terms for relevant vocabularies.\n" +
264
+ "4. Draft the article — title, body (well-structured HTML), summary, and meta description.\n" +
265
+ "5. Suggest appropriate taxonomy tags.\n" +
266
+ "6. Call drupal_create_node with status: false (draft) and show me the result.\n" +
267
+ "7. Ask me to review before publishing."
268
+ }},
269
+ ],
270
+ "drupal-seo-fix": [
271
+ { role: "user", content: { type: "text", text:
272
+ `Please find and fix SEO issues in "${type}" content ${site}.\n\n` +
273
+ "1. Call drupal_report_seo_audit to identify all issues.\n" +
274
+ "2. For nodes missing meta descriptions: generate appropriate descriptions (max 160 chars) and update them.\n" +
275
+ "3. For thin content (under 300 words): flag for editorial review — do not auto-expand.\n" +
276
+ "4. For title length issues: suggest better titles but ask before updating.\n" +
277
+ "5. Report what was fixed, what needs human review, and any patterns you noticed."
278
+ }},
279
+ ],
280
+ "drupal-user-cleanup": [
281
+ { role: "user", content: { type: "text", text:
282
+ `Please audit user accounts ${site} and recommend cleanup actions.\n\n` +
283
+ "1. Call drupal_report_user_activity to identify inactive and never-logged-in accounts.\n" +
284
+ "2. Call drupal_list_users with no filter to get the full list.\n" +
285
+ "3. Call drupal_list_roles to see all available roles.\n" +
286
+ "4. Identify: (a) accounts inactive 90+ days, (b) never-logged-in accounts, (c) accounts with admin roles that look like test/temp accounts.\n" +
287
+ "5. For each category, recommend action (block, delete, or keep) with reasoning.\n" +
288
+ "6. Ask for approval before making any changes."
289
+ }},
290
+ ],
291
+ };
292
+
293
+ return new Map(Object.entries(prompts)).get(name) ?? [{ role: "user", content: { type: "text", text: `Run the ${name} workflow ${site}.` } }];
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // MCP Server construction
298
+ // ---------------------------------------------------------------------------
299
+
300
+ const server = new Server(
301
+ { name: "drupal-mcp-connector", version: "0.6.0" },
302
+ { capabilities: { tools: {}, resources: {}, prompts: {} } }
303
+ );
304
+
305
+ // Tools
306
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allDefinitions }));
307
+
308
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
309
+ const { name, arguments: args } = request.params;
310
+ // eslint-disable-next-line security/detect-object-injection -- name is an MCP tool name from validated schema; allHandlers is a closed dispatch table built at startup
311
+ const handler = allHandlers[name];
312
+
313
+ if (!handler) {
314
+ return toolError(new Error(
315
+ `Unknown tool "${name}". Call drupal_list_entity_types to discover available resources.`
316
+ ));
317
+ }
318
+
319
+ try {
320
+ const result = await securityMiddleware(name, args ?? {}, handler);
321
+ return toolResult(result);
322
+ } catch (err) {
323
+ // Translate known error classes into clear, non-leaky isError responses;
324
+ // anything else falls through to toolError for a generic envelope.
325
+ if (err instanceof SecurityError) {
326
+ return { content: [{ type: "text", text: `Access denied: ${err.message}` }], isError: true };
327
+ }
328
+ if (err instanceof BackendCapabilityError) {
329
+ return { content: [{ type: "text", text: `Not supported by this site's backend: ${err.message}` }], isError: true };
330
+ }
331
+ if (err instanceof BackendResolutionError) {
332
+ return { content: [{ type: "text", text: `Backend resolution failed: ${err.message}` }], isError: true };
333
+ }
334
+ return toolError(err);
335
+ }
336
+ });
337
+
338
+ // Resources
339
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCES }));
340
+
341
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
342
+ const { uri } = request.params;
343
+ try {
344
+ const data = await readResource(uri);
345
+ return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
346
+ } catch (err) {
347
+ throw new Error(`Resource read failed (${uri}): ${err.message}`);
348
+ }
349
+ });
350
+
351
+ // Prompts
352
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
353
+
354
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
355
+ const { name, arguments: args } = request.params;
356
+ const known = PROMPTS.find((p) => p.name === name);
357
+ if (!known) throw new Error(`Unknown prompt: "${name}"`);
358
+ return { description: known.description, messages: getPromptMessages(name, args) };
359
+ });
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Transport
363
+ // ---------------------------------------------------------------------------
364
+
365
+ const transport = process.env.MCP_TRANSPORT || "stdio";
366
+
367
+ if (transport === "stdio") {
368
+ const stdioTransport = new StdioServerTransport();
369
+ await server.connect(stdioTransport);
370
+ console.error(
371
+ "[drupal-mcp-connector v0.6.0] stdio transport active. " +
372
+ `${allDefinitions.length} tools · ${RESOURCES.length} resources · ${PROMPTS.length} prompts`
373
+ );
374
+
375
+ } else if (transport === "https" || transport === "http") {
376
+
377
+ // Dynamically import the HTTP transport — only needed in server mode
378
+ const { StreamableHTTPServerTransport } = await import(
379
+ "@modelcontextprotocol/sdk/server/streamableHttp.js"
380
+ );
381
+
382
+ const tlsCfg = getTlsConfig();
383
+ const port = tlsCfg.port;
384
+ const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
385
+
386
+ const authToken = process.env.MCP_AUTH_TOKEN || "";
387
+ const checkAuth = makeBearerCheck(authToken);
388
+ if (!authToken) {
389
+ console.error(
390
+ "[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
391
+ "Set MCP_AUTH_TOKEN to require a bearer token, or front it with a trusted " +
392
+ "boundary (private network / auth proxy). Acceptable only behind such a boundary."
393
+ );
394
+ }
395
+
396
+ // Security headers applied to every response
397
+ function applySecurityHeaders(res) {
398
+ res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
399
+ res.setHeader("X-Content-Type-Options", "nosniff");
400
+ res.setHeader("X-Frame-Options", "DENY");
401
+ res.setHeader("Referrer-Policy", "no-referrer");
402
+ res.setHeader("Cache-Control", "no-store");
403
+ res.setHeader("Content-Security-Policy", "default-src 'none'");
404
+ }
405
+
406
+ function createNodeServer(onRequest) {
407
+ if (tlsCfg.certPath && tlsCfg.keyPath) {
408
+ // HTTPS — the only acceptable mode for non-local deployments
409
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
410
+ const cert = readFileSync(tlsCfg.certPath);
411
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
412
+ const key = readFileSync(tlsCfg.keyPath);
413
+ return createHttpsServer({ cert, key }, (req, res) => {
414
+ applySecurityHeaders(res);
415
+ onRequest(req, res);
416
+ });
417
+ }
418
+
419
+ // No TLS certs — allow only if explicitly opted in AND on localhost
420
+ if (!allowHttp) {
421
+ console.error(
422
+ "[drupal-mcp-connector] FATAL: HTTP transport requires TLS certificates.\n" +
423
+ " Set TLS_CERT_PATH and TLS_KEY_PATH, or MCP_ALLOW_HTTP=1 for localhost-only dev.\n" +
424
+ " See docs/getting-started.md for TLS setup instructions."
425
+ );
426
+ process.exit(1);
427
+ }
428
+
429
+ console.error(
430
+ "[drupal-mcp-connector] WARNING: Running plain HTTP (MCP_ALLOW_HTTP=1). " +
431
+ "ONLY acceptable for local development. Never expose this to the internet."
432
+ );
433
+ return createHttpServer((req, res) => {
434
+ applySecurityHeaders(res);
435
+ onRequest(req, res);
436
+ });
437
+ }
438
+
439
+ // Map of sessionId → transport for multi-client support
440
+ const sessions = new Map();
441
+
442
+ const nodeServer = createNodeServer(async (req, res) => {
443
+ if (req.url === "/mcp" && (req.method === "POST" || req.method === "GET")) {
444
+ if (!checkAuth(req.headers["authorization"])) {
445
+ res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
446
+ return;
447
+ }
448
+ }
449
+
450
+ if (req.method === "POST" && req.url === "/mcp") {
451
+ const sessionId = req.headers["mcp-session-id"];
452
+ let mcpTransport;
453
+
454
+ if (sessionId && sessions.has(sessionId)) {
455
+ mcpTransport = sessions.get(sessionId);
456
+ } else {
457
+ mcpTransport = new StreamableHTTPServerTransport({
458
+ sessionIdGenerator: () => randomUUID(),
459
+ onsessioninitialized: (id) => sessions.set(id, mcpTransport),
460
+ });
461
+ mcpTransport.onclose = () => sessions.delete(mcpTransport.sessionId);
462
+ await server.connect(mcpTransport);
463
+ }
464
+ await mcpTransport.handleRequest(req, res);
465
+
466
+ } else if (req.method === "GET" && req.url === "/mcp") {
467
+ const sessionId = req.headers["mcp-session-id"];
468
+ if (!sessionId || !sessions.has(sessionId)) {
469
+ res.writeHead(400).end("Missing or unknown MCP-Session-Id");
470
+ return;
471
+ }
472
+ await sessions.get(sessionId).handleRequest(req, res);
473
+
474
+ } else if (req.url === "/health") {
475
+ res.writeHead(200, { "Content-Type": "application/json" })
476
+ .end(JSON.stringify({ status: "ok", tools: allDefinitions.length }));
477
+
478
+ } else {
479
+ res.writeHead(404).end("Not found");
480
+ }
481
+ });
482
+
483
+ const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
484
+ // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
485
+ // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
486
+ const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
487
+
488
+ nodeServer.listen(port, bindHost, () => {
489
+ const proto = hasTls ? "https" : "http";
490
+ console.error(
491
+ `[drupal-mcp-connector v0.6.0] Listening on ${proto}://${bindHost}:${port}/mcp\n` +
492
+ ` ${allDefinitions.length} tools · ${RESOURCES.length} resources · ${PROMPTS.length} prompts`
493
+ );
494
+ });
495
+
496
+ } else {
497
+ console.error(`[drupal-mcp-connector] Unknown MCP_TRANSPORT: "${transport}". Use "stdio" or "https".`);
498
+ process.exit(1);
499
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Backend interface contract for the dual-protocol data layer.
3
+ *
4
+ * Single responsibility: define the abstract surface that every concrete
5
+ * adapter (JsonApiBackend, GraphqlBackend) must implement so the rest of the
6
+ * connector can read/write Drupal entities without knowing which protocol is
7
+ * in use. Each method here throws until overridden; adapters override every
8
+ * method and return the canonical shapes documented below.
9
+ *
10
+ * Canonical shapes referenced throughout:
11
+ * - QueryDescriptor: the entity descriptor used by list/count operations,
12
+ * { entityType, bundle, filters?, sort?, fields?, include?, page? }
13
+ * (see {@link import("../canonical.js").QueryDescriptor}).
14
+ * - CanonicalEntity: the protocol-neutral entity returned by read/write ops
15
+ * (see {@link import("../canonical.js").CanonicalEntity}).
16
+ *
17
+ * @typedef {Object} Capabilities
18
+ * @property {boolean} read Adapter can read entities.
19
+ * @property {boolean} write Adapter can create/update entities.
20
+ * @property {boolean} delete Adapter can delete entities.
21
+ * @property {boolean} count Adapter can return exact totals cheaply.
22
+ * @property {boolean} filter Adapter supports server-side field filtering.
23
+ * @property {"full"|"enum"|"none"} sort Server-side sort support: arbitrary
24
+ * fields ("full"), a fixed enum of keys ("enum"), or none ("none").
25
+ * @property {boolean} revisions Adapter exposes entity revisions.
26
+ * @property {((entityType: string, bundle: string) => string[])|null} fieldAvailability
27
+ * Optional resolver returning the known field names for a bundle, or null
28
+ * when the adapter cannot cheaply enumerate fields.
29
+ *
30
+ * @typedef {Object} ListResult
31
+ * @property {import("../canonical.js").CanonicalEntity[]} entities Page of entities.
32
+ * @property {{total: ?number, hasNext: boolean, cursor: ?string}} page Paging
33
+ * info; `total` is null when the backend cannot count.
34
+ * @property {boolean} approximate True when paging/total are estimated (e.g.
35
+ * client-side filtered results) rather than authoritative.
36
+ * @property {boolean} truncated True when the client-side record cap was hit
37
+ * and results may be incomplete.
38
+ */
39
+
40
+ /**
41
+ * Throw a uniform "not implemented" error for an un-overridden method.
42
+ * @param {string} name Method name being invoked on the base class.
43
+ * @returns {never}
44
+ * @throws {Error} Always.
45
+ */
46
+ function notImplemented(name) {
47
+ throw new Error(`Backend.${name} is not implemented`);
48
+ }
49
+
50
+ /**
51
+ * Abstract backend. Concrete adapters extend this and override every method.
52
+ */
53
+ export class Backend {
54
+ /**
55
+ * Report what this adapter can do, so callers can adapt behavior.
56
+ * @returns {Capabilities}
57
+ */
58
+ capabilities() { return notImplemented("capabilities"); }
59
+
60
+ /**
61
+ * List entities matching an entity descriptor.
62
+ * @param {import("../canonical.js").QueryDescriptor} _descriptor
63
+ * @returns {Promise<ListResult>}
64
+ */
65
+ async listEntities(_descriptor) { return notImplemented("listEntities"); }
66
+
67
+ /**
68
+ * Fetch a single entity by reference.
69
+ * @param {{entityType: string, bundle: string, id: string}} _ref
70
+ * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null when absent.
71
+ */
72
+ async getEntity(_ref) { return notImplemented("getEntity"); }
73
+
74
+ /**
75
+ * Create an entity.
76
+ * @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} _input
77
+ * @returns {Promise<import("../canonical.js").CanonicalEntity>}
78
+ */
79
+ async createEntity(_input) { return notImplemented("createEntity"); }
80
+
81
+ /**
82
+ * Update an entity.
83
+ * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} _input
84
+ * @returns {Promise<import("../canonical.js").CanonicalEntity>}
85
+ */
86
+ async updateEntity(_input) { return notImplemented("updateEntity"); }
87
+
88
+ /**
89
+ * Delete an entity by reference.
90
+ * @param {{entityType: string, bundle: string, id: string}} _ref
91
+ * @returns {Promise<void>}
92
+ */
93
+ async deleteEntity(_ref) { return notImplemented("deleteEntity"); }
94
+
95
+ /**
96
+ * Discover the resource/content surface exposed by the backend.
97
+ * @param {object} [_opts]
98
+ * @returns {Promise<{resourceTypes: string[]}>} `entityType--bundle` identifiers.
99
+ */
100
+ async introspect(_opts) { return notImplemented("introspect"); }
101
+
102
+ /**
103
+ * List node content types.
104
+ * @returns {Promise<Array<{id: string, label: string, description: ?string}>>}
105
+ */
106
+ async listContentTypes() { return notImplemented("listContentTypes"); }
107
+
108
+ /**
109
+ * List the bundles of a given entity type.
110
+ * @param {string} _entityType
111
+ * @returns {Promise<Array<{id: string, label: ?string, description: ?string}>>}
112
+ */
113
+ async listBundles(_entityType) { return notImplemented("listBundles"); }
114
+
115
+ /**
116
+ * List all resource types as entityType/bundle pairs.
117
+ * @returns {Promise<Array<{resourceType: string, entityType: string, bundle: string}>>}
118
+ */
119
+ async listResourceTypes() { return notImplemented("listResourceTypes"); }
120
+
121
+ /**
122
+ * Describe a bundle's fields/relationships.
123
+ * @param {string} _entityType
124
+ * @param {string} _bundle
125
+ * @returns {Promise<{entityType: string, bundle: string, attributes: object, relationships: object}>}
126
+ */
127
+ async getEntitySchema(_entityType, _bundle) { return notImplemented("getEntitySchema"); }
128
+
129
+ /**
130
+ * List user roles.
131
+ * @returns {Promise<Array<{id: string, machineName: string, label: string, weight: number}>>}
132
+ */
133
+ async listRoles() { return notImplemented("listRoles"); }
134
+
135
+ /**
136
+ * Upload a file and return its descriptor.
137
+ * @param {{entityType?: string, bundle: string, fieldName: string, filePath: string}} _opts
138
+ * @returns {Promise<{id: string, drupalId: number, filename: string, uri: ?string, url: ?string, size: number, mimeType: string}>}
139
+ */
140
+ async uploadFile(_opts) { return notImplemented("uploadFile"); }
141
+
142
+ /**
143
+ * Count entities matching an entity descriptor.
144
+ * @param {import("../canonical.js").QueryDescriptor} _descriptor
145
+ * @returns {Promise<{count: number, approximate: boolean}>}
146
+ */
147
+ async countEntities(_descriptor) { return notImplemented("countEntities"); }
148
+
149
+ /**
150
+ * Escape hatch: run a protocol-native request and return its raw response.
151
+ * @param {object} _input Protocol-specific request shape.
152
+ * @returns {Promise<*>} Raw backend response.
153
+ */
154
+ async rawQuery(_input) { return notImplemented("rawQuery"); }
155
+
156
+ /**
157
+ * Resolve the first usable field name from a candidate list for a bundle.
158
+ * @param {string} _entityType
159
+ * @param {string} _bundle
160
+ * @param {string[]} _candidates Field names in preference order.
161
+ * @returns {?string|Promise<?string>} Chosen field name, or null when none match.
162
+ */
163
+ resolveFieldName(_entityType, _bundle, _candidates) { return notImplemented("resolveFieldName"); }
164
+ }