drupal-mcp-connector 2.7.3 → 2.7.4

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 (130) hide show
  1. package/.claude/commands/drupal-audit-config-best-practices.md +1 -1
  2. package/.claude/commands/drupal-audit-site-health.md +1 -1
  3. package/.claude/commands/drupal-block-user.md +1 -1
  4. package/.claude/commands/drupal-bulk-create.md +1 -1
  5. package/.claude/commands/drupal-bulk-update.md +1 -1
  6. package/.claude/commands/drupal-config-get.md +1 -1
  7. package/.claude/commands/drupal-config-list.md +1 -1
  8. package/.claude/commands/drupal-config-set.md +1 -1
  9. package/.claude/commands/drupal-content-by-moderation-state.md +3 -3
  10. package/.claude/commands/drupal-create-block.md +1 -1
  11. package/.claude/commands/drupal-create-media.md +1 -1
  12. package/.claude/commands/drupal-create-menu-link.md +1 -1
  13. package/.claude/commands/drupal-create-node.md +1 -1
  14. package/.claude/commands/drupal-create-paragraph.md +1 -1
  15. package/.claude/commands/drupal-create-redirect.md +1 -1
  16. package/.claude/commands/drupal-create-taxonomy-term.md +1 -1
  17. package/.claude/commands/drupal-create-translation.md +1 -1
  18. package/.claude/commands/drupal-create-user.md +1 -1
  19. package/.claude/commands/drupal-delete-media.md +1 -1
  20. package/.claude/commands/drupal-delete-node.md +1 -1
  21. package/.claude/commands/drupal-delete-taxonomy-term.md +1 -1
  22. package/.claude/commands/drupal-describe-fields.md +1 -1
  23. package/.claude/commands/drupal-drush-cache-rebuild.md +1 -1
  24. package/.claude/commands/drupal-drush-config-export.md +1 -1
  25. package/.claude/commands/drupal-drush-config-import.md +1 -1
  26. package/.claude/commands/drupal-drush-config-status.md +1 -1
  27. package/.claude/commands/drupal-drush-cron.md +1 -1
  28. package/.claude/commands/drupal-drush-module-disable.md +1 -1
  29. package/.claude/commands/drupal-drush-module-enable.md +1 -1
  30. package/.claude/commands/drupal-drush-module-list.md +1 -1
  31. package/.claude/commands/drupal-drush-security-updates.md +1 -1
  32. package/.claude/commands/drupal-drush-sql-query.md +1 -1
  33. package/.claude/commands/drupal-drush-status.md +1 -1
  34. package/.claude/commands/drupal-drush-updatedb.md +1 -1
  35. package/.claude/commands/drupal-drush-user-create.md +1 -1
  36. package/.claude/commands/drupal-drush-user-list.md +1 -1
  37. package/.claude/commands/drupal-drush-watchdog.md +1 -1
  38. package/.claude/commands/drupal-entity-create.md +1 -1
  39. package/.claude/commands/drupal-entity-delete.md +1 -1
  40. package/.claude/commands/drupal-entity-get.md +1 -1
  41. package/.claude/commands/drupal-entity-list.md +1 -1
  42. package/.claude/commands/drupal-entity-update.md +1 -1
  43. package/.claude/commands/drupal-find-orphaned-media.md +1 -1
  44. package/.claude/commands/drupal-get-entity-schema.md +1 -1
  45. package/.claude/commands/drupal-get-media.md +1 -1
  46. package/.claude/commands/drupal-get-node.md +1 -1
  47. package/.claude/commands/drupal-get-paragraph.md +1 -1
  48. package/.claude/commands/drupal-get-revision.md +1 -1
  49. package/.claude/commands/drupal-get-taxonomy-term.md +1 -1
  50. package/.claude/commands/drupal-get-taxonomy-terms.md +1 -1
  51. package/.claude/commands/drupal-get-user-by-name.md +1 -1
  52. package/.claude/commands/drupal-get-user.md +1 -1
  53. package/.claude/commands/drupal-governance-status.md +1 -1
  54. package/.claude/commands/drupal-graphql-introspect.md +1 -1
  55. package/.claude/commands/drupal-graphql.md +1 -1
  56. package/.claude/commands/drupal-list-blocks.md +1 -1
  57. package/.claude/commands/drupal-list-content-types.md +1 -1
  58. package/.claude/commands/drupal-list-entity-types.md +1 -1
  59. package/.claude/commands/drupal-list-media-types.md +1 -1
  60. package/.claude/commands/drupal-list-media.md +1 -1
  61. package/.claude/commands/drupal-list-menu-links.md +1 -1
  62. package/.claude/commands/drupal-list-moderation-states.md +1 -1
  63. package/.claude/commands/drupal-list-nodes.md +1 -1
  64. package/.claude/commands/drupal-list-revisions.md +1 -1
  65. package/.claude/commands/drupal-list-roles.md +1 -1
  66. package/.claude/commands/drupal-list-translations.md +1 -1
  67. package/.claude/commands/drupal-list-users.md +1 -1
  68. package/.claude/commands/drupal-list-vocabularies.md +1 -1
  69. package/.claude/commands/drupal-mcp-whoami.md +1 -1
  70. package/.claude/commands/drupal-report-404-log.md +1 -1
  71. package/.claude/commands/drupal-report-accessibility-audit.md +1 -1
  72. package/.claude/commands/drupal-report-alias-coverage.md +1 -1
  73. package/.claude/commands/drupal-report-broken-embeds.md +1 -1
  74. package/.claude/commands/drupal-report-broken-links.md +1 -1
  75. package/.claude/commands/drupal-report-cache-config.md +1 -1
  76. package/.claude/commands/drupal-report-config-drift.md +1 -1
  77. package/.claude/commands/drupal-report-content-by-author.md +1 -1
  78. package/.claude/commands/drupal-report-content-summary.md +1 -1
  79. package/.claude/commands/drupal-report-duplicate-content.md +1 -1
  80. package/.claude/commands/drupal-report-field-completeness.md +1 -1
  81. package/.claude/commands/drupal-report-menu-integrity.md +1 -1
  82. package/.claude/commands/drupal-report-missing-field.md +1 -1
  83. package/.claude/commands/drupal-report-module-audit.md +1 -1
  84. package/.claude/commands/drupal-report-orphan-pages.md +1 -1
  85. package/.claude/commands/drupal-report-orphaned-references.md +1 -1
  86. package/.claude/commands/drupal-report-permission-audit.md +1 -1
  87. package/.claude/commands/drupal-report-pii-exposure.md +1 -1
  88. package/.claude/commands/drupal-report-readability.md +1 -1
  89. package/.claude/commands/drupal-report-recently-published.md +1 -1
  90. package/.claude/commands/drupal-report-redirect-health.md +1 -1
  91. package/.claude/commands/drupal-report-revision-hotspots.md +1 -1
  92. package/.claude/commands/drupal-report-scheduled-content.md +1 -1
  93. package/.claude/commands/drupal-report-seo-audit.md +1 -1
  94. package/.claude/commands/drupal-report-seo-meta-coverage.md +1 -1
  95. package/.claude/commands/drupal-report-stale-content.md +1 -1
  96. package/.claude/commands/drupal-report-status-report.md +1 -1
  97. package/.claude/commands/drupal-report-taxonomy-usage.md +1 -1
  98. package/.claude/commands/drupal-report-text-format-audit.md +1 -1
  99. package/.claude/commands/drupal-report-translation-coverage.md +1 -1
  100. package/.claude/commands/drupal-report-unpublished.md +1 -1
  101. package/.claude/commands/drupal-report-user-activity.md +1 -1
  102. package/.claude/commands/drupal-report-workflow-bottlenecks.md +1 -1
  103. package/.claude/commands/drupal-resolve-reference.md +1 -1
  104. package/.claude/commands/drupal-revert-revision.md +1 -1
  105. package/.claude/commands/drupal-schedule-publish.md +1 -1
  106. package/.claude/commands/drupal-search-content.md +1 -1
  107. package/.claude/commands/drupal-search.md +1 -1
  108. package/.claude/commands/drupal-security-info.md +1 -1
  109. package/.claude/commands/drupal-set-moderation-state.md +1 -1
  110. package/.claude/commands/drupal-site-info.md +1 -1
  111. package/.claude/commands/drupal-update-media.md +1 -1
  112. package/.claude/commands/drupal-update-menu-link.md +1 -1
  113. package/.claude/commands/drupal-update-node.md +1 -1
  114. package/.claude/commands/drupal-update-paragraph.md +1 -1
  115. package/.claude/commands/drupal-update-redirect.md +1 -1
  116. package/.claude/commands/drupal-update-taxonomy-term.md +1 -1
  117. package/.claude/commands/drupal-update-user.md +1 -1
  118. package/.claude/commands/drupal-upload-file-and-create-media.md +1 -1
  119. package/.claude/commands/drupal-upload-file.md +1 -1
  120. package/CHANGELOG.md +24 -0
  121. package/README.md +2 -1
  122. package/package.json +1 -1
  123. package/src/lib/dispatch.js +63 -15
  124. package/src/lib/operations.js +39 -2
  125. package/src/lib/security.js +1 -1
  126. package/src/lib/site-target.js +78 -0
  127. package/src/lib/tool-prompts.js +2 -1
  128. package/src/tools/config.js +5 -2
  129. package/src/tools/index.js +21 -1
  130. package/src/tools/moderation.js +88 -9
@@ -14,7 +14,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
14
14
  - `id` (string): Menu link UUID
15
15
 
16
16
  **Optional:**
17
- - `site` (string): omit for the default site
17
+ - `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).
18
18
  - `title` (string): New link label. Omit to leave unchanged.
19
19
  - `link` (string): New target URI (e.g. 'entity:node/42'). Omit to leave unchanged.
20
20
  - `menu` (string): Move the link to this menu. Omit to leave unchanged.
@@ -15,7 +15,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
15
15
  - `id` (string): Node UUID
16
16
 
17
17
  **Optional:**
18
- - `site` (string): omit for the default site
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
  - `title` (string)
20
20
  - `body` (string)
21
21
  - `summary` (string): Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` — a value written here will be stored but may never be rendered.
@@ -15,7 +15,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
15
15
  - `id` (string): Paragraph UUID
16
16
 
17
17
  **Optional:**
18
- - `site` (string): Named site (omit for default)
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)): Paragraph field values to change, keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }
20
20
 
21
21
  If a required parameter is missing from `$ARGUMENTS`, 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.
@@ -14,7 +14,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
14
14
  - `id` (string): Redirect entity UUID
15
15
 
16
16
  **Optional:**
17
- - `site` (string): omit for the default site
17
+ - `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).
18
18
  - `source` (string): New source/old path (leading slash optional). Omit to leave unchanged.
19
19
  - `target` (string): New destination path/URI. Omit to leave unchanged.
20
20
  - `statusCode` (number): New HTTP redirect status code (301/302/303/307/308). Omit to leave unchanged.
@@ -15,7 +15,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
15
15
  - `id` (string)
16
16
 
17
17
  **Optional:**
18
- - `site` (string): omit for the default site
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
  - `name` (string)
20
20
  - `description` (string)
21
21
  - `weight` (number)
@@ -14,7 +14,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
14
14
  - `id` (string): User UUID
15
15
 
16
16
  **Optional:**
17
- - `site` (string): omit for the default site
17
+ - `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).
18
18
  - `name` (string)
19
19
  - `mail` (string)
20
20
  - `password` (string)
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
16
16
  - `fieldName` (string): Source field machine name, e.g. 'field_media_image'
17
17
 
18
18
  **Optional:**
19
- - `site` (string): omit for the default site
19
+ - `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).
20
20
  - `mediaName` (string): Name for the media entity (defaults to filename)
21
21
  - `altText` (string): Alt text for image media
22
22
  - `status` (boolean (true/false)): Published flag. Defaults to false. Requires allowPublish when true.
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
16
16
  - `fieldName` (string): Field machine name, e.g. 'field_media_image', 'field_image'
17
17
 
18
18
  **Optional:**
19
- - `site` (string): omit for the default site
19
+ - `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).
20
20
  - `entityType` (string): Drupal entity type (usually 'media' or 'node')
21
21
 
22
22
  If a required parameter is missing from `$ARGUMENTS`, 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,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.4] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - **`drupal_content_by_moderation_state` no longer 500s on stock JSON:API
14
+ (#162).** `moderation_state` is a computed field and is not filterable
15
+ over core JSON:API. The tool still tries the server-side filter (so a
16
+ jsonapi_extras alias keeps working), and when Drupal rejects it the
17
+ connector samples recent nodes and filters client-side. The payload
18
+ reports `source: "sampled"` and `approximate` instead of a raw Drupal
19
+ 500. If the field is not exposed at all, the result is a gated
20
+ `unavailable` payload.
21
+ - **Every tool response names the resolved site (#167).** Omitting `site`
22
+ still defaults to `defaultSite`, but the payload now carries
23
+ `_target: { name, baseUrl, source }` — the same block `drupal_mcp_whoami`
24
+ already returned as `target`. `source` is `hint` when the caller named a
25
+ site, `default` when the configured default was used, or `grant` when a
26
+ principal had exactly one entitled site. A list_nodes-shaped success with
27
+ only `_backend` is no longer claimable as production. Writes (including
28
+ GraphQL mutations and generic `drupal_entity_*` writes) refuse a silent
29
+ default when more than one site is configured; a write on the wrong site
30
+ is not recoverable. Single-site configs are unchanged. Array-shaped
31
+ results are wrapped as `{ items, _target }` so the field survives
32
+ `JSON.stringify`.
33
+
10
34
  ## [2.7.3] - 2026-08-18
11
35
 
12
36
  ### 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.3** (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 **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).
13
13
 
14
14
  ---
15
15
 
@@ -44,6 +44,7 @@ Each site declares which backend(s) it exposes via the `api` key:
44
44
  - **`api` accepts** `"jsonapi"`, `"graphql"`, or a priority array like `["graphql","jsonapi"]`. Omit it to **auto-detect** (the connector probes both once and caches the result).
45
45
  - **One canonical shape.** Both backends return entities as
46
46
  `{ id, entityType, bundle, title, status, langcode, created, changed, url, fields, relationships, _backend }`, so tool output is identical regardless of protocol.
47
+ Every site-addressing response also includes `_target: { name, baseUrl, source }` so a defaulted call cannot be mistaken for another environment.
47
48
  - **Capability-aware.** Each backend advertises what it supports (read, write, delete, server-side filter/sort, revisions). GraphQL via GraphQL Compose is **read-only** (no mutations) and has no server-side field filter, so filters are applied client-side over a bounded fetch and flagged `approximate`/`truncated`. Write tools against a read-only backend return a clear capability error rather than failing silently.
48
49
  - **Writes go through JSON:API.** Use a JSON:API-enabled site as the write plane; keep GraphQL as a read plane where that suits your architecture.
49
50
  - **`defaultTextFormat` sets the body text format** used by the `body` convenience
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.7.3",
3
+ "version": "2.7.4",
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",
@@ -16,7 +16,10 @@ 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";
20
23
  import { allHandlers } from "../tools/index.js";
21
24
 
22
25
  /**
@@ -39,6 +42,47 @@ export function listResolvableSiteConfigs() {
39
42
  });
40
43
  }
41
44
 
45
+ /**
46
+ * Resolve which site this call addresses and how that name was chosen.
47
+ * Returns null for tools that do not address a single site (`list_sites`,
48
+ * unscoped `governance_status`).
49
+ *
50
+ * @param {string} toolName
51
+ * @param {object} rawArgs Caller arguments before any rewrite.
52
+ * @param {object} [context]
53
+ * @returns {?{site: object, source: string, name: string}}
54
+ * @throws {SecurityError}
55
+ */
56
+ export function resolveCallTarget(toolName, rawArgs, context = {}) {
57
+ if (toolName === "drupal_list_sites") return null;
58
+ if (toolName === "drupal_governance_status" && callerTargetHints(rawArgs).length === 0) {
59
+ return null;
60
+ }
61
+
62
+ const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
63
+ if (identity) {
64
+ return assertPrincipalEntitlement({
65
+ toolName,
66
+ args: rawArgs,
67
+ identity,
68
+ sites: context.sites ?? listResolvableSiteConfigs(),
69
+ grants: context.grants,
70
+ defaultSite: context.defaultSite,
71
+ });
72
+ }
73
+
74
+ const hints = callerTargetHints(rawArgs);
75
+ const unique = [...new Set(hints.map((hint) => hint.value))];
76
+ if (unique.length > 1) {
77
+ throw new SecurityError(
78
+ "Conflicting caller target hints do not select a single target.",
79
+ );
80
+ }
81
+ const source = unique.length === 1 ? "hint" : "default";
82
+ const site = getSiteConfig(unique[0]);
83
+ return { site, source, name: site._name };
84
+ }
85
+
42
86
  /**
43
87
  * Derive the entity type a tool acts on, for destructive-allow assertions.
44
88
  *
@@ -72,18 +116,21 @@ export async function securityMiddleware(toolName, args, handler, context = {})
72
116
  const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
73
117
  let nextArgs = rawArgs;
74
118
 
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
- }
119
+ const resolved = resolveCallTarget(toolName, rawArgs, { ...context, identity });
120
+ if (context && typeof context === "object") {
121
+ context.resolvedTarget = resolved;
122
+ }
123
+ assertExplicitSiteForWrite(
124
+ toolName,
125
+ rawArgs,
126
+ resolved,
127
+ context.siteNames ?? listSiteNames(),
128
+ );
129
+
130
+ if (resolved) {
131
+ // Authoritative name plus the real source. Injecting `site` alone would
132
+ // make whoami report source:"hint" for a defaulted call (#167 / identity path).
133
+ nextArgs = { ...rawArgs, site: resolved.name, _resolvedSource: resolved.source };
87
134
  }
88
135
 
89
136
  // Tools with no site context skip per-site checks. governance_status
@@ -137,8 +184,9 @@ export async function callTool(name, args, context = {}) {
137
184
  }
138
185
 
139
186
  try {
140
- const result = await securityMiddleware(name, args ?? {}, handler, context);
141
- return toolResult(result);
187
+ const ctx = { ...context };
188
+ const result = await securityMiddleware(name, args ?? {}, handler, ctx);
189
+ return toolResult(withResolvedTarget(result, ctx.resolvedTarget));
142
190
  } catch (err) {
143
191
  // Translate known error classes into clear, non-leaky isError responses;
144
192
  // anything else falls through to toolError for a generic envelope.
@@ -10,6 +10,8 @@
10
10
  * warnings surfaced to users can never drift apart.
11
11
  */
12
12
 
13
+ import { graphqlHasMutation } from "./security.js";
14
+
13
15
  export const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
14
16
  "drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
15
17
  "drupal_drush_config_export", "drupal_drush_config_import",
@@ -39,8 +41,13 @@ export function inferOperation(toolName) {
39
41
  // Generic entity tools carry their target type/bundle in ARGS, not the tool name,
40
42
  // so the security middleware gates them inside their handlers (assertDeleteAllowed /
41
43
  // assertWriteAllowed with full context) and inferOperation() intentionally leaves
42
- // them classified "read". For the user-facing confirm-first WARNING, though, we
43
- // still want the destructive hint to show, so classify them by name here.
44
+ // them classified "read". Write-site gating and the user-facing confirm-first
45
+ // WARNING still classify them by name.
46
+ const ENTITY_WRITE_TOOLS = new Set([
47
+ "drupal_entity_create",
48
+ "drupal_entity_update",
49
+ "drupal_entity_delete",
50
+ ]);
44
51
  const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
45
52
 
46
53
  /**
@@ -55,3 +62,33 @@ const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
55
62
  export function isDestructiveTool(toolName) {
56
63
  return inferOperation(toolName) === "delete" || DESTRUCTIVE_ENTITY_TOOLS.has(toolName);
57
64
  }
65
+
66
+ /**
67
+ * Whether a tool is a write or delete regardless of how inferOperation classifies it.
68
+ * Covers the generic `drupal_entity_{create,update,delete}` tools that self-gate
69
+ * inside their handlers and are therefore left as "read" by prefix inference.
70
+ *
71
+ * @param {string} toolName - The MCP tool name.
72
+ * @returns {boolean}
73
+ */
74
+ export function isWriteLikeTool(toolName) {
75
+ if (ENTITY_WRITE_TOOLS.has(toolName)) return true;
76
+ const op = inferOperation(toolName);
77
+ return op === "write" || op === "delete";
78
+ }
79
+
80
+ /**
81
+ * Whether this invocation mutates the target. GraphQL is a write only when the
82
+ * document contains a mutation; a query is still a read.
83
+ *
84
+ * @param {string} toolName
85
+ * @param {object} [args]
86
+ * @returns {boolean}
87
+ */
88
+ export function isWriteLikeCall(toolName, args = {}) {
89
+ if (isWriteLikeTool(toolName)) return true;
90
+ if (inferOperation(toolName) === "graphql" && typeof args.query === "string") {
91
+ return graphqlHasMutation(args.query);
92
+ }
93
+ return false;
94
+ }
@@ -470,7 +470,7 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
470
470
  * @param {string} query GraphQL document text.
471
471
  * @returns {boolean} True if any operation is a mutation.
472
472
  */
473
- function graphqlHasMutation(query) {
473
+ export function graphqlHasMutation(query) {
474
474
  try {
475
475
  const doc = parse(query);
476
476
  return doc.definitions.some(
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resolved-target disclosure and the multi-site write guard (#167).
3
+ *
4
+ * A silent default that returns plausible content from the wrong environment
5
+ * is the failure class: the response looks like success and every field a
6
+ * caller would sanity-check (id, title, url) is correct. `_target` uses the
7
+ * same `{ name, baseUrl, source }` block as `drupal_mcp_whoami` so there is
8
+ * one vocabulary. `source` is load-bearing — `hint` when the caller named a
9
+ * target, `default` when `defaultSite` was used, `grant` when a principal
10
+ * had exactly one entitled site.
11
+ *
12
+ * Reads may still default. Writes (and GraphQL mutations) may not when more
13
+ * than one site is configured: a write on the wrong site is not recoverable.
14
+ */
15
+
16
+ import { describeTarget } from "./principal.js";
17
+ import { isWriteLikeCall } from "./operations.js";
18
+ import { SecurityError } from "./security.js";
19
+
20
+ /** Shared `site` argument schema. Injected onto every tool that accepts `site`. */
21
+ export const SITE_PARAM = {
22
+ type: "string",
23
+ description:
24
+ "Named site from connector config. Omit only on reads: multi-site configs " +
25
+ "fall back to defaultSite (often local/dev, not production). Writes require " +
26
+ "an explicit site when more than one site is configured. Every response " +
27
+ "includes `_target` { name, baseUrl, source } (`hint` when you passed site, " +
28
+ "`default` when you did not).",
29
+ };
30
+
31
+ /**
32
+ * Attach the resolved target to a tool payload so JSON.stringify keeps it.
33
+ *
34
+ * Object results get `_target` as a sibling. Arrays are wrapped as
35
+ * `{ items, _target }` because extra properties on an array are dropped by
36
+ * JSON.stringify. Tools that do not address a single site pass `resolved` as
37
+ * null and are returned unchanged.
38
+ *
39
+ * @param {*} result Handler return value.
40
+ * @param {?{site: object, source: string}} resolved
41
+ * @returns {*}
42
+ */
43
+ export function withResolvedTarget(result, resolved) {
44
+ if (!resolved?.site) return result;
45
+ const _target = describeTarget(resolved.site, resolved.source);
46
+ if (result && typeof result === "object" && !Array.isArray(result)) {
47
+ return { ...result, _target };
48
+ }
49
+ if (Array.isArray(result)) {
50
+ return { items: result, _target };
51
+ }
52
+ if (result === undefined || result === null) {
53
+ return { _target };
54
+ }
55
+ return { result, _target };
56
+ }
57
+
58
+ /**
59
+ * Refuse a write that would silently land on defaultSite when more than one
60
+ * site is configured. Resolution behaviour for reads is unchanged.
61
+ *
62
+ * @param {string} toolName
63
+ * @param {object} args Raw caller arguments (before any default rewrite).
64
+ * @param {?{site: object, source: string, name: string}} resolved
65
+ * @param {string[]} siteNames Configured site names.
66
+ * @returns {void}
67
+ * @throws {SecurityError}
68
+ */
69
+ export function assertExplicitSiteForWrite(toolName, args, resolved, siteNames) {
70
+ if (!resolved || resolved.source !== "default") return;
71
+ if (!Array.isArray(siteNames) || siteNames.length < 2) return;
72
+ if (!isWriteLikeCall(toolName, args)) return;
73
+ throw new SecurityError(
74
+ "Write tools require an explicit site when more than one site is configured. " +
75
+ "Omitted site would default to \"" + resolved.name + "\" (" + resolved.site.baseUrl + "). " +
76
+ "Pass site explicitly. Configured sites: " + siteNames.join(", ") + ".",
77
+ );
78
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { isDestructiveTool } from "./operations.js";
18
+ import { SITE_PARAM } from "./site-target.js";
18
19
 
19
20
  /** Convert a tool name to its prompt/command name: `drupal_create_node` → `drupal-create-node`. */
20
21
  export const toolNameToPromptName = (name) => name.replace(/_/g, "-");
@@ -54,7 +55,7 @@ export function paramList(inputSchema) {
54
55
  name,
55
56
  required: required.has(name),
56
57
  hint: typeHint(spec),
57
- description: spec?.description || (name === "site" ? "omit for the default site" : ""),
58
+ description: spec?.description || (name === "site" ? SITE_PARAM.description : ""),
58
59
  }));
59
60
  }
60
61
 
@@ -107,7 +107,7 @@ function inferTier(site, sec) {
107
107
  * @param {object} args - { site? }.
108
108
  * @returns {Promise<object>} Effective identity + capability summary.
109
109
  */
110
- async function whoami({ site: siteName }) {
110
+ async function whoami({ site: siteName, _resolvedSource }) {
111
111
  const site = getSiteConfig(siteName);
112
112
  const sec = resolveSecurityConfig(site);
113
113
  const summary = getSecuritySummary(site);
@@ -119,9 +119,12 @@ async function whoami({ site: siteName }) {
119
119
  const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
120
120
  const canConfig = hasScope(site, "mcp_config");
121
121
  const identity = getRequestIdentity();
122
+ // Dispatch may inject `site` after a default/grant resolution. Prefer the
123
+ // authoritative source so `target` and `_target` cannot disagree (#167).
124
+ const source = _resolvedSource ?? (siteName ? "hint" : "default");
122
125
  return {
123
126
  site: site._name,
124
- target: describeTarget(site, siteName ? "hint" : "default"),
127
+ target: describeTarget(site, source),
125
128
  principal: identity
126
129
  ? { sub: identity.sub, clientId: identity.clientId, scopes: [...(identity.scopes ?? [])] }
127
130
  : null,
@@ -37,14 +37,34 @@ import * as reportsConfig from "./reports-config.js";
37
37
  import * as reportsContent from "./reports-content.js";
38
38
  import * as auditComposite from "./audit-composite.js";
39
39
  import * as config from "./config.js";
40
+ import { SITE_PARAM } from "../lib/site-target.js";
40
41
 
41
42
  export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
42
43
  revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
43
44
  reportsLinks, reportsConfig, reportsContent, auditComposite, config];
44
45
 
46
+ /**
47
+ * Stamp the shared `site` description onto every tool that accepts one so
48
+ * agents are not told "omit for the default" as if that meant production.
49
+ *
50
+ * @param {object} def Tool definition.
51
+ * @returns {object}
52
+ */
53
+ function withSiteParam(def) {
54
+ const props = def.inputSchema?.properties;
55
+ if (!props?.site) return def;
56
+ return {
57
+ ...def,
58
+ inputSchema: {
59
+ ...def.inputSchema,
60
+ properties: { ...props, site: { ...props.site, ...SITE_PARAM } },
61
+ },
62
+ };
63
+ }
64
+
45
65
  // Flatten every module's tool definitions into one ListTools payload, and merge
46
66
  // their handler maps into a single closed dispatch table keyed by tool name.
47
- export const allDefinitions = allModules.flatMap((m) => m.definitions);
67
+ export const allDefinitions = allModules.flatMap((m) => m.definitions).map(withSiteParam);
48
68
  export const allHandlers = Object.assign({}, ...allModules.map((m) => m.handlers));
49
69
 
50
70
  // Fast name → definition lookup for prompt/command rendering.
@@ -20,6 +20,25 @@ import {
20
20
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertPublishAllowed,
21
21
  redactCanonicalEntity,
22
22
  } from "../lib/security.js";
23
+ import { collectEntities } from "../lib/reports-support.js";
24
+
25
+ /** Cap for the client-side scan when JSON:API cannot filter the field. */
26
+ const SAMPLE_CAP = 500;
27
+
28
+ /**
29
+ * Drupal core JSON:API cannot filter on the computed `moderation_state`
30
+ * field. The error is a 500 whose detail is `'moderation_state' not found`
31
+ * (#162). jsonapi_extras (or a similar alias) can make the field filterable;
32
+ * those sites never hit this.
33
+ *
34
+ * @param {unknown} err
35
+ * @returns {boolean}
36
+ */
37
+ export function isUnfilterableModerationState(err) {
38
+ const msg = String(err?.message || "");
39
+ if (!/moderation_state/i.test(msg)) return false;
40
+ return /not found/i.test(msg) || /not filterable/i.test(msg) || /invalid filter/i.test(msg);
41
+ }
23
42
 
24
43
  /** Read a node's moderation_state from a canonical entity, tolerating shapes. */
25
44
  function moderationStateOf(entity) {
@@ -49,6 +68,12 @@ async function setModerationState({ site: siteName, type, id, state }) {
49
68
 
50
69
  /**
51
70
  * List nodes of a type in a given moderation state, paged + redacted.
71
+ *
72
+ * Stock JSON:API cannot filter on the computed `moderation_state` field
73
+ * (#162). When the site accepts the filter (jsonapi_extras or similar) the
74
+ * result is exact. Otherwise we scan a bounded recent set and filter
75
+ * client-side rather than surfacing Drupal's 500 as a transient error.
76
+ *
52
77
  * @param {object} args - { site?, type, state, limit?, offset? }.
53
78
  */
54
79
  async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
@@ -56,14 +81,68 @@ async function contentByModerationState({ site: siteName, type, state, limit = 2
56
81
  const sec = resolveSecurityConfig(site);
57
82
  assertReadAllowed(sec, "node", type);
58
83
  const backend = await resolveBackend(site);
59
- const res = await backend.listEntities({
60
- entityType: "node", bundle: type,
61
- filters: [{ field: "moderation_state", op: "eq", value: state }],
62
- sort: [{ field: "changed", dir: "desc" }],
63
- page: { limit, offset },
64
- });
65
- const nodes = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
66
- return { type, state, total: res.page?.total ?? nodes.length, approximate: res.approximate ?? false, offset, nextOffset: offset + nodes.length, nodes };
84
+ const sort = [{ field: "changed", dir: "desc" }];
85
+ const canFilter = typeof backend.capabilities === "function"
86
+ ? Boolean(backend.capabilities()?.filter)
87
+ : true;
88
+
89
+ if (canFilter) {
90
+ try {
91
+ const res = await backend.listEntities({
92
+ entityType: "node", bundle: type,
93
+ filters: [{ field: "moderation_state", op: "eq", value: state }],
94
+ sort,
95
+ page: { limit, offset },
96
+ });
97
+ const nodes = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
98
+ return {
99
+ type, state, source: "filter",
100
+ total: res.page?.total ?? nodes.length,
101
+ approximate: res.approximate ?? false,
102
+ offset, nextOffset: offset + nodes.length, nodes,
103
+ };
104
+ } catch (err) {
105
+ if (!isUnfilterableModerationState(err)) throw err;
106
+ }
107
+ }
108
+
109
+ const scanned = await collectEntities(
110
+ backend,
111
+ { entityType: "node", bundle: type, sort },
112
+ SAMPLE_CAP,
113
+ );
114
+ const wanted = String(state).toLowerCase();
115
+ const matches = [];
116
+ let sawState = false;
117
+ for (const entity of scanned) {
118
+ const got = moderationStateOf(entity);
119
+ if (got === null || got === undefined) continue;
120
+ sawState = true;
121
+ if (String(got).toLowerCase() === wanted) matches.push(entity);
122
+ }
123
+ if (!sawState && scanned.length > 0) {
124
+ return {
125
+ type, state, unavailable: true, source: "sampled", scanned: scanned.length,
126
+ reason:
127
+ "moderation_state is not filterable over JSON:API on this site (it is a " +
128
+ "computed field) and no sampled entity exposed the field. Enable " +
129
+ "content_moderation on the bundle, or expose a filterable alias " +
130
+ "(jsonapi_extras).",
131
+ };
132
+ }
133
+ const page = matches.slice(offset, offset + limit);
134
+ return {
135
+ type, state, source: "sampled",
136
+ approximate: scanned.length >= SAMPLE_CAP,
137
+ scanned: scanned.length,
138
+ total: matches.length,
139
+ offset, nextOffset: offset + page.length,
140
+ nodes: page.map((e) => redactCanonicalEntity(e, sec, "node")),
141
+ note:
142
+ "Stock JSON:API cannot filter on moderation_state (computed field). " +
143
+ "Results are a client-side sample of recent nodes, not a complete list. " +
144
+ "A filterable alias (jsonapi_extras) makes this exact.",
145
+ };
67
146
  }
68
147
 
69
148
  /**
@@ -101,7 +180,7 @@ export const definitions = [
101
180
  },
102
181
  {
103
182
  name: "drupal_content_by_moderation_state",
104
- description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review').",
183
+ description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
105
184
  inputSchema: {
106
185
  type: "object", required: ["type", "state"],
107
186
  properties: {