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 +44 -0
- package/config/config.example.json +3 -3
- package/package.json +1 -1
- package/src/lib/security.js +108 -9
- package/src/lib/server-tools.js +9 -4
- package/src/tools/config.js +17 -5
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
|
|
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.
|
|
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
package/src/lib/security.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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.
|
package/src/lib/server-tools.js
CHANGED
|
@@ -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
|
-
*
|
|
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: "
|
|
32
|
-
configList: "
|
|
33
|
-
configSet: "
|
|
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
|
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,
|