Skip to main content

MCP Policy Enforcement

AxonFlow enforces policy at several points during MCP connector operations:

  1. REQUEST Phase: Decides incoming queries before they reach the connector
  2. RESPONSE Phase: Scans connector responses before they return to the client
  3. Exfiltration Detection: Prevents large-scale data extraction via row/volume limits

In v11 both phases are decided by the policy decision engine that decides every other enforcement plane, reading the shipped policies and your organization's own: the request phase and the response phase are the two scopes of the one mcp plane, mcp:request and mcp:response.

Concept + reference. The first half explains the phase-aware enforcement model. The second half is the API and SDK reference for the standalone check-input / check-output endpoints. New to MCP? Start with the MCP Connectors Overview. For how response redaction behaves in detail, see Response Redaction.

For many teams, this page is part of a broader regulated assessment path rather than a standalone connector feature. If you are reviewing whether AxonFlow is safe to trial in a healthcare, banking, or government environment, pair this with Assessing AxonFlow in Regulated Environments.

Why Response-Side Checks Exist​

Response-side policy enforcement 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 masking 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 on mcp:request = warn → ALLOWED (recorded)

Connector returns: { "name": "John Doe", "ssn": "123-45-6789", "email": "[email protected]" }

Output: → PII detected (sys_pii_ssn), action on mcp: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 recorded for compliance audit.

In v11 this is the shipped default rather than something to configure: a shipped PII policy carries one action for the request planes and another for the response planes, and Shipped Policy Posture lists both for every policy.

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 carry client_id in the request body, and user_token when the caller has a per-user identity. They are used for organization 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).

A request with a user_token that verifies is decided for that user; a request without one is decided for the client credential that authenticated it; a user_token that fails verification is refused. On a Community deployment, which verifies no per-user identity, every request is decided for its client credential.

Phase-Aware Decisions​

Request Phase​

The request phase decides queries before they are sent to the connector. By default:

  • Prompt injection in a query or argument is blocked.
  • Dangerous commands (reverse shells, destructive filesystem operations, credential and cloud-metadata access) and DROP TABLE and TRUNCATE are blocked by organization-editable shipped policies.
  • SQL injection patterns are recorded as warnings; the two organization-editable SQL-injection policies (sql_injection_or, sql_injection_union) block.
  • PII in a query is recorded as a warning or a log entry, so a legitimate lookup is not refused.
  • Your organization's ceilings deny what they describe.

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 masked placeholders
  • Audit Logging: Records what data was accessed and redacted

Most shipped PII policies redact on this plane, and the four prompt-injection policies redact the injected text rather than refusing the response. The exact action of every policy on each plane is in Shipped Policy Posture, and your organization can change any of them.

Response Schema​

MCP responses include policy enforcement metadata. In this example the shipped PII policies redact both the SSN and the email address on the response:

{
"success": true,
"connector": "postgres-demo",
"data": [
{
"name": "John Doe",
"ssn": "[REDACTED:ssn]",
"email": "[REDACTED:email]"
}
],
"row_count": 1,
"duration_ms": 45,
"redacted": true,
"redacted_fields": ["user.ssn", "user.email"],
"policy_info": {
"policies_evaluated": 15,
"blocked": false,
"redactions_applied": 2,
"processing_time_ms": 3,
"matched_policies": [
{
"policy_id": "corpus:static_policies:sys__pii__ssn:redact",
"policy_name": "SSN Detection",
"category": "pii-us",
"severity": "critical",
"action": "redact"
},
{
"policy_id": "corpus:static_policies:sys__pii__email:redact",
"policy_name": "Email Address Detection",
"category": "pii-global",
"severity": "medium",
"action": "redact"
}
],
"exfiltration_check": {
"rows_returned": 1,
"row_limit": 10000,
"bytes_returned": 256,
"byte_limit": 10485760,
"within_limits": true
}
}
}

Matched policies are named by their v11 identifier.

New Response Fields​

FieldTypeDescription
redactedbooleanWhether any fields were redacted
redacted_fieldsstring[]Field paths of redacted fields (e.g., user.ssn, user.email)
policy_infoobjectPolicy enforcement 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 enforcement time
matched_policiesarrayPolicies that matched

PolicyMatchInfo Object​

Each entry in matched_policies contains:

FieldTypeDescription
policy_idstringThe policy's 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​

In v10.x, dynamic_policy_info carried the results of Orchestrator-evaluated tenant policies: policies_evaluated, matched_policies (each with policy_id, policy_name, policy_type, action and reason), orchestrator_reachable and processing_time_ms. The tenant-policy engine authors no verdict in v11; see Tenant Policy Evaluation.

Policy Categories​

What the shipped policies do on the two MCP planes, by category. The full per-policy list, with each action's planes, is in Shipped Policy Posture:

  • Prompt injection (security-dangerous): blocked in requests, and the injected text is redacted in responses.
  • PII (pii-*): most policies warn or log in requests and redact in responses.
  • Dangerous commands, DROP TABLE and TRUNCATE (organization-editable): block on every plane that runs them.
  • SQL injection (security-sqli): the platform policies warn; the two organization-editable ones, sql_injection_or and sql_injection_union, block.
  • Secrets and credentials (sensitive-data): warn.

SDK Integration​

Every SDK returns the policy metadata alongside the connector result. Switch language with the tabs:

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`);
}

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 Policies on MCP Requests (v10.x)​

In v10.x the Orchestrator evaluated tenant policies on MCP requests when MCP_DYNAMIC_POLICIES_ENABLED was set: rate-limit, budget, time-access and role-access policies, with MCP_DYNAMIC_POLICIES_TIMEOUT, MCP_DYNAMIC_POLICIES_GRACEFUL and MCP_DYNAMIC_POLICIES_CONNECTORS shaping how.

The tenant-policy engine authors no verdict in v11, so those policies no longer run on MCP requests. A role restriction becomes a ceiling on your organization's document; a spend cap belongs to Cost Management. Policy Parameters Explained → Access-control policy types maps each type.

A request-phase decision that cannot load the policies it needs is blocked, never allowed: an unavailable policy source is not treated as an empty one. See governance coverage.

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

Decides a query or command against the policies that govern your organization, the shipped set and your own, before you execute it.

Both the statement and individual parameters values are scanned. 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.

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 corpus:static_policies:sql__injection__union in parameter '1'",
"policies_evaluated": 3,
"decision_id": "dec_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"risk_level": "high",
"policy_matches": [
{
"policy_id": "corpus:static_policies:sql__injection__union",
"policy_name": "SQL Injection - UNION Attack",
"action": "block",
"risk_level": "high",
"allow_override": false,
"policy_description": "Blocks UNION-based SQL injection patterns"
}
],
"override_available": false
}

The connector_type field specifies the MCP connector type (e.g., postgres, snowflake, salesforce). The operation field defaults to "execute".

The risk_level, policy_matches, override_available, and override_existing_id fields are part of the explainability response surface and are present only when a block occurs. Allowed responses carry allowed, policies_evaluated, and a decision_id (plus policy_info and the redaction fields when populated).

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).

When the output is blocked, the response carries the block reason and a stable decision_id (plus exfiltration_info when an exfiltration limit triggered the block):

{
"allowed": false,
"block_reason": "Output blocked: PII over policy threshold",
"policies_evaluated": 0,
"decision_id": "98765432-10ab-cdef-0123-456789abcdef"
}

policies_evaluated is always serialized but is populated only on the allow path — block responses report 0. When a redact (rather than block) policy matches, the output is allowed and the engine returns the masked content in redacted_data — masked rows for query-style responses, or a masked string for execute-style responses. A blocked output returns no content at all.

Explainability Fields on a Block​

When a request or response is blocked, the response carries additional context to help operators reason about the decision and surface override flow to end users.

The platform has emitted these fields since v7.1.0 (Plugin Batch 1). The Go v5.8.0, TypeScript v6.1.0, Python v6.8.0, and Java v6.1.0 SDKs (and later) surface them through typed accessors; older SDKs still work but read the additional fields as raw JSON.

On check-input block responses:

FieldTypeDescription
decision_idstring (UUID)Stable identifier for this decision. Use it to fetch the full DecisionExplanation via GET /api/v1/decisions/{decision_id}/explain, to thread the decision into audit search, or to correlate a session override against a specific block.
risk_levelenumlow / medium / high / critical. Mirrors the highest matched policy's configured risk level.
policy_matchesarrayEvery policy that contributed to the block. Each element carries policy_id, optional policy_name, risk_level, allow_override, and (when known) policy_version.
override_availableboolean (nullable)true when at least one matched policy has allow_override: true AND the caller is permitted to issue a session override. false when no matched policy permits override. Absent or null on platforms older than v7.1.0.
override_existing_idstring (omitted when no active)If a session override is already active and is suppressing or modifying this decision, this returns the override's identifier. Plugins use it to render "you have an active override on this rule" UX.

On check-output block responses: decision_id (alongside block_reason and policies_evaluated; an exfiltration-limit block also carries exfiltration_info). check-output does not return policy_matches — use the decisions explain endpoint with the decision_id to resolve the matched policies.

These fields are additive — pre-v7.1.0 platforms and older SDK versions still work; the fields are simply absent from the response. Allowed responses also mint a decision_id, but do not carry the block-explainability fields (risk_level, policy_matches, override fields).

override_available and override_existing_id describe v10.x session overrides. In v11 a session bypass does not silence the policy decision engine, so neither field names a lever that changes a v11 decision; change the policy for your organization instead.

To resolve the full chain of matched policies and rules — including matched_rules, historical_hit_count_session, policy_source_link, and tool_signature — call the decisions explain endpoint with the decision_id.

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.

// 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;
}

Reading Explainability Fields on a Block​

When allowed is false, the response carries the explainability fields described above. The four SDKs surface them through typed accessors. The snippets below show how to read them and decide whether the caller should offer a session override.

const inputResp = await client.mcpCheckInput({
connectorType: 'postgres',
statement: 'SELECT * FROM users WHERE id = $1',
});
if (!inputResp.allowed) {
console.log(`blocked decision_id=${inputResp.decision_id} risk=${inputResp.risk_level}`);
for (const p of inputResp.policy_matches ?? []) {
console.log(` matched policy ${p.policy_id} (${p.policy_name}) action=${p.action}`);
}
// override_available is `boolean | undefined`. `=== true` keeps the
// "field absent" path distinct from `false`.
if (inputResp.override_available === true) {
// Offer override flow to the caller
}
}

To fetch the full DecisionExplanation — including matched_rules, historical_hit_count_session, and policy_source_link — pass decision_id to the decisions explain API.

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 policy, exfiltration limit, or SQLi (allowed: false)
503A decision could not be produced; the request is refused rather than allowed-

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:

  • In-process decisions: the engine decides against a signed policy bundle held in the process, so the hot path has no network dependency on a policy service
  • Response Phase: low-overhead PII redaction on the response path
  • Caching: Compiled regex patterns cached for reuse
  • Thread Safety: Engine is safe for concurrent use by multiple request goroutines

Configuration​

Environment Variables​

VariableDefaultDescription
MCP_MAX_ROWS_PER_QUERY10000Exfiltration row limit
MCP_MAX_BYTES_PER_QUERY10485760Exfiltration byte limit (10MB)
MCP_EXFILTRATION_ENABLEDtrueEnable exfiltration detection for MCP queries

No environment variable sets a detection action in v11: the action a shipped policy takes is the one Shipped Policy Posture lists, or the one your organization gives it. PII_ACTION and the MCP_*_ACTION variables are ignored with a boot warning. See Environment Variables Reference.

No process setting narrows what MCP governance decides

MCP_STATIC_POLICIES_CONNECTORS once limited which connectors the detectors ran on. A narrowed detector would leave the engine unable to decide the policies that read it, so in v11 a narrowing value refuses boot and names the variable; the default (empty) boots. A policy that should apply to one connector is a policy on your organization's document.

Where the policies live​

The shipped policies ship inside the platform as a signed policy set, not as rows to edit, and your organization's policies live in its typed policy document. The v10.x static_policies table, and its phase-aware action_request / action_response columns, are read-only in v11 and decide nothing.

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.

Connector Rollout Checklist​

Before exposing this connector to agents, make the governance boundary explicit:

  • confirm the connector belongs in the MCP governance layer rather than direct application code
  • map allowed operations in the Connector Capability Matrix
  • decide which shipped policy's action or which policy of your own governs the connector, using Policy Hierarchy
  • use Runtime Request Paths to decide whether the caller should use MCP endpoints, gateway mode, or workflow gates