drupal-mcp-connector 2.8.0 → 2.9.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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.9.0] - 2026-08-26
11
+
12
+ ### Fixed
13
+ - **Adapter-contract execute trusts a fresh evaluation (#181).**
14
+ Manifest digests are always hashed (caller-supplied digests are ignored).
15
+ `execute` re-evaluates and will not honor a forged allow. Actor-bound
16
+ approvals cannot be consumed without that actor. Vendor keys are rejected
17
+ on `hints`. `propose` and `policyDigest` use the same relay hint keys as
18
+ evaluate. A required-evidence failure after a backend write rolls the
19
+ mutation back. `publish` with an id updates that entity instead of
20
+ creating a duplicate.
21
+
22
+ ### Added
23
+ - **Provider-neutral adapter contracts and a Drupal conformance kit (#181).**
24
+ Versioned evaluator, relay, approval, evidence-sink, and system-of-record
25
+ contracts live at `src/lib/contracts/` (contract 1.0). Typed decisions
26
+ (`deny` / `allow` / `allow_with_obligations` / `require_approval`), stable
27
+ reason codes, obligations, and execution receipts are verified at that
28
+ seam. Final target-side denial stays authoritative: an upstream allow
29
+ cannot widen local policy. Model and agent vendors are outside the
30
+ contract. The Drupal adapter is the only system-of-record implementation;
31
+ JSON:API and GraphQL remain transport adapters. The offline conformance
32
+ kit covers allowed and denied actions, hostile input, tenant escape,
33
+ required-evidence write failure, replay, and post-condition discrepancy.
34
+ See [docs/adapter-contracts.md](docs/adapter-contracts.md).
35
+
10
36
  ## [2.8.0] - 2026-08-25
11
37
 
12
38
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.4** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
@@ -308,6 +308,7 @@ an operator channel: keep the agent's credentials off it, and pin
308
308
  | [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
309
309
  | [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
310
310
  | [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
311
+ | [Adapter Contracts](docs/adapter-contracts.md) | Provider-neutral evaluator / relay / approval / evidence / SoR contracts and the Drupal conformance kit |
311
312
  | [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
312
313
  | [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
313
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Approval-interface contract (#181).
3
+ *
4
+ * An approval binds to an action-manifest digest and a single actor. It is
5
+ * one-use. Replay, digest mismatch, or actor mismatch invalidate it.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { ContractError, REASON } from "./decisions.js";
10
+
11
+ /**
12
+ * @typedef {Object} ApprovalInterface
13
+ * @property {(manifest: object, actor?: string) => {approvalId: string, digest: string}} issue
14
+ * @property {(approvalId: string, digest: string, actor?: string) => {approvalId: string, digest: string}} consume
15
+ */
16
+
17
+ /**
18
+ * In-process one-use approval ledger.
19
+ * @returns {ApprovalInterface & {size: () => number}}
20
+ */
21
+ export function createMemoryApproval() {
22
+ const store = new Map();
23
+
24
+ return Object.freeze({
25
+ /**
26
+ * @param {object} manifest
27
+ * @param {string} [actor]
28
+ * @returns {{approvalId: string, digest: string}}
29
+ */
30
+ issue(manifest, actor) {
31
+ if (!manifest?.digest) {
32
+ throw new ContractError("Approval requires a manifest digest.", REASON.APPROVAL_REQUIRED);
33
+ }
34
+ const approvalId = randomUUID();
35
+ store.set(approvalId, {
36
+ digest: manifest.digest,
37
+ actor: actor ?? null,
38
+ used: false,
39
+ });
40
+ return { approvalId, digest: manifest.digest };
41
+ },
42
+
43
+ /**
44
+ * @param {string} approvalId
45
+ * @param {string} digest
46
+ * @param {string} [actor]
47
+ * @returns {{approvalId: string, digest: string}}
48
+ */
49
+ consume(approvalId, digest, actor) {
50
+ if (!approvalId) {
51
+ throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
52
+ }
53
+ const entry = store.get(approvalId);
54
+ if (!entry || entry.used) {
55
+ throw new ContractError("Approval already used or unknown.", REASON.REPLAY);
56
+ }
57
+ if (entry.digest !== digest) {
58
+ throw new ContractError("Approval digest mismatch.", REASON.REPLAY);
59
+ }
60
+ if (entry.actor && entry.actor !== actor) {
61
+ throw new ContractError("Approval actor mismatch.", REASON.REPLAY);
62
+ }
63
+ store.set(approvalId, { ...entry, used: true });
64
+ return { approvalId, digest };
65
+ },
66
+
67
+ /** @returns {number} */
68
+ size() {
69
+ return store.size;
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Typed decisions, stable reason codes, and the narrowing compose rule (#181).
3
+ *
4
+ * An upstream allow cannot widen a local or target deny. Obligations union
5
+ * only when both sides allow. Model and agent vendor keys are not part of
6
+ * any contract record.
7
+ */
8
+
9
+ /** Stable contract-level reason codes. Do not invent a parallel set. */
10
+ export const REASON = Object.freeze({
11
+ POLICY_DENIED: "policy_denied",
12
+ TARGET_DENIED: "target_denied",
13
+ TENANT_ESCAPE: "tenant_escape",
14
+ HOSTILE_INPUT: "hostile_input",
15
+ EVIDENCE_WRITE_FAILED: "evidence_write_failed",
16
+ REPLAY: "replay_detected",
17
+ POSTCONDITION: "postcondition_discrepancy",
18
+ APPROVAL_REQUIRED: "approval_required",
19
+ INCOMPATIBLE_CONTRACT: "incompatible_contract_version",
20
+ VENDOR_FIELD: "vendor_field_rejected",
21
+ });
22
+
23
+ /** Keys that name a model or agent vendor. They stay outside the contract. */
24
+ export const VENDOR_FIELD_NAMES = Object.freeze([
25
+ "model",
26
+ "modelVendor",
27
+ "agentVendor",
28
+ "agentFramework",
29
+ "llmProvider",
30
+ "openai",
31
+ "anthropic",
32
+ "vendor",
33
+ ]);
34
+
35
+ const VENDOR_FIELD_SET = new Set(VENDOR_FIELD_NAMES);
36
+
37
+ /**
38
+ * Decision / proposal error that carries a stable reason code.
39
+ */
40
+ export class ContractError extends Error {
41
+ /**
42
+ * @param {string} message Operator-facing description (no secrets).
43
+ * @param {string} reason Stable machine reason.
44
+ */
45
+ constructor(message, reason) {
46
+ super(message);
47
+ this.name = "ContractError";
48
+ this.reason = reason;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reject records that carry a model or agent vendor key.
54
+ *
55
+ * @param {object} record Candidate identity, proposal, or manifest.
56
+ * @returns {void}
57
+ * @throws {ContractError}
58
+ */
59
+ export function assertNoVendorFields(record) {
60
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
61
+ return;
62
+ }
63
+ const keys = Object.keys(record);
64
+ for (const key of keys) {
65
+ if (VENDOR_FIELD_SET.has(key)) {
66
+ throw new ContractError(
67
+ "Model and agent vendor fields are outside the adapter contract.",
68
+ REASON.VENDOR_FIELD,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {Array<{type: string, value?: string}>} left
76
+ * @param {Array<{type: string, value?: string}>} right
77
+ * @returns {Array<{type: string, value?: string}>}
78
+ */
79
+ export function unionObligations(left = [], right = []) {
80
+ const seen = new Set();
81
+ const out = [];
82
+ for (const item of [...left, ...right]) {
83
+ if (!item || typeof item.type !== "string") continue;
84
+ const key = `${item.type}:${item.value ?? ""}`;
85
+ if (seen.has(key)) continue;
86
+ seen.add(key);
87
+ out.push(Object.freeze({ type: item.type, value: item.value }));
88
+ }
89
+ return out;
90
+ }
91
+
92
+ const RESULT_RANK = new Map([
93
+ ["deny", 0],
94
+ ["require_approval", 1],
95
+ ["allow_with_obligations", 2],
96
+ ["allow", 3],
97
+ ]);
98
+
99
+ /**
100
+ * Compose an optional upstream decision with the local / target decision.
101
+ * Local deny is authoritative. Upstream allow never widens a local deny.
102
+ *
103
+ * @param {object|null|undefined} upstream Upstream evaluator decision.
104
+ * @param {object} local Local / target-side decision.
105
+ * @returns {object} Frozen composed decision.
106
+ */
107
+ export function composeDecisions(upstream, local) {
108
+ if (!upstream) return local;
109
+ if (local.result === "deny") return local;
110
+ if (upstream.result === "deny") return upstream;
111
+
112
+ const localRank = RESULT_RANK.get(local.result) ?? 0;
113
+ const upstreamRank = RESULT_RANK.get(upstream.result) ?? 0;
114
+ const narrower = localRank <= upstreamRank ? local : upstream;
115
+ const obligations = unionObligations(upstream.obligations, local.obligations);
116
+
117
+ if (narrower.result === "require_approval") {
118
+ return Object.freeze({
119
+ ...narrower,
120
+ result: "require_approval",
121
+ obligations: Object.freeze(obligations),
122
+ });
123
+ }
124
+
125
+ if (obligations.length > 0 || narrower.result === "allow_with_obligations") {
126
+ return Object.freeze({
127
+ ...local,
128
+ result: "allow_with_obligations",
129
+ reason: local.reason,
130
+ obligations: Object.freeze(obligations),
131
+ });
132
+ }
133
+
134
+ return Object.freeze({
135
+ ...local,
136
+ result: "allow",
137
+ obligations: Object.freeze([]),
138
+ });
139
+ }