MCP Policy Enforcement
AxonFlow enforces policies at multiple levels during MCP connector operations:
- REQUEST Phase: Evaluates incoming queries before they reach the connector
- RESPONSE Phase: Scans connector responses before returning to the client
- Exfiltration Detection: Prevents large-scale data extraction via row/volume limits
- Tenant Policies: Orchestrator-evaluated policies for rate limiting, budgets, and access control
This provides comprehensive security for both data input and output.
For many teams, this page is part of a broader regulated evaluation path rather than a standalone connector feature. If you are assessing whether AxonFlow is safe to trial in a healthcare, banking, or government environment, pair this with Evaluating AxonFlow in Regulated Environments.
Why Response-Side Evaluation Exists
Response-side policy evaluation is not redundant with request-side checks. Three threat categories require evaluating responses separately:
1. LLM Outputs Are Untrusted
An LLM can generate PII (SSNs, credit card numbers, personal addresses) in its output even when the input was clean. A user might ask "generate a sample customer record" and the LLM could produce realistic PII. Output-phase redaction is the last line of defense against data exposure in LLM responses.
2. Connector Data Can Contain Unexpected Content
A database query could return rows containing SQL injection payloads, PII that the user didn't request, or sensitive data from adjacent columns. Output-phase scanning catches exfiltration and injection patterns in returned data before it reaches the client.
3. Asymmetric Enforcement Is a Real Requirement
Legitimate workflows require allowing certain content in queries while blocking the same content in responses. This is not a theoretical edge case — it's the standard pattern for support, fraud investigation, and compliance teams.
Example — Support agent workflow:
Input: "Find customer with SSN 123-45-6789"
→ PII detected (sys_pii_ssn), action_request = warn → ALLOWED (audit-logged)
Connector returns: { "name": "John Doe", "ssn": "123-45-6789", "email": "[email protected]" }
Output: → PII detected (sys_pii_ssn), action_response = redact
→ Response: { "name": "John Doe", "ssn": "[REDACTED:ssn]", "email": "[REDACTED:email]" }
The agent found the customer. The SSN was never exposed in the response. The query was logged for compliance audit.
This phase-aware enforcement is implemented via the action_request and action_response columns in the static_policies table, allowing the same policy pattern to have different actions depending on evaluation phase.
For more context on why this governance layer exists alongside OAuth authentication, see OAuth vs Governance Policies.
Enterprise MCP Authentication
In enterprise deployments, MCP handler requests must include client_id and user_token fields in the request body. These are used for tenant resolution and per-user audit attribution. The permission model uses the format mcp:*:* for full MCP access or mcp:<connector>:<operation> for scoped access (e.g., mcp:postgres:query).
Community deployments do not require these fields, though including them enables richer audit logging.
Phase-Aware Evaluation
Request Phase
The request phase evaluates queries before they are sent to the connector:
- SQL Injection Detection: Blocks malicious SQL patterns (DROP, DELETE without WHERE, UNION injection)
- Dangerous Operation Blocking: Prevents schema modifications, privilege escalation
- Access Control: Enforces tenant-specific permissions
If a policy blocks the request, the connector is never called and a 403 response is returned.
Response Phase
The response phase evaluates data after it's returned from the connector:
- PII Detection: Identifies sensitive data patterns (SSN, credit cards, national IDs)
- Data Redaction: Replaces sensitive values with redacted placeholders
- Audit Logging: Records what data was accessed and redacted
Response Schema
All MCP responses now include policy enforcement metadata:
{
"success": true,
"connector": "postgres-demo",
"data": [
{
"name": "John Doe",
"ssn": "[REDACTED:ssn]",
"email": "[email protected]"
}
],
"row_count": 1,
"duration_ms": 45,
"redacted": true,
"redacted_fields": ["user.ssn"],
"policy_info": {
"policies_evaluated": 15,
"blocked": false,
"redactions_applied": 1,
"processing_time_ms": 3,
"matched_policies": [
{
"policy_id": "pii-us-ssn",
"policy_name": "US Social Security Number",
"category": "pii-us",
"severity": "critical",
"action": "redact"
}
],
"exfiltration_check": {
"rows_returned": 1,
"row_limit": 10000,
"bytes_returned": 256,
"byte_limit": 10485760,
"within_limits": true
},
"dynamic_policy_info": {
"policies_evaluated": 2,
"matched_policies": [
{
"policy_id": "rate-limit-1",
"policy_name": "API Rate Limit",
"policy_type": "rate-limit",
"action": "allow"
}
],
"orchestrator_reachable": true,
"processing_time_ms": 3
}
}
}
New Response Fields
| Field | Type | Description |
|---|---|---|
redacted | boolean | Whether any fields were redacted |
redacted_fields | string[] | Field paths of redacted fields (e.g., user.ssn, user.email) |
policy_info | object | Policy evaluation details |
PolicyInfo Object
| Field | Type | Description |
|---|---|---|
policies_evaluated | integer | Number of policies checked |
blocked | boolean | Whether request was blocked |
block_reason | string | Reason if blocked |
redactions_applied | integer | Number of fields redacted |
processing_time_ms | integer | Policy evaluation time |
matched_policies | array | Policies that matched |
PolicyMatchInfo Object
Each entry in matched_policies contains:
| Field | Type | Description |
|---|---|---|
policy_id | string | Unique policy identifier |
policy_name | string | Human-readable policy name |
category | string | Policy category (e.g., pii-us, security-sqli) |
severity | string | Match severity (critical, high, medium, low) |
action | string | Action taken (block, redact, warn, log) |
ExfiltrationCheckInfo Object
Tracks row and volume limits to prevent data exfiltration:
| Field | Type | Description |
|---|---|---|
rows_returned | integer | Number of rows in the response |
row_limit | integer | Configured maximum rows per query |
bytes_returned | integer | Response data size in bytes |
byte_limit | integer | Configured maximum bytes per response |
within_limits | boolean | Whether response is within all limits |
DynamicPolicyInfo Object
Contains results of Orchestrator-evaluated tenant policies:
| Field | Type | Description |
|---|---|---|
policies_evaluated | integer | Number of tenant policies checked |
matched_policies | array | Tenant policies that matched |
orchestrator_reachable | boolean | Whether Orchestrator was reachable |
processing_time_ms | integer | Tenant policy evaluation time |
DynamicPolicyMatch Object
Each entry in dynamic matched_policies contains:
| Field | Type | Description |
|---|---|---|
policy_id | string | Unique policy identifier |
policy_name | string | Human-readable policy name |
policy_type | string | Policy type (rate-limit, budget, time-access, role-access) |
action | string | Action taken (allow, block, warn) |
reason | string | Context for the policy match |
Policy Categories
Security Policies (Request Phase)
| Category | Description | Action |
|---|---|---|
security-sqli | SQL injection patterns | Block |
security-dangerous | DDL, privilege escalation | Block |
PII Policies (Response Phase)
| Category | Description | Action |
|---|---|---|
pii-us | US SSN, Driver's License | Redact |
pii-global | Credit cards, email, phone | Redact |
pii-india | Aadhaar, PAN | Redact |
pii-singapore | NRIC, FIN, UEN, phone, postal | Redact |
pii-eu | National IDs, IBAN | Redact |
SDK Integration
Go SDK
resp, err := client.MCPQuery(ctx, axonflow.MCPQueryRequest{
Connector: "postgres-demo",
Statement: "SELECT name, ssn FROM customers",
})
if resp.Redacted {
fmt.Printf("Redacted fields: %v\n", resp.RedactedFields)
}
if resp.PolicyInfo != nil {
fmt.Printf("Evaluated %d policies in %dms\n",
resp.PolicyInfo.PoliciesEvaluated,
resp.PolicyInfo.ProcessingTimeMs)
}
TypeScript SDK
const resp = await client.mcpQuery({
connector: "postgres-demo",
statement: "SELECT name, ssn FROM customers",
});
if (resp.redacted) {
console.log("Redacted fields:", resp.redacted_fields);
}
if (resp.policy_info) {
console.log(`Evaluated ${resp.policy_info.policies_evaluated} policies`);
}
Python SDK
resp = await client.mcp_query(
connector="postgres-demo",
statement="SELECT name, ssn FROM customers",
)
if resp.redacted:
print(f"Redacted fields: {resp.redacted_fields}")
if resp.policy_info:
print(f"Evaluated {resp.policy_info.policies_evaluated} policies")
Java SDK
ConnectorResponse resp = client.mcpQuery(
"postgres-demo",
"SELECT name, ssn FROM customers"
);
if (resp.isRedacted()) {
System.out.println("Redacted fields: " + resp.getRedactedFields());
}
ConnectorPolicyInfo info = resp.getPolicyInfo();
if (info != null) {
System.out.println("Evaluated " + info.getPoliciesEvaluated() + " policies");
}
Handling Blocked Requests
When a policy blocks an MCP query, the platform returns HTTP 403 with a valid response body. All SDKs treat this as a successful response (not an exception) with blocked=true:
Python (v5.4.0+)
response = await client.mcp_query(
connector="postgres-demo",
statement="SELECT name, ssn FROM customers"
)
if response.blocked:
print(f"Blocked by policy: {response.block_reason}")
else:
print(f"Got {len(response.data)} rows")
Prior to v5.3.0, the Python SDK raised ConnectorError on all 403 responses. v5.3.0+ now treats any 403 from mcp_query as a policy block, returning ConnectorResponse with blocked=True and block_reason instead of raising an exception.
The platform returns 403 when a policy blocks a connector query. The Python SDK v6.0.0+ returns a typed ConnectorResponse with blocked=True. The Go, TypeScript, and Java SDKs currently raise errors on 403 responses — check policy_info on the error object for block details. A future release will align all SDKs to the Python v6.1.0 pattern.
Exfiltration Detection
Exfiltration detection prevents large-scale data extraction via MCP queries by enforcing row count and data volume limits.
Configuration
| Variable | Default | Description |
|---|---|---|
MCP_MAX_ROWS_PER_QUERY | 10000 | Maximum rows per MCP query |
MCP_MAX_BYTES_PER_QUERY | 10485760 | Maximum bytes per response (10MB) |
Behavior
- Responses within limits include
exfiltration_checkwithwithin_limits: true - Responses exceeding limits return 403 with details about which limit was exceeded
- Limits are enforced after PII redaction (redacted data counts toward limits)
Example: Checking Exfiltration Limits
resp, err := client.MCPQuery(ctx, req)
if resp.PolicyInfo != nil && resp.PolicyInfo.ExfiltrationCheck != nil {
info := resp.PolicyInfo.ExfiltrationCheck
fmt.Printf("Rows: %d/%d, Bytes: %d/%d\n",
info.RowsReturned, info.RowLimit,
info.BytesReturned, info.ByteLimit)
if !info.WithinLimits {
fmt.Println("Warning: Response exceeded limits")
}
}
Tenant Policy Evaluation
Tenant policies are evaluated by the Orchestrator and support advanced use cases like rate limiting, budget controls, and access restrictions.
Configuration
| Variable | Default | Description |
|---|---|---|
MCP_DYNAMIC_POLICIES_ENABLED | false | Enable tenant policy evaluation |
MCP_DYNAMIC_POLICIES_TIMEOUT | 5s | Timeout for Orchestrator call |
MCP_DYNAMIC_POLICIES_GRACEFUL | true | Continue if Orchestrator unavailable |
MCP_DYNAMIC_POLICIES_CONNECTORS | (empty) | Comma-separated list of connectors that get dynamic policy evaluation. Empty means all connectors. |
Supported Policy Types
| Type | Description | Example |
|---|---|---|
rate-limit | Limit requests per time window | Max 100 queries per minute |
budget | Track and limit API costs | $1000 monthly budget |
time-access | Restrict to business hours | Allow 9am-5pm only |
role-access | Role-based access control | Admin-only connectors |
Graceful Degradation
When MCP_DYNAMIC_POLICIES_GRACEFUL=true (default), MCP queries continue even if the Orchestrator is unavailable. The response will include orchestrator_reachable: false in the dynamic_policy_info.
Example: Checking Tenant Policy Results
resp, err := client.MCPQuery(ctx, req)
if resp.PolicyInfo != nil && resp.PolicyInfo.DynamicPolicyInfo != nil {
info := resp.PolicyInfo.DynamicPolicyInfo
fmt.Printf("Evaluated %d tenant policies\n", info.PoliciesEvaluated)
for _, match := range info.MatchedPolicies {
fmt.Printf(" - %s (%s): %s\n",
match.PolicyName, match.PolicyType, match.Action)
}
}
Standalone Policy Check
Added in v4.7.0
The standalone policy-check endpoints let you use AxonFlow as a policy-only gate without routing MCP execution through AxonFlow. This is designed for external orchestrators (LangGraph, CrewAI, custom pipelines) that manage MCP connector execution natively but need AxonFlow's policy enforcement.
When to Use
| Scenario | Standalone Check | Integrated (mcpQuery) |
|---|---|---|
| External orchestrator runs MCP connectors | ✅ | |
| AxonFlow manages full MCP lifecycle | ✅ | |
| Pre-validate query before external execution | ✅ | |
| Post-validate data fetched externally | ✅ |
check-input: Pre-Execution Validation
POST /api/v1/mcp/check-input
Validates a query or command against all configured policies (system + tenant) before you execute it.
Both the statement and individual parameters values are scanned against all active policies (SQLi detection, PII scanning, compliance rules). Parameter values are scanned individually — not concatenated with the statement — to catch payloads embedded in parameter values while avoiding false positives from concatenation artifacts. Numeric values are converted to strings for PII/compliance detection; boolean values are skipped. Nested objects are JSON-serialized before scanning.
Tenant policies can also match on parameter values using parameters.<key> condition fields (e.g., parameters.table_name) and parameter_count for cardinality-based rules.
Request:
{
"connector_type": "postgres",
"statement": "SELECT * FROM users WHERE id = $1",
"parameters": {"1": "usr-001"},
"operation": "query"
}
Response (allowed):
{
"allowed": true,
"policies_evaluated": 12
}
Response (blocked by parameter):
{
"allowed": false,
"block_reason": "Blocked by policy sys_sqli_union_based in parameter '1'",
"policies_evaluated": 3
}
The connector_type field specifies the MCP connector type (e.g., postgres, snowflake, salesforce). The operation field defaults to "execute" and affects tenant policy evaluation (rate limits may differ for "query" vs "execute").
check-output: Post-Execution Validation
POST /api/v1/mcp/check-output
Validates response data against output-side policies (PII redaction, exfiltration limits, SQLi response scanning).
Request (query-style):
{
"connector_type": "postgres",
"response_data": [
{"id": 1, "name": "Alice", "ssn": "123-45-6789"},
{"id": 2, "name": "Bob", "ssn": "987-65-4321"}
],
"row_count": 2
}
Response (PII redacted):
{
"allowed": true,
"redacted_data": [
{"id": 1, "name": "Alice", "ssn": "[REDACTED:ssn]"},
{"id": 2, "name": "Bob", "ssn": "[REDACTED:ssn]"}
],
"policies_evaluated": 8
}
For execute-style responses, use the message field instead of response_data:
{
"connector_type": "postgres",
"message": "3 rows updated",
"metadata": {"query": "UPDATE users SET status = 'active'"}
}
Exfiltration limits are only applied to query-style responses (response_data), not execute-style (message).
SDK Methods
All four SDKs provide dedicated methods for the check endpoints.
If you use the check endpoints for general tool governance (not just MCP connectors), the SDKs also expose clearer aliases: checkToolInput() / checkToolOutput() (TypeScript), CheckToolInput() / CheckToolOutput() (Go), check_tool_input() / check_tool_output() (Python), and checkToolInput() / checkToolOutput() (Java). These are identical to mcpCheckInput() / mcpCheckOutput() — same parameters, same behavior, same HTTP endpoints. Use whichever name fits your context.
Go
// Pre-execution check
inputResp, err := client.MCPCheckInput(ctx, axonflow.MCPCheckInputRequest{
ConnectorType: "postgres",
Statement: "SELECT * FROM users WHERE id = $1",
Operation: "query",
})
if !inputResp.Allowed {
log.Fatalf("Blocked: %s", inputResp.BlockReason)
}
// Post-execution check
outputResp, err := client.MCPCheckOutput(ctx, axonflow.MCPCheckOutputRequest{
ConnectorType: "postgres",
ResponseData: rows,
RowCount: len(rows),
})
if outputResp.RedactedData != nil {
// Use redacted data instead of original
}
Python
# Pre-execution check
resp = client.mcp_check_input(
connector_type="postgres",
statement="SELECT * FROM users WHERE id = $1",
operation="query",
)
if not resp.allowed:
raise Exception(f"Blocked: {resp.block_reason}")
# Post-execution check
resp = client.mcp_check_output(
connector_type="postgres",
response_data=rows,
row_count=len(rows),
)
if resp.redacted_data:
rows = resp.redacted_data # Use redacted version
TypeScript
// Pre-execution check
const inputResp = await client.mcpCheckInput({
connectorType: 'postgres',
statement: 'SELECT * FROM users WHERE id = $1',
operation: 'query',
});
if (!inputResp.allowed) {
throw new Error(`Blocked: ${inputResp.block_reason}`);
}
// Post-execution check
const outputResp = await client.mcpCheckOutput({
connectorType: 'postgres',
responseData: rows,
rowCount: rows.length,
});
if (outputResp.redacted_data) {
rows = outputResp.redacted_data;
}
Java
// Pre-execution check
MCPCheckInputResponse inputResp = client.mcpCheckInput("postgres",
"SELECT * FROM users WHERE id = $1");
if (!inputResp.isAllowed()) {
throw new RuntimeException("Blocked: " + inputResp.getBlockReason());
}
// Post-execution check
MCPCheckOutputResponse outputResp = client.mcpCheckOutput("postgres", rows);
if (outputResp.getRedactedData() != null) {
// Use redacted data
}
HTTP Status Codes
| Code | check-input | check-output |
|---|---|---|
| 200 | Input passed all policies | Output passed (may include redacted_data) |
| 400 | Missing connector_type or statement | Missing connector_type or both response_data and message |
| 401 | Invalid client/user authentication | Invalid client/user authentication |
| 403 | Blocked by policy (allowed: false) | Blocked by static policy, exfiltration limit, or SQLi (allowed: false) |
| 503 | Tenant policy evaluator unavailable | — |
Audit Logging
Check endpoint evaluations are logged to mcp_query_audits with operation set to "check-input" or "check-output". This provides the same compliance trail as integrated MCP operations.
Performance
The policy engine is designed for minimal latency impact:
- Request Phase: Sub-5ms p99 for static policy evaluation
- Response Phase: Sub-10ms p99 for PII redaction
- Caching: Compiled regex patterns cached for reuse
- Thread Safety: Engine is safe for concurrent use by multiple request goroutines
- Background Refresh: Policy definitions refresh periodically without blocking requests
Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
MCP_DYNAMIC_POLICIES_ENABLED | false | Enable dynamic (tenant) policy evaluation for MCP |
MCP_DYNAMIC_POLICIES_TIMEOUT | 5s | Timeout for dynamic policy evaluation |
MCP_DYNAMIC_POLICIES_GRACEFUL | true | Allow request if dynamic policy engine is unreachable |
PII_ACTION | redact | Default PII action (block/redact/warn/log) |
MCP_MAX_ROWS_PER_QUERY | 10000 | Exfiltration row limit |
MCP_MAX_BYTES_PER_QUERY | 10485760 | Exfiltration byte limit (10MB) |
MCP_EXFILTRATION_ENABLED | true | Enable exfiltration detection for MCP queries |
Database Configuration
Policies are stored in the static_policies table with phase-aware columns:
SELECT id, name, category, phase, action_request, action_response
FROM static_policies
WHERE enabled = true AND tenant_id = 'your-tenant';
Audit Logging
All MCP connector operations are logged for compliance and security analysis. The mcp_query_audits table captures:
- Request phase results: Whether the query was blocked, which policies matched
- Response phase results: Whether PII was redacted, which fields were affected
- Exfiltration checks: Row counts and volume limit violations
- Performance metrics: Operation duration, success/failure status
For detailed information about MCP audit logging, see Audit Logging.
Examples
See the MCP policy examples in the AxonFlow repository:
- MCP Policy Examples - Basic policy enforcement (SQLi blocking, PII redaction)
- Check Endpoints - Standalone policy check for external orchestrators (HTTP + all 4 SDKs)
- Exfiltration Detection - Row and volume limit enforcement
- Tenant Policies - Rate limiting, budgets, access control
All examples include implementations in Go, TypeScript, Python, and Java.
Related Documentation
- Audit Logging - Complete audit trail for MCP operations
- Governance Overview
- PII Detection
- SQL Injection Scanning
- Agent API Reference
