drupal-mcp-connector 2.12.0 → 2.13.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.
@@ -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 an id-mismatch PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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."
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 an id-mismatch PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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.
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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun.
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 an id-mismatch PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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."
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 an id-mismatch PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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.
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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun.
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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.13.1] - 2026-09-05
11
+
12
+ ### Fixed
13
+ - Existing node drafts use Sentinel's governed draft-continuation endpoint
14
+ instead of a revision-selected core PATCH, which Drupal rejects (#166).
15
+ Preflight sends the actual fields and revision preconditions without saving.
16
+ An unavailable server endpoint fails closed without discarding the draft.
17
+
18
+ ### Changed
19
+ - Comments, test names, lab notes, and older changelog entries now reference
20
+ GitHub issues, release versions, or plain descriptions instead of internal
21
+ tracker keys (#258). No behaviour change.
22
+
23
+ ## [2.13.0] - 2026-09-02
24
+
25
+ ### Security
26
+ - **Attributable usage, quotas, and abuse signals on the relay edge (#256).**
27
+ Optional usage ledger (`MCP_EDGE_USAGE_MAX_RECORDS` / `relay.usage`)
28
+ records every edge decision (allow or deny) and every fan-down receipt
29
+ against the grant-resolved tenant and the validated principal, carrying
30
+ the frame's `requestId`, a `decisionId`, the bound `policyDigest`, and
31
+ measured cost signals (units, bytes, duration). Optional `auth.quotas`
32
+ (tenant / principal request windows plus an abuse lock) fails closed at
33
+ the edge: an unlisted tenant or principal is `not_entitled`, an exhausted
34
+ window is `429 quota_exceeded` with `Retry-After`, and a principal that
35
+ keeps earning refusals is `429 abuse_locked` — all with zero frames on any
36
+ tunnel. A shared tenant window running out never feeds an individual
37
+ principal's lock; a quota table the edge cannot read refuses startup and
38
+ names the offending path. A response the listener cannot relay is
39
+ `502 fan_down_failed` with a `failed` receipt, and a metering failure
40
+ never changes a verdict. `GET /usage` serves the caller's own tenant
41
+ partition (tenant from `auth.tenantGrants`; any other tenant is
42
+ `not_entitled` with no records) with a reconciliation naming `missing`,
43
+ `duplicate`, and `uncertain` chains; unmatched response frames are
44
+ recorded against the sending tunnel. Omitting both keeps the 2.12.0 path.
45
+ Lab/loopback only — measured usage, not pricing, billing, a hosted
46
+ metering sink, or a hosted-service claim.
47
+
10
48
  ## [2.12.0] - 2026-09-02
11
49
 
12
50
  ### Security
@@ -99,7 +137,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
137
  credentials never appear in any frame crossing the tunnel. The lab
100
138
  README now states plainly that the lab northbound does not authenticate
101
139
  the caller (the lab credential authenticates the agent channel) — the
102
- product edge's northbound OAuth is DEV-294 work, not this harness.
140
+ product edge's northbound OAuth is #232 work, not this harness.
103
141
 
104
142
  ### Added
105
143
  - **Deployment documentation for the relay edge and tenant-agent entry
@@ -124,13 +162,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
124
162
  window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
125
163
  teardown, and timeout bounds. These are entry points and libraries only;
126
164
  nothing here is a hosted service.
127
- - **Lab-only outbound-relay harness (DEV-293).** Isolated under
165
+ - **Lab-only outbound-relay harness.** Isolated under
128
166
  `lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
129
167
  to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
130
168
  no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
131
169
  keeps the same tunnel identity; revocation is checked per request (next
132
170
  request denied, no grace window). This is not a public surface, not a
133
- hosted-service claim, and not DEV-294. Hosted MCP is unstarted.
171
+ hosted-service claim, and not the product edge (#232). Hosted MCP is unstarted.
134
172
 
135
173
  ## [2.9.0] - 2026-08-26
136
174
 
@@ -910,7 +948,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
910
948
  ## [1.5.1] - 2026-06-29
911
949
 
912
950
  ### Fixed
913
- - **Node URL aliases set via the connector now actually persist (DEV-116).** Setting an
951
+ - **Node URL aliases set via the connector now actually persist.** Setting an
914
952
  alias with `drupal_update_node` (`fields.path = { alias, pathauto: 0 }`) returned
915
953
  success but silently reverted, causing nav 404s. Root cause: JSON:API deserialized the
916
954
  `path` field without the existing alias's **`pid`**, so Drupal's `PathItem::postSave`
@@ -918,7 +956,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
918
956
  in place. The connector now reads the current alias's `pid` (new
919
957
  `backend.getPathInfo`) and round-trips it, so the alias is **updated in place** — one
920
958
  canonical alias, no duplicate. Verified end-to-end over JSON:API on Drupal 11.
921
- - **Path-less updates no longer create duplicate aliases.** The DEV-114 "preserve" path
959
+ - **Path-less updates no longer create duplicate aliases.** The 1.5.0 "preserve" path
922
960
  re-pinned the current alias *without* its `pid`, hitting the same duplicate bug; it now
923
961
  round-trips the `pid` too.
924
962
  - **Honest write responses.** `drupal_create_node` / `drupal_update_node` now **re-read**
@@ -948,29 +986,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
948
986
  ### Added
949
987
  - **`drupal_update_paragraph`** — update an existing Paragraph entity's field values
950
988
  in place (partial JSON:API PATCH) by bundle + UUID, so component / key-capability
951
- paragraphs can be maintained end-to-end without re-embedding (DEV-114).
989
+ paragraphs can be maintained end-to-end without re-embedding.
952
990
  - **`drupal_update_menu_link`** — update a menu link by UUID (rename, re-weight,
953
991
  re-target, re-parent, enable/disable). `enabled` is preserved across edits unless
954
- passed explicitly (DEV-114).
992
+ passed explicitly.
955
993
  - **`drupal_create_menu_link`** now accepts **`parent`** (nest under a parent link
956
994
  plugin id) and **`enabled`** on create, and creates links **enabled by default** so
957
995
  they render immediately — closing the "menu links created disabled / no parent on
958
- create" gap (DEV-114).
996
+ create" gap.
959
997
 
960
998
  ### Fixed
961
999
  - **Menu links no longer silently regress to disabled.** Every menu-link write now
962
1000
  asserts `enabled` explicitly (default true on create; the current value re-pinned on
963
1001
  update), so an unrelated edit can't drop a live link to disabled through the JSON:API
964
- write path (DEV-114).
1002
+ write path.
965
1003
  - **Node updates preserve the existing URL alias.** When `drupal_update_node` is called
966
1004
  without a `path`, the connector reads the current alias and re-pins it
967
1005
  (`{ alias, pathauto: 0 }`) so a save can't let Pathauto revert the alias to a stale
968
- value. Pass `fields.path` to set the alias explicitly (DEV-114).
1006
+ value. Pass `fields.path` to set the alias explicitly.
969
1007
  - **Intermittent `drupal_create_menu_link` 422 "path '/…' is inaccessible".** This is a
970
1008
  transient path-validator/access-cache race in Drupal's `LinkAccessConstraint`; menu-link
971
1009
  create/update now retries once after a short delay when it hits that specific error.
972
1010
  Prefer an `entity:node/<id>` target over `internal:/<alias>` to avoid the alias
973
- resolution step entirely (DEV-114).
1011
+ resolution step entirely.
974
1012
 
975
1013
  ## [1.4.0] - 2026-06-29
976
1014
 
@@ -986,7 +1024,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
986
1024
  its status code via a partial update — the path to activate/fix a redirect that
987
1025
  isn't firing. Both are governed by the per-site security policy (redirect writes /
988
1026
  `administer redirects`). Resolves the gap where connector-created redirects were
989
- inactive and could not be enabled (DEV-111).
1027
+ inactive and could not be enabled.
990
1028
 
991
1029
  ## [1.3.2] - 2026-06-27
992
1030
 
@@ -1393,6 +1431,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1393
1431
  - User tools gained explicit PII-access assertions.
1394
1432
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1395
1433
 
1434
+ [2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
1435
+ [2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
1396
1436
  [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
1397
1437
  [2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
1398
1438
  [2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
@@ -32,6 +32,13 @@
32
32
  * MCP_RATE_LIMIT / MCP_RATE_WINDOW_SEC
33
33
  * Northbound /mcp rate limit (same defaults as the
34
34
  * primary entry point).
35
+ * MCP_EDGE_USAGE_MAX_RECORDS
36
+ * Positive integer enables the in-process usage ledger
37
+ * (or config relay.usage.maxRecords). Every decision and
38
+ * receipt is recorded per tenant and principal, and
39
+ * GET /usage serves the caller's own tenant partition.
40
+ * Unset: nothing is recorded and /usage is 404. Set but
41
+ * unreadable: fatal.
35
42
  *
36
43
  * Config: auth.grants (client id -> [site names]) is mandatory; the edge
37
44
  * refuses to start without it. Optional auth.tenantGrants (client id ->
@@ -41,14 +48,27 @@
41
48
  * (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
42
49
  * Optional auth.promotions (digest -> sealed document + two operator ids)
43
50
  * is the W&L-operated dual-control ledger; the edge fans eligible bundles
44
- * to the tenant agent and requires a matching local attestation.
51
+ * to the tenant agent and requires a matching local attestation. Optional
52
+ * auth.quotas (tenant / principal request windows plus an abuse lock)
53
+ * fails closed at the edge with zero frames on any refusal; a table the
54
+ * edge cannot read refuses startup.
45
55
  */
46
56
 
47
57
  import { readFileSync } from "node:fs";
48
58
  import process from "node:process";
49
- import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundPromotions, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
59
+ import {
60
+ getInboundActors,
61
+ getInboundGrants,
62
+ getInboundPolicies,
63
+ getInboundPromotions,
64
+ getInboundQuotas,
65
+ getInboundTenantGrants,
66
+ getTlsConfig,
67
+ loadConfig,
68
+ } from "../src/lib/config.js";
50
69
  import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
51
70
  import { createRateLimiter } from "../src/lib/rate-limit.js";
71
+ import { createUsageLedger } from "../src/lib/usage.js";
52
72
  import {
53
73
  createChannelCredentialStore,
54
74
  startEdge,
@@ -126,6 +146,29 @@ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
126
146
  ? rateLimitDefault
127
147
  : Number(rateLimitEnv);
128
148
 
149
+ // Usage ledger (#256): opt-in, in-process, bounded. A restart clears it.
150
+ // A value that is set but unreadable is fatal: the operator asked for
151
+ // metering, and silently running without it would be a lie.
152
+ const usageEnv = process.env.MCP_EDGE_USAGE_MAX_RECORDS;
153
+ const usageRaw = usageEnv !== undefined && usageEnv !== ""
154
+ ? usageEnv
155
+ : config.relay?.usage?.maxRecords;
156
+ let usageMaxRecords = 0;
157
+ if (usageRaw !== undefined && usageRaw !== null) {
158
+ const parsed = typeof usageRaw === "string" && /^[0-9]+$/.test(usageRaw.trim())
159
+ ? Number(usageRaw.trim())
160
+ : usageRaw;
161
+ if (!Number.isInteger(parsed) || parsed <= 0) {
162
+ fatal(
163
+ "MCP_EDGE_USAGE_MAX_RECORDS (or relay.usage.maxRecords) must be a positive "
164
+ + `integer; got ${JSON.stringify(usageRaw)}. Unset it to run without the usage ledger.`,
165
+ );
166
+ }
167
+ usageMaxRecords = parsed;
168
+ }
169
+ const usage = usageMaxRecords > 0 ? createUsageLedger({ maxRecords: usageMaxRecords }) : null;
170
+ const quotas = getInboundQuotas();
171
+
129
172
  let edge;
130
173
  try {
131
174
  edge = await startEdge({
@@ -135,6 +178,8 @@ try {
135
178
  actors: getInboundActors(),
136
179
  policies: getInboundPolicies(),
137
180
  promotions: getInboundPromotions(),
181
+ quotas,
182
+ usage,
138
183
  sites,
139
184
  defaultSite: config.defaultSite,
140
185
  channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
@@ -162,3 +207,11 @@ if (rateLimit > 0) {
162
207
  `[drupal-mcp-edge] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`,
163
208
  );
164
209
  }
210
+ if (usage) {
211
+ console.error(
212
+ `[drupal-mcp-edge] Usage ledger: in-process, ${usageMaxRecords} records max; GET /usage serves one tenant partition.`,
213
+ );
214
+ }
215
+ if (quotas) {
216
+ console.error("[drupal-mcp-edge] Quotas: auth.quotas in force; unlisted tenants / principals are refused.");
217
+ }
@@ -44,6 +44,9 @@
44
44
  "promotions": {
45
45
  "_comment": "Optional W&L-operated dual-control ledger. Map a SHA-256 digest to { document, approvals } where document is the sealed portable Sentinel bundle (claims + digest + hmac-sha256 seal) and approvals is two distinct operator ids. When present, the edge fans eligible documents to the tenant agent; non-diagnostic tools/call require a matching local attestation. The edge never mints a seal. Omit to keep the digest-only path. Tenant self-service is not this table."
46
46
  },
47
+ "quotas": {
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
+ },
47
50
  "revocationFile": "",
48
51
  "introspectionUrl": "",
49
52
  "introspectionClientIdEnv": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
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",
@@ -78,7 +78,7 @@ export class Backend {
78
78
  * Read the raw `path` field (alias/pid/langcode) and internal id of an entity,
79
79
  * for callers that must round-trip the alias `pid` on an in-place update (the
80
80
  * canonical entity only exposes `path.alias` as `url`). Optional capability:
81
- * the default returns nulls so read-only/path-less backends are safe. See DEV-116.
81
+ * the default returns nulls so read-only/path-less backends are safe. See the 1.5.1 alias fix.
82
82
  * @param {{entityType: string, bundle: string, id: string}} _ref
83
83
  * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
84
84
  */
@@ -137,7 +137,7 @@ function applyFilter(params, { field, op = "eq", value }) {
137
137
  /**
138
138
  * Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
139
139
  * User entities are left unchanged. No actor claim (auth.actors not in effect
140
- * for this principal) leaves relationships as-is — the prior, pre-DEV-123 path.
140
+ * for this principal) leaves relationships as-is — the path before actor mapping (#247).
141
141
  * A *present* actor claim that fails UUID validation fails closed (throws)
142
142
  * rather than silently keeping a caller-supplied uid: resolveActor()/
143
143
  * normalizeActors() already validate the shape before stamping identity.actor,
@@ -309,7 +309,7 @@ export class JsonApiBackend extends Backend {
309
309
  * correct in-place alias *update* must round-trip the existing alias's `pid`
310
310
  * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
311
311
  * — so this method exposes it. Returns nulls for entities/backends without a
312
- * path field. See DEV-116.
312
+ * path field. See the 1.5.1 alias fix.
313
313
  * @param {{entityType: string, bundle: string, id: string}} ref
314
314
  * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
315
315
  */
package/src/lib/config.js CHANGED
@@ -308,6 +308,30 @@ export function getInboundPromotions() {
308
308
  return entries.length ? Object.fromEntries(entries) : null;
309
309
  }
310
310
 
311
+ /**
312
+ * Tenant / principal quota table plus abuse lock (`auth.quotas`).
313
+ * When present, the relay edge fails closed: a tenant or principal without
314
+ * a row is refused, exhausted windows and locked principals are refused,
315
+ * and a table the edge cannot read refuses startup. Validation lives in
316
+ * usage.js (`normalizeQuotas`).
317
+ * @returns {object|null|unknown} Null when omitted or comment-only; the
318
+ * comment-stripped table when it is an object; otherwise the configured
319
+ * value unchanged (a string, array, number, ...) so `startEdge()` refuses
320
+ * to start on it instead of running unmetered.
321
+ */
322
+ export function getInboundQuotas() {
323
+ const quotas = loadConfig().auth?.quotas;
324
+ if (quotas === undefined || quotas === null) return null;
325
+ // A present value that is not an object is passed through unchanged so
326
+ // the edge refuses to start on it (usage.js normalizeQuotas names it);
327
+ // returning null here would silently run without quotas.
328
+ if (typeof quotas !== "object" || Array.isArray(quotas)) return quotas;
329
+ const entries = Object.entries(quotas)
330
+ .map(([key, value]) => [key.trim(), value])
331
+ .filter(([key]) => key && !key.startsWith("_"));
332
+ return entries.length ? Object.fromEntries(entries) : null;
333
+ }
334
+
311
335
  // ---------------------------------------------------------------------------
312
336
  // Auth headers — never logged, never exposed in tool responses
313
337
  // ---------------------------------------------------------------------------
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Sentinel's governed draft-continuation contract (d.o #3621022).
3
+ * Core JSON:API revision selectors support reads, not PATCH requests.
4
+ */
5
+
6
+ /**
7
+ * Validate or continue a draft, using the same payload and revision precondition.
8
+ * No canonical fallback: an absent endpoint or refused precondition stops work.
9
+ * @param {object} backend JSON:API backend.
10
+ * @param {object} input Canonical update input plus draftRevision.
11
+ * @param {boolean} preflight Validate without saving.
12
+ * @returns {Promise<object>} Preflight metadata or the written canonical entity.
13
+ */
14
+ export async function writeDraft(backend, input, preflight = false) {
15
+ const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
16
+ const live = String(draftRevision?.liveVid ?? "");
17
+ const working = String(draftRevision?.workingVid ?? "");
18
+ if (entityType !== "node" || !/^[1-9]\d*$/.test(live)
19
+ || !/^[1-9]\d*$/.test(working) || live === working) {
20
+ throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
21
+ }
22
+ if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
23
+ throw new Error("This backend does not support governed draft continuation.");
24
+ }
25
+ const data = { type: `${entityType}--${bundle}`, id, attributes };
26
+ if (relationships) data.relationships = relationships;
27
+ let result;
28
+ try {
29
+ result = await backend.rawQuery({
30
+ path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
31
+ options: {
32
+ method: "PATCH",
33
+ headers: {
34
+ "If-Match": `"${live}:${working}"`,
35
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
36
+ },
37
+ body: JSON.stringify({ data }),
38
+ },
39
+ });
40
+ } catch (error) {
41
+ if (/Drupal (404|405)\b/.test(String(error?.message))) {
42
+ throw new Error("The site does not provide Sentinel's governed draft endpoint (d.o #3621022). Update the server-side module; the draft was not discarded and no canonical fallback was attempted.", { cause: error });
43
+ }
44
+ throw error;
45
+ }
46
+ if (preflight) {
47
+ if (result?.meta?.draft_preflight !== true
48
+ || String(result.meta.live) !== live || String(result.meta.working) !== working) {
49
+ throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
50
+ }
51
+ return result;
52
+ }
53
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
54
+ throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
55
+ }
56
+ return backend.toCanonical(result.data);
57
+ }
@@ -12,19 +12,19 @@
12
12
  *
13
13
  * Two distinct cases share that core 400:
14
14
  *
15
- * - **#166** — `rel:working-copy` resolves. PATCH (and the dryRun probe)
16
- * target `?resourceVersion=rel:working-copy`. Do not discard, do not
17
- * retry the canonical URL, and do not tell the caller to publish first.
15
+ * - **#166** — `rel:working-copy` resolves. Both preflight and write use
16
+ * Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
17
+ * Core rejects resourceVersion on PATCH. Never retry the canonical URL.
18
18
  * - **#201** — the working-copy alias does not resolve, but core still
19
19
  * blocks. That is a stray revision row. Refuse with revision-surgery
20
20
  * language. `workingCopy: null` is not proof the node is writable.
21
21
  *
22
- * A working-copy 400 *after* targeting the working copy is a stale or
23
- * concurrent write — refuse, do not fall back to discard.
22
+ * Unsupported endpoints and stale revisions fail closed, without discard.
24
23
  */
25
24
 
26
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
27
26
  import { entityRevisionId } from "./write-revision.js";
27
+ import { writeDraft } from "./draft-write.js";
28
28
 
29
29
  /** Stable error code for a core working-copy / not-latest-revision block. */
30
30
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
@@ -59,7 +59,7 @@ export const PATCH_WORKING_COPY_STALE_MESSAGE =
59
59
 
60
60
  /**
61
61
  * Operator message for a core working-copy 400.
62
- * A resolvable working copy is edited in place (#166) — this message is only
62
+ * A resolvable working copy gets a new draft revision (#166) — this message is only
63
63
  * for the invisible-row case (#201).
64
64
  * @returns {string}
65
65
  */
@@ -300,11 +300,18 @@ export async function preflightPatchWritable({
300
300
  * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
301
301
  */
302
302
  export async function prepareGuardedPatch(backend, {
303
- entityType, bundle, id, existing, attributes,
303
+ entityType, bundle, id, existing, attributes, relationships,
304
304
  }) {
305
305
  const target = shouldPreflightPatch({ existing, attributes })
306
306
  ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
307
307
  : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
308
+ if (target.resourceVersion) {
309
+ target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
310
+ await writeDraft(backend, {
311
+ entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
312
+ }, true);
313
+ return target;
314
+ }
308
315
  await preflightPatchWritable({
309
316
  backend, entityType, bundle, id, existing, attributes,
310
317
  resourceVersion: target.resourceVersion,
@@ -323,6 +330,10 @@ export async function prepareGuardedPatch(backend, {
323
330
  */
324
331
  export async function updateEntityGuarded(backend, input) {
325
332
  try {
333
+ if (input?.draftRevision) return await writeDraft(backend, input);
334
+ if (input?.resourceVersion) {
335
+ throw new Error("Core JSON:API does not support revision-selected PATCH. A governed draft preflight is required.");
336
+ }
326
337
  return await backend.updateEntity(input);
327
338
  } catch (err) {
328
339
  if (!isWorkingCopyPatchError(err)) throw err;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tenant-side local policy enforcement (#253 / DEV-125).
2
+ * Tenant-side local policy enforcement (#253).
3
3
  *
4
4
  * Loopback stand-in for mcp_sentinel `McpPolicyBundleRegistry`: mint / verify
5
5
  * / activate / simulate / revoke / rollback / emergency deny. The relay edge
@@ -1,5 +1,5 @@
1
1
  /**
2
- * W&L-operated dual-control promotion ledger (#253 / DEV-125).
2
+ * W&L-operated dual-control promotion ledger (#253).
3
3
  *
4
4
  * The edge never mints a Sentinel HMAC seal. A promotion is an already-sealed
5
5
  * portable document plus two distinct operator ids. Eligibility is the gate
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Relay tenant agent (#232) — the DEV-294 AC4 slice, tenant side.
2
+ * Relay tenant agent (#232) — the hosted-edge slice, tenant side.
3
3
  *
4
4
  * Dials OUT to the edge's agent channel and serves the real connector server
5
5
  * (`createConnectorServerFactory`) over the framed tunnel. The agent never