drupal-mcp-connector 2.19.0 → 2.20.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 (70) 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 +254 -0
  20. package/README.md +10 -3
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  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/dispatch.js +6 -6
  32. package/src/lib/drupal-fetch.js +97 -26
  33. package/src/lib/dry-run-checks.js +78 -0
  34. package/src/lib/error-body.js +448 -0
  35. package/src/lib/error-status.js +38 -0
  36. package/src/lib/errors.js +0 -11
  37. package/src/lib/evidence.js +0 -6
  38. package/src/lib/governance.js +2 -8
  39. package/src/lib/link-checker.js +3 -3
  40. package/src/lib/mcp-server.js +7 -1
  41. package/src/lib/metatag-audit.js +2 -1
  42. package/src/lib/module-tools.js +23 -2
  43. package/src/lib/operations.js +2 -2
  44. package/src/lib/patch-preflight.js +23 -5
  45. package/src/lib/policy-enforcement.js +4 -4
  46. package/src/lib/principal.js +3 -3
  47. package/src/lib/relay/edge.js +2 -2
  48. package/src/lib/reports-support.js +75 -0
  49. package/src/lib/security.js +234 -18
  50. package/src/lib/sentinel-draft.js +3 -2
  51. package/src/lib/server-tools.js +188 -27
  52. package/src/lib/tool-prompts.js +139 -8
  53. package/src/lib/usage.js +0 -9
  54. package/src/lib/verify.js +164 -61
  55. package/src/tools/config.js +70 -5
  56. package/src/tools/drush.js +191 -17
  57. package/src/tools/entities.js +18 -6
  58. package/src/tools/fields.js +40 -4
  59. package/src/tools/graphql.js +10 -5
  60. package/src/tools/nodes.js +16 -6
  61. package/src/tools/paragraphs.js +1 -1
  62. package/src/tools/reports-config.js +3 -3
  63. package/src/tools/reports-content.js +34 -26
  64. package/src/tools/reports-extra.js +46 -38
  65. package/src/tools/reports.js +50 -25
  66. package/src/tools/scheduler.js +1 -1
  67. package/src/tools/structure.js +12 -2
  68. package/src/tools/translations.js +5 -1
  69. package/src/lib/draft-write.js +0 -19
  70. package/src/lib/node-draft-inventory.js +0 -5
@@ -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,257 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.20.0] - 2026-09-19
11
+
12
+ ### Added
13
+ - **Prompts for module-owned tools (#332).** Every module tool that discovery
14
+ returns for a request also gets a per-tool prompt
15
+ (`drupal-module-<operation>-<namespace>--<alias>`). Visibility follows the
16
+ tool: same caller scope, site grant and source checks, no cross-request
17
+ cache. The prompt lists the module's own parameters and explains the
18
+ `{ catalogRevision, arguments }` call shape without embedding a revision.
19
+ Write prompts say that module writes are not retried; delete prompts carry
20
+ the destructive warning.
21
+ - **Opt-in slash stubs for module-owned tools (#332).**
22
+ `npm run install:commands -- --modules` discovers the module tools your local
23
+ config approves and writes `drupal-<namespace>-<alias>.md` stubs next to the
24
+ built-in ones (and adds them to the Codex catalog). A name that matches a
25
+ built-in command, or that two module tools share, is refused. If a source
26
+ returns nothing the run fails and writes nothing; if it returns only some
27
+ configured tools, the missing ones are named and older stubs are kept. A
28
+ plain `install:commands` never writes module stubs and leaves installed ones
29
+ in place.
30
+ - **Example config documents module tools and bindings (#348).**
31
+ `config/config.example.json` now has a `_server_tools.example` block with
32
+ `serverTools.modules` (a namespace, two module-owned tools) and all seven
33
+ `serverTools.bindings`, plus notes on each key. The `drushSsh` SQL note names
34
+ the `sqlQuery` binding. A test runs the example through the module registry's
35
+ validation.
36
+
37
+ ### Changed
38
+ - Prompts and command stubs show a parameter's allowed values when its schema
39
+ has a short string `enum` (`one of: full, minimal`). Eight built-in stubs
40
+ under `.agents/commands/` were regenerated.
41
+ - **`drupal_drush_module_disable` refuses protected modules (#346).** The tool
42
+ no longer uninstalls governance, integrity, secrets, auth and API modules
43
+ (`mcp_sentinel`, `audit_chain`, `field_guard`, `file_gate`, `key`, `encrypt`,
44
+ `simple_oauth`, `consumers`, `jsonapi`, `serialization`, `mcp_server`,
45
+ `mcp_server_tool_bridge`, `tool`, `content_moderation`, `workflows`). The list
46
+ is on for every preset. `security.protectedModules` adds modules;
47
+ `security.allowProtectedModuleUninstall` is the explicit opt-out. A malformed
48
+ value in either key blocks every uninstall. The bridge now answers the
49
+ `pm:uninstall` cascade prompt "no": an uninstall that would also remove
50
+ dependents is refused and names them. The handler also checks
51
+ `allowDestructive` itself. `drupal_security_info` shows the effective list.
52
+ One stub under `.agents/commands/` was regenerated.
53
+ - **`core.extension` can no longer be changed through config tools by default (#349).**
54
+ The protected-module list covered `drupal_drush_module_disable` only. Two
55
+ other tools could still uninstall a module. `drupal_config_set` now refuses a
56
+ write to `core.extension` and names `drupal_drush_module_enable` and
57
+ `drupal_drush_module_disable`; the name check ignores surrounding space and
58
+ case, and covers the binding path and the unbound path.
59
+ `drupal_drush_config_import` now runs `drush config:status` first and imports
60
+ nothing when `core.extension` differs, or when the status cannot be read or
61
+ understood. The connector cannot read the sync directory, so it refuses on
62
+ "core.extension differs" and does not name the modules. **Operators:** if
63
+ `drushSsh.allowedCommands` is set and lists `config:import`, add
64
+ `config:status`, or every import is refused. An import that leaves
65
+ `core.extension` alone, and `drupal_config_set` on any other object, work as
66
+ before. New per-site key `security.allowCoreExtensionChange` (`true` or
67
+ `false`, default `false` on every preset) opens both; any other value keeps
68
+ both refused. With it set, `drupal_config_set` still reads the current module
69
+ list and refuses a value that removes or alters an installed protected module,
70
+ and `drupal_drush_config_import` runs unchecked. `drupal_security_info` shows
71
+ the key. `docs/security.md` lists what stays out of the connector's reach (an
72
+ import run on the server, another client, the window between the status read
73
+ and the import) and points to MCP Sentinel's `denied_config_types` as the
74
+ source-side control. Two stubs under `.agents/commands/` were regenerated.
75
+
76
+ ### Fixed
77
+ - **Server-tool bridge errors no longer relay the response body or raw tool
78
+ text (#362).** `src/lib/server-tools.js` put four untrusted strings into its
79
+ errors with no cleaning and no bound: the body of a non-2xx `tools/call` or
80
+ `tools/list`, the body of a failed session handshake, a JSON-RPC
81
+ `error.message`, and the text of a tool result with `isError`. An HTML error
82
+ page, a server path or a backtrace reached the MCP client through the config
83
+ tools, the Drush-bound tools and the verifier's evidence. They now go through
84
+ `describeErrorBody()` and `cleanErrorText()`, as the JSON:API, GraphQL and
85
+ upload paths do. The prefix, the HTTP status and an integer JSON-RPC code are
86
+ unchanged (`Server-tool call <tool> failed <status>: …`,
87
+ `Server-tool <tool> error (<code>): …`,
88
+ `Server-tool <tool> reported an error: …`), so the verifier still classifies
89
+ them. A JSON-RPC error sent with a 4xx or 5xx reads
90
+ `failed <status>: JSON-RPC error <code>: <message>`. An empty body reads
91
+ `the server returned an empty body`. A JSON-RPC code that is not an integer
92
+ is no longer printed, and `error.data` is never read. A governed refusal's
93
+ text stays readable. Source budget codes are still looked for in the whole
94
+ text before it is cut.
95
+ - **A module tool's failure is cleaned before it is relayed (#362).** The
96
+ module registry relays a failed result (`isError`, or `success: false`)
97
+ because the module's message is what the caller needs. It now goes through
98
+ the new `cleanErrorData()`: the shape, keys, codes, numbers and booleans
99
+ stay, every string is cleaned like any other error detail, and the payload
100
+ is bounded (400 characters a string, 4,000 in total, 50 entries a level, 6
101
+ levels). A successful result is not changed. A transport failure behind the
102
+ registry was already replaced by a fixed message and still is.
103
+ - **The verifier reads the HTTP status from the response, not from its body
104
+ (#361).** `classifyBridgeError()` found the status of a failed bridge call
105
+ with a pattern that also matched the response body, so a 500 whose body held
106
+ "failed 403:" scored as a refusal and the config probe passed although the
107
+ tool never ran. The words "reported an error" and a quoted JSON-RPC code in a
108
+ body had the same effect. The bridge client now sets `status` on the error
109
+ for a non-2xx response, as `drupalFetch` does, and marks what failed
110
+ (`bridgeFailure`: `session`, `http`, `rpc` or `tool`, plus `rpcCode`). The
111
+ verifier reads those properties through `httpStatusOf()`. An error with no
112
+ marker is read only from the start of a documented message. A 401 or 403
113
+ from the token endpoint or the session handshake is no longer a refusal: no
114
+ tool was reached. A JSON-RPC error with no integer code is `skipped`.
115
+ - **A server failure no longer passes `probe_mass_read` or `principal_auth`
116
+ (#361).** Any failed mass read passed, including a 5xx, a 404 and a request
117
+ that never answered. The probe now passes on 401, 403 or 429, or on another
118
+ 4xx (not 404) that carries the source's refusal code; anything else is
119
+ `skipped`. `principal_auth` is `skipped` when the anonymous request fails
120
+ with a 5xx or no answer, instead of counting that as "anonymous access is
121
+ refused".
122
+ - **Error details keep the URL paths the caller supplied (#357).** The error
123
+ cleaning added for #343 and #345 replaced every slash-led path of two or more
124
+ segments with `[path]`, so "The alias /about/team is already in use" arrived
125
+ as "The alias [path] is already in use". A path is now redacted only when it
126
+ looks like a filesystem path: it starts with a filesystem root (`/var`,
127
+ `/tmp`, `/home`, …), has a segment that marks a code tree or a file directory
128
+ (`vendor`, `modules`, `core`, `files`, …), or has a server-side file extension
129
+ (`.php`, `.yml`, `.log`, …). `/about/team`, `/node/12/edit` and
130
+ `/jsonapi/node/article/<id>` are kept. Windows drive and UNC paths,
131
+ `file://` and `phar://` URIs, and a filesystem path straight after a colon
132
+ (`include_path=.:/usr/share/php`) were not redacted before and now are.
133
+ Stream-wrapper redaction is unchanged. `docs/security.md` lists the rules and
134
+ their limits.
135
+ - **`dryRun` says what it checked (#336).** A preview could return without a
136
+ refusal and the real write then failed with a field-access 403. The core PATCH
137
+ probe sends no fields, and core rejects its id before it checks field access
138
+ or validation. Drupal never evaluated an unmoderated update, a create or a
139
+ delete preview. Every `dryRun` result on `drupal_create_node`, `drupal_update_node`,
140
+ `drupal_delete_node`, `drupal_entity_create`, `drupal_entity_update`,
141
+ `drupal_entity_delete` and `drupal_create_translation` now carries a `checks`
142
+ block (`serverPreflight`, `connectorPolicy`, `entityAccess`, `revisionGuard`,
143
+ `fieldAccess`, `entityValidation`, each `checked` or `not_checked`) and a
144
+ `caveat` when anything was not checked. `fieldAccess` and `entityValidation`
145
+ are `checked` only when Sentinel's non-saving draft endpoint evaluated the
146
+ real payload. No new probe was added, and existing preview fields are
147
+ unchanged. Tool descriptions, `docs/tools-reference.md` and the README state
148
+ the limit. Seven stubs under `.agents/commands/` were regenerated. The
149
+ internal `preflightPatchWritable` result drops `writable: true` for
150
+ `revisionGuardPassed` and `payloadEvaluated: false`.
151
+ - **Config tools call the wire name the source advertises (#335).** Without
152
+ `serverTools.bindings`, `drupal_config_get` / `_list` / `_set` and the config
153
+ reports called `tool_api.mcp_sentinel_config_*`. Current `mcp_server`
154
+ releases publish `tool_api__mcp_sentinel_config_*`. The connector now reads
155
+ the source's `tools/list` and uses the name it lists, accepting either form
156
+ and preferring the double underscore. If the source lists neither, the call
157
+ fails with "not advertised by the source" and nothing is sent. The
158
+ `SERVER_TOOLS` export in `src/lib/server-tools.js` is replaced by
159
+ `SERVER_TOOL_IDS` and `resolveServerToolName`. The bindings path is unchanged.
160
+ - **The verifier's config probe can no longer pass on a missing tool (#335).**
161
+ `probe_config_change` and `entitlement_filtering` read the source catalog
162
+ first and score a refusal only for a tool the catalog lists. A tool that is
163
+ not advertised, or a catalog that cannot be read, is `skipped` with the
164
+ reason, and the run is not ok. A served write still fails the probe whether
165
+ or not the tool was advertised. `verifyLive` takes a new `listTools`
166
+ dependency; without it the probe cannot pass. This applies to a configured
167
+ `configSet` binding too.
168
+ - **A field the account may not view is no longer reported as empty (#337).**
169
+ JSON:API leaves a view-denied field out of the resource and keeps the key of
170
+ an empty one. `drupal_report_missing_field` counted every entity as missing a
171
+ field it could not see. When the field is absent from every sampled entity
172
+ the report now returns `notVisible: true`, `totalMissing: null` and no
173
+ findings, and says the field may be denied, not on the bundle, or misspelled.
174
+ When only some entities omit it, each finding carries `reason` (`empty` or
175
+ `absent`) and the result adds `totalEmpty` and `totalAbsent`.
176
+ `drupal_describe_fields` reports `fieldDefinitions` (`available` or
177
+ `unavailable`) and, when `field_config` is readable, lists fields defined for
178
+ the bundle but absent from the sampled entity as `notVisible`. No request was
179
+ added per entity. Tool descriptions and `docs/tools-reference.md` state the
180
+ limit. Two stubs under `.agents/commands/` were regenerated.
181
+ - **Northbound Drupal HTTP timeouts.** JSON:API, GraphQL, and file-upload
182
+ `node-fetch` calls abort after 30s (`AbortSignal.timeout`), matching the
183
+ Drush SSH bound. A hung Drupal host no longer stalls the MCP process.
184
+ - **Upload failures no longer return the raw response body (#343).**
185
+ `drupal_upload_file` and `drupal_upload_file_and_create_media` threw
186
+ `File upload failed <status>: <body>`. The error now keeps the status and
187
+ surfaces Drupal's `errors[].detail` (or `title`, or a plain `message`), with
188
+ markup and control characters stripped, server paths and stream-wrapper URIs
189
+ redacted, and a 400-character bound. An HTML page or a JSON body with no
190
+ error detail is never shown. New helper: `src/lib/error-body.js`.
191
+ - **JSON:API and GraphQL failures no longer return the raw response body (#345).**
192
+ `drupalFetch()` fell back to the whole body when it was not a JSON:API error
193
+ document, and `drupalGraphqlFetch()` always sent the whole body, so an HTML
194
+ error page from Drupal, PHP or a proxy reached the MCP client. Both now use
195
+ `describeErrorBody()`, like uploads since #343. The messages keep their
196
+ shape, `Drupal <status> on <method> <path>: <detail>` and
197
+ `GraphQL request failed <status>: <detail>`, and keep `errors[].detail` and
198
+ GraphQL `errors[].message`. The detail has markup and control characters
199
+ stripped, server paths and stream-wrapper URIs redacted and any backtrace
200
+ removed. One detail is cut to 400 characters and a list of details to 1,200.
201
+ Each detail is cleaned on its own, so a backtrace in one error does not
202
+ remove the next. An OAuth error document surfaces `error` and
203
+ `error_description`, never `hint`. An empty or unreadable body reports the
204
+ status with `(empty response body)` or `(response body could not be read)`.
205
+ Path redaction was narrowed afterwards; see #357. Successful responses
206
+ are unchanged. Every matcher on these messages has a regression test in
207
+ `tests/lib/fetch-error-matchers.test.js`.
208
+ - **GraphQL errors on a 200 response are cleaned and bounded (#356).** GraphQL
209
+ reports a failed query as HTTP 200 with an `errors` array, so those errors
210
+ skipped the cleaning added for #345. `drupal_graphql` (the thrown message and
211
+ the `warnings` of a partial result), `drupal_graphql_introspect`, the GraphQL
212
+ backend, the backend probe and the SEO audit's metatag lookup joined
213
+ `errors[].message` as received, with no length limit. `drupalGraphqlFetch()`
214
+ now replaces the array with a cleaned form before any caller reads it:
215
+ `message`, `path`, `locations` and the machine values `extensions.code`,
216
+ `category` and `classification`. `extensions.trace`, `debugMessage`, `file`,
217
+ `line` and any stack are dropped. A message has markup and control characters
218
+ stripped, paths redacted, any backtrace removed, and is cut to 400 characters.
219
+ At most 50 errors and 4,000 characters of message are kept, and a last entry
220
+ says how many were left out. Thrown messages are cut to 1,200 characters.
221
+ `drupal_graphql_introspect` with a `typeName` now reports the server's errors
222
+ for a failed lookup instead of "Type not found in schema", and the GraphQL
223
+ backend's schema load reports them instead of a `TypeError`. `data` is
224
+ unchanged.
225
+ - **Field reports score reference fields and name fields they cannot see (#341).**
226
+ `drupal_report_field_completeness` skipped entity-reference fields and dropped
227
+ a requested field that was absent from every sampled node. It now reads
228
+ references, counts a node that omits the key as `absent`, lists such a
229
+ requested field in `notVisible`, and sets `approximate` from `sampleSize`. A
230
+ link value counts as populated. `drupal_report_seo_meta_coverage` follows the
231
+ same rules: `coverage: null` for an absent field, and no node flagged when no
232
+ checked field is visible. Two stubs under `.agents/commands/` were regenerated.
233
+ - **A status number in an error's text is no longer read as the response status (#355).**
234
+ `drupal_report_orphaned_references` reported a reference as an orphan when the
235
+ lookup failed with a 500 whose detail mentioned "404". Four other matchers had
236
+ the same fault. A 404 on `/jsonapi/node/article/401` read as an authentication
237
+ failure and cleared the cached OAuth token. A failed PATCH probe whose detail
238
+ mentioned "Drupal 422" read as a passed working-copy check. A 403 or 500 that
239
+ mentioned "422" and "inaccessible" triggered the menu-link retry. A 500 on a
240
+ Sentinel draft or translation endpoint whose detail mentioned "Drupal 404" was
241
+ reported as a missing endpoint. Errors thrown by `drupalFetch()`,
242
+ `drupalGraphqlFetch()` and the upload helper now carry the HTTP status on a
243
+ `status` property, and all five matchers read it through `httpStatusOf()`
244
+ (`src/lib/error-status.js`). An error with no `status` is read only from the
245
+ status token at the start of a documented message (`Drupal <status> …`,
246
+ `GraphQL request failed <status>`, `File upload failed <status>`). The orphan
247
+ report no longer treats a bare "404" elsewhere in a message as a missing
248
+ target; such a failure counts as unverifiable.
249
+
250
+ ## [2.19.1] - 2026-09-17
251
+
252
+ ### Changed
253
+ - **Remove leftover Sentinel facades (#328).** `draft-write.js` and
254
+ `node-draft-inventory.js` were re-exports of `sentinel-draft.js`. Tests
255
+ import the client directly. Unused exports (`toolMessage`, `redactResponse`)
256
+ are gone; `DIAGNOSTIC_TOOLS` and `POLICY_DIGEST` each live in one place.
257
+ - README and docs now report **123 tools across 27 modules** and **128 prompts**
258
+ (one per tool plus 5 workflow prompts). Codegen (`drupal_codegen_*`) shipped
259
+ in 2.16.0 but was missing from the public counts and the npm project page.
260
+
10
261
  ## [2.19.0] - 2026-09-16
11
262
 
12
263
  ### Added
@@ -1679,6 +1930,9 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1679
1930
  - User tools gained explicit PII-access assertions.
1680
1931
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1681
1932
 
1933
+ [2.19.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.19.1
1934
+ [2.19.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.19.0
1935
+ [2.18.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.18.0
1682
1936
  [2.17.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.17.0
1683
1937
  [2.16.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.16.0
1684
1938
  [2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2