Enterprise governance for AI-agent access to Drupal — policy profiles, field redaction, DLP, tamper-evident audit logging, and HMAC webhooks over MCP / JSON:API / GraphQL.
MCP Sentinel
Introduction
Enterprise security presets, audit logging, content locks, and HMAC webhooks for MCP-connected Drupal sites.
MCP Sentinel is the governance control plane that sits in front of AI-agent access to Drupal over the Model Context Protocol (MCP), JSON:API, and GraphQL: it decides what an agent may touch, redacts what it must not see, records what it did, and protects content humans are editing. It is an ecosystem module for MCP Server and the Tool API.
Trust model
Governance triggers on the validated OAuth agent channel — the consumer and
token scopes on the request's access token, as resolved by Simple OAuth — never
on a request header. A request is "governed" when it arrives on a configured
agent OAuth client/scope, or (as a configurable local-dev fallback,
governed_role_fallback, default false) when its authenticated account holds
one of the configured governed roles (default: the mcp_api role created on
install). An admin's direct cookie-session Drupal UI is never governed. The
X-MCP-Client header is at most a log hint — an agent cannot bypass policy by
omitting it, and a non-agent user cannot be governed by adding it. The
anonymous and authenticated roles can never be governed.
Each governed agent is matched to an mcp_policy_profile (the highest-weight
enabled profile whose roles it holds, else the shipped default profile), which
defines its operation gates, entity allow/deny lists, and redacted fields. Manage
profiles at Configuration → Web services → MCP Sentinel → MCP policy profiles.
Requirements
- Drupal 10.3+ or 11
- Tool API (
drupal/tool) - Key (
drupal/key) — stores the webhook signing secret and (optionally) the audit encryption key outside exported configuration - Encrypt (
drupal/encrypt) — provides Encryption Profiles for optional at-rest encryption of audit metadata - Strongly recommended: MCP Server, Simple OAuth
Installation
composer require drupal/mcp_sentinel drupal/mcp_server drupal/simple_oauth
# Enable the base module plus the mcp_server integration submodule.
drush en mcp_sentinel mcp_sentinel_server mcp_server_tool_bridge -y
drush cr
# Register the MCP Sentinel Tool plugins with mcp_server.
drush mcp-sentinel:setup
mcp_server 2.x requires a patch to
mcp/sdk. It adds the runtime element handler interfaces (RuntimeToolHandlerInterfaceet al.) that the Tool bridge depends on; without it, tool discovery fails. The patch ships inside the mcp_server project atweb/modules/contrib/mcp_server/.gitlab-ci/patches/mcp-sdk-runtime-handlers.patch. Register it withcweagans/composer-patchesfor themcp/sdkpackage and reinstall/repatch (composer patches-relock && composer patches-repatchfor composer-patches v2).
Submodules
| Submodule | Purpose |
|-----------|---------|
| mcp_sentinel_server | Registers the Tool plugins with mcp_server (mcp_tool_config entities) and wires OAuth scopes. Provides drush mcp-sentinel:setup / :teardown. Depends on mcp_server_tool_bridge. |
| mcp_sentinel_graphql | Extends governance to the GraphQL endpoint: gates mutations/reads, redacts fields, and audits operations for governed agents. Depends on graphql_compose. |
| mcp_sentinel_approval | Optional human-approval gate: queues governed destructive operations (bulk delete) as approval requests instead of executing them, for an authorized human to approve or deny. Depends only on mcp_sentinel. |
Approval workflow (mcp_sentinel_approval)
When enabled, governed destructive operations (currently the bulk-delete
path) are not executed immediately. Instead the base bulk tool dispatches a
veto-capable McpDestructiveOpEvent; this submodule's subscriber records a
pending mcp_approval_request and vetoes execution, so the entity is left
intact and reported back to the agent as queued for approval.
An operator with the Approve MCP Sentinel operations permission reviews the
queue at /admin/reports/mcp-sentinel/approvals and approves or denies each
request. Approving replays the stored operation (re-checking the approver's own
delete access), marks the request approved, and writes an approval_decision
row to the audit log; denying records the denial and leaves the target intact.
Which operations are gated is configurable via the
mcp_sentinel_approval.settings:gated_operations key (default: [delete]).
The base module has no dependency on this submodule — with the submodule
absent, the event is never vetoed and destructive operations proceed unchanged.
GraphQL governance (mcp_sentinel_graphql)
For governed agents — requests whose authenticated account holds a governed role (see Trust model; never a request header):
- Mutations are blocked unless the agent's policy profile allows both write and GraphQL mutations; reads are blocked when the profile disallows read; all GraphQL access is blocked when MCP access is disabled. Non-governed traffic (the site's own GraphQL consumers) is untouched.
- Redacted fields are replaced with
[REDACTED]. The coreuser.rolescache context keeps agent and public responses cached separately, so redacted values never leak across the boundary. - Operations are audited to the MCP Sentinel audit log (queries honour the Log read operations setting; mutations are always logged). Gating and audit apply on cache hits too, so the response cache cannot be used to bypass policy.
- Entity allow/deny lists already apply to GraphQL reads through Drupal's entity access system (no extra configuration needed).
A mcp_sentinel_graphql_schema tool exposes the GraphQL SDL so agents can
discover available types, queries, and mutations.
What This Adds
| Feature | Without module | With module | |---------|:-:|:-:| | JSON:API access | ✅ | ✅ | | Security presets (read-only, auditor, etc.) | ❌ | ✅ | | Entity type allow/deny lists | ❌ | ✅ | | Field-level PII redaction | ❌ | ✅ | | DLP value-pattern redaction / masking | ❌ | ✅ | | Audit log | ❌ | ✅ | | Filterable audit UI + CSV/JSON export | ❌ | ✅ | | Redaction-aware change diffs | ❌ | ✅ | | Tamper-evident audit hash chain | ❌ | ✅ | | At-rest audit metadata encryption | ❌ | ✅ | | SIEM streaming | ❌ | ✅ | | Content locks | ❌ | ✅ | | HMAC webhooks | ❌ | ✅ | | Human approval workflow (submodule) | ❌ | ✅ | | Rich context endpoint | ❌ | ✅ | | mcp_api role | ❌ | ✅ |
Tamper-evident audit log
Every audit row stores a prev_hash and a row_hash (a hash of the prior row's
hash concatenated with a canonical JSON of this row's content). The hash is
HMAC-SHA256 when audit_hash_key is set to a Key entity ID (use a File or
Environment key provider so the secret never appears in exported config), and
plain SHA-256 as a zero-config fallback. The canonical also covers the forensic
columns entity_label, ip_address, and user_agent, so inserting, deleting,
or editing any historical row — including those columns — breaks the chain.
Verify the chain at any time:
drush mcp-sentinel:audit-verify
The command exits 0 if the chain is intact, non-zero (and prints the first
broken row id) if tampering is detected. Run update_10003 (via
drush updb) to add the prev_hash/row_hash columns to an existing install.
New rows written after the update are automatically chained; rows written before
the update have NULL hashes and are skipped by the verifier.
Audit metadata encryption at rest
MCP Sentinel can encrypt the metadata column of every audit row using
drupal/encrypt Encryption Profiles.
Setup
- Install and enable drupal/encrypt:
composer require drupal/encrypt && drush en encrypt -y. - Create a Key entity at Configuration → System → Keys (use a File or Environment key provider so the secret never appears in exported config).
- Create an Encryption Profile at Configuration → System → Encryption → Encryption Profiles, pointing it at the key you just created.
- In the MCP Sentinel settings form (Configuration → Web services → MCP Sentinel), open the Audit Logging fieldset and choose your Encryption Profile from the Audit metadata encryption profile select.
- Save the form. New audit rows will be encrypted; existing plaintext rows remain readable (decryption failure falls back to plain JSON decode, so no data migration is needed).
Hash chain and encryption
The tamper-evident hash chain hashes plaintext canonical content before
encryption occurs. This means drush mcp-sentinel:audit-verify continues to
work correctly regardless of key rotation or profile changes — only the stored
column is encrypted; the canonical used for hashing is always plaintext.
SIEM streaming
When the Enable SIEM streaming checkbox is checked in the Audit Logging
settings, every successful audit write emits an info-level record to the
dedicated mcp_sentinel_audit logger channel. The structured context array
contains: operation, uid, entity_type, bundle, entity_id,
timestamp, and row_hash (which ties the SIEM record back to the hash-chain
entry in the database).
To route this channel to a SIEM, enable syslog output via the core Syslog module (no additional composer packages required):
# Example: enable the Syslog module and configure the facility.
drush en syslog -y
With Syslog enabled, all Drupal log channels (including mcp_sentinel_audit)
are written to the system log; your log-shipping agent (Filebeat, Fluentd,
etc.) can then forward them to your SIEM.
For finer-grained control — e.g. writing only the audit channel to a dedicated
file or sending it to a remote aggregator — use
drupal/monolog. Define a handler
for the mcp_sentinel_audit channel in your monolog.services.yml and route
it to syslog, Logstash, or any other Monolog handler.
DLP value-pattern redaction (opt-in)
Beyond field-name redaction, MCP Sentinel can scan the values of governed field output for PII patterns and either fully redact or partially mask matches. DLP scanning is off by default and must be explicitly enabled.
Setup
- Go to Configuration → Web services → MCP Sentinel and open the Data Loss Prevention (DLP) fieldset.
- Check Enable DLP value-pattern scanning.
- Choose the Mask mode:
- Redact — replaces the full match with
[REDACTED]. - Partial — keeps the last 4 characters of the match and replaces the
rest with
*(e.g.************4567for a 16-digit credit-card number).
- Redact — replaces the full match with
- Save. DLP takes effect immediately for new governed requests.
Built-in patterns
Four patterns are pre-configured (all disabled by default via dlp_enabled: false):
| Label | Matches |
|-------|---------|
| email | RFC-5321 email addresses |
| us_phone | US phone numbers (dashes, dots, spaces, parentheses) |
| ssn | US Social Security Numbers (NNN-NN-NNNN) |
| credit_card | 16-digit card numbers in 4-group format (dashes or spaces) |
Adding custom patterns
Operators can configure custom patterns directly from the settings form:
-
Go to Configuration → Web services → MCP Sentinel.
-
Enable DLP and open the Custom DLP patterns textarea.
-
Enter one pattern per line in the format
label|regex|mask(maskis optional and defaults to*). Example:employee_id|EMP-\d{6}|* internal_ref|CUST-\d{8} -
Save. Invalid regex lines are rejected with a validation error before saving.
Leaving the textarea empty clears any custom patterns and falls back to the four built-in defaults (email, US phone, SSN, credit card) at runtime.
Custom patterns can also be managed directly in mcp_sentinel.settings.yml:
dlp_patterns:
- label: my_pattern
regex: 'CUST-\d{8}'
mask: '*'
Regex convention: store the PCRE pattern body without delimiters. The
service wraps each pattern in #...#i at runtime (case-insensitive, #
delimiter avoids escaping / in URLs). Do not include leading or trailing
/ or # characters in the regex value. Invalid patterns are silently
skipped with a warning logged to the mcp_sentinel logger channel so a
badly-formed custom regex cannot cause a fatal error.
V1 scope
DLP scanning is wired into two output paths:
- GraphQL Compose field output (
mcp_sentinel_graphqlsubmodule): string field values returned byhook_graphql_compose_field_results_alterare scanned before delivery to the agent. - Audit change-diff capture (
McpAuditLogger::computeChangeDiff): field values in thechangesdiff stored in audit log metadata are masked before storage, so PII never appears in the audit trail in plaintext.
JSON:API and REST per-field value scanning is deferred to a future release. Drupal core's normalizer stack has no stable per-value alter hook, so a clean wiring point does not yet exist.
Rate limiting & quotas
MCP Sentinel can throttle governed agent traffic on a per-profile basis using Drupal's core flood service. Limits apply per authenticated user account within each policy profile window, so a single compromised token cannot saturate the server.
Setup
- Go to Configuration → Web services → MCP Sentinel → MCP policy profiles and edit the target profile.
- In the Rate limits fieldset:
- Max requests per window — maximum governed tool calls allowed in the
window.
0means unlimited (the default on all shipped profiles). - Window (seconds) — the rolling window duration. Default is
60.
- Max requests per window — maximum governed tool calls allowed in the
window.
- Save. The limit takes effect immediately for new requests.
Recommended starting point for production
300 requests per 60 second window is a reasonable baseline for most sites.
Adjust based on observed agent traffic patterns.
How it works
- The flood key is
mcp_sentinel.profile.{profile_id}.{uid}where{uid}is the server-resolved authenticated user ID — never an agent-supplied value. This prevents key-cycling bypass attacks. - A limit of
0short-circuits before touching the flood service, so an unconfigured profile never incurs unnecessary flood writes. - When the limit is exceeded, governed tool calls return a "rate limit exceeded"
failure and an audit row is written with operation
rate_limit_exceeded. Enforcement is applied at the top of each governed tool's execution, before any business logic.
Exfiltration guards / quotas
MCP Sentinel caps the volume of data a governed agent can retrieve in a single call, preventing mass-read attacks and accidental data exfiltration.
Setup
- Go to Configuration → Web services → MCP Sentinel → MCP policy profiles and edit the target profile.
- In the Exfiltration guards fieldset:
- Max result items — maximum items returned per Tool call, JSON:API page
request, or GraphQL multi-value field result list.
0means unlimited (the default on all shipped profiles). Recommended:500. - Max response size in bytes — maximum serialized response size for
governed Tool calls. Responses exceeding this limit are denied.
0means unlimited. Recommended:2097152(2 MB).
- Max result items — maximum items returned per Tool call, JSON:API page
request, or GraphQL multi-value field result list.
- Save. Limits take effect immediately.
Enforcement seams
| Seam | How caps are applied |
|------|----------------------|
| Tool output | McpBulkOperationsTool truncates succeeded list to result_count_cap; adds _result_truncated: true + _result_cap to the result data. Response-size cap is measured on the serialized payload. |
| JSON:API | A KernelEvents::REQUEST subscriber blocks page[limit] values above result_count_cap for governed requests with HTTP 400 before the DB query runs. (hook_jsonapi_resource_params_alter does not exist in Drupal 11.3; the subscriber is the correct implementation.) |
| GraphQL | hook_graphql_compose_field_results_alter in mcp_sentinel_graphql truncates multi-value field result lists to result_count_cap as a third pass after redaction and DLP masking. |
Ungoverned requests and profiles with result_count_cap = 0 are never capped.
The response-size cap applies to Tool output only (JSON:API and GraphQL
response-size enforcement is deferred to a future pass).
Anomaly detection & alerting
MCP Sentinel can evaluate threshold rules over the audit log on each cron run and fire alerts when a rule trips. This lets you detect bulk-delete spikes, access-denial storms, and other unusual agent behaviour automatically.
How rules work
Each rule specifies:
| Field | Description |
|---|---|
| id | Unique machine name (lowercase letters, numbers, underscores). |
| label | Human-readable name shown in alerts. |
| operation_pattern | Match against the audit log operation column. Exact match by default — entity_delete matches only entity_delete. Append * for prefix matching — entity* matches entity_save, entity_delete, etc. The denied_access operation is written by governed Tool plugins when an agent is denied by policy or core access. |
| window_seconds | Look-back window. Only rows newer than now - window_seconds are counted. |
| threshold | Minimum row count to trigger the rule. |
| debounce_seconds | Minimum seconds between alerts for this rule (default 3600). Prevents alert storms. |
| enabled | 1 to enable; 0 to disable. |
Rules are evaluated on cron. All rules ship disabled by default (D4.7) to avoid false positives during content imports. Enable and tune rules per-site.
Alert channels
Three channels are available — mix and match:
| Channel | Setting | Behaviour |
|---|---|---|
| Log | anomaly_alert_log (default true) | Writes a warning to the mcp_sentinel logger channel. Route this channel to syslog/SIEM for monitoring. |
| Email | anomaly_alert_email | Sends an email to the configured address when non-empty; empty = disabled. Requires a working mail setup. |
| Webhook | anomaly_alert_webhook | Enqueues an mcp.anomaly.alert event through the F9 webhook queue manager. Endpoints whose event filter includes mcp.anomaly.alert (or an empty filter) receive it, with retry/backoff/HMAC inherited. |
Enabling anomaly detection
- Go to Configuration → Web services → MCP Sentinel.
- In the Anomaly detection fieldset, check Enable anomaly detection.
- Configure alert channels (log is on by default; add an email and/or enable webhook delivery as needed).
- Add rules in the textarea (one per line):
The first rule uses an exact pattern (denied_access_storm|Denied access storm|denied_access|300|20|3600|1 bulk_delete|Bulk delete spike|entity_delete|300|20|3600|1 entity_activity|Entity write spike|entity*|300|100|3600|0denied_access) and fires when a governed agent is denied 20+ times in 5 minutes. The third rule uses the*prefix to match allentity_*operations. - Save. Alerts will fire on the next cron run where a rule is tripped.
Debounce (alert-storm suppression)
The debounce_seconds field prevents a misconfigured rule from alerting on
every cron run. After a rule fires, it is suppressed for debounce_seconds
using @state (key mcp_sentinel.anomaly_last_alert.{rule_id}). Set
debounce_seconds to 0 only for rules where you need unrestricted frequency.
Performance notes
Queries hit only the indexed operation and timestamp columns of
mcp_sentinel_audit_log — no full-table scans. Each rule is one
lightweight COUNT query. A bad rule (zero threshold, empty pattern) is
skipped with a log warning rather than fataling cron.
Reliable webhooks
MCP change events (mcp.entity.presave, mcp.entity.delete) are delivered to
external HTTPS endpoints through the Drupal queue system — not fire-and-forget —
so a notification is never lost if the request ends before the HTTP call
settles. Each delivery is signed, retried with backoff, recorded in a delivery
log, and replayable.
Endpoints
Configure one or more endpoints under the Reliable webhooks section of the settings form. Each endpoint has:
| Field | Purpose | |-------|---------| | Machine ID | Stable identifier used in the delivery log. | | Label | Human-readable name. | | URL | HTTPS endpoint (plain HTTP is rejected). | | Signing secret | A Key entity holding the HMAC secret. Use a File or Environment key provider so the value never lands in exported config. | | Event filter | One event name per line; leave empty to receive all events. | | Enabled | Toggles delivery without deleting the endpoint. |
The request body is signed with HMAC-SHA256 and sent in the
X-MCP-Signature: sha256=… header. Verify it with:
hash_equals('sha256=' . hash_hmac('sha256', $body, $secret), $header)
Retry behavior
Delivery runs in the mcp_sentinel_webhook_delivery QueueWorker (processed on
cron). A non-2xx response or network error schedules a retry: 5 attempts
with backoff intervals of 30 s, 5 min, 30 min, 2 h, 8 h. After the final
attempt the delivery is marked failed. A row already sent is never
re-delivered, so duplicate queue items or concurrent workers cannot double-send.
Delivery log + replay
/admin/reports/mcp-sentinel/webhooks (permission Administer MCP Sentinel)
lists recent deliveries with status, attempts, last response code, and next
attempt time. Use the CSRF-protected Replay action — or
drush mcp-sentinel:webhook-replay <delivery-id> — to reset a failed/sent
row to pending and re-queue it.
SSRF protection (HTTPS required)
All endpoints must use https://. A two-layer SSRF guard runs at enqueue time
and again at send time (DNS can rebind in between): the worker resolves the
host and blocks any address in a private, loopback, link-local, or reserved
range (RFC1918 10/8, 172.16/12, 192.168/16, 169.254/16, 127/8, ::1,
fc00::/7, …); such deliveries are marked failed_ssrf. For legitimate
internal-network targets, set allow_internal_webhook_urls to TRUE — this
disables the resolved-IP check only; HTTPS is still enforced.
Retention / prune
The delivery log is bounded by webhook_delivery_retention_days (default 30,
0 = forever). Rows past the window are deleted on cron and by
drush mcp-sentinel:webhook-prune.
Migrating from the legacy single webhook
Sites that used the old single webhook_url/webhook_secret_key settings are
migrated automatically (update_10008) into one webhook_endpoints entry. The
legacy fields remain visible in the form with a deprecation notice for review;
clear them once the migrated endpoint is verified.
Configuration
Configuration → Web services → MCP Sentinel (/admin/config/services/mcp-sentinel)
Companion Node.js Connector
drupal-mcp-server — external MCP connector with 66 tools, multi-site, GraphQL, and Drush bridge.
Maintainers
- Wilkes & Liberty — drupal.org/u/wilkes-liberty
See MAINTAINERS.txt. Report issues and feature requests in the
project issue queue; report
sensitive security issues to the Drupal security team at security@drupal.org.