drupal-mcp-connector 2.7.3 → 2.8.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.
Files changed (229) hide show
  1. package/.agents/commands/drupal-audit-config-best-practices.md +15 -0
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +4 -5
  3. package/.agents/commands/drupal-block-user.md +19 -0
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +4 -5
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +4 -5
  6. package/.agents/commands/drupal-config-get.md +18 -0
  7. package/.agents/commands/drupal-config-list.md +16 -0
  8. package/.agents/commands/drupal-config-set.md +19 -0
  9. package/.agents/commands/drupal-content-by-moderation-state.md +21 -0
  10. package/.agents/commands/drupal-create-block.md +20 -0
  11. package/{.claude → .agents}/commands/drupal-create-media.md +4 -5
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +4 -5
  13. package/{.claude → .agents}/commands/drupal-create-node.md +7 -8
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +4 -5
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +4 -5
  16. package/.agents/commands/drupal-create-taxonomy-term.md +22 -0
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +4 -5
  18. package/.agents/commands/drupal-create-user.md +23 -0
  19. package/.agents/commands/drupal-delete-media.md +21 -0
  20. package/.agents/commands/drupal-delete-node.md +22 -0
  21. package/.agents/commands/drupal-delete-taxonomy-term.md +21 -0
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +4 -5
  23. package/.agents/commands/drupal-drush-cache-rebuild.md +15 -0
  24. package/.agents/commands/drupal-drush-config-export.md +15 -0
  25. package/.agents/commands/drupal-drush-config-import.md +15 -0
  26. package/.agents/commands/drupal-drush-config-status.md +15 -0
  27. package/.agents/commands/drupal-drush-cron.md +15 -0
  28. package/.agents/commands/drupal-drush-module-disable.md +20 -0
  29. package/.agents/commands/drupal-drush-module-enable.md +18 -0
  30. package/.agents/commands/drupal-drush-module-list.md +16 -0
  31. package/.agents/commands/drupal-drush-security-updates.md +15 -0
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +4 -5
  33. package/.agents/commands/drupal-drush-status.md +15 -0
  34. package/.agents/commands/drupal-drush-updatedb.md +15 -0
  35. package/.agents/commands/drupal-drush-user-create.md +21 -0
  36. package/.agents/commands/drupal-drush-user-list.md +18 -0
  37. package/.agents/commands/drupal-drush-watchdog.md +18 -0
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +4 -5
  39. package/.agents/commands/drupal-entity-delete.md +23 -0
  40. package/.agents/commands/drupal-entity-get.md +21 -0
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +4 -5
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +7 -8
  43. package/.agents/commands/drupal-find-orphaned-media.md +17 -0
  44. package/.agents/commands/drupal-get-entity-schema.md +19 -0
  45. package/.agents/commands/drupal-get-media.md +19 -0
  46. package/.agents/commands/drupal-get-node.md +19 -0
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +4 -5
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +4 -5
  49. package/.agents/commands/drupal-get-taxonomy-term.md +19 -0
  50. package/.agents/commands/drupal-get-taxonomy-terms.md +20 -0
  51. package/.agents/commands/drupal-get-user-by-name.md +18 -0
  52. package/.agents/commands/drupal-get-user.md +18 -0
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +4 -5
  54. package/.agents/commands/drupal-graphql-introspect.md +16 -0
  55. package/{.claude → .agents}/commands/drupal-graphql.md +4 -5
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +4 -5
  57. package/.agents/commands/drupal-list-content-types.md +15 -0
  58. package/.agents/commands/drupal-list-entity-types.md +15 -0
  59. package/.agents/commands/drupal-list-media-types.md +15 -0
  60. package/.agents/commands/drupal-list-media.md +20 -0
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +4 -5
  62. package/.agents/commands/drupal-list-moderation-states.md +19 -0
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +4 -5
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +4 -5
  65. package/.agents/commands/drupal-list-roles.md +15 -0
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +4 -5
  68. package/.agents/commands/drupal-list-users.md +19 -0
  69. package/.agents/commands/drupal-list-vocabularies.md +15 -0
  70. package/.agents/commands/drupal-mcp-whoami.md +15 -0
  71. package/.agents/commands/drupal-report-404-log.md +16 -0
  72. package/.agents/commands/drupal-report-accessibility-audit.md +17 -0
  73. package/.agents/commands/drupal-report-alias-coverage.md +17 -0
  74. package/.agents/commands/drupal-report-broken-embeds.md +17 -0
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +4 -5
  76. package/.agents/commands/drupal-report-cache-config.md +15 -0
  77. package/.agents/commands/drupal-report-config-drift.md +15 -0
  78. package/.agents/commands/drupal-report-content-by-author.md +17 -0
  79. package/.agents/commands/drupal-report-content-summary.md +15 -0
  80. package/.agents/commands/drupal-report-duplicate-content.md +17 -0
  81. package/.agents/commands/drupal-report-field-completeness.md +20 -0
  82. package/.agents/commands/drupal-report-menu-integrity.md +16 -0
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +4 -5
  84. package/.agents/commands/drupal-report-module-audit.md +15 -0
  85. package/.agents/commands/drupal-report-orphan-pages.md +17 -0
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +4 -5
  87. package/.agents/commands/drupal-report-permission-audit.md +15 -0
  88. package/.agents/commands/drupal-report-pii-exposure.md +18 -0
  89. package/.agents/commands/drupal-report-readability.md +18 -0
  90. package/.agents/commands/drupal-report-recently-published.md +17 -0
  91. package/.agents/commands/drupal-report-redirect-health.md +16 -0
  92. package/.agents/commands/drupal-report-revision-hotspots.md +17 -0
  93. package/.agents/commands/drupal-report-scheduled-content.md +17 -0
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +4 -5
  95. package/.agents/commands/drupal-report-seo-meta-coverage.md +18 -0
  96. package/.agents/commands/drupal-report-stale-content.md +19 -0
  97. package/.agents/commands/drupal-report-status-report.md +16 -0
  98. package/.agents/commands/drupal-report-taxonomy-usage.md +21 -0
  99. package/.agents/commands/drupal-report-text-format-audit.md +15 -0
  100. package/.agents/commands/drupal-report-translation-coverage.md +18 -0
  101. package/.agents/commands/drupal-report-unpublished.md +17 -0
  102. package/.agents/commands/drupal-report-user-activity.md +17 -0
  103. package/.agents/commands/drupal-report-workflow-bottlenecks.md +19 -0
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +4 -5
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +4 -5
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +4 -5
  107. package/.agents/commands/drupal-search-content.md +21 -0
  108. package/.agents/commands/drupal-search.md +20 -0
  109. package/.agents/commands/drupal-security-info.md +15 -0
  110. package/.agents/commands/drupal-set-moderation-state.md +20 -0
  111. package/.agents/commands/drupal-site-info.md +15 -0
  112. package/.agents/commands/drupal-update-media.md +22 -0
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +4 -5
  114. package/{.claude → .agents}/commands/drupal-update-node.md +10 -11
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +4 -5
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +4 -5
  117. package/.agents/commands/drupal-update-taxonomy-term.md +22 -0
  118. package/.agents/commands/drupal-update-user.md +24 -0
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +4 -5
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +4 -5
  121. package/CHANGELOG.md +80 -0
  122. package/README.md +23 -12
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/data-flow.js +437 -0
  131. package/src/lib/dispatch.js +99 -30
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/operations.js +39 -2
  135. package/src/lib/patch-preflight.js +157 -66
  136. package/src/lib/security.js +15 -1
  137. package/src/lib/server-tools.js +9 -0
  138. package/src/lib/site-target.js +78 -0
  139. package/src/lib/tool-prompts.js +2 -1
  140. package/src/lib/write-revision.js +82 -2
  141. package/src/tools/bulk.js +4 -3
  142. package/src/tools/config.js +5 -2
  143. package/src/tools/entities.js +11 -7
  144. package/src/tools/index.js +24 -3
  145. package/src/tools/moderation.js +88 -9
  146. package/src/tools/nodes.js +55 -43
  147. package/.claude/commands/drupal-audit-config-best-practices.md +0 -16
  148. package/.claude/commands/drupal-block-user.md +0 -20
  149. package/.claude/commands/drupal-config-get.md +0 -19
  150. package/.claude/commands/drupal-config-list.md +0 -17
  151. package/.claude/commands/drupal-config-set.md +0 -20
  152. package/.claude/commands/drupal-content-by-moderation-state.md +0 -22
  153. package/.claude/commands/drupal-create-block.md +0 -21
  154. package/.claude/commands/drupal-create-taxonomy-term.md +0 -23
  155. package/.claude/commands/drupal-create-user.md +0 -24
  156. package/.claude/commands/drupal-delete-media.md +0 -22
  157. package/.claude/commands/drupal-delete-node.md +0 -23
  158. package/.claude/commands/drupal-delete-taxonomy-term.md +0 -22
  159. package/.claude/commands/drupal-drush-cache-rebuild.md +0 -16
  160. package/.claude/commands/drupal-drush-config-export.md +0 -16
  161. package/.claude/commands/drupal-drush-config-import.md +0 -16
  162. package/.claude/commands/drupal-drush-config-status.md +0 -16
  163. package/.claude/commands/drupal-drush-cron.md +0 -16
  164. package/.claude/commands/drupal-drush-module-disable.md +0 -21
  165. package/.claude/commands/drupal-drush-module-enable.md +0 -19
  166. package/.claude/commands/drupal-drush-module-list.md +0 -17
  167. package/.claude/commands/drupal-drush-security-updates.md +0 -16
  168. package/.claude/commands/drupal-drush-status.md +0 -16
  169. package/.claude/commands/drupal-drush-updatedb.md +0 -16
  170. package/.claude/commands/drupal-drush-user-create.md +0 -22
  171. package/.claude/commands/drupal-drush-user-list.md +0 -19
  172. package/.claude/commands/drupal-drush-watchdog.md +0 -19
  173. package/.claude/commands/drupal-entity-delete.md +0 -24
  174. package/.claude/commands/drupal-entity-get.md +0 -22
  175. package/.claude/commands/drupal-find-orphaned-media.md +0 -18
  176. package/.claude/commands/drupal-get-entity-schema.md +0 -20
  177. package/.claude/commands/drupal-get-media.md +0 -20
  178. package/.claude/commands/drupal-get-node.md +0 -20
  179. package/.claude/commands/drupal-get-taxonomy-term.md +0 -20
  180. package/.claude/commands/drupal-get-taxonomy-terms.md +0 -21
  181. package/.claude/commands/drupal-get-user-by-name.md +0 -19
  182. package/.claude/commands/drupal-get-user.md +0 -19
  183. package/.claude/commands/drupal-graphql-introspect.md +0 -17
  184. package/.claude/commands/drupal-list-content-types.md +0 -16
  185. package/.claude/commands/drupal-list-entity-types.md +0 -16
  186. package/.claude/commands/drupal-list-media-types.md +0 -16
  187. package/.claude/commands/drupal-list-media.md +0 -21
  188. package/.claude/commands/drupal-list-moderation-states.md +0 -20
  189. package/.claude/commands/drupal-list-roles.md +0 -16
  190. package/.claude/commands/drupal-list-users.md +0 -20
  191. package/.claude/commands/drupal-list-vocabularies.md +0 -16
  192. package/.claude/commands/drupal-mcp-whoami.md +0 -16
  193. package/.claude/commands/drupal-report-404-log.md +0 -17
  194. package/.claude/commands/drupal-report-accessibility-audit.md +0 -18
  195. package/.claude/commands/drupal-report-alias-coverage.md +0 -18
  196. package/.claude/commands/drupal-report-broken-embeds.md +0 -18
  197. package/.claude/commands/drupal-report-cache-config.md +0 -16
  198. package/.claude/commands/drupal-report-config-drift.md +0 -16
  199. package/.claude/commands/drupal-report-content-by-author.md +0 -18
  200. package/.claude/commands/drupal-report-content-summary.md +0 -16
  201. package/.claude/commands/drupal-report-duplicate-content.md +0 -18
  202. package/.claude/commands/drupal-report-field-completeness.md +0 -21
  203. package/.claude/commands/drupal-report-menu-integrity.md +0 -17
  204. package/.claude/commands/drupal-report-module-audit.md +0 -16
  205. package/.claude/commands/drupal-report-orphan-pages.md +0 -18
  206. package/.claude/commands/drupal-report-permission-audit.md +0 -16
  207. package/.claude/commands/drupal-report-pii-exposure.md +0 -19
  208. package/.claude/commands/drupal-report-readability.md +0 -19
  209. package/.claude/commands/drupal-report-recently-published.md +0 -18
  210. package/.claude/commands/drupal-report-redirect-health.md +0 -17
  211. package/.claude/commands/drupal-report-revision-hotspots.md +0 -18
  212. package/.claude/commands/drupal-report-scheduled-content.md +0 -18
  213. package/.claude/commands/drupal-report-seo-meta-coverage.md +0 -19
  214. package/.claude/commands/drupal-report-stale-content.md +0 -20
  215. package/.claude/commands/drupal-report-status-report.md +0 -17
  216. package/.claude/commands/drupal-report-taxonomy-usage.md +0 -22
  217. package/.claude/commands/drupal-report-text-format-audit.md +0 -16
  218. package/.claude/commands/drupal-report-translation-coverage.md +0 -19
  219. package/.claude/commands/drupal-report-unpublished.md +0 -18
  220. package/.claude/commands/drupal-report-user-activity.md +0 -18
  221. package/.claude/commands/drupal-report-workflow-bottlenecks.md +0 -20
  222. package/.claude/commands/drupal-search-content.md +0 -22
  223. package/.claude/commands/drupal-search.md +0 -21
  224. package/.claude/commands/drupal-security-info.md +0 -16
  225. package/.claude/commands/drupal-set-moderation-state.md +0 -21
  226. package/.claude/commands/drupal-site-info.md +0 -16
  227. package/.claude/commands/drupal-update-media.md +0 -23
  228. package/.claude/commands/drupal-update-taxonomy-term.md +0 -23
  229. package/.claude/commands/drupal-update-user.md +0 -25
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Northbound data-flow budgets (#179).
3
+ *
4
+ * Binds the same finite budget classes mcp_sentinel already enforces
5
+ * (rows, bytes, pages, requests, chained actions) to the inbound principal
6
+ * and the authoritative target. Budget counters are keyed by those two
7
+ * values — never by MCP session, client IP, or a caller-supplied field —
8
+ * so pagination, retries, batching, and a new chain id cannot reset them.
9
+ *
10
+ * Classification and destination travel on the governed request as the
11
+ * source's narrow-only wire contract (X-MCP-Declared-Ceiling /
12
+ * X-MCP-Declared-Destination). Denials name a stable reason and a
13
+ * correlation id and never echo restricted payload.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ import { SecurityError } from "./security.js";
19
+
20
+ /** Wire header: narrow-only classification ceiling (mcp_sentinel 2.9.0). */
21
+ export const HEADER_DECLARED_CEILING = "X-MCP-Declared-Ceiling";
22
+
23
+ /** Wire header: declared destination, recorded in source evidence. */
24
+ export const HEADER_DECLARED_DESTINATION = "X-MCP-Declared-Destination";
25
+
26
+ /** Stable source / connector reason codes — do not invent a second set. */
27
+ export const REASON_READ = "read_budget_exceeded";
28
+ export const REASON_PAGE = "page_budget_exceeded";
29
+ export const REASON_RESPONSE_SIZE = "response_size_cap_exceeded";
30
+ export const REASON_CHAINED_ACTION = "chained_action_budget_exceeded";
31
+ export const REASON_BUDGET_CONTEXT_MISSING = "budget_context_missing";
32
+
33
+ /** Codes the source already emits; remapped onto a connector correlation id. */
34
+ export const SOURCE_BUDGET_REASONS = Object.freeze([
35
+ REASON_READ,
36
+ REASON_PAGE,
37
+ REASON_RESPONSE_SIZE,
38
+ REASON_CHAINED_ACTION,
39
+ "classification_egress_denied",
40
+ ]);
41
+
42
+ /**
43
+ * Built-in defaults — identical to mcp_sentinel McpReadBudgetResolver.
44
+ * A second model is out of scope; these numbers are the shared floor.
45
+ */
46
+ export const DEFAULT_BUDGETS = Object.freeze({
47
+ results: 500,
48
+ bytes: 8388608,
49
+ requests: 600,
50
+ requestWindowSec: 60,
51
+ pages: 120,
52
+ pageWindowSec: 60,
53
+ chainedActions: 600,
54
+ chainedActionWindowSec: 60,
55
+ });
56
+
57
+ const DECLARATION_MAX_LENGTH = 128;
58
+ const DECLARATION_PATTERN = /^[A-Za-z0-9._:-]+$/;
59
+
60
+ const store = new AsyncLocalStorage();
61
+
62
+ /** @type {Map<string, {count: number, resetAt: number}>} */
63
+ const windows = new Map();
64
+
65
+ const WINDOW_KINDS = new Set(["request", "page", "chained_action"]);
66
+
67
+ /**
68
+ * Denial for a data-flow budget or missing request-scoped context.
69
+ * Extends SecurityError so tools/call already wraps it as Access denied.
70
+ */
71
+ export class DataFlowBudgetError extends SecurityError {
72
+ /**
73
+ * @param {string} reason Stable reason code.
74
+ * @param {string} correlationId Request-scoped correlation id.
75
+ * @param {string} [budget] Budget class (requests, pages, rows, bytes, chained_actions).
76
+ */
77
+ constructor(reason, correlationId, budget) {
78
+ super(`${reason} (correlation ${correlationId})`);
79
+ this.name = "DataFlowBudgetError";
80
+ this.reason = reason;
81
+ this.correlationId = correlationId;
82
+ this.budget = budget;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Process-global budget key for an inbound identity. Session ids are never used.
88
+ * @param {object|null} identity
89
+ * @returns {string}
90
+ */
91
+ export function principalBudgetKey(identity) {
92
+ if (!identity) return "local-operator";
93
+ const sub = typeof identity.sub === "string" && identity.sub.trim()
94
+ ? identity.sub.trim()
95
+ : "-";
96
+ const clientId = typeof identity.clientId === "string" && identity.clientId.trim()
97
+ ? identity.clientId.trim()
98
+ : "-";
99
+ return `${sub}:${clientId}`;
100
+ }
101
+
102
+ /**
103
+ * @param {string|undefined} value
104
+ * @returns {string|undefined}
105
+ */
106
+ function sanitizeDeclaration(value) {
107
+ if (typeof value !== "string") return undefined;
108
+ const trimmed = value.trim();
109
+ if (!trimmed || trimmed.length > DECLARATION_MAX_LENGTH) return undefined;
110
+ return DECLARATION_PATTERN.test(trimmed) ? trimmed : undefined;
111
+ }
112
+
113
+ /**
114
+ * @param {object} [raw]
115
+ * @returns {typeof DEFAULT_BUDGETS}
116
+ */
117
+ export function resolveBudgetLimits(raw = {}) {
118
+ const merged = new Map(Object.entries(DEFAULT_BUDGETS));
119
+ const supplied = new Map(Object.entries(raw));
120
+ for (const [key, fallback] of Object.entries(DEFAULT_BUDGETS)) {
121
+ const value = supplied.get(key);
122
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
123
+ merged.set(key, value);
124
+ } else {
125
+ merged.set(key, fallback);
126
+ }
127
+ }
128
+ return Object.fromEntries(merged);
129
+ }
130
+
131
+ /**
132
+ * Build request-scoped enforcement context. Target is required; identity may
133
+ * be null (local operator). Caller-supplied session fields are ignored.
134
+ *
135
+ * @param {object} params
136
+ * @param {object|null} [params.identity]
137
+ * @param {?{name?: string, baseUrl?: string, source?: string}} params.target
138
+ * @param {object} [params.site]
139
+ * @param {object} [params.limits]
140
+ * @param {() => number} [params.now]
141
+ * @param {string} [params.correlationId]
142
+ * @returns {object}
143
+ * @throws {DataFlowBudgetError}
144
+ */
145
+ export function buildDataFlowContext({
146
+ identity = null,
147
+ target,
148
+ site,
149
+ limits,
150
+ now = () => Date.now(),
151
+ correlationId,
152
+ } = {}) {
153
+ const id = correlationId || randomUUID();
154
+ const targetName = typeof target?.name === "string" ? target.name.trim() : "";
155
+ if (!targetName) {
156
+ throw new DataFlowBudgetError(REASON_BUDGET_CONTEXT_MISSING, id, "context");
157
+ }
158
+
159
+ const sec = site?.security ?? {};
160
+ const clientId = typeof identity?.clientId === "string" && identity.clientId.trim()
161
+ ? identity.clientId.trim()
162
+ : "local-operator";
163
+ const destination = sanitizeDeclaration(`${clientId}:${targetName}`);
164
+ const ceiling = sanitizeDeclaration(sec.declaredCeiling) ?? "internal";
165
+ const declaredCeiling = sanitizeDeclaration(sec.declaredCeiling);
166
+
167
+ return {
168
+ principalKey: principalBudgetKey(identity),
169
+ targetName,
170
+ destination,
171
+ ceiling: declaredCeiling ?? ceiling,
172
+ declaredCeiling,
173
+ correlationId: id,
174
+ limits: resolveBudgetLimits(limits ?? sec.readBudgets),
175
+ now,
176
+ currentRequest: { rows: 0, bytes: 0, counted: false },
177
+ // Connector-side counters bind to an authenticated inbound principal.
178
+ // Stdio / local operator still send declared headers; source uid budgets
179
+ // remain the floor there (see #178).
180
+ enforce: Boolean(identity),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * @param {object} context
186
+ * @param {Function} fn
187
+ * @returns {*}
188
+ */
189
+ export function runWithDataFlow(context, fn) {
190
+ return store.run(context, fn);
191
+ }
192
+
193
+ /**
194
+ * @returns {object|null}
195
+ */
196
+ export function getDataFlowContext() {
197
+ return store.getStore() ?? null;
198
+ }
199
+
200
+ /**
201
+ * Headers for one northbound Drupal request. Empty when no context is bound.
202
+ * @param {object} [context]
203
+ * @returns {Object<string,string>}
204
+ */
205
+ export function northboundHeaders(context) {
206
+ const ctx = context ?? getDataFlowContext();
207
+ if (!ctx?.targetName || !ctx.principalKey) return {};
208
+ const headers = new Map();
209
+ const ceiling = sanitizeDeclaration(ctx.declaredCeiling);
210
+ if (ceiling) headers.set(HEADER_DECLARED_CEILING, ceiling);
211
+ const destination = sanitizeDeclaration(ctx.destination);
212
+ if (destination) headers.set(HEADER_DECLARED_DESTINATION, destination);
213
+ return Object.fromEntries(headers);
214
+ }
215
+
216
+ /**
217
+ * @param {object|null} ctx
218
+ * @returns {object}
219
+ * @throws {DataFlowBudgetError}
220
+ */
221
+ function requireBoundContext(ctx) {
222
+ if (!ctx?.principalKey || !ctx.targetName) {
223
+ throw new DataFlowBudgetError(
224
+ REASON_BUDGET_CONTEXT_MISSING,
225
+ ctx?.correlationId || randomUUID(),
226
+ "context",
227
+ );
228
+ }
229
+ return ctx;
230
+ }
231
+
232
+ /**
233
+ * @param {string} kind
234
+ * @returns {string}
235
+ */
236
+ function reasonFor(kind) {
237
+ switch (kind) {
238
+ case "page":
239
+ return REASON_PAGE;
240
+ case "bytes":
241
+ return REASON_RESPONSE_SIZE;
242
+ case "chained_action":
243
+ return REASON_CHAINED_ACTION;
244
+ case "request":
245
+ case "rows":
246
+ return REASON_READ;
247
+ default: {
248
+ const _exhaustive = kind;
249
+ return _exhaustive && REASON_READ;
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * @param {string} kind
256
+ * @returns {string}
257
+ */
258
+ function budgetClass(kind) {
259
+ switch (kind) {
260
+ case "chained_action":
261
+ return "chained_actions";
262
+ case "rows":
263
+ return "rows";
264
+ case "bytes":
265
+ return "bytes";
266
+ case "page":
267
+ return "pages";
268
+ case "request":
269
+ return "requests";
270
+ default: {
271
+ const _exhaustive = kind;
272
+ return _exhaustive || "requests";
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Consume a budget class only when an inbound principal is bound.
279
+ * @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
280
+ * @param {number} [amount]
281
+ * @param {{retry?: boolean}} [options]
282
+ * @returns {void}
283
+ */
284
+ export function consumeBudgetIfEnforced(kind, amount = 1, options = {}) {
285
+ if (!getDataFlowContext()?.enforce) return;
286
+ consumeBudget(kind, amount, options);
287
+ }
288
+
289
+ /**
290
+ * Account a successful northbound body against row and byte caps.
291
+ * @param {object|null} json
292
+ * @param {string} [text]
293
+ * @returns {void}
294
+ */
295
+ export function accountNorthboundBody(json, text = "") {
296
+ if (!getDataFlowContext()?.enforce) return;
297
+ if (text) consumeBudget("bytes", Buffer.byteLength(text));
298
+ const rows = Array.isArray(json?.data) ? json.data.length : (json?.data ? 1 : 0);
299
+ if (rows) consumeBudget("rows", rows);
300
+ }
301
+
302
+ /**
303
+ * Consume one budget class against the request-scoped principal+target key.
304
+ *
305
+ * @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
306
+ * @param {number} [amount]
307
+ * @param {{retry?: boolean}} [options]
308
+ * @returns {void}
309
+ * @throws {DataFlowBudgetError}
310
+ */
311
+ export function consumeBudget(kind, amount = 1, options = {}) {
312
+ const ctx = requireBoundContext(getDataFlowContext());
313
+ const qty = typeof amount === "number" && amount > 0 ? amount : 1;
314
+
315
+ if (options.retry && (kind === "request" || kind === "page")) {
316
+ if (!ctx.currentRequest.counted) {
317
+ throw deny(ctx, kind);
318
+ }
319
+ return;
320
+ }
321
+
322
+ if (kind === "rows" || kind === "bytes") {
323
+ const cap = kind === "rows" ? ctx.limits.results : ctx.limits.bytes;
324
+ const used = kind === "rows" ? ctx.currentRequest.rows : ctx.currentRequest.bytes;
325
+ if (used + qty > cap) {
326
+ throw deny(ctx, kind);
327
+ }
328
+ if (kind === "rows") ctx.currentRequest.rows += qty;
329
+ else ctx.currentRequest.bytes += qty;
330
+ return;
331
+ }
332
+
333
+ if (!WINDOW_KINDS.has(kind)) {
334
+ throw deny(ctx, kind);
335
+ }
336
+
337
+ if (!windowConsume(ctx, kind, qty)) {
338
+ throw deny(ctx, kind);
339
+ }
340
+
341
+ if (kind === "request") {
342
+ ctx.currentRequest = { rows: 0, bytes: 0, counted: true };
343
+ } else {
344
+ ctx.currentRequest.counted = true;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * @param {object} ctx
350
+ * @param {string} kind
351
+ * @returns {DataFlowBudgetError}
352
+ */
353
+ function deny(ctx, kind) {
354
+ return new DataFlowBudgetError(reasonFor(kind), ctx.correlationId, budgetClass(kind));
355
+ }
356
+
357
+ /**
358
+ * @param {object} ctx
359
+ * @param {string} kind
360
+ * @returns {{limit: number, windowMs: number}}
361
+ */
362
+ function windowSpec(ctx, kind) {
363
+ if (kind === "page") {
364
+ return { limit: ctx.limits.pages, windowMs: ctx.limits.pageWindowSec * 1000 };
365
+ }
366
+ if (kind === "chained_action") {
367
+ return {
368
+ limit: ctx.limits.chainedActions,
369
+ windowMs: ctx.limits.chainedActionWindowSec * 1000,
370
+ };
371
+ }
372
+ return { limit: ctx.limits.requests, windowMs: ctx.limits.requestWindowSec * 1000 };
373
+ }
374
+
375
+ /**
376
+ * @param {object} ctx
377
+ * @param {string} kind
378
+ * @returns {string}
379
+ */
380
+ function windowKey(ctx, kind) {
381
+ return `${ctx.principalKey}::${ctx.targetName}::${kind}`;
382
+ }
383
+
384
+ /**
385
+ * @param {object} ctx
386
+ * @param {string} kind
387
+ * @param {number} qty
388
+ * @returns {boolean}
389
+ */
390
+ function windowConsume(ctx, kind, qty) {
391
+ const { limit, windowMs } = windowSpec(ctx, kind);
392
+ const now = ctx.now();
393
+ const key = windowKey(ctx, kind);
394
+ let bucket = windows.get(key);
395
+ if (!bucket || now >= bucket.resetAt) {
396
+ bucket = { count: 0, resetAt: now + windowMs };
397
+ windows.set(key, bucket);
398
+ }
399
+ if (bucket.count + qty > limit) return false;
400
+ bucket.count += qty;
401
+ return true;
402
+ }
403
+
404
+ /**
405
+ * Map a source-side refusal body onto a connector denial that carries our
406
+ * correlation id and none of the upstream payload.
407
+ *
408
+ * @param {string} detail
409
+ * @param {string} [correlationId]
410
+ * @returns {DataFlowBudgetError|null}
411
+ */
412
+ export function sourceBudgetDenial(detail, correlationId) {
413
+ const text = String(detail ?? "");
414
+ const reason = SOURCE_BUDGET_REASONS.find((code) => text.includes(code));
415
+ if (!reason) return null;
416
+ const id = correlationId || getDataFlowContext()?.correlationId || randomUUID();
417
+ return new DataFlowBudgetError(reason, id, reason);
418
+ }
419
+
420
+ /**
421
+ * Drop in-memory windows. Tests only.
422
+ * @returns {void}
423
+ */
424
+ export function resetDataFlowBudgets() {
425
+ windows.clear();
426
+ }
427
+
428
+ /**
429
+ * JSON:API collection paths are type/bundle with no uuid segment.
430
+ * @param {string} path
431
+ * @returns {boolean}
432
+ */
433
+ export function isCollectionJsonApiPath(path) {
434
+ const pathname = String(path ?? "").split("?")[0];
435
+ const parts = pathname.split("/").filter(Boolean);
436
+ return parts[0] === "jsonapi" && parts.length === 3;
437
+ }
@@ -16,7 +16,11 @@ import { toolError, toolResult } from "./errors.js";
16
16
  import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
17
17
  import { inferOperation } from "./operations.js";
18
18
  import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
19
- import { assertPrincipalEntitlement, getRequestIdentity } from "./principal.js";
19
+ import {
20
+ assertPrincipalEntitlement, callerTargetHints, getRequestIdentity,
21
+ } from "./principal.js";
22
+ import { assertExplicitSiteForWrite, withResolvedTarget } from "./site-target.js";
23
+ import { buildDataFlowContext, consumeBudgetIfEnforced, runWithDataFlow } from "./data-flow.js";
20
24
  import { allHandlers } from "../tools/index.js";
21
25
 
22
26
  /**
@@ -39,6 +43,47 @@ export function listResolvableSiteConfigs() {
39
43
  });
40
44
  }
41
45
 
46
+ /**
47
+ * Resolve which site this call addresses and how that name was chosen.
48
+ * Returns null for tools that do not address a single site (`list_sites`,
49
+ * unscoped `governance_status`).
50
+ *
51
+ * @param {string} toolName
52
+ * @param {object} rawArgs Caller arguments before any rewrite.
53
+ * @param {object} [context]
54
+ * @returns {?{site: object, source: string, name: string}}
55
+ * @throws {SecurityError}
56
+ */
57
+ export function resolveCallTarget(toolName, rawArgs, context = {}) {
58
+ if (toolName === "drupal_list_sites") return null;
59
+ if (toolName === "drupal_governance_status" && callerTargetHints(rawArgs).length === 0) {
60
+ return null;
61
+ }
62
+
63
+ const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
64
+ if (identity) {
65
+ return assertPrincipalEntitlement({
66
+ toolName,
67
+ args: rawArgs,
68
+ identity,
69
+ sites: context.sites ?? listResolvableSiteConfigs(),
70
+ grants: context.grants,
71
+ defaultSite: context.defaultSite,
72
+ });
73
+ }
74
+
75
+ const hints = callerTargetHints(rawArgs);
76
+ const unique = [...new Set(hints.map((hint) => hint.value))];
77
+ if (unique.length > 1) {
78
+ throw new SecurityError(
79
+ "Conflicting caller target hints do not select a single target.",
80
+ );
81
+ }
82
+ const source = unique.length === 1 ? "hint" : "default";
83
+ const site = getSiteConfig(unique[0]);
84
+ return { site, source, name: site._name };
85
+ }
86
+
42
87
  /**
43
88
  * Derive the entity type a tool acts on, for destructive-allow assertions.
44
89
  *
@@ -72,18 +117,21 @@ export async function securityMiddleware(toolName, args, handler, context = {})
72
117
  const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
73
118
  let nextArgs = rawArgs;
74
119
 
75
- if (identity) {
76
- const resolved = assertPrincipalEntitlement({
77
- toolName,
78
- args: rawArgs,
79
- identity,
80
- sites: context.sites ?? listResolvableSiteConfigs(),
81
- grants: context.grants,
82
- defaultSite: context.defaultSite,
83
- });
84
- if (resolved) {
85
- nextArgs = { ...rawArgs, site: resolved.name };
86
- }
120
+ const resolved = resolveCallTarget(toolName, rawArgs, { ...context, identity });
121
+ if (context && typeof context === "object") {
122
+ context.resolvedTarget = resolved;
123
+ }
124
+ assertExplicitSiteForWrite(
125
+ toolName,
126
+ rawArgs,
127
+ resolved,
128
+ context.siteNames ?? listSiteNames(),
129
+ );
130
+
131
+ if (resolved) {
132
+ // Authoritative name plus the real source. Injecting `site` alone would
133
+ // make whoami report source:"hint" for a defaulted call (#167 / identity path).
134
+ nextArgs = { ...rawArgs, site: resolved.name, _resolvedSource: resolved.source };
87
135
  }
88
136
 
89
137
  // Tools with no site context skip per-site checks. governance_status
@@ -96,25 +144,45 @@ export async function securityMiddleware(toolName, args, handler, context = {})
96
144
 
97
145
  const site = getSiteConfig(nextArgs.site);
98
146
 
99
- // Source-governance gate (#176). The diagnostic tools stay callable while
100
- // governance fails they are how an operator learns which condition failed.
101
- if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
102
- await assertSourceGovernance(site);
103
- }
147
+ const assertCallAllowed = async () => {
148
+ // Source-governance gate (#176). The diagnostic tools stay callable while
149
+ // governance fails — they are how an operator learns which condition failed.
150
+ if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
151
+ await assertSourceGovernance(site);
152
+ }
104
153
 
105
- const sec = resolveSecurityConfig(site);
106
- const op = inferOperation(toolName);
154
+ const sec = resolveSecurityConfig(site);
155
+ const op = inferOperation(toolName);
107
156
 
108
- if (op === "delete") {
109
- assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
110
- assertNotReadOnly(sec, toolName);
111
- } else if (op === "write") {
112
- assertNotReadOnly(sec, toolName);
113
- } else if (op === "graphql" && nextArgs?.query) {
114
- assertGraphqlMutationAllowed(sec, nextArgs.query);
157
+ if (op === "delete") {
158
+ assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
159
+ assertNotReadOnly(sec, toolName);
160
+ } else if (op === "write") {
161
+ assertNotReadOnly(sec, toolName);
162
+ } else if (op === "graphql" && nextArgs?.query) {
163
+ assertGraphqlMutationAllowed(sec, nextArgs.query);
164
+ }
165
+ };
166
+
167
+ if (!resolved) {
168
+ await assertCallAllowed();
169
+ return handler(nextArgs);
115
170
  }
116
171
 
117
- return handler(nextArgs);
172
+ const flow = buildDataFlowContext({
173
+ identity,
174
+ target: { name: resolved.name, baseUrl: site.baseUrl, source: resolved.source },
175
+ site,
176
+ });
177
+ return runWithDataFlow(flow, async () => {
178
+ await assertCallAllowed();
179
+ // Charge only after governance and policy gates pass, so an outage or a
180
+ // local deny cannot exhaust the window. Diagnostics do not consume.
181
+ if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
182
+ consumeBudgetIfEnforced("chained_action");
183
+ }
184
+ return handler(nextArgs);
185
+ });
118
186
  }
119
187
 
120
188
  /**
@@ -137,8 +205,9 @@ export async function callTool(name, args, context = {}) {
137
205
  }
138
206
 
139
207
  try {
140
- const result = await securityMiddleware(name, args ?? {}, handler, context);
141
- return toolResult(result);
208
+ const ctx = { ...context };
209
+ const result = await securityMiddleware(name, args ?? {}, handler, ctx);
210
+ return toolResult(withResolvedTarget(result, ctx.resolvedTarget));
142
211
  } catch (err) {
143
212
  // Translate known error classes into clear, non-leaky isError responses;
144
213
  // anything else falls through to toolError for a generic envelope.