drupal-mcp-connector 2.19.1 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +264 -0
  20. package/README.md +9 -1
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +63 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +83 -5
  25. package/scripts/install-commands.js +199 -11
  26. package/src/index.js +34 -120
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/drupal-fetch.js +97 -26
  32. package/src/lib/dry-run-checks.js +78 -0
  33. package/src/lib/error-body.js +448 -0
  34. package/src/lib/error-status.js +38 -0
  35. package/src/lib/governance.js +9 -2
  36. package/src/lib/mcp-server.js +7 -1
  37. package/src/lib/metatag-audit.js +2 -1
  38. package/src/lib/module-tools.js +23 -2
  39. package/src/lib/oauth.js +23 -9
  40. package/src/lib/patch-preflight.js +23 -5
  41. package/src/lib/principal.js +14 -0
  42. package/src/lib/reports-support.js +75 -0
  43. package/src/lib/security.js +233 -0
  44. package/src/lib/sentinel-draft.js +3 -2
  45. package/src/lib/server-tools.js +202 -34
  46. package/src/lib/tool-prompts.js +160 -6
  47. package/src/lib/verify.js +161 -58
  48. package/src/lib/workflow-prompts.js +348 -0
  49. package/src/lib/workflows/builtin.js +134 -0
  50. package/src/tools/config.js +70 -5
  51. package/src/tools/drush.js +191 -17
  52. package/src/tools/entities.js +18 -6
  53. package/src/tools/fields.js +40 -4
  54. package/src/tools/graphql.js +10 -5
  55. package/src/tools/moderation.js +1 -1
  56. package/src/tools/nodes.js +16 -6
  57. package/src/tools/reports-config.js +3 -3
  58. package/src/tools/reports-content.js +34 -26
  59. package/src/tools/reports-extra.js +46 -38
  60. package/src/tools/reports.js +50 -25
  61. package/src/tools/scheduler.js +1 -1
  62. package/src/tools/structure.js +12 -2
  63. package/src/tools/translations.js +5 -1
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR."
2
+ description: "Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR. Refused for `core.extension`, which lists installed modules and themes: use drupal_drush_module_enable or drupal_drush_module_disable instead. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info), and a value that removes a protected module is still refused."
3
3
  argument-hint: "<name> <value> [site]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_config_set`.
7
7
 
8
- Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR.
8
+ Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR. Refused for `core.extension`, which lists installed modules and themes: use drupal_drush_module_enable or drupal_drush_module_disable instead. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info), and a value that removes a protected module is still refused.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -22,7 +22,7 @@ Parse the arguments supplied with this command into this tool's parameters:
22
22
  - `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
23
23
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`.
24
24
  - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }.
25
- - `dryRun` (boolean (true/false)): Validate and return a preview of the write without committing.
26
- - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
25
+ - `dryRun` (boolean (true/false)): Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked.
26
+ - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
27
27
 
28
28
  If a required parameter is missing, 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.
@@ -20,6 +20,6 @@ Parse the arguments supplied with this command into this tool's parameters:
20
20
  - `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
21
21
  - `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
22
22
  - `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
23
- - `dryRun` (boolean (true/false)): Validate without saving
23
+ - `dryRun` (boolean (true/false)): Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked.
24
24
 
25
25
  If a required parameter is missing, 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.
@@ -17,6 +17,6 @@ Parse the arguments supplied with this command into this tool's parameters:
17
17
 
18
18
  **Optional:**
19
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
- - `dryRun` (boolean (true/false)): Validate and return a preview of the delete without committing.
20
+ - `dryRun` (boolean (true/false)): Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so.
21
21
 
22
22
  If a required parameter is missing, 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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Introspect the fields of a Drupal entity type + bundle: returns a per-field list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on schema SAMPLING (an existing entity), so results are approximate — only populated fields are visible and required/cardinality/allowedValues are inferred from value shape. When JSON:API field_config is readable, translatable is copied from Field API; omitted means unknown, not false. Authoritative field metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names."
2
+ description: "Introspect the fields of a Drupal entity type + bundle: returns a per-field list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on schema SAMPLING (an existing entity), so results are approximate — only populated fields are visible and required/cardinality/allowedValues are inferred from value shape. When JSON:API field_config is readable, translatable is copied from Field API; omitted means unknown, not false. A field this account may not view is left out of the JSON:API resource, so it is NOT in `fields` and looks like a field that does not exist. When field_config is readable (`fieldDefinitions: 'available'`), fields defined for the bundle but absent from the sampled entity are listed apart as `notVisible`; when it is not, denied fields cannot be detected. Absent is not empty. Authoritative field metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names."
3
3
  argument-hint: "<site> [type] [entityType] [bundle]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_describe_fields`.
7
7
 
8
- Introspect the fields of a Drupal entity type + bundle: returns a per-field list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on schema SAMPLING (an existing entity), so results are approximate — only populated fields are visible and required/cardinality/allowedValues are inferred from value shape. When JSON:API field_config is readable, translatable is copied from Field API; omitted means unknown, not false. Authoritative field metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.
8
+ Introspect the fields of a Drupal entity type + bundle: returns a per-field list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on schema SAMPLING (an existing entity), so results are approximate — only populated fields are visible and required/cardinality/allowedValues are inferred from value shape. When JSON:API field_config is readable, translatable is copied from Field API; omitted means unknown, not false. A field this account may not view is left out of the JSON:API resource, so it is NOT in `fields` and looks like a field that does not exist. When field_config is readable (`fieldDefinitions: 'available'`), fields defined for the bundle but absent from the sampled entity are listed apart as `notVisible`; when it is not, denied fields cannot be detected. Absent is not empty. Authoritative field metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production."
2
+ description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production. Reads `drush config:status` first and refuses the import when `core.extension` differs, because such an import installs and uninstalls modules and can remove a protected one; nothing is imported, and the status read must be allowed in `drushSsh.allowedCommands`. Use drupal_drush_module_enable or drupal_drush_module_disable for a module change. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info)."
3
3
  argument-hint: "[site]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_drush_config_import`.
7
7
 
8
- Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production.
8
+ Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production. Reads `drush config:status` first and refuses the import when `core.extension` differs, because such an import installs and uninstalls modules and can remove a protected one; nothing is imported, and the status read must be allowed in `drushSsh.allowedCommands`. Use drupal_drush_module_enable or drupal_drush_module_disable for a module change. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info).
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user."
2
+ description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user. Refused for a protected module: governance, integrity, secrets, auth and API modules such as mcp_sentinel, audit_chain, key, simple_oauth and jsonapi (see `protectedModules` in drupal_security_info). Only an operator can change that list, in site config. Also refused when the uninstall would cascade to dependents: nothing is uninstalled, the dependents are named, and each must be uninstalled by name first."
3
3
  argument-hint: "<moduleName> [site]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_drush_module_disable`.
7
7
 
8
- Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user.
8
+ Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user. Refused for a protected module: governance, integrity, secrets, auth and API modules such as mcp_sentinel, audit_chain, key, simple_oauth and jsonapi (see `protectedModules` in drupal_security_info). Only an operator can change that list, in site config. Also refused when the uninstall would cascade to dependents: nothing is uninstalled, the dependents are named, and each must be uninstalled by name first.
9
9
 
10
10
  > ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.
11
11
 
@@ -11,6 +11,6 @@ Parse the arguments supplied with this command into this tool's parameters:
11
11
 
12
12
  **Optional:**
13
13
  - `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).
14
- - `status` (string)
14
+ - `status` (one of: enabled, disabled)
15
15
 
16
16
  If a required parameter is missing, 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.
@@ -11,7 +11,7 @@ Parse the arguments supplied with this command into this tool's parameters:
11
11
 
12
12
  **Optional:**
13
13
  - `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).
14
- - `status` (string)
14
+ - `status` (one of: active, blocked)
15
15
  - `role` (string)
16
16
  - `limit` (number)
17
17
 
@@ -12,7 +12,7 @@ Parse the arguments supplied with this command into this tool's parameters:
12
12
  **Optional:**
13
13
  - `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).
14
14
  - `type` (string)
15
- - `severity` (string)
15
+ - `severity` (one of: emergency, alert, critical, error, warning, notice, info, debug)
16
16
  - `limit` (number)
17
17
 
18
18
  If a required parameter is missing, 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.
@@ -17,7 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
17
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
  - `attributes` (object (pass as JSON)): Field values keyed by Drupal machine name
19
19
  - `relationships` (object (pass as JSON)): Relationship data keyed by field name
20
- - `dryRun` (boolean (true/false)): Validate and return a preview of the create without committing.
21
- - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
20
+ - `dryRun` (boolean (true/false)): Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked.
21
+ - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
22
22
 
23
23
  If a required parameter is missing, 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.
@@ -18,6 +18,6 @@ Parse the arguments supplied with this command into this tool's parameters:
18
18
 
19
19
  **Optional:**
20
20
  - `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).
21
- - `dryRun` (boolean (true/false)): Validate and return a preview of the delete without committing.
21
+ - `dryRun` (boolean (true/false)): Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so.
22
22
 
23
23
  If a required parameter is missing, 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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
2
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [langcode] [attributes] [relationships] [dryRun] [returning]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_entity_update`.
7
7
 
8
- Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.
8
+ Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -19,7 +19,7 @@ Parse the arguments supplied with this command into this tool's parameters:
19
19
  - `langcode` (string): Target language for an unpublished working translation (nodes). Continues that translation via Sentinel.
20
20
  - `attributes` (object (pass as JSON))
21
21
  - `relationships` (object (pass as JSON))
22
- - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
23
- - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
22
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
23
+ - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
24
24
 
25
25
  If a required parameter is missing, 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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc."
2
+ description: "Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc. Reads scalar and entity-reference fields; a reference with no target is empty. Sampling-bounded — flags 'approximate' when the scan hits sampleSize. An absent field is not an empty one: JSON:API leaves out a field this account may not view. A node that omits the key is counted as `absent` and stays out of completenessPercent. A field you name that is absent from every sampled node is listed in `notVisible` and not scored (it may be denied to this account, not exist on the content type, or be misspelled). Default field guesses that are absent are dropped."
3
3
  argument-hint: "<type> [site] [fields] [sampleSize]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_report_field_completeness`.
7
7
 
8
- Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc.
8
+ Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc. Reads scalar and entity-reference fields; a reference with no target is empty. Sampling-bounded — flags 'approximate' when the scan hits sampleSize. An absent field is not an empty one: JSON:API leaves out a field this account may not view. A node that omits the key is counted as `absent` and stays out of completenessPercent. A field you name that is absent from every sampled node is listed in `notVisible` and not scored (it may be denied to this account, not exist on the content type, or be misspelled). Default field guesses that are absent are dropped.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped."
2
+ description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied)."
3
3
  argument-hint: "<field> [site] [type] [sampleSize]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_report_missing_field`.
7
7
 
8
- Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped.
8
+ Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage."
2
+ description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view."
3
3
  argument-hint: "[site] [type] [fields] [sampleSize]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_report_seo_meta_coverage`.
7
7
 
8
- Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage.
8
+ Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -11,6 +11,6 @@ Parse the arguments supplied with this command into this tool's parameters:
11
11
 
12
12
  **Optional:**
13
13
  - `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).
14
- - `minSeverity` (string): Lowest severity to include
14
+ - `minSeverity` (one of: warning, error): Lowest severity to include
15
15
 
16
16
  If a required parameter is missing, 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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
2
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran."
3
3
  argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [dryRun] [returning]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_node`.
7
7
 
8
- Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
8
+ Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -24,7 +24,7 @@ Parse the arguments supplied with this command into this tool's parameters:
24
24
  - `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
25
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
26
26
  - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused.
27
- - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
28
- - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
27
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
28
+ - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
29
29
 
30
30
  If a required parameter is missing, 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,270 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.21.0] - 2026-09-19
11
+
12
+ ### Added
13
+ - **Module-owned workflow prompts (#333).** Workflows are definitions the
14
+ connector loads, filters and relays. v1 sources them from built-in
15
+ definitions (the five connector-authored workflows, now the same format)
16
+ and from `serverTools.modules.workflows` next to approved module tools.
17
+ A workflow names tools by local alias (`{tool:alias}`), is listed only
18
+ when every named tool is visible, and cannot invent a tool the site did
19
+ not already approve. Write workflows require `mcp_write`, tell the model
20
+ to confirm, and append that module writes are not retried. Design:
21
+ `docs/module-workflows.md`. The example config ships `review_and_log` on
22
+ `example_site`. Drupal `prompts/list` is specified there and not
23
+ implemented in this change.
24
+
25
+ ### Fixed
26
+ - **Remaining northbound HTTP timeouts.** Server-tool `tools/call` (including
27
+ governed config), the OAuth token POST, and the Sentinel readiness GET now
28
+ abort with `AbortSignal.timeout` so a hung Drupal/Sentinel host cannot stall
29
+ the MCP process. `requestServerTool` always attaches the 256 KiB body cap and
30
+ a 15s abort; those were previously gated on `maxBytes`, so `callGovernedServerTool`
31
+ could hang unbounded. Token acquisition fails as `OAuthError` after 30s.
32
+ A hung readiness probe stays `sentinel_unreachable`.
33
+
34
+ ## [2.20.0] - 2026-09-19
35
+
36
+ ### Added
37
+ - **Prompts for module-owned tools (#332).** Every module tool that discovery
38
+ returns for a request also gets a per-tool prompt
39
+ (`drupal-module-<operation>-<namespace>--<alias>`). Visibility follows the
40
+ tool: same caller scope, site grant and source checks, no cross-request
41
+ cache. The prompt lists the module's own parameters and explains the
42
+ `{ catalogRevision, arguments }` call shape without embedding a revision.
43
+ Write prompts say that module writes are not retried; delete prompts carry
44
+ the destructive warning.
45
+ - **Opt-in slash stubs for module-owned tools (#332).**
46
+ `npm run install:commands -- --modules` discovers the module tools your local
47
+ config approves and writes `drupal-<namespace>-<alias>.md` stubs next to the
48
+ built-in ones (and adds them to the Codex catalog). A name that matches a
49
+ built-in command, or that two module tools share, is refused. If a source
50
+ returns nothing the run fails and writes nothing; if it returns only some
51
+ configured tools, the missing ones are named and older stubs are kept. A
52
+ plain `install:commands` never writes module stubs and leaves installed ones
53
+ in place.
54
+ - **Example config documents module tools and bindings (#348).**
55
+ `config/config.example.json` now has a `_server_tools.example` block with
56
+ `serverTools.modules` (a namespace, two module-owned tools) and all seven
57
+ `serverTools.bindings`, plus notes on each key. The `drushSsh` SQL note names
58
+ the `sqlQuery` binding. A test runs the example through the module registry's
59
+ validation.
60
+
61
+ ### Changed
62
+ - Prompts and command stubs show a parameter's allowed values when its schema
63
+ has a short string `enum` (`one of: full, minimal`). Eight built-in stubs
64
+ under `.agents/commands/` were regenerated.
65
+ - **`drupal_drush_module_disable` refuses protected modules (#346).** The tool
66
+ no longer uninstalls governance, integrity, secrets, auth and API modules
67
+ (`mcp_sentinel`, `audit_chain`, `field_guard`, `file_gate`, `key`, `encrypt`,
68
+ `simple_oauth`, `consumers`, `jsonapi`, `serialization`, `mcp_server`,
69
+ `mcp_server_tool_bridge`, `tool`, `content_moderation`, `workflows`). The list
70
+ is on for every preset. `security.protectedModules` adds modules;
71
+ `security.allowProtectedModuleUninstall` is the explicit opt-out. A malformed
72
+ value in either key blocks every uninstall. The bridge now answers the
73
+ `pm:uninstall` cascade prompt "no": an uninstall that would also remove
74
+ dependents is refused and names them. The handler also checks
75
+ `allowDestructive` itself. `drupal_security_info` shows the effective list.
76
+ One stub under `.agents/commands/` was regenerated.
77
+ - **`core.extension` can no longer be changed through config tools by default (#349).**
78
+ The protected-module list covered `drupal_drush_module_disable` only. Two
79
+ other tools could still uninstall a module. `drupal_config_set` now refuses a
80
+ write to `core.extension` and names `drupal_drush_module_enable` and
81
+ `drupal_drush_module_disable`; the name check ignores surrounding space and
82
+ case, and covers the binding path and the unbound path.
83
+ `drupal_drush_config_import` now runs `drush config:status` first and imports
84
+ nothing when `core.extension` differs, or when the status cannot be read or
85
+ understood. The connector cannot read the sync directory, so it refuses on
86
+ "core.extension differs" and does not name the modules. **Operators:** if
87
+ `drushSsh.allowedCommands` is set and lists `config:import`, add
88
+ `config:status`, or every import is refused. An import that leaves
89
+ `core.extension` alone, and `drupal_config_set` on any other object, work as
90
+ before. New per-site key `security.allowCoreExtensionChange` (`true` or
91
+ `false`, default `false` on every preset) opens both; any other value keeps
92
+ both refused. With it set, `drupal_config_set` still reads the current module
93
+ list and refuses a value that removes or alters an installed protected module,
94
+ and `drupal_drush_config_import` runs unchecked. `drupal_security_info` shows
95
+ the key. `docs/security.md` lists what stays out of the connector's reach (an
96
+ import run on the server, another client, the window between the status read
97
+ and the import) and points to MCP Sentinel's `denied_config_types` as the
98
+ source-side control. Two stubs under `.agents/commands/` were regenerated.
99
+
100
+ ### Fixed
101
+ - **Server-tool bridge errors no longer relay the response body or raw tool
102
+ text (#362).** `src/lib/server-tools.js` put four untrusted strings into its
103
+ errors with no cleaning and no bound: the body of a non-2xx `tools/call` or
104
+ `tools/list`, the body of a failed session handshake, a JSON-RPC
105
+ `error.message`, and the text of a tool result with `isError`. An HTML error
106
+ page, a server path or a backtrace reached the MCP client through the config
107
+ tools, the Drush-bound tools and the verifier's evidence. They now go through
108
+ `describeErrorBody()` and `cleanErrorText()`, as the JSON:API, GraphQL and
109
+ upload paths do. The prefix, the HTTP status and an integer JSON-RPC code are
110
+ unchanged (`Server-tool call <tool> failed <status>: …`,
111
+ `Server-tool <tool> error (<code>): …`,
112
+ `Server-tool <tool> reported an error: …`), so the verifier still classifies
113
+ them. A JSON-RPC error sent with a 4xx or 5xx reads
114
+ `failed <status>: JSON-RPC error <code>: <message>`. An empty body reads
115
+ `the server returned an empty body`. A JSON-RPC code that is not an integer
116
+ is no longer printed, and `error.data` is never read. A governed refusal's
117
+ text stays readable. Source budget codes are still looked for in the whole
118
+ text before it is cut.
119
+ - **A module tool's failure is cleaned before it is relayed (#362).** The
120
+ module registry relays a failed result (`isError`, or `success: false`)
121
+ because the module's message is what the caller needs. It now goes through
122
+ the new `cleanErrorData()`: the shape, keys, codes, numbers and booleans
123
+ stay, every string is cleaned like any other error detail, and the payload
124
+ is bounded (400 characters a string, 4,000 in total, 50 entries a level, 6
125
+ levels). A successful result is not changed. A transport failure behind the
126
+ registry was already replaced by a fixed message and still is.
127
+ - **The verifier reads the HTTP status from the response, not from its body
128
+ (#361).** `classifyBridgeError()` found the status of a failed bridge call
129
+ with a pattern that also matched the response body, so a 500 whose body held
130
+ "failed 403:" scored as a refusal and the config probe passed although the
131
+ tool never ran. The words "reported an error" and a quoted JSON-RPC code in a
132
+ body had the same effect. The bridge client now sets `status` on the error
133
+ for a non-2xx response, as `drupalFetch` does, and marks what failed
134
+ (`bridgeFailure`: `session`, `http`, `rpc` or `tool`, plus `rpcCode`). The
135
+ verifier reads those properties through `httpStatusOf()`. An error with no
136
+ marker is read only from the start of a documented message. A 401 or 403
137
+ from the token endpoint or the session handshake is no longer a refusal: no
138
+ tool was reached. A JSON-RPC error with no integer code is `skipped`.
139
+ - **A server failure no longer passes `probe_mass_read` or `principal_auth`
140
+ (#361).** Any failed mass read passed, including a 5xx, a 404 and a request
141
+ that never answered. The probe now passes on 401, 403 or 429, or on another
142
+ 4xx (not 404) that carries the source's refusal code; anything else is
143
+ `skipped`. `principal_auth` is `skipped` when the anonymous request fails
144
+ with a 5xx or no answer, instead of counting that as "anonymous access is
145
+ refused".
146
+ - **Error details keep the URL paths the caller supplied (#357).** The error
147
+ cleaning added for #343 and #345 replaced every slash-led path of two or more
148
+ segments with `[path]`, so "The alias /about/team is already in use" arrived
149
+ as "The alias [path] is already in use". A path is now redacted only when it
150
+ looks like a filesystem path: it starts with a filesystem root (`/var`,
151
+ `/tmp`, `/home`, …), has a segment that marks a code tree or a file directory
152
+ (`vendor`, `modules`, `core`, `files`, …), or has a server-side file extension
153
+ (`.php`, `.yml`, `.log`, …). `/about/team`, `/node/12/edit` and
154
+ `/jsonapi/node/article/<id>` are kept. Windows drive and UNC paths,
155
+ `file://` and `phar://` URIs, and a filesystem path straight after a colon
156
+ (`include_path=.:/usr/share/php`) were not redacted before and now are.
157
+ Stream-wrapper redaction is unchanged. `docs/security.md` lists the rules and
158
+ their limits.
159
+ - **`dryRun` says what it checked (#336).** A preview could return without a
160
+ refusal and the real write then failed with a field-access 403. The core PATCH
161
+ probe sends no fields, and core rejects its id before it checks field access
162
+ or validation. Drupal never evaluated an unmoderated update, a create or a
163
+ delete preview. Every `dryRun` result on `drupal_create_node`, `drupal_update_node`,
164
+ `drupal_delete_node`, `drupal_entity_create`, `drupal_entity_update`,
165
+ `drupal_entity_delete` and `drupal_create_translation` now carries a `checks`
166
+ block (`serverPreflight`, `connectorPolicy`, `entityAccess`, `revisionGuard`,
167
+ `fieldAccess`, `entityValidation`, each `checked` or `not_checked`) and a
168
+ `caveat` when anything was not checked. `fieldAccess` and `entityValidation`
169
+ are `checked` only when Sentinel's non-saving draft endpoint evaluated the
170
+ real payload. No new probe was added, and existing preview fields are
171
+ unchanged. Tool descriptions, `docs/tools-reference.md` and the README state
172
+ the limit. Seven stubs under `.agents/commands/` were regenerated. The
173
+ internal `preflightPatchWritable` result drops `writable: true` for
174
+ `revisionGuardPassed` and `payloadEvaluated: false`.
175
+ - **Config tools call the wire name the source advertises (#335).** Without
176
+ `serverTools.bindings`, `drupal_config_get` / `_list` / `_set` and the config
177
+ reports called `tool_api.mcp_sentinel_config_*`. Current `mcp_server`
178
+ releases publish `tool_api__mcp_sentinel_config_*`. The connector now reads
179
+ the source's `tools/list` and uses the name it lists, accepting either form
180
+ and preferring the double underscore. If the source lists neither, the call
181
+ fails with "not advertised by the source" and nothing is sent. The
182
+ `SERVER_TOOLS` export in `src/lib/server-tools.js` is replaced by
183
+ `SERVER_TOOL_IDS` and `resolveServerToolName`. The bindings path is unchanged.
184
+ - **The verifier's config probe can no longer pass on a missing tool (#335).**
185
+ `probe_config_change` and `entitlement_filtering` read the source catalog
186
+ first and score a refusal only for a tool the catalog lists. A tool that is
187
+ not advertised, or a catalog that cannot be read, is `skipped` with the
188
+ reason, and the run is not ok. A served write still fails the probe whether
189
+ or not the tool was advertised. `verifyLive` takes a new `listTools`
190
+ dependency; without it the probe cannot pass. This applies to a configured
191
+ `configSet` binding too.
192
+ - **A field the account may not view is no longer reported as empty (#337).**
193
+ JSON:API leaves a view-denied field out of the resource and keeps the key of
194
+ an empty one. `drupal_report_missing_field` counted every entity as missing a
195
+ field it could not see. When the field is absent from every sampled entity
196
+ the report now returns `notVisible: true`, `totalMissing: null` and no
197
+ findings, and says the field may be denied, not on the bundle, or misspelled.
198
+ When only some entities omit it, each finding carries `reason` (`empty` or
199
+ `absent`) and the result adds `totalEmpty` and `totalAbsent`.
200
+ `drupal_describe_fields` reports `fieldDefinitions` (`available` or
201
+ `unavailable`) and, when `field_config` is readable, lists fields defined for
202
+ the bundle but absent from the sampled entity as `notVisible`. No request was
203
+ added per entity. Tool descriptions and `docs/tools-reference.md` state the
204
+ limit. Two stubs under `.agents/commands/` were regenerated.
205
+ - **Northbound Drupal HTTP timeouts.** JSON:API, GraphQL, and file-upload
206
+ `node-fetch` calls abort after 30s (`AbortSignal.timeout`), matching the
207
+ Drush SSH bound. A hung Drupal host no longer stalls the MCP process.
208
+ - **Upload failures no longer return the raw response body (#343).**
209
+ `drupal_upload_file` and `drupal_upload_file_and_create_media` threw
210
+ `File upload failed <status>: <body>`. The error now keeps the status and
211
+ surfaces Drupal's `errors[].detail` (or `title`, or a plain `message`), with
212
+ markup and control characters stripped, server paths and stream-wrapper URIs
213
+ redacted, and a 400-character bound. An HTML page or a JSON body with no
214
+ error detail is never shown. New helper: `src/lib/error-body.js`.
215
+ - **JSON:API and GraphQL failures no longer return the raw response body (#345).**
216
+ `drupalFetch()` fell back to the whole body when it was not a JSON:API error
217
+ document, and `drupalGraphqlFetch()` always sent the whole body, so an HTML
218
+ error page from Drupal, PHP or a proxy reached the MCP client. Both now use
219
+ `describeErrorBody()`, like uploads since #343. The messages keep their
220
+ shape, `Drupal <status> on <method> <path>: <detail>` and
221
+ `GraphQL request failed <status>: <detail>`, and keep `errors[].detail` and
222
+ GraphQL `errors[].message`. The detail has markup and control characters
223
+ stripped, server paths and stream-wrapper URIs redacted and any backtrace
224
+ removed. One detail is cut to 400 characters and a list of details to 1,200.
225
+ Each detail is cleaned on its own, so a backtrace in one error does not
226
+ remove the next. An OAuth error document surfaces `error` and
227
+ `error_description`, never `hint`. An empty or unreadable body reports the
228
+ status with `(empty response body)` or `(response body could not be read)`.
229
+ Path redaction was narrowed afterwards; see #357. Successful responses
230
+ are unchanged. Every matcher on these messages has a regression test in
231
+ `tests/lib/fetch-error-matchers.test.js`.
232
+ - **GraphQL errors on a 200 response are cleaned and bounded (#356).** GraphQL
233
+ reports a failed query as HTTP 200 with an `errors` array, so those errors
234
+ skipped the cleaning added for #345. `drupal_graphql` (the thrown message and
235
+ the `warnings` of a partial result), `drupal_graphql_introspect`, the GraphQL
236
+ backend, the backend probe and the SEO audit's metatag lookup joined
237
+ `errors[].message` as received, with no length limit. `drupalGraphqlFetch()`
238
+ now replaces the array with a cleaned form before any caller reads it:
239
+ `message`, `path`, `locations` and the machine values `extensions.code`,
240
+ `category` and `classification`. `extensions.trace`, `debugMessage`, `file`,
241
+ `line` and any stack are dropped. A message has markup and control characters
242
+ stripped, paths redacted, any backtrace removed, and is cut to 400 characters.
243
+ At most 50 errors and 4,000 characters of message are kept, and a last entry
244
+ says how many were left out. Thrown messages are cut to 1,200 characters.
245
+ `drupal_graphql_introspect` with a `typeName` now reports the server's errors
246
+ for a failed lookup instead of "Type not found in schema", and the GraphQL
247
+ backend's schema load reports them instead of a `TypeError`. `data` is
248
+ unchanged.
249
+ - **Field reports score reference fields and name fields they cannot see (#341).**
250
+ `drupal_report_field_completeness` skipped entity-reference fields and dropped
251
+ a requested field that was absent from every sampled node. It now reads
252
+ references, counts a node that omits the key as `absent`, lists such a
253
+ requested field in `notVisible`, and sets `approximate` from `sampleSize`. A
254
+ link value counts as populated. `drupal_report_seo_meta_coverage` follows the
255
+ same rules: `coverage: null` for an absent field, and no node flagged when no
256
+ checked field is visible. Two stubs under `.agents/commands/` were regenerated.
257
+ - **A status number in an error's text is no longer read as the response status (#355).**
258
+ `drupal_report_orphaned_references` reported a reference as an orphan when the
259
+ lookup failed with a 500 whose detail mentioned "404". Four other matchers had
260
+ the same fault. A 404 on `/jsonapi/node/article/401` read as an authentication
261
+ failure and cleared the cached OAuth token. A failed PATCH probe whose detail
262
+ mentioned "Drupal 422" read as a passed working-copy check. A 403 or 500 that
263
+ mentioned "422" and "inaccessible" triggered the menu-link retry. A 500 on a
264
+ Sentinel draft or translation endpoint whose detail mentioned "Drupal 404" was
265
+ reported as a missing endpoint. Errors thrown by `drupalFetch()`,
266
+ `drupalGraphqlFetch()` and the upload helper now carry the HTTP status on a
267
+ `status` property, and all five matchers read it through `httpStatusOf()`
268
+ (`src/lib/error-status.js`). An error with no `status` is read only from the
269
+ status token at the start of a documented message (`Drupal <status> …`,
270
+ `GraphQL request failed <status>`, `File upload failed <status>`). The orphan
271
+ report no longer treats a bare "404" elsewhere in a message as a missing
272
+ target; such a failure counts as unverifiable.
273
+
10
274
  ## [2.19.1] - 2026-09-17
11
275
 
12
276
  ### Changed
package/README.md CHANGED
@@ -100,7 +100,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
100
100
  | **Audit (composite)** | `drupal_audit_site_health` — scored content/links/config dashboard with a roll-up grade |
101
101
  | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
102
102
 
103
- **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that validates the request and returns a preview of exactly what would be written — without committing anything to Drupal.
103
+ **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that runs the connector's checks and returns a preview of the payload, without committing anything to Drupal. The result's `checks` block says what was checked. Field access and entity validation are checked only when Sentinel's non-saving draft endpoint ran; see [docs/tools-reference.md](docs/tools-reference.md#preview-writes-with-dryrun).
104
104
 
105
105
  ### MCP Resources
106
106
  Browsable, always-fresh context the client can read without calling a tool:
@@ -118,6 +118,8 @@ and other MCP-aware agents). Two kinds ship with the connector:
118
118
  - `drupal-create-article` — guided article creation with all fields
119
119
  - `drupal-seo-fix` — find and fix SEO gaps
120
120
  - `drupal-user-cleanup` — identify and handle inactive accounts
121
+ - Module-owned workflows (`drupal-<namespace>-<workflow>`) when
122
+ `serverTools.modules.workflows` is enabled — see `docs/module-workflows.md`
121
123
 
122
124
  **One prompt per tool** — every `drupal_*` tool is also exposed as a
123
125
  `drupal-<tool>` prompt (e.g. `drupal-create-node`, `drupal-list-nodes`,
@@ -145,8 +147,14 @@ npm run install:commands # copies into ~/.claude/commands, ~/.grok/commands,
145
147
  # npm run install:commands -- --clients claude
146
148
  # npm run install:commands -- --clients codex
147
149
  # npm run install:commands -- --home /path/to/fake-home # tests / CI
150
+ # npm run install:commands -- --modules # also write stubs for module-owned tools
148
151
  ```
149
152
 
153
+ Module-owned tools ([docs/module-tools.md](docs/module-tools.md)) get a prompt
154
+ whenever they are visible to the caller. Their bare `/drupal-<namespace>-<alias>`
155
+ stubs are written only with `--modules`, which discovers them from the sources
156
+ in `config/config.json`.
157
+
150
158
  From an npm install, the same files ship at
151
159
  `node_modules/drupal-mcp-connector/.agents/commands/`. Do not copy them into an
152
160
  application repo's `.claude/` or `.grok/` directory.