drupal-mcp-connector 2.7.2 → 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 (132) 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 +38 -0
  121. package/README.md +2 -1
  122. package/package.json +1 -1
  123. package/src/index.js +5 -2
  124. package/src/lib/dispatch.js +63 -15
  125. package/src/lib/load-secrets.js +40 -5
  126. package/src/lib/operations.js +39 -2
  127. package/src/lib/security.js +1 -1
  128. package/src/lib/site-target.js +78 -0
  129. package/src/lib/tool-prompts.js +2 -1
  130. package/src/tools/config.js +5 -2
  131. package/src/tools/index.js +21 -1
  132. 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,44 @@ 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
+
34
+ ## [2.7.3] - 2026-08-18
35
+
36
+ ### Fixed
37
+ - **Launcher and startup name a secret-table / config mismatch (#211).**
38
+ The shipped Keychain table matches `config/config.example.json`. A
39
+ `config.json` that uses different `clientSecretEnv` names without a
40
+ `config/secrets.map` left the table and the config each valid and
41
+ jointly inert. Per-item Keychain misses stay silent (break-glass).
42
+ When the table matches **no** named secret, `bin/drupal-mcp-launch.sh`
43
+ and `src/lib/load-secrets.js` now print one stderr line that names
44
+ the unmapped variables and says `secrets.map` is absent (or that the
45
+ map does not name them). Fail-closed start when every named secret is
46
+ unset is unchanged.
47
+
10
48
  ## [2.7.2] - 2026-08-18
11
49
 
12
50
  ### 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.2** (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.2",
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",
package/src/index.js CHANGED
@@ -34,7 +34,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
34
34
  import { toNodeHandler } from "@modelcontextprotocol/node";
35
35
 
36
36
  import { listSiteNames, getTlsConfig, loadConfig, CLIENT_VERSION } from "./lib/config.js";
37
- import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
37
+ import { loadLocalSecrets, secretLoadFatalMessage, secretTableMismatchMessage } from "./lib/load-secrets.js";
38
38
  import {
39
39
  makeBearerCheck,
40
40
  resolveInboundAuthConfig,
@@ -68,7 +68,10 @@ if (secretFatal) {
68
68
  console.error(`[drupal-mcp-connector] FATAL: ${secretFatal}`);
69
69
  process.exit(1);
70
70
  }
71
- if (secretLoad.unset.length) {
71
+ const secretMismatch = secretTableMismatchMessage(secretLoad);
72
+ if (secretMismatch) {
73
+ console.error(`[drupal-mcp-connector] WARNING: ${secretMismatch}`);
74
+ } else if (secretLoad.unset.length) {
72
75
  console.error(
73
76
  "[drupal-mcp-connector] WARNING: config.json names secret env vars that are unset: " +
74
77
  `${secretLoad.unset.join(", ")}. Those sites will fail closed.`
@@ -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.
@@ -9,8 +9,10 @@
9
9
  * The default table matches config/config.example.json. A gitignored
10
10
  * config/secrets.map replaces that table for a deployment whose env-var
11
11
  * names differ. Per-item Keychain misses stay silent (inert break-glass).
12
- * If the active config.json names secret env vars and none of them are set
13
- * after this step, the caller must refuse to start.
12
+ * Zero overlap between the table and the names in config.json is a
13
+ * distinct case the two files are jointly inert — and the caller should
14
+ * say so. If every named secret is still unset after this step, the caller
15
+ * must refuse to start.
14
16
  */
15
17
 
16
18
  import { execFileSync } from "node:child_process";
@@ -101,7 +103,7 @@ export function lookupKeychainItem(item) {
101
103
  * @param {NodeJS.ProcessEnv} [options.env] Mutated when a lookup succeeds.
102
104
  * @param {typeof readFileSync} [options.readFile]
103
105
  * @param {(item: string) => string} [options.lookup]
104
- * @returns {{pairs: number, resolved: number, named: string[], unset: string[]}}
106
+ * @returns {{pairs: number, resolved: number, named: string[], unset: string[], tableVars: string[], source: "map"|"default"}}
105
107
  */
106
108
  export function loadLocalSecrets({
107
109
  cwd = process.cwd(),
@@ -109,11 +111,13 @@ export function loadLocalSecrets({
109
111
  readFile = readFileSync,
110
112
  lookup = lookupKeychainItem,
111
113
  } = {}) {
114
+ let source = "map";
112
115
  let pairs;
113
116
  try {
114
117
  pairs = parseSecretMap(readFile(join(cwd, "config", "secrets.map"), "utf8"));
115
118
  } catch {
116
119
  pairs = DEFAULT_SECRET_PAIRS;
120
+ source = "default";
117
121
  }
118
122
 
119
123
  let resolved = 0;
@@ -140,16 +144,47 @@ export function loadLocalSecrets({
140
144
 
141
145
  const envMap = new Map(Object.entries(env));
142
146
  const unset = named.filter((name) => !envMap.get(name));
143
- return { pairs: pairs.length, resolved, named, unset };
147
+ const tableVars = pairs.map(([varName]) => varName);
148
+ return { pairs: pairs.length, resolved, named, unset, tableVars, source };
149
+ }
150
+
151
+ /**
152
+ * One-line diagnosis when the secret table and config.json share no names.
153
+ * Null when there is any overlap, or when config names no secrets.
154
+ * Per-item Keychain misses are not a mismatch.
155
+ * @param {{named: string[], tableVars?: string[], source?: "map"|"default"}} loaded
156
+ * @returns {string|null}
157
+ */
158
+ export function secretTableMismatchMessage(loaded) {
159
+ if (!loaded.named.length) return null;
160
+ const table = new Set(loaded.tableVars || []);
161
+ const unmapped = loaded.named.filter((name) => !table.has(name));
162
+ if (unmapped.length !== loaded.named.length) return null;
163
+ const source = loaded.source === "map"
164
+ ? "config/secrets.map does not name them"
165
+ : "using shipped defaults; config/secrets.map is absent";
166
+ const unset = Array.isArray(loaded.unset) ? loaded.unset : [];
167
+ const closer = unset.length
168
+ ? "Unset: " + unset.join(", ") + ". Those sites will fail closed."
169
+ : "Named secrets already in the environment stay set; the table will not populate any of them.";
170
+ return (
171
+ "no secret-table entries match clientSecretEnv/apiTokenEnv names in config.json " +
172
+ "(" + unmapped.join(", ") + "); " + source + ". " +
173
+ closer
174
+ );
144
175
  }
145
176
 
146
177
  /**
147
178
  * Refuse to boot a server that can only advertise diagnostic tools.
148
- * @param {{named: string[], unset: string[]}} loaded
179
+ * @param {{named: string[], unset: string[], tableVars?: string[], source?: "map"|"default"}} loaded
149
180
  * @returns {string|null} Fatal message, or null when start is allowed.
150
181
  */
151
182
  export function secretLoadFatalMessage(loaded) {
152
183
  if (!loaded.named.length || loaded.unset.length !== loaded.named.length) return null;
184
+ const mismatch = secretTableMismatchMessage(loaded);
185
+ if (mismatch) {
186
+ return mismatch + " Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) or export them before launch.";
187
+ }
153
188
  return (
154
189
  `every clientSecretEnv/apiTokenEnv named in config.json is unset (${loaded.unset.join(", ")}). ` +
155
190
  "Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) " +
@@ -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.