Configuration & Environment
An MCP tool performs an irreversible action (DELETE FROM, DROP TABLE, fs.unlink, shutil.rmtree, subprocess, HTTP DELETE/PUT/PATCH) and the file emits no audit-event identifier anywhere. There is no forensic trail of who did what to which resource โ the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap, narrower than MCP-283.
An audit event is distinct from a generic invocation log: it records the security-relevant fields a forensic investigator needs (actor, action, target, outcome, request_id) in a tamper-evident sink that survives long enough to be subpoenaed. CWE-778 (Insufficient Logging) covers both โ but for destructive actions specifically, regulators (SOC2 CC7.2, GDPR Art. 30, HIPAA ยง164.312(b)) explicitly require an immutable record of every state change. A tool that deletes a record without an audit event is the difference between a defensible operations review and a compliance finding.
MCP tools are increasingly given destructive capabilities (database writes, file deletion, ticket closure, payment refunds) because that is where the agentic value lives. The same architecture means the destructive call sits behind layers of LLM reasoning โ when something goes wrong, the question is rarely "did the API work?" but "why did the model decide to call delete_record on this customer?" Without an audit event tying the call to a specific user_id, request_id, and tool argument set, post-incident review collapses into log archaeology. MCP-201 covers the pre-action confirmation gap; MCP-284 covers the post-action audit gap; both can co-fire on the same handler and both have distinct remediations.
from fastmcp import FastMCP |
from acme.db import cursor |
mcp = FastMCP("acme") |
@mcp.tool() |
def delete_user(user_id: str) -> dict: |
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) |
# No audit event โ the delete is silent. |
return {"deleted": user_id} |
from fastmcp import FastMCP |
from acme.db import cursor |
from acme.audit import audit_log |
mcp = FastMCP("acme") |
@mcp.tool() |
def delete_user(user_id: str, actor: str, request_id: str) -> dict: |
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) |
audit_log( |
actor=actor, |
action="delete_user", |
target=user_id, |
outcome="success", |
request_id=request_id, |
) |
return {"deleted": user_id} |
MCPSafe fires when the file registers an MCP tool, contains a destructive sink (the MCP-201 sink list โ `fs.unlink`/`shutil.rmtree`/`DROP TABLE`/`DELETE FROM`/`TRUNCATE TABLE`/`UPDATE ... SET`/HTTP `DELETE`|`PUT`|`PATCH`/`subprocess.run|Popen|call`/`child_process.exec|execSync|spawn|spawnSync`), and contains no audit-event identifier file-wide. Allow-list silences: `audit_log`, `audit.log|write|emit|record`, `emit_audit`, `audit_event`, `auditLog`, `record_audit`, `track_event` / `trackEvent`, `capture_event` / `captureEvent`, `structured_log`. The rule co-fires with MCP-201 (no confirmation) when both gaps are present โ different remediations.
See the full threat catalog for every documented detection.
MCPSafe runs this check โ and every other rule in the catalog โ on any MCP server you paste in.
Scan now