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 +32 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/security.js +35 -0
- package/src/lib/server-tools.js +222 -40
- package/src/tools/config.js +17 -5
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
package/src/lib/security.js
CHANGED
|
@@ -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.
|
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
|
/**
|
package/src/tools/config.js
CHANGED
|
@@ -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:
|
|
112
|
-
write:
|
|
113
|
-
delete: sec.allowDestructive &&
|
|
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,
|