drupal-mcp-connector 1.2.0 → 1.3.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 CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.1] - 2026-06-27
11
+
12
+ ### Fixed
13
+ - The server-tool bridge client now performs the MCP Streamable-HTTP session handshake
14
+ before calling governed config tools. It POSTs `initialize`, reads the `Mcp-Session-Id`
15
+ response header, sends `notifications/initialized`, then issues `tools/call` carrying that
16
+ session id — caching the session per site and re-initialising transparently on server-side
17
+ expiry. Previously it POSTed a bare `tools/call` with no session, which Drupal's
18
+ session-mandatory `mcp_server` rejected with `-32600` ("A valid session id is REQUIRED for
19
+ non-initialize requests"), so `drupal_config_get` / `_list` / `_set` always failed. Responses
20
+ are now parsed for both `application/json` and `text/event-stream` (SSE) transports, and the
21
+ request advertises `MCP-Protocol-Version: 2025-06-18`. The existing 401 → token-refresh retry
22
+ is preserved and layered with a single session re-init/replay.
23
+
24
+ ## [1.3.0] - 2026-06-27
25
+
26
+ ### Fixed
27
+ - `drupal_mcp_whoami` no longer over-reports configuration capabilities. Capabilities are
28
+ now the intersection of the connector security preset **and** the token's effective OAuth
29
+ scopes: `configRead` / `configWrite` require the dedicated `mcp_config` scope (config-editor
30
+ / Developer tier), and `write` / `delete` require `mcp_write`. Previously a content-tier
31
+ token (`mcp_read` / `mcp_write`) was reported with `configRead: true` even though the server
32
+ denies every `config_*` tool without `mcp_config`. When a site declares no OAuth scopes,
33
+ behaviour is unchanged (preset-only).
34
+
35
+ ### Changed
36
+ - The config tools (`config_get` / `config_list` / `config_set`) now check for the
37
+ `mcp_config` scope up front (when OAuth scopes are configured) and fail fast with a clear
38
+ message instead of dispatching a call the governed server will deny — keeping connector
39
+ behaviour consistent with `drupal_mcp_whoami`. Aligns with mcp_sentinel isolating the config
40
+ tools under the dedicated `mcp_config` scope.
41
+
10
42
  ## [1.2.0] - 2026-06-27
11
43
 
12
44
  ### Changed
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.2.0",
3
+ "version": "1.3.1",
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",
@@ -323,6 +323,41 @@ export function assertConfigWriteAllowed(secConfig) {
323
323
  }
324
324
  }
325
325
 
326
+ /**
327
+ * Whether the site's OAuth token carries a given scope. When the site declares
328
+ * no OAuth scopes (no agent channel configured), scope gating is a no-op and
329
+ * this returns true so preset-only (non-OAuth) setups are unaffected.
330
+ * @param {object} site Resolved site config.
331
+ * @param {string} scope OAuth scope machine id (e.g. "mcp_config").
332
+ * @returns {boolean} True if the scope is present, or no scopes are configured.
333
+ */
334
+ export function hasScope(site, scope) {
335
+ const scopes = site?.oauth?.scopes ?? [];
336
+ return scopes.length === 0 || scopes.includes(scope);
337
+ }
338
+
339
+ /**
340
+ * Gate the config tools (get/list/set) on the dedicated `mcp_config` OAuth
341
+ * scope, which the governed server requires for every config_* tool. When OAuth
342
+ * scopes are configured but `mcp_config` is absent, fail fast with a clear
343
+ * message instead of dispatching a call the server will deny — keeping the
344
+ * connector's behaviour and its drupal_mcp_whoami report consistent with what
345
+ * the token can actually exercise.
346
+ * @param {object} site Resolved site config.
347
+ * @param {string} operationLabel Label used in the error message.
348
+ * @returns {void}
349
+ * @throws {SecurityError} if scopes are configured and `mcp_config` is missing.
350
+ */
351
+ export function assertConfigScope(site, operationLabel) {
352
+ if (!hasScope(site, "mcp_config")) {
353
+ throw new SecurityError(
354
+ "Config tools require the 'mcp_config' OAuth scope (config-editor / " +
355
+ "Developer tier); this token does not carry it. " +
356
+ `Operation blocked: ${operationLabel}.`
357
+ );
358
+ }
359
+ }
360
+
326
361
  /**
327
362
  * @param {object} secConfig Resolved security config.
328
363
  * @param {string} entityType Entity type targeted by the delete.
@@ -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
  /**
@@ -18,6 +18,8 @@ import {
18
18
  assertNotReadOnly,
19
19
  assertConfigReadAllowed,
20
20
  assertConfigWriteAllowed,
21
+ assertConfigScope,
22
+ hasScope,
21
23
  } from "../lib/security.js";
22
24
  import { callServerTool, SERVER_TOOLS } from "../lib/server-tools.js";
23
25
 
@@ -33,6 +35,7 @@ import { callServerTool, SERVER_TOOLS } from "../lib/server-tools.js";
33
35
  */
34
36
  async function configGet({ site: siteName, name }) {
35
37
  const site = getSiteConfig(siteName);
38
+ assertConfigScope(site, `config:get ${name}`);
36
39
  assertConfigReadAllowed(resolveSecurityConfig(site));
37
40
  return callServerTool(site, SERVER_TOOLS.configGet, { name });
38
41
  }
@@ -45,6 +48,7 @@ async function configGet({ site: siteName, name }) {
45
48
  */
46
49
  async function configList({ site: siteName, prefix }) {
47
50
  const site = getSiteConfig(siteName);
51
+ assertConfigScope(site, "config:list");
48
52
  assertConfigReadAllowed(resolveSecurityConfig(site));
49
53
  const args = prefix ? { prefix } : {};
50
54
  return callServerTool(site, SERVER_TOOLS.configList, args);
@@ -60,6 +64,7 @@ async function configList({ site: siteName, prefix }) {
60
64
  async function configSet({ site: siteName, name, value }) {
61
65
  const site = getSiteConfig(siteName);
62
66
  const sec = resolveSecurityConfig(site);
67
+ assertConfigScope(site, `config:set ${name}`);
63
68
  assertNotReadOnly(sec, `config:set ${name}`);
64
69
  assertConfigWriteAllowed(sec);
65
70
  return callServerTool(site, SERVER_TOOLS.configSet, { name, value });
@@ -100,6 +105,13 @@ async function whoami({ site: siteName }) {
100
105
  const site = getSiteConfig(siteName);
101
106
  const sec = resolveSecurityConfig(site);
102
107
  const summary = getSecuritySummary(site);
108
+ // Effective capability = connector preset AND the scope the server demands.
109
+ // Reporting the preset alone over-states what the token can do — e.g. the
110
+ // content-editor preset allows config reads locally, but every config_* tool
111
+ // is gated server-side on mcp_config, which the content tier does not hold.
112
+ // When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
113
+ const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
114
+ const canConfig = hasScope(site, "mcp_config");
103
115
  return {
104
116
  site: site._name,
105
117
  tier: inferTier(site, sec),
@@ -108,11 +120,11 @@ async function whoami({ site: siteName }) {
108
120
  api: site.api ?? "auto",
109
121
  serverToolsConfigured: Boolean(site.serverTools?.url),
110
122
  capabilities: {
111
- read: true,
112
- write: !sec.readOnly,
113
- delete: sec.allowDestructive && !sec.readOnly,
114
- configRead: sec.allowConfigRead,
115
- configWrite: sec.allowConfigWrite && !sec.readOnly,
123
+ read: hasScope(site, "mcp_read"),
124
+ write: canWrite,
125
+ delete: sec.allowDestructive && canWrite,
126
+ configRead: sec.allowConfigRead && canConfig,
127
+ configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
116
128
  // Publishing is always gated server-side (editorial workflow); the agent
117
129
  // never holds the publish transition. Surfaced here so it is explicit.
118
130
  publish: false,