How We Built Runtime DLP for MCP Tool Calls
Inspecting every tool argument and result for secrets, PII, and prompt injection — at sub-millisecond latency. Architecture of the DLP pipeline, the pattern-matching engine, the redaction strategy, and the tradeoffs we made.
When an AI agent calls a tool through MCP, data flows in two directions. Arguments flow from the agent to the upstream server. Results flow back. Every byte of that data is a potential leak — an API key pasted into a prompt, a credit card number returned from a CRM query, a database connection string exposed in a tool result. Standard MCP gateways check which tools the agent can call. We wanted to check what data flows through them, in both directions, at every call. This is how we built the runtime DLP engine.
The problem: gateways gate tools, not data
Most MCP gateways work like an API gateway — they answer one question: "is this tool call allowed?" They check the tool name, the user identity, maybe a consent record, and return allow or deny. But that is access control, not data security. An allowed tool call can still exfiltrate secrets, leak PII, or carry a prompt injection payload. Allow/deny on the tool name is the wrong abstraction — the threat vector is the data itself.
Architecture: inline on the hot path
DLP has to run on every tool call — in both directions — which puts it on the critical path. Every millisecond of DLP processing adds directly to the agent's perceived latency. Our constraint was clear: the DLP scan must complete in under 2ms for typical payloads (under 10KB) and degrade gracefully for larger ones.
Agent → [args scan: secrets, PII, injection] → Upstream MCP Server
Agent ← [result scan: secrets, PII, injection] ← Upstream MCP ServerThe scan runs before the args reach the upstream server and after the results come back — but crucially, before either reaches the agent's context window. Once data enters the agent's context, it has been "seen" — any subsequent redaction is cosmetic, not security.
Scanner design: a pipeline of pattern matchers
We built the DLP engine as a composable pipeline of pattern matchers. Each matcher implements a single detection strategy. Matchers are configurable per scope — a fintech tenant might enable credit card and SSN detection; a developer tools tenant might only need API key and token detection.
interface DlPMatcher {
name: string
scan(input: string): { matches: DlPMatch[]; redacted: string }
}
interface DlPMatch {
type: 'secret' | 'pii' | 'injection'
pattern: string
start: number
end: number
confidence: number
}The current pipeline includes:
- Secret scanner: regex-based detection for 150+ credential patterns — AWS keys (AKIA*), GitHub tokens (ghp_*, ghs_*), Stripe keys (sk_live_*), JWT tokens, private key headers
- PII scanner: credit card numbers (Luhn check), SSN patterns, email addresses in unexpected contexts, phone numbers
- Injection scanner: instruction-override patterns ("ignore previous instructions," "you are now," system prompt injection markers), hidden unicode (zero-width characters, homoglyphs)
- Custom patterns: per-tenant regex rules defined via the dashboard — company-specific identifiers, internal project codes, environment variable patterns
Redaction strategy: block vs redact vs warn
Not all detections should block the tool call. A false positive that blocks a legitimate call destroys trust in the agent. We implemented three response actions, configurable per matcher type:
- Block: reject the tool call entirely, return an error to the agent. Used for high-confidence injection detections and known secret patterns.
- Redact: replace the detected text with
[REDACTED: {type}]and pass the call through. Used for PII and medium-confidence secret patterns. - Warn: log the detection to the audit trail but allow the call. Used for low-confidence matches and custom patterns. The security team can review warnings and escalate patterns to block/redact.
Performance: sub-2ms, most of the time
Early prototypes of the DLP engine clocked in at 12-15ms for a typical 5KB payload — far too slow for the hot path. Three optimizations brought it under 2ms:
- Compiled regex caching: pattern matchers compile regex once at initialization. The compiled patterns are cached per scope configuration — no recompilation on every call.
- Early termination: if the pipeline is configured to block on first detection, we stop scanning after the first match. For most calls, only one matcher fires.
- Size-based fast path: payloads under 1KB skip the PII scanner entirely — the overhead of Luhn validation and pattern matching exceeds any realistic detection value for tiny inputs.
// Fast path: tiny payloads skip expensive scanners
function scan(input: string, config: DlpConfig): DlpResult {
if (input.length < 1024 && !config.alwaysFullScan) {
return scanFast(input, config) // secrets + injection only
}
return scanFull(input, config) // all matchers
}Tradeoffs we made
- Regex over ML: we chose regex-based pattern matching over ML classifiers for the hot path. ML models add latency and complexity that are hard to justify when regex catches 95% of security-relevant patterns. We may add an async ML classifier for post-hoc analysis, but not on the critical path.
- No structured data parsing: the DLP engine scans the raw JSON/string payload, not the parsed object. This misses structured attacks (a JSON field named "instruction" with an injection payload) but avoids the latency and complexity of schema-aware scanning. Structured scanning is on the roadmap.
- Configurable, not universal: every tenant configures their own DLP rules. We considered shipping a universal ruleset — "block secrets everywhere" — but false positives on legitimate data (e.g., a hex string that looks like an AWS key) would destroy trust. Defaults are conservative; tenants opt into stricter scanning.
What we learned
The hardest part was not the scanning — it was the false positive rate on secret detection. Hex strings, Base64-encoded data, and UUIDs all trigger secret pattern matches. We solved this by adding a confidence score to every match: exact API key patterns (known prefixes, checksum validation) get high confidence; generic "looks like a secret" patterns get low confidence. Low-confidence matches warn by default; tenants can escalate to block if their use case tolerates the noise.