drupal-mcp-connector 1.1.1 → 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,50 @@ 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
+
28
+ ## [1.2.0] - 2026-06-27
29
+
30
+ ### Changed
31
+ - Widened the content/developer security presets so the connector supports full content
32
+ building and management. `content-editor` and `write-plane` now allow `paragraph`,
33
+ `block_content`, `menu_link_content`, `redirect`, `path_alias`, and `file` in addition
34
+ to `node`/`taxonomy_term`/`media`. `config-editor` (developer tier) additionally allows
35
+ the site-building config entities (`node_type`, `paragraphs_type`, `block_content_type`,
36
+ `media_type`, `field_config`, `field_storage_config`, `entity_form_display`,
37
+ `entity_view_display`, `taxonomy_vocabulary`) for read/introspection — content-model
38
+ changes go through the governed config bridge / `drush config:import`, not JSON:API
39
+ entity create.
40
+ - Corrected the server-tool bridge tool names: `mcp_server_tool_bridge` exposes Tool-API
41
+ tools as `tool_api.<id>`, so the governed config tools are
42
+ `tool_api.mcp_sentinel_config_get` / `_list` / `_set` (previously documented as bare
43
+ `config_get` / `_list` / `_set`, which never resolved). Updated `SERVER_TOOLS` and
44
+ `docs/integration-contract.md` accordingly. These tools must be registered as enabled
45
+ `mcp_tool_config` entities on the Drupal site; they are not exposed by default.
46
+
47
+ ### Security
48
+ - Deny-hardened the content/developer presets: `oauth2_token`, `key`, `consumer`,
49
+ `encryption_profile`, `mcp_tool_config`, and `mcp_policy_profile` are now in
50
+ `deniedEntityTypes` alongside `user`, so secrets, the agent's own governance config,
51
+ and account data stay blocked even if an allowlist is later widened. PII-bearing
52
+ `webform_submission` and `profile` are intentionally left off the allowlists.
53
+
10
54
  ## [1.1.1] - 2026-06-26
11
55
 
12
56
  ### Fixed
@@ -100,11 +100,11 @@
100
100
 
101
101
  "_security_presets": {
102
102
  "development": "All operations allowed, incl. config read/write. Local dev / break-glass only.",
103
- "content-editor": "Create/edit nodes+media+terms. No deletes. No user entity access. Config read-only.",
104
- "config-editor": "content-editor + governed config read/write (Developer tier).",
103
+ "content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
104
+ "config-editor": "content-editor + site-building config entities (read/introspection) + governed config read/write (Developer tier). Model changes go via the config bridge, not JSON:API.",
105
105
  "auditor": "Read-only. All entity types. User PII fields redacted. Config read-only.",
106
106
  "production-strict": "Read-only. No user entities. Broad PII redaction. No config access.",
107
- "write-plane": "Governed writes (no delete/mutations) on node, taxonomy_term, media. No user. Redacts pass/mail. Config read-only."
107
+ "write-plane": "Governed writes (no delete/mutations) on the content set (node, taxonomy_term, media + structural content entities). Denies user + secrets/governance types. Redacts pass/mail. Config read-only."
108
108
  },
109
109
 
110
110
  "_server_tools": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.1.1",
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",
@@ -13,11 +13,17 @@ import { parse } from "graphql";
13
13
  * ─── Quick presets ────────────────────────────────────────────────────────
14
14
  *
15
15
  * "preset": "development" Everything allowed. Default if no security key.
16
- * "preset": "content-editor" Create/edit nodes+media. No user mgmt, no deletes. Config read-only.
17
- * "preset": "config-editor" content-editor + governed config read/write (Developer tier).
16
+ * "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, blocks,
17
+ * menu links, redirects, aliases, files). No deletes. Config read-only.
18
+ * "preset": "config-editor" content-editor + site-building config READ + governed config
19
+ * read/write (Developer tier). Model changes go via the config bridge.
18
20
  * "preset": "auditor" Read-only. All entity types. User fields redacted.
19
21
  * "preset": "production-strict" Read-only. Explicit allowlist required. Redacts PII.
20
- * "preset": "write-plane" Governed writes (no delete/mutations) on node, term, media.
22
+ * "preset": "write-plane" Governed writes (no delete/mutations) on the content set
23
+ * (node, term, media + structural content entities).
24
+ *
25
+ * Secrets, the agent's own governance config, and account data (see SENSITIVE_DENY)
26
+ * are always denied on the content/developer tiers, regardless of the allowlist.
21
27
  *
22
28
  * Presets can be overridden by adding explicit keys alongside them.
23
29
  *
@@ -51,6 +57,57 @@ import { parse } from "graphql";
51
57
  * The connector never writes these fields either when redaction is active.
52
58
  */
53
59
 
60
+ // ---------------------------------------------------------------------------
61
+ // Shared entity-type groups
62
+ // ---------------------------------------------------------------------------
63
+ //
64
+ // The connector allowlist is deliberately safe-by-default: the Drupal site
65
+ // exposes secret-, governance-, and PII-bearing entity types over JSON:API
66
+ // (oauth2_token, key, consumer, mcp_tool_config, profile, webform_submission,
67
+ // …), so anything not explicitly listed stays denied. Widen these groups to
68
+ // grant capability; never flip a content/developer tier to allowedEntityTypes:
69
+ // null.
70
+
71
+ // Content (fieldable) entities used to build and manage page content. All are
72
+ // JSON:API-writable, so the standard entity tools create/update them directly.
73
+ const CONTENT_STRUCTURAL = [
74
+ "paragraph",
75
+ "block_content",
76
+ "menu_link_content",
77
+ "redirect",
78
+ "path_alias",
79
+ "file",
80
+ ];
81
+
82
+ // Content-model *config* entities. Allowlisted for READ / introspection only —
83
+ // they are config entities, so building/changing them goes through the governed
84
+ // config bridge (config_set → mcp_sentinel) or `drush config:import`, NOT
85
+ // drupal_entity_create. Granted to the developer tier only.
86
+ const SITE_BUILDER_CONFIG = [
87
+ "node_type",
88
+ "paragraphs_type",
89
+ "block_content_type",
90
+ "media_type",
91
+ "field_config",
92
+ "field_storage_config",
93
+ "entity_form_display",
94
+ "entity_view_display",
95
+ "taxonomy_vocabulary",
96
+ ];
97
+
98
+ // Always-blocked: secrets, the agent's own governance config, and account data.
99
+ // Belt-and-suspenders denylist — these stay blocked even if a future change
100
+ // widens an allowlist. (deniedEntityTypes takes priority over allowedEntityTypes.)
101
+ const SENSITIVE_DENY = [
102
+ "user",
103
+ "oauth2_token",
104
+ "key",
105
+ "consumer",
106
+ "encryption_profile",
107
+ "mcp_tool_config",
108
+ "mcp_policy_profile",
109
+ ];
110
+
54
111
  // ---------------------------------------------------------------------------
55
112
  // Preset definitions
56
113
  // ---------------------------------------------------------------------------
@@ -74,8 +131,10 @@ const PRESETS = {
74
131
  allowGraphqlMutations: false,
75
132
  allowConfigRead: true, // config read-only
76
133
  allowConfigWrite: false,
77
- allowedEntityTypes: ["node", "media", "file", "taxonomy_term", "menu_link_content"],
78
- deniedEntityTypes: ["user"],
134
+ // Full content building: base content types + structural content entities
135
+ // (paragraphs, custom blocks, menu links, redirects, aliases, files).
136
+ allowedEntityTypes: ["node", "media", "taxonomy_term", ...CONTENT_STRUCTURAL],
137
+ deniedEntityTypes: [...SENSITIVE_DENY],
79
138
  entityRules: {
80
139
  node: { allowedOperations: ["read", "create", "update"] },
81
140
  media: { allowedOperations: ["read", "create", "update"] },
@@ -93,8 +152,11 @@ const PRESETS = {
93
152
  allowGraphqlMutations: false,
94
153
  allowConfigRead: true,
95
154
  allowConfigWrite: true, // governed config writes via drupal_config_set
96
- allowedEntityTypes: ["node", "media", "file", "taxonomy_term", "menu_link_content"],
97
- deniedEntityTypes: ["user"],
155
+ // content-editor's content set PLUS site-building config entities, the
156
+ // latter for READ / introspection only — model changes go through the
157
+ // governed config bridge (drupal_config_set) / drush config:import.
158
+ allowedEntityTypes: ["node", "media", "taxonomy_term", ...CONTENT_STRUCTURAL, ...SITE_BUILDER_CONFIG],
159
+ deniedEntityTypes: [...SENSITIVE_DENY],
98
160
  entityRules: {
99
161
  node: { allowedOperations: ["read", "create", "update"] },
100
162
  media: { allowedOperations: ["read", "create", "update"] },
@@ -141,8 +203,10 @@ const PRESETS = {
141
203
  allowGraphqlMutations: false, // writes go through the JSON:API plane
142
204
  allowConfigRead: true, // config read-only
143
205
  allowConfigWrite: false,
144
- allowedEntityTypes: ["node", "taxonomy_term", "media"],
145
- deniedEntityTypes: ["user"],
206
+ // Full content building on the content tier: base content + structural
207
+ // content entities. No site-building config entities (developer tier only).
208
+ allowedEntityTypes: ["node", "taxonomy_term", "media", ...CONTENT_STRUCTURAL],
209
+ deniedEntityTypes: [...SENSITIVE_DENY],
146
210
  entityRules: {},
147
211
  globalRedactedFields: ["pass", "mail"],
148
212
  },
@@ -259,6 +323,41 @@ export function assertConfigWriteAllowed(secConfig) {
259
323
  }
260
324
  }
261
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
+
262
361
  /**
263
362
  * @param {object} secConfig Resolved security config.
264
363
  * @param {string} entityType Entity type targeted by the delete.
@@ -25,12 +25,17 @@ import { clearToken } from "./oauth.js";
25
25
 
26
26
  /**
27
27
  * Canonical server-side tool names for governed config operations.
28
- * Keep the mapping here so a server-side rename is a one-line change.
28
+ *
29
+ * Drupal's mcp_server_tool_bridge exposes every Tool-API tool through the MCP
30
+ * protocol under the derivative name `tool_api.<mcp_tool_config id>`, so the
31
+ * governed config tools registered against mcp_sentinel's McpConfigGet/List/Set
32
+ * plugins surface as `tool_api.mcp_sentinel_config_*`. Keep the mapping here so
33
+ * a server-side rename is a one-line change.
29
34
  */
30
35
  export const SERVER_TOOLS = {
31
- configGet: "config_get",
32
- configList: "config_list",
33
- configSet: "config_set",
36
+ configGet: "tool_api.mcp_sentinel_config_get",
37
+ configList: "tool_api.mcp_sentinel_config_list",
38
+ configSet: "tool_api.mcp_sentinel_config_set",
34
39
  };
35
40
 
36
41
  // Monotonic JSON-RPC request id. A simple counter keeps ids unique per process
@@ -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,