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.
- package/.claude/commands/drupal-audit-config-best-practices.md +1 -1
- package/.claude/commands/drupal-audit-site-health.md +1 -1
- package/.claude/commands/drupal-block-user.md +1 -1
- package/.claude/commands/drupal-bulk-create.md +1 -1
- package/.claude/commands/drupal-bulk-update.md +1 -1
- package/.claude/commands/drupal-config-get.md +1 -1
- package/.claude/commands/drupal-config-list.md +1 -1
- package/.claude/commands/drupal-config-set.md +1 -1
- package/.claude/commands/drupal-content-by-moderation-state.md +3 -3
- package/.claude/commands/drupal-create-block.md +1 -1
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-create-menu-link.md +1 -1
- package/.claude/commands/drupal-create-node.md +1 -1
- package/.claude/commands/drupal-create-paragraph.md +1 -1
- package/.claude/commands/drupal-create-redirect.md +1 -1
- package/.claude/commands/drupal-create-taxonomy-term.md +1 -1
- package/.claude/commands/drupal-create-translation.md +1 -1
- package/.claude/commands/drupal-create-user.md +1 -1
- package/.claude/commands/drupal-delete-media.md +1 -1
- package/.claude/commands/drupal-delete-node.md +1 -1
- package/.claude/commands/drupal-delete-taxonomy-term.md +1 -1
- package/.claude/commands/drupal-describe-fields.md +1 -1
- package/.claude/commands/drupal-drush-cache-rebuild.md +1 -1
- package/.claude/commands/drupal-drush-config-export.md +1 -1
- package/.claude/commands/drupal-drush-config-import.md +1 -1
- package/.claude/commands/drupal-drush-config-status.md +1 -1
- package/.claude/commands/drupal-drush-cron.md +1 -1
- package/.claude/commands/drupal-drush-module-disable.md +1 -1
- package/.claude/commands/drupal-drush-module-enable.md +1 -1
- package/.claude/commands/drupal-drush-module-list.md +1 -1
- package/.claude/commands/drupal-drush-security-updates.md +1 -1
- package/.claude/commands/drupal-drush-sql-query.md +1 -1
- package/.claude/commands/drupal-drush-status.md +1 -1
- package/.claude/commands/drupal-drush-updatedb.md +1 -1
- package/.claude/commands/drupal-drush-user-create.md +1 -1
- package/.claude/commands/drupal-drush-user-list.md +1 -1
- package/.claude/commands/drupal-drush-watchdog.md +1 -1
- package/.claude/commands/drupal-entity-create.md +1 -1
- package/.claude/commands/drupal-entity-delete.md +1 -1
- package/.claude/commands/drupal-entity-get.md +1 -1
- package/.claude/commands/drupal-entity-list.md +1 -1
- package/.claude/commands/drupal-entity-update.md +1 -1
- package/.claude/commands/drupal-find-orphaned-media.md +1 -1
- package/.claude/commands/drupal-get-entity-schema.md +1 -1
- package/.claude/commands/drupal-get-media.md +1 -1
- package/.claude/commands/drupal-get-node.md +1 -1
- package/.claude/commands/drupal-get-paragraph.md +1 -1
- package/.claude/commands/drupal-get-revision.md +1 -1
- package/.claude/commands/drupal-get-taxonomy-term.md +1 -1
- package/.claude/commands/drupal-get-taxonomy-terms.md +1 -1
- package/.claude/commands/drupal-get-user-by-name.md +1 -1
- package/.claude/commands/drupal-get-user.md +1 -1
- package/.claude/commands/drupal-governance-status.md +1 -1
- package/.claude/commands/drupal-graphql-introspect.md +1 -1
- package/.claude/commands/drupal-graphql.md +1 -1
- package/.claude/commands/drupal-list-blocks.md +1 -1
- package/.claude/commands/drupal-list-content-types.md +1 -1
- package/.claude/commands/drupal-list-entity-types.md +1 -1
- package/.claude/commands/drupal-list-media-types.md +1 -1
- package/.claude/commands/drupal-list-media.md +1 -1
- package/.claude/commands/drupal-list-menu-links.md +1 -1
- package/.claude/commands/drupal-list-moderation-states.md +1 -1
- package/.claude/commands/drupal-list-nodes.md +1 -1
- package/.claude/commands/drupal-list-revisions.md +1 -1
- package/.claude/commands/drupal-list-roles.md +1 -1
- package/.claude/commands/drupal-list-translations.md +1 -1
- package/.claude/commands/drupal-list-users.md +1 -1
- package/.claude/commands/drupal-list-vocabularies.md +1 -1
- package/.claude/commands/drupal-mcp-whoami.md +1 -1
- package/.claude/commands/drupal-report-404-log.md +1 -1
- package/.claude/commands/drupal-report-accessibility-audit.md +1 -1
- package/.claude/commands/drupal-report-alias-coverage.md +1 -1
- package/.claude/commands/drupal-report-broken-embeds.md +1 -1
- package/.claude/commands/drupal-report-broken-links.md +1 -1
- package/.claude/commands/drupal-report-cache-config.md +1 -1
- package/.claude/commands/drupal-report-config-drift.md +1 -1
- package/.claude/commands/drupal-report-content-by-author.md +1 -1
- package/.claude/commands/drupal-report-content-summary.md +1 -1
- package/.claude/commands/drupal-report-duplicate-content.md +1 -1
- package/.claude/commands/drupal-report-field-completeness.md +1 -1
- package/.claude/commands/drupal-report-menu-integrity.md +1 -1
- package/.claude/commands/drupal-report-missing-field.md +1 -1
- package/.claude/commands/drupal-report-module-audit.md +1 -1
- package/.claude/commands/drupal-report-orphan-pages.md +1 -1
- package/.claude/commands/drupal-report-orphaned-references.md +1 -1
- package/.claude/commands/drupal-report-permission-audit.md +1 -1
- package/.claude/commands/drupal-report-pii-exposure.md +1 -1
- package/.claude/commands/drupal-report-readability.md +1 -1
- package/.claude/commands/drupal-report-recently-published.md +1 -1
- package/.claude/commands/drupal-report-redirect-health.md +1 -1
- package/.claude/commands/drupal-report-revision-hotspots.md +1 -1
- package/.claude/commands/drupal-report-scheduled-content.md +1 -1
- package/.claude/commands/drupal-report-seo-audit.md +1 -1
- package/.claude/commands/drupal-report-seo-meta-coverage.md +1 -1
- package/.claude/commands/drupal-report-stale-content.md +1 -1
- package/.claude/commands/drupal-report-status-report.md +1 -1
- package/.claude/commands/drupal-report-taxonomy-usage.md +1 -1
- package/.claude/commands/drupal-report-text-format-audit.md +1 -1
- package/.claude/commands/drupal-report-translation-coverage.md +1 -1
- package/.claude/commands/drupal-report-unpublished.md +1 -1
- package/.claude/commands/drupal-report-user-activity.md +1 -1
- package/.claude/commands/drupal-report-workflow-bottlenecks.md +1 -1
- package/.claude/commands/drupal-resolve-reference.md +1 -1
- package/.claude/commands/drupal-revert-revision.md +1 -1
- package/.claude/commands/drupal-schedule-publish.md +1 -1
- package/.claude/commands/drupal-search-content.md +1 -1
- package/.claude/commands/drupal-search.md +1 -1
- package/.claude/commands/drupal-security-info.md +1 -1
- package/.claude/commands/drupal-set-moderation-state.md +1 -1
- package/.claude/commands/drupal-site-info.md +1 -1
- package/.claude/commands/drupal-update-media.md +1 -1
- package/.claude/commands/drupal-update-menu-link.md +1 -1
- package/.claude/commands/drupal-update-node.md +1 -1
- package/.claude/commands/drupal-update-paragraph.md +1 -1
- package/.claude/commands/drupal-update-redirect.md +1 -1
- package/.claude/commands/drupal-update-taxonomy-term.md +1 -1
- package/.claude/commands/drupal-update-user.md +1 -1
- package/.claude/commands/drupal-upload-file-and-create-media.md +1 -1
- package/.claude/commands/drupal-upload-file.md +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/index.js +5 -2
- package/src/lib/dispatch.js +63 -15
- package/src/lib/load-secrets.js +40 -5
- package/src/lib/operations.js +39 -2
- package/src/lib/security.js +1 -1
- package/src/lib/site-target.js +78 -0
- package/src/lib/tool-prompts.js +2 -1
- package/src/tools/config.js +5 -2
- package/src/tools/index.js +21 -1
- 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):
|
|
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):
|
|
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 (
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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.
|
|
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
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
|
-
|
|
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.`
|
package/src/lib/dispatch.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
141
|
-
|
|
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.
|
package/src/lib/load-secrets.js
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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) " +
|
package/src/lib/operations.js
CHANGED
|
@@ -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".
|
|
43
|
-
// still
|
|
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
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -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" ?
|
|
58
|
+
description: spec?.description || (name === "site" ? SITE_PARAM.description : ""),
|
|
58
59
|
}));
|
|
59
60
|
}
|
|
60
61
|
|
package/src/tools/config.js
CHANGED
|
@@ -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,
|
|
127
|
+
target: describeTarget(site, source),
|
|
125
128
|
principal: identity
|
|
126
129
|
? { sub: identity.sub, clientId: identity.clientId, scopes: [...(identity.scopes ?? [])] }
|
|
127
130
|
: null,
|
package/src/tools/index.js
CHANGED
|
@@ -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.
|