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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
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
|
+
}
|