drupal-mcp-connector 1.3.0 → 1.3.2

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 CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.2] - 2026-06-27
11
+
12
+ ### Fixed
13
+ - `drupal_config_set` now forwards the configuration map under the `data` key the
14
+ server-side tool requires, instead of `value`. The governed tool
15
+ (`tool_api.mcp_sentinel_config_set`) declares its inputs as `name` plus `data` (a
16
+ map of top-level keys to new values, applied as a partial update). Previously the
17
+ connector sent `{ name, value }`, so every `config_set` was rejected with
18
+ `-32602 Invalid parameters … Missing required properties: \`data\``. The public
19
+ tool surface is unchanged — callers still pass `value` (a map); it is translated to
20
+ `data` at the call site. `config_get` / `config_list` were unaffected.
21
+
22
+ ## [1.3.1] - 2026-06-27
23
+
24
+ ### Fixed
25
+ - The server-tool bridge client now performs the MCP Streamable-HTTP session handshake
26
+ before calling governed config tools. It POSTs `initialize`, reads the `Mcp-Session-Id`
27
+ response header, sends `notifications/initialized`, then issues `tools/call` carrying that
28
+ session id — caching the session per site and re-initialising transparently on server-side
29
+ expiry. Previously it POSTed a bare `tools/call` with no session, which Drupal's
30
+ session-mandatory `mcp_server` rejected with `-32600` ("A valid session id is REQUIRED for
31
+ non-initialize requests"), so `drupal_config_get` / `_list` / `_set` always failed. Responses
32
+ are now parsed for both `application/json` and `text/event-stream` (SSE) transports, and the
33
+ request advertises `MCP-Protocol-Version: 2025-06-18`. The existing 401 → token-refresh retry
34
+ is preserved and layered with a single session re-init/replay.
35
+
10
36
  ## [1.3.0] - 2026-06-27
11
37
 
12
38
  ### Fixed
package/README.md CHANGED
@@ -166,7 +166,7 @@ The connector works out of the box against Drupal core's JSON:API and a GraphQL
166
166
  - Role-bound policy profiles (operation gates, entity allow/deny, field redaction)
167
167
  - Tamper-evident audit log of every governed MCP operation, attributed to the acting account
168
168
  - Content locks that prevent edits to content a human is actively editing
169
- - OAuth scope enforcement (`mcp_read` / `mcp_write`) per tool
169
+ - OAuth scope enforcement (`mcp_read` / `mcp_write` / `mcp_config`) per tool
170
170
  - HMAC-signed webhooks on MCP-driven entity changes
171
171
 
172
172
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -7,10 +7,13 @@
7
7
  * the connector an MCP *client* of that server so config get/list/set are
8
8
  * mediated by Drupal's authoritative governance layer rather than by drush.
9
9
  *
10
- * Transport: JSON-RPC 2.0 over HTTPS POST to the per-site `serverTools.url`
11
- * endpoint, authenticated with the same OAuth bearer used for JSON:API. The
12
- * endpoint path is configurable so it tracks whatever route the Drupal-side
13
- * bridge publishes.
10
+ * Transport: JSON-RPC 2.0 over the MCP Streamable-HTTP transport, POSTed to the
11
+ * per-site `serverTools.url` endpoint and authenticated with the same OAuth
12
+ * bearer used for JSON:API. Drupal's `mcp_server` is session-mandatory, so each
13
+ * call site performs the MCP session handshake — `initialize` (read the
14
+ * `Mcp-Session-Id` response header) → `notifications/initialized` → `tools/call`
15
+ * carrying that session id. The session is cached per site and transparently
16
+ * re-initialised when the server expires it.
14
17
  *
15
18
  * Config (per site):
16
19
  * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
@@ -20,7 +23,7 @@
20
23
  */
21
24
 
22
25
  import fetch from "node-fetch";
23
- import { authHeadersAsync, clientHeaders } from "./config.js";
26
+ import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
24
27
  import { clearToken } from "./oauth.js";
25
28
 
26
29
  /**
@@ -38,10 +41,20 @@ export const SERVER_TOOLS = {
38
41
  configSet: "tool_api.mcp_sentinel_config_set",
39
42
  };
40
43
 
44
+ /** MCP protocol version advertised on the handshake and every subsequent POST. */
45
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
46
+
41
47
  // Monotonic JSON-RPC request id. A simple counter keeps ids unique per process
42
48
  // without relying on Math.random()/Date.now().
43
49
  let rpcId = 0;
44
50
 
51
+ /**
52
+ * Per-site MCP session cache, keyed by site._name. Holds the `Mcp-Session-Id`
53
+ * issued by the server's `initialize` response; cleared and re-acquired when the
54
+ * server reports the session is gone (expiry).
55
+ */
56
+ const sessions = new Map();
57
+
45
58
  /**
46
59
  * Resolve a site's server-tools endpoint, or throw a clear, actionable error
47
60
  * when the site has no `serverTools` block (mirrors the drush bridge's
@@ -63,11 +76,175 @@ function resolveEndpoint(site) {
63
76
  return /^https?:\/\//.test(url) ? url : `${site.baseUrl}${url}`;
64
77
  }
65
78
 
79
+ /**
80
+ * Build the common header set for an MCP POST: JSON-RPC content type, dual Accept
81
+ * (the server may answer with JSON or an SSE stream), the protocol version, the
82
+ * outbound client identity, the site's auth, and — when present — the session id.
83
+ * @param {object} site Resolved site config.
84
+ * @param {?string} sessionId Active MCP session id, or null before initialize.
85
+ * @returns {Promise<Object<string,string>>} Header map.
86
+ */
87
+ async function baseHeaders(site, sessionId) {
88
+ const headers = {
89
+ "Content-Type": "application/json",
90
+ Accept: "application/json, text/event-stream",
91
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
92
+ ...clientHeaders(),
93
+ ...(await authHeadersAsync(site)),
94
+ };
95
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId;
96
+ return headers;
97
+ }
98
+
99
+ /**
100
+ * Read an MCP response body once, handling both `application/json` and
101
+ * `text/event-stream` (SSE) transports. SSE frames are split on blank lines and
102
+ * each event's concatenated `data:` payload is parsed as the JSON-RPC body.
103
+ * @param {object} res node-fetch Response.
104
+ * @returns {Promise<{body: ?object, rawText: string}>} Parsed JSON-RPC body
105
+ * (null for empty/unparseable bodies, e.g. a notification's 202) plus the raw text.
106
+ */
107
+ async function readBody(res) {
108
+ const rawText = await res.text();
109
+ if (!rawText) return { body: null, rawText };
110
+
111
+ const contentType = res.headers.get("content-type") || "";
112
+ if (contentType.includes("text/event-stream")) {
113
+ return { body: parseSse(rawText), rawText };
114
+ }
115
+ try {
116
+ return { body: JSON.parse(rawText), rawText };
117
+ } catch {
118
+ return { body: null, rawText };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Parse an SSE stream into the JSON-RPC body it carries. Returns the last event
124
+ * whose `data:` payload parses to a JSON-RPC object (a `tools/call` reply is a
125
+ * single event), or null if none do.
126
+ * @param {string} text Raw event-stream text.
127
+ * @returns {?object} The decoded JSON-RPC body, or null.
128
+ */
129
+ function parseSse(text) {
130
+ let found = null;
131
+ for (const event of text.split(/\r?\n\r?\n/)) {
132
+ const data = event
133
+ .split(/\r?\n/)
134
+ .filter((line) => line.startsWith("data:"))
135
+ .map((line) => line.slice(5).trim())
136
+ .join("\n");
137
+ if (!data) continue;
138
+ try {
139
+ found = JSON.parse(data);
140
+ } catch {
141
+ // Skip non-JSON events (e.g. comments/keep-alives).
142
+ }
143
+ }
144
+ return found;
145
+ }
146
+
147
+ /**
148
+ * Perform the MCP session handshake against the server and cache the resulting
149
+ * session id: `initialize` (read the `Mcp-Session-Id` response header) followed
150
+ * by a best-effort `notifications/initialized`. A 401 on OAuth sites triggers a
151
+ * single token-refresh retry, mirroring the tools/call path.
152
+ * @param {object} site Resolved site config.
153
+ * @param {string} endpoint Fully-qualified endpoint URL.
154
+ * @returns {Promise<string>} The issued MCP session id.
155
+ * @throws {Error} on transport failure, a JSON-RPC error, or a missing session id.
156
+ */
157
+ async function initializeSession(site, endpoint) {
158
+ const payload = {
159
+ jsonrpc: "2.0",
160
+ id: ++rpcId,
161
+ method: "initialize",
162
+ params: {
163
+ protocolVersion: MCP_PROTOCOL_VERSION,
164
+ capabilities: {},
165
+ clientInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
166
+ },
167
+ };
168
+
169
+ const post = async () =>
170
+ fetch(endpoint, {
171
+ method: "POST",
172
+ headers: await baseHeaders(site, null),
173
+ body: JSON.stringify(payload),
174
+ });
175
+
176
+ let res = await post();
177
+ if (res.status === 401 && site.oauth) {
178
+ clearToken(site);
179
+ res = await post();
180
+ }
181
+
182
+ const { body, rawText } = await readBody(res);
183
+ if (!res.ok) {
184
+ throw new Error(`Server-tool session initialize failed ${res.status}: ${rawText}`);
185
+ }
186
+ if (body?.error) {
187
+ const { code, message } = body.error;
188
+ const hasCode = code !== undefined && code !== null;
189
+ throw new Error(`Server-tool session initialize error${hasCode ? ` (${code})` : ""}: ${message}`);
190
+ }
191
+
192
+ const sessionId = res.headers.get("mcp-session-id");
193
+ if (!sessionId) {
194
+ throw new Error(
195
+ `Server-tool session initialize for site "${site._name}" returned no Mcp-Session-Id header.`
196
+ );
197
+ }
198
+
199
+ // Best-effort: the server may not require notifications/initialized, and a
200
+ // non-2xx here must not fail the call. Errors are swallowed deliberately.
201
+ try {
202
+ await fetch(endpoint, {
203
+ method: "POST",
204
+ headers: await baseHeaders(site, sessionId),
205
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
206
+ });
207
+ } catch {
208
+ // Notification is advisory; proceed with the established session.
209
+ }
210
+
211
+ sessions.set(site._name, sessionId);
212
+ return sessionId;
213
+ }
214
+
215
+ /**
216
+ * Return the cached session id for a site, initialising one if absent.
217
+ * @param {object} site Resolved site config.
218
+ * @param {string} endpoint Fully-qualified endpoint URL.
219
+ * @returns {Promise<string>} The active MCP session id.
220
+ */
221
+ async function ensureSession(site, endpoint) {
222
+ const cached = sessions.get(site._name);
223
+ return cached || initializeSession(site, endpoint);
224
+ }
225
+
226
+ /**
227
+ * Whether a tools/call response indicates the MCP session is gone (expired or
228
+ * unknown), warranting a single re-initialise and replay: an HTTP 404, or the
229
+ * server's `-32600` "session id is REQUIRED" JSON-RPC error.
230
+ * @param {object} res node-fetch Response.
231
+ * @param {?object} body Parsed JSON-RPC body, if any.
232
+ * @returns {boolean}
233
+ */
234
+ function isSessionError(res, body) {
235
+ if (res.status === 404) return true;
236
+ const err = body?.error;
237
+ if (!err) return false;
238
+ return err.code === -32600 || /session id/i.test(String(err.message || ""));
239
+ }
240
+
66
241
  /**
67
242
  * Call a governed MCP tool on the Drupal server via JSON-RPC `tools/call`.
68
243
  *
69
- * For OAuth2 sites a 401 triggers a single retry: the cached token is cleared,
70
- * re-acquired, and the request replayed once (mirrors drupalFetch).
244
+ * Establishes/reuses an MCP session (see initializeSession) and POSTs the
245
+ * `tools/call`. Two single-shot recoveries layer on top of each other: a 401 on
246
+ * OAuth sites clears and re-acquires the token then replays (same session); a
247
+ * server-side session expiry re-initialises the session then replays.
71
248
  * @param {object} site Resolved site config (provides baseUrl + auth).
72
249
  * @param {string} toolName Server-side MCP tool name (see SERVER_TOOLS).
73
250
  * @param {object} [args] Tool arguments object.
@@ -83,48 +260,53 @@ export async function callServerTool(site, toolName, args = {}) {
83
260
  params: { name: toolName, arguments: args },
84
261
  };
85
262
 
86
- async function attempt() {
87
- return fetch(endpoint, {
263
+ let sessionId = await ensureSession(site, endpoint);
264
+ let refreshedAuth = false;
265
+ let reinitedSession = false;
266
+
267
+ // Retry loop: at most one auth refresh and one session re-init, each replayed once.
268
+ while (true) {
269
+ const res = await fetch(endpoint, {
88
270
  method: "POST",
89
- headers: {
90
- "Content-Type": "application/json",
91
- Accept: "application/json",
92
- ...clientHeaders(),
93
- ...(await authHeadersAsync(site)),
94
- },
271
+ headers: await baseHeaders(site, sessionId),
95
272
  body: JSON.stringify(payload),
96
273
  });
97
- }
274
+ const { body, rawText } = await readBody(res);
98
275
 
99
- let res = await attempt();
276
+ // OAuth sites: a 401 may mean the token expired server-side. Refresh once.
277
+ if (res.status === 401 && site.oauth && !refreshedAuth) {
278
+ refreshedAuth = true;
279
+ clearToken(site);
280
+ continue;
281
+ }
100
282
 
101
- // OAuth sites: a 401 may mean the token expired server-side. Refresh once.
102
- if (res.status === 401 && site.oauth) {
103
- clearToken(site);
104
- res = await attempt();
105
- }
283
+ // Session expired/unknown: re-initialise once and replay.
284
+ if (isSessionError(res, body) && !reinitedSession) {
285
+ reinitedSession = true;
286
+ sessions.delete(site._name);
287
+ sessionId = await ensureSession(site, endpoint);
288
+ continue;
289
+ }
106
290
 
107
- if (!res.ok) {
108
- const text = await res.text();
109
- throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${text}`);
110
- }
291
+ if (!res.ok) {
292
+ throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
293
+ }
111
294
 
112
- const body = await res.json();
113
-
114
- // JSON-RPC transport-level error.
115
- if (body.error) {
116
- const { code, message } = body.error;
117
- const hasCode = code !== undefined && code !== null;
118
- throw new Error(`Server-tool ${toolName} error${hasCode ? ` (${code})` : ""}: ${message}`);
119
- }
295
+ // JSON-RPC transport-level error.
296
+ if (body?.error) {
297
+ const { code, message } = body.error;
298
+ const hasCode = code !== undefined && code !== null;
299
+ throw new Error(`Server-tool ${toolName} error${hasCode ? ` (${code})` : ""}: ${message}`);
300
+ }
120
301
 
121
- // MCP tools/call result: { content: [...], isError?: boolean }.
122
- const result = body.result;
123
- if (result?.isError) {
124
- const detail = extractTextContent(result) || "tool reported an error";
125
- throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
302
+ // MCP tools/call result: { content: [...], isError?: boolean }.
303
+ const result = body?.result;
304
+ if (result?.isError) {
305
+ const detail = extractTextContent(result) || "tool reported an error";
306
+ throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
307
+ }
308
+ return result;
126
309
  }
127
- return result;
128
310
  }
129
311
 
130
312
  /**
@@ -57,6 +57,11 @@ async function configList({ site: siteName, prefix }) {
57
57
  /**
58
58
  * Set a configuration value. Governed and audited server-side; the connector
59
59
  * additionally enforces the config-write cap before dispatching.
60
+ *
61
+ * The public `value` is a map of top-level config keys to their new values; the
62
+ * server-side tool (mcp_sentinel McpConfigSetTool) takes that map under the key
63
+ * `data` and applies a partial `$editable->set($key, $value)` per entry, so we
64
+ * translate `value` → `data` at the call site.
60
65
  * @param {object} args - { site?, name, value }.
61
66
  * @returns {Promise<*>} The server tool's result.
62
67
  * @throws {SecurityError} if the site is read-only or config writes are disabled.
@@ -67,7 +72,7 @@ async function configSet({ site: siteName, name, value }) {
67
72
  assertConfigScope(site, `config:set ${name}`);
68
73
  assertNotReadOnly(sec, `config:set ${name}`);
69
74
  assertConfigWriteAllowed(sec);
70
- return callServerTool(site, SERVER_TOOLS.configSet, { name, value });
75
+ return callServerTool(site, SERVER_TOOLS.configSet, { name, data: value });
71
76
  }
72
77
 
73
78
  // ---------------------------------------------------------------------------
@@ -163,7 +168,7 @@ export const definitions = [
163
168
  properties: {
164
169
  site: { type: "string" },
165
170
  name: { type: "string" },
166
- value: { description: "The configuration value to set (object, array, or scalar)." },
171
+ value: { type: "object", description: "A map of top-level config keys to their new values (e.g. { \"slogan\": \"Information Technology\" }). Other keys in the object are preserved server-side." },
167
172
  },
168
173
  },
169
174
  },