Skip to main content

MCP Policy Enforcement

AxonFlow enforces policies at multiple levels during MCP connector operations:

  1. REQUEST Phase: Evaluates incoming queries before they reach the connector
  2. RESPONSE Phase: Scans connector responses before returning to the client
  3. Exfiltration Detection: Prevents large-scale data extraction via row/volume limits
  4. 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

FieldTypeDescription
redactedbooleanWhether any fields were redacted
redacted_fieldsstring[]Field paths of redacted fields (e.g., user.ssn, user.email)
policy_infoobjectPolicy evaluation details

PolicyInfo Object

FieldTypeDescription
policies_evaluatedintegerNumber of policies checked
blockedbooleanWhether request was blocked
block_reasonstringReason if blocked
redactions_appliedintegerNumber of fields redacted
processing_time_msintegerPolicy evaluation time
matched_policiesarrayPolicies that matched

PolicyMatchInfo Object

Each entry in matched_policies contains:

FieldTypeDescription
policy_idstringUnique policy identifier
policy_namestringHuman-readable policy name
categorystringPolicy category (e.g., pii-us, security-sqli)
severitystringMatch severity (critical, high, medium, low)
actionstringAction taken (block, redact, warn, log)

ExfiltrationCheckInfo Object

Tracks row and volume limits to prevent data exfiltration:

FieldTypeDescription
rows_returnedintegerNumber of rows in the response
row_limitintegerConfigured maximum rows per query
bytes_returnedintegerResponse data size in bytes
byte_limitintegerConfigured maximum bytes per response
within_limitsbooleanWhether response is within all limits

DynamicPolicyInfo Object

Contains results of Orchestrator-evaluated tenant policies:

FieldTypeDescription
policies_evaluatedintegerNumber of tenant policies checked
matched_policiesarrayTenant policies that matched
orchestrator_reachablebooleanWhether Orchestrator was reachable
processing_time_msintegerTenant policy evaluation time

DynamicPolicyMatch Object

Each entry in dynamic matched_policies contains:

FieldTypeDescription
policy_idstringUnique policy identifier
policy_namestringHuman-readable policy name
policy_typestringPolicy type (rate-limit, budget, time-access, role-access)
actionstringAction taken (allow, block, warn)
reasonstringContext for the policy match

Policy Categories

Security Policies (Request Phase)

CategoryDescriptionAction
security-sqliSQL injection patternsBlock
security-dangerousDDL, privilege escalationBlock

PII Policies (Response Phase)

CategoryDescriptionAction
pii-usUS SSN, Driver's LicenseRedact
pii-globalCredit cards, email, phoneRedact
pii-indiaAadhaar, PANRedact
pii-singaporeNRIC, FIN, UEN, phone, postalRedact
pii-euNational IDs, IBANRedact

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.

HTTP 403 handling across SDKs

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

VariableDefaultDescription
MCP_MAX_ROWS_PER_QUERY10000Maximum rows per MCP query
MCP_MAX_BYTES_PER_QUERY10485760Maximum bytes per response (10MB)

Behavior

  • Responses within limits include exfiltration_check with within_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

VariableDefaultDescription
MCP_DYNAMIC_POLICIES_ENABLEDfalseEnable tenant policy evaluation
MCP_DYNAMIC_POLICIES_TIMEOUT5sTimeout for Orchestrator call
MCP_DYNAMIC_POLICIES_GRACEFULtrueContinue 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

TypeDescriptionExample
rate-limitLimit requests per time windowMax 100 queries per minute
budgetTrack and limit API costs$1000 monthly budget
time-accessRestrict to business hoursAllow 9am-5pm only
role-accessRole-based access controlAdmin-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

ScenarioStandalone CheckIntegrated (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.

Aliases for non-MCP usage

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

Codecheck-inputcheck-output
200Input passed all policiesOutput passed (may include redacted_data)
400Missing connector_type or statementMissing connector_type or both response_data and message
401Invalid client/user authenticationInvalid client/user authentication
403Blocked by policy (allowed: false)Blocked by static policy, exfiltration limit, or SQLi (allowed: false)
503Tenant 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

VariableDefaultDescription
MCP_DYNAMIC_POLICIES_ENABLEDfalseEnable dynamic (tenant) policy evaluation for MCP
MCP_DYNAMIC_POLICIES_TIMEOUT5sTimeout for dynamic policy evaluation
MCP_DYNAMIC_POLICIES_GRACEFULtrueAllow request if dynamic policy engine is unreachable
PII_ACTIONredactDefault PII action (block/redact/warn/log)
MCP_MAX_ROWS_PER_QUERY10000Exfiltration row limit
MCP_MAX_BYTES_PER_QUERY10485760Exfiltration byte limit (10MB)
MCP_EXFILTRATION_ENABLEDtrueEnable 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:

All examples include implementations in Go, TypeScript, Python, and Java.