drupal-mcp-connector 2.13.0 → 2.14.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/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +66 -12
- package/bin/drupal-mcp-anchor.js +52 -0
- package/bin/drupal-mcp-edge.js +17 -1
- package/config/config.example.json +6 -0
- package/package.json +3 -2
- package/src/lib/anchor.js +338 -0
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/config.js +39 -0
- package/src/lib/contracts/approval.js +5 -0
- package/src/lib/draft-write.js +57 -0
- package/src/lib/evidence.js +607 -0
- package/src/lib/http-auth.js +65 -3
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +559 -15
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +1 -2
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets
|
|
2
|
+
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
|
|
3
3
|
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_entity_update`.
|
|
7
7
|
|
|
8
|
-
Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets
|
|
8
|
+
Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
18
18
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
19
19
|
- `attributes` (object (pass as JSON))
|
|
20
20
|
- `relationships` (object (pass as JSON))
|
|
21
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
21
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
|
22
22
|
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
23
23
|
|
|
24
24
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
2
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_node`.
|
|
7
7
|
|
|
8
|
-
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets
|
|
8
|
+
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -23,7 +23,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
23
23
|
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
|
|
24
24
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
|
|
25
25
|
- `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot.
|
|
26
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write.
|
|
26
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
|
27
27
|
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
28
28
|
|
|
29
29
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.14.0] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Laboratory tenant offboarding on the relay edge (#267).**
|
|
14
|
+
`offboardTenant` fail-closes unless a tenant-scoped assessor snapshot
|
|
15
|
+
can be taken, then overwrite-removes the channel digest, drops the
|
|
16
|
+
grant and session, and tombstones the principal. Revoke and destroy
|
|
17
|
+
rows are independently anchored only after that teardown succeeds.
|
|
18
|
+
`GET /assessor` cites P9.9 only when those anchored rows match the
|
|
19
|
+
digest frozen at export. The held pack stays independently verifiable
|
|
20
|
+
after access is removed. A former principal sees `offboarded`; a
|
|
21
|
+
stranger stays `not_entitled`. The pack never writes `passed`.
|
|
22
|
+
Lab/loopback only — not a hosted-service or design-partner admission
|
|
23
|
+
claim.
|
|
24
|
+
- **Laboratory tenant onboarding on the relay edge (#265).** Optional
|
|
25
|
+
`auth.approvalRequiredTools` issues a one-use approval before fan-down
|
|
26
|
+
for named tools; a later `tools/call` with `arguments.approvalId`
|
|
27
|
+
consumes it (stripped before the tenant sees it). `GET /assessor` cites
|
|
28
|
+
P9.8 when one tenant pack holds anchored allow, deny, and
|
|
29
|
+
approval-gated executions against the live policy digest. Omit the
|
|
30
|
+
list to keep the prior path. A table the edge cannot read refuses
|
|
31
|
+
startup. Lab/loopback only — not a hosted-service or design-partner
|
|
32
|
+
admission claim.
|
|
33
|
+
|
|
34
|
+
### Security
|
|
35
|
+
- **Independently verifiable evidence on the relay edge (#261).** Optional
|
|
36
|
+
`auth.evidenceAnchor` (`url` + pinned Ed25519 `publicKey`) submits a
|
|
37
|
+
minimized receipt digest to a notary process (`drupal-mcp-anchor`) whose
|
|
38
|
+
private key the edge never holds. One governed execution reconciles by
|
|
39
|
+
stable identifiers (identity, delegation, decision, obligations,
|
|
40
|
+
approval, local execution, target revision, receipt — explicit `none:*`
|
|
41
|
+
absences count). `GET /assessor` serves the caller's own tenant pack:
|
|
42
|
+
data-minimized, bound to the live policy digest, controls cite evidence
|
|
43
|
+
ids or stay `residual`, never `passed`. A table the edge cannot pin, or
|
|
44
|
+
a ledger without a pin, refuses startup. Omitting the table keeps the
|
|
45
|
+
2.13.0 path (`/assessor` is 404). Lab/loopback only — not a hosted-service
|
|
46
|
+
or design-partner admission claim. Audit Chain NDJSON remains the
|
|
47
|
+
off-system stream, not this anchor.
|
|
48
|
+
|
|
49
|
+
## [2.13.1] - 2026-09-05
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- Existing node drafts use Sentinel's governed draft-continuation endpoint
|
|
53
|
+
instead of a revision-selected core PATCH, which Drupal rejects (#166).
|
|
54
|
+
Preflight sends the actual fields and revision preconditions without saving.
|
|
55
|
+
An unavailable server endpoint fails closed without discarding the draft.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
- Comments, test names, lab notes, and older changelog entries now reference
|
|
59
|
+
GitHub issues, release versions, or plain descriptions instead of internal
|
|
60
|
+
tracker keys (#258). No behaviour change.
|
|
61
|
+
|
|
10
62
|
## [2.13.0] - 2026-09-02
|
|
11
63
|
|
|
12
64
|
### Security
|
|
@@ -124,7 +176,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
124
176
|
credentials never appear in any frame crossing the tunnel. The lab
|
|
125
177
|
README now states plainly that the lab northbound does not authenticate
|
|
126
178
|
the caller (the lab credential authenticates the agent channel) — the
|
|
127
|
-
product edge's northbound OAuth is
|
|
179
|
+
product edge's northbound OAuth is #232 work, not this harness.
|
|
128
180
|
|
|
129
181
|
### Added
|
|
130
182
|
- **Deployment documentation for the relay edge and tenant-agent entry
|
|
@@ -149,13 +201,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
149
201
|
window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
|
|
150
202
|
teardown, and timeout bounds. These are entry points and libraries only;
|
|
151
203
|
nothing here is a hosted service.
|
|
152
|
-
- **Lab-only outbound-relay harness
|
|
204
|
+
- **Lab-only outbound-relay harness.** Isolated under
|
|
153
205
|
`lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
|
|
154
206
|
to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
|
|
155
207
|
no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
|
|
156
208
|
keeps the same tunnel identity; revocation is checked per request (next
|
|
157
209
|
request denied, no grace window). This is not a public surface, not a
|
|
158
|
-
hosted-service claim, and not
|
|
210
|
+
hosted-service claim, and not the product edge (#232). Hosted MCP is unstarted.
|
|
159
211
|
|
|
160
212
|
## [2.9.0] - 2026-08-26
|
|
161
213
|
|
|
@@ -935,7 +987,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
935
987
|
## [1.5.1] - 2026-06-29
|
|
936
988
|
|
|
937
989
|
### Fixed
|
|
938
|
-
- **Node URL aliases set via the connector now actually persist
|
|
990
|
+
- **Node URL aliases set via the connector now actually persist.** Setting an
|
|
939
991
|
alias with `drupal_update_node` (`fields.path = { alias, pathauto: 0 }`) returned
|
|
940
992
|
success but silently reverted, causing nav 404s. Root cause: JSON:API deserialized the
|
|
941
993
|
`path` field without the existing alias's **`pid`**, so Drupal's `PathItem::postSave`
|
|
@@ -943,7 +995,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
943
995
|
in place. The connector now reads the current alias's `pid` (new
|
|
944
996
|
`backend.getPathInfo`) and round-trips it, so the alias is **updated in place** — one
|
|
945
997
|
canonical alias, no duplicate. Verified end-to-end over JSON:API on Drupal 11.
|
|
946
|
-
- **Path-less updates no longer create duplicate aliases.** The
|
|
998
|
+
- **Path-less updates no longer create duplicate aliases.** The 1.5.0 "preserve" path
|
|
947
999
|
re-pinned the current alias *without* its `pid`, hitting the same duplicate bug; it now
|
|
948
1000
|
round-trips the `pid` too.
|
|
949
1001
|
- **Honest write responses.** `drupal_create_node` / `drupal_update_node` now **re-read**
|
|
@@ -973,29 +1025,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
973
1025
|
### Added
|
|
974
1026
|
- **`drupal_update_paragraph`** — update an existing Paragraph entity's field values
|
|
975
1027
|
in place (partial JSON:API PATCH) by bundle + UUID, so component / key-capability
|
|
976
|
-
paragraphs can be maintained end-to-end without re-embedding
|
|
1028
|
+
paragraphs can be maintained end-to-end without re-embedding.
|
|
977
1029
|
- **`drupal_update_menu_link`** — update a menu link by UUID (rename, re-weight,
|
|
978
1030
|
re-target, re-parent, enable/disable). `enabled` is preserved across edits unless
|
|
979
|
-
passed explicitly
|
|
1031
|
+
passed explicitly.
|
|
980
1032
|
- **`drupal_create_menu_link`** now accepts **`parent`** (nest under a parent link
|
|
981
1033
|
plugin id) and **`enabled`** on create, and creates links **enabled by default** so
|
|
982
1034
|
they render immediately — closing the "menu links created disabled / no parent on
|
|
983
|
-
create" gap
|
|
1035
|
+
create" gap.
|
|
984
1036
|
|
|
985
1037
|
### Fixed
|
|
986
1038
|
- **Menu links no longer silently regress to disabled.** Every menu-link write now
|
|
987
1039
|
asserts `enabled` explicitly (default true on create; the current value re-pinned on
|
|
988
1040
|
update), so an unrelated edit can't drop a live link to disabled through the JSON:API
|
|
989
|
-
write path
|
|
1041
|
+
write path.
|
|
990
1042
|
- **Node updates preserve the existing URL alias.** When `drupal_update_node` is called
|
|
991
1043
|
without a `path`, the connector reads the current alias and re-pins it
|
|
992
1044
|
(`{ alias, pathauto: 0 }`) so a save can't let Pathauto revert the alias to a stale
|
|
993
|
-
value. Pass `fields.path` to set the alias explicitly
|
|
1045
|
+
value. Pass `fields.path` to set the alias explicitly.
|
|
994
1046
|
- **Intermittent `drupal_create_menu_link` 422 "path '/…' is inaccessible".** This is a
|
|
995
1047
|
transient path-validator/access-cache race in Drupal's `LinkAccessConstraint`; menu-link
|
|
996
1048
|
create/update now retries once after a short delay when it hits that specific error.
|
|
997
1049
|
Prefer an `entity:node/<id>` target over `internal:/<alias>` to avoid the alias
|
|
998
|
-
resolution step entirely
|
|
1050
|
+
resolution step entirely.
|
|
999
1051
|
|
|
1000
1052
|
## [1.4.0] - 2026-06-29
|
|
1001
1053
|
|
|
@@ -1011,7 +1063,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1011
1063
|
its status code via a partial update — the path to activate/fix a redirect that
|
|
1012
1064
|
isn't firing. Both are governed by the per-site security policy (redirect writes /
|
|
1013
1065
|
`administer redirects`). Resolves the gap where connector-created redirects were
|
|
1014
|
-
inactive and could not be enabled
|
|
1066
|
+
inactive and could not be enabled.
|
|
1015
1067
|
|
|
1016
1068
|
## [1.3.2] - 2026-06-27
|
|
1017
1069
|
|
|
@@ -1418,6 +1470,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1418
1470
|
- User tools gained explicit PII-access assertions.
|
|
1419
1471
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1420
1472
|
|
|
1473
|
+
[2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
|
|
1474
|
+
[2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
|
|
1421
1475
|
[2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
|
|
1422
1476
|
[2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
|
|
1423
1477
|
[2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* drupal-mcp-anchor — independent Ed25519 notary (#261).
|
|
4
|
+
*
|
|
5
|
+
* Separate process from the relay edge. Holds the private key and never
|
|
6
|
+
* shares it. Prints the public pin the edge must configure in
|
|
7
|
+
* `auth.evidenceAnchor.publicKey`. Loopback only. Not a hosted-service
|
|
8
|
+
* or design-partner admission claim. Audit Chain NDJSON is the off-system
|
|
9
|
+
* stream, not this process.
|
|
10
|
+
*
|
|
11
|
+
* Environment variables:
|
|
12
|
+
* MCP_ANCHOR_PORT Listen port (default 0 = ephemeral; printed).
|
|
13
|
+
* MCP_ANCHOR_BIND Bind host (default 127.0.0.1). Non-loopback refused.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { createNotary, startAnchorServer } from "../src/lib/anchor.js";
|
|
18
|
+
|
|
19
|
+
function fatal(message) {
|
|
20
|
+
console.error(`[drupal-mcp-anchor] FATAL: ${message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bindHost = process.env.MCP_ANCHOR_BIND || "127.0.0.1";
|
|
25
|
+
const loopback = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
26
|
+
if (!loopback) {
|
|
27
|
+
fatal(
|
|
28
|
+
"The notary binds loopback only. Set MCP_ANCHOR_BIND to 127.0.0.1 "
|
|
29
|
+
+ "(or omit it). A separately administered production host is not chosen here.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const portRaw = process.env.MCP_ANCHOR_PORT;
|
|
34
|
+
const port = portRaw === undefined || portRaw === "" ? 0 : Number(portRaw);
|
|
35
|
+
if (!Number.isInteger(port) || port < 0) {
|
|
36
|
+
fatal("MCP_ANCHOR_PORT must be a non-negative integer (0 = ephemeral).");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const notary = createNotary();
|
|
40
|
+
const server = await startAnchorServer({ notary, bindHost, port });
|
|
41
|
+
|
|
42
|
+
console.error(
|
|
43
|
+
`[drupal-mcp-anchor] Listening ${server.url} · keyId ${notary.keyId}`,
|
|
44
|
+
);
|
|
45
|
+
console.error("[drupal-mcp-anchor] Pin this public key on the edge (auth.evidenceAnchor.publicKey):");
|
|
46
|
+
console.error(notary.publicPin);
|
|
47
|
+
|
|
48
|
+
function shutdown() {
|
|
49
|
+
server.close().then(() => process.exit(0), () => process.exit(1));
|
|
50
|
+
}
|
|
51
|
+
process.on("SIGINT", shutdown);
|
|
52
|
+
process.on("SIGTERM", shutdown);
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -51,13 +51,21 @@
|
|
|
51
51
|
* to the tenant agent and requires a matching local attestation. Optional
|
|
52
52
|
* auth.quotas (tenant / principal request windows plus an abuse lock)
|
|
53
53
|
* fails closed at the edge with zero frames on any refusal; a table the
|
|
54
|
-
* edge cannot read refuses startup.
|
|
54
|
+
* edge cannot read refuses startup. Optional auth.evidenceAnchor
|
|
55
|
+
* ({ url, publicKey }) pins an independent Ed25519 notary; GET /assessor
|
|
56
|
+
* then serves one tenant-scoped pack bound to the live policy digest. A
|
|
57
|
+
* pin the edge cannot read refuses startup. Omit to keep the prior path
|
|
58
|
+
* (404 on /assessor). Optional auth.approvalRequiredTools issues a
|
|
59
|
+
* one-use approval before fan-down for named tools. Lab/loopback only —
|
|
60
|
+
* not a hosted-service claim.
|
|
55
61
|
*/
|
|
56
62
|
|
|
57
63
|
import { readFileSync } from "node:fs";
|
|
58
64
|
import process from "node:process";
|
|
59
65
|
import {
|
|
60
66
|
getInboundActors,
|
|
67
|
+
getInboundApprovalRequiredTools,
|
|
68
|
+
getInboundEvidenceAnchor,
|
|
61
69
|
getInboundGrants,
|
|
62
70
|
getInboundPolicies,
|
|
63
71
|
getInboundPromotions,
|
|
@@ -168,6 +176,7 @@ if (usageRaw !== undefined && usageRaw !== null) {
|
|
|
168
176
|
}
|
|
169
177
|
const usage = usageMaxRecords > 0 ? createUsageLedger({ maxRecords: usageMaxRecords }) : null;
|
|
170
178
|
const quotas = getInboundQuotas();
|
|
179
|
+
const evidenceAnchor = getInboundEvidenceAnchor();
|
|
171
180
|
|
|
172
181
|
let edge;
|
|
173
182
|
try {
|
|
@@ -180,6 +189,8 @@ try {
|
|
|
180
189
|
promotions: getInboundPromotions(),
|
|
181
190
|
quotas,
|
|
182
191
|
usage,
|
|
192
|
+
evidenceAnchor,
|
|
193
|
+
approvalRequiredTools: getInboundApprovalRequiredTools(),
|
|
183
194
|
sites,
|
|
184
195
|
defaultSite: config.defaultSite,
|
|
185
196
|
channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
|
|
@@ -215,3 +226,8 @@ if (usage) {
|
|
|
215
226
|
if (quotas) {
|
|
216
227
|
console.error("[drupal-mcp-edge] Quotas: auth.quotas in force; unlisted tenants / principals are refused.");
|
|
217
228
|
}
|
|
229
|
+
if (evidenceAnchor) {
|
|
230
|
+
console.error(
|
|
231
|
+
"[drupal-mcp-edge] Evidence: independent notary pin in force; GET /assessor serves one tenant pack.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
"quotas": {
|
|
48
48
|
"_comment": "Optional, drupal-mcp-edge only. { tenants: { \"<agent id>\": { requests, windowSec } }, principals: { \"<sub or client_id>\": { requests, windowSec } }, abuse: { denials, windowSec, lockSec } }. When a tenants or principals table names any id, an id without a row is not_entitled; an exhausted window is 429 quota_exceeded with Retry-After; a principal that earns `denials` refusals inside windowSec is 429 abuse_locked for lockSec. Every refusal is zero frames on any tunnel. A shared tenant window running out never feeds an individual principal's abuse lock. A table the edge cannot read refuses startup and names the offending path. Omit to keep the prior path (no quota at the edge). Counting is measured usage, not pricing."
|
|
49
49
|
},
|
|
50
|
+
"evidenceAnchor": {
|
|
51
|
+
"_comment": "Optional, drupal-mcp-edge only. Independent Ed25519 notary the edge never holds the private key for: { url, publicKey } (publicKey is SPKI DER, standard base64; url is https, or http on loopback). When present, every settled receipt is reconciled by stable identifiers and submitted to that notary; GET /assessor serves one tenant-scoped, data-minimized pack bound to the live policy digest. A table the edge cannot pin refuses startup. Omit to keep the prior path (404 on /assessor). Lab/loopback only — not a hosted-service or design-partner admission claim. Run `drupal-mcp-anchor` to mint a pin."
|
|
52
|
+
},
|
|
53
|
+
"approvalRequiredTools": {
|
|
54
|
+
"_comment": "Optional, drupal-mcp-edge only. Array of tool names that require a one-use approval the edge issues before fan-down, e.g. [\"drupal_create_node\"]. A later tools/call with arguments.approvalId consumes that ticket (stripped before the tenant sees it). Omit to keep the prior path (no edge approval gate). A table the edge cannot read refuses startup. Lab/loopback only — not a hosted-service or design-partner admission claim."
|
|
55
|
+
},
|
|
50
56
|
"revocationFile": "",
|
|
51
57
|
"introspectionUrl": "",
|
|
52
58
|
"introspectionClientIdEnv": "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.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",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"drupal-mcp-connector": "src/index.js",
|
|
9
9
|
"drupal-mcp-verify": "bin/drupal-mcp-verify.js",
|
|
10
10
|
"drupal-mcp-edge": "bin/drupal-mcp-edge.js",
|
|
11
|
-
"drupal-mcp-agent": "bin/drupal-mcp-agent.js"
|
|
11
|
+
"drupal-mcp-agent": "bin/drupal-mcp-agent.js",
|
|
12
|
+
"drupal-mcp-anchor": "bin/drupal-mcp-anchor.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src/",
|