drupal-mcp-connector 1.3.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 +14 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/server-tools.js +222 -40
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ 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
|
+
|
|
10
24
|
## [1.3.0] - 2026-06-27
|
|
11
25
|
|
|
12
26
|
### 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
package/src/lib/server-tools.js
CHANGED
|
@@ -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
|
|
11
|
-
* endpoint
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
|
|
293
|
+
}
|
|
111
294
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
/**
|