Configuration & Environment
An MCP server has authentication wired (JWT, Bearer, API key) but emits no invocation log anywhere in the tool file. Unauthorized actions cannot be detected, attributed, or reconstructed after the fact — a forensics dead-end that closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap.
Insufficient logging (CWE-778) is the gap between an MCP server that knows who is calling it and a server that can prove who called what, when. Authentication tells the runtime the caller is allowed; an invocation log tells the operator what the caller did. Without it, every authorized abuse — a curious employee browsing customer records, a compromised token exfiltrating data tool by tool — is invisible. The first time you find out is when an external regulator, customer, or auditor asks you a question you cannot answer.
MCP servers sit between an LLM agent and arbitrary backend systems — they are exactly the surface where investigators most need a per-tool-call ledger. Yet because invocation logging is not part of the MCP transport spec, every server author re-implements it (or doesn't). When the agent does something surprising, the question "which tool did the model call, with what args, on whose behalf?" should have a one-row-per-call answer — not a stack trace from the one path that happens to error, and not a CloudWatch dashboard that rolls up to the request. If your authenticated MCP server cannot answer that question for an arbitrary call from yesterday, it is non-compliant by SOC2 / ISO 27001 / GDPR audit-trail standards regardless of how clean the auth code is.
from fastmcp import FastMCP |
import jwt |
mcp = FastMCP("acme") |
SECRET = "k" |
@mcp.tool() |
def get_invoice(token: str, invoice_id: str) -> dict: |
# Auth is wired — but no log emitter anywhere in this file. |
claims = jwt.decode(token, SECRET, algorithms=["HS256"]) |
return {"id": invoice_id, "owner": claims["sub"]} |
import logging |
from fastmcp import FastMCP |
import jwt |
logger = logging.getLogger(__name__) |
mcp = FastMCP("acme") |
SECRET = "k" |
@mcp.tool() |
def get_invoice(token: str, invoice_id: str) -> dict: |
claims = jwt.decode(token, SECRET, algorithms=["HS256"]) |
logger.info( |
"tool.invoke", |
extra={ |
"tool": "get_invoice", |
"user_id": claims["sub"], |
"invoice_id": invoice_id, |
}, |
) |
return {"id": invoice_id, "owner": claims["sub"]} |
MCPSafe fires when the file registers an MCP tool, contains at least one auth marker (`jwt.decode|verify`, `verify_token`, `Depends(*auth*)`, `OAuth2*Bearer`, `Authorization`/`X-API-Key` header check), and emits no log identifier file-wide. Allow-list silences: `logger.info|debug|warn|error|exception`, `log.<level>`, `logging.getLogger`, `pino()`, `winston.`, `console.log|info|warn|error|debug`, `print()`, `audit_log` / `audit.log|write|emit|record`, `emit_audit`, `audit_event`, `auditLog`, `track_event`, `capture_event`, `structured_log`, `record_audit`. v1 limitation: scope is single-file; a server that authenticates in `auth.py` but logs from `tools.py` will fire on `auth.py`.
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