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.
- 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 +24 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/lib/dispatch.js +63 -15
- 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,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.
|
|
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/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/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.
|
package/src/tools/moderation.js
CHANGED
|
@@ -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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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: {
|