drupal-mcp-connector 1.2.0 → 1.3.0

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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-06-27
11
+
12
+ ### Fixed
13
+ - `drupal_mcp_whoami` no longer over-reports configuration capabilities. Capabilities are
14
+ now the intersection of the connector security preset **and** the token's effective OAuth
15
+ scopes: `configRead` / `configWrite` require the dedicated `mcp_config` scope (config-editor
16
+ / Developer tier), and `write` / `delete` require `mcp_write`. Previously a content-tier
17
+ token (`mcp_read` / `mcp_write`) was reported with `configRead: true` even though the server
18
+ denies every `config_*` tool without `mcp_config`. When a site declares no OAuth scopes,
19
+ behaviour is unchanged (preset-only).
20
+
21
+ ### Changed
22
+ - The config tools (`config_get` / `config_list` / `config_set`) now check for the
23
+ `mcp_config` scope up front (when OAuth scopes are configured) and fail fast with a clear
24
+ message instead of dispatching a call the governed server will deny — keeping connector
25
+ behaviour consistent with `drupal_mcp_whoami`. Aligns with mcp_sentinel isolating the config
26
+ tools under the dedicated `mcp_config` scope.
27
+
10
28
  ## [1.2.0] - 2026-06-27
11
29
 
12
30
  ### Changed
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.0",
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.
@@ -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,