Agents & LLM Integration¶
Kontra is designed for programmatic use by LLM agents and services.
Official MCP Server¶
Install the server with its PostgreSQL backend dependencies:
The server accepts configured datasource names and trusted contracts from a single directory. It does not accept arbitrary database URLs, filesystem paths, inline rules, or SQL from tool calls.
export KONTRA_CONFIG=/etc/kontra/config.yml
export KONTRA_MCP_CONTRACTS_DIR=/etc/kontra/contracts
export KONTRA_MCP_POSTGRES_URI='postgresql://kontra@db/kontra'
kontra-mcp
Connect it to your coding agent¶
Most clients launch the server over stdio and manage its lifecycle for you.
Point them at kontra-mcp through uvx so no
manual install step is needed.
Claude Code, Claude Desktop, Cursor — add to the client's MCP config
(.mcp.json, claude_desktop_config.json, or Cursor's mcp.json):
{
"mcpServers": {
"kontra": {
"command": "uvx",
"args": ["--from", "kontra[mcp-postgres]", "kontra-mcp"],
"env": {
"KONTRA_MCP_POSTGRES_URI": "postgresql://kontra@db/kontra",
"KONTRA_CONFIG": "/etc/kontra/config.yml",
"KONTRA_MCP_CONTRACTS_DIR": "/etc/kontra/contracts"
}
}
}
}
Claude Code can add the same entry from the CLI:
claude mcp add kontra \
--env KONTRA_MCP_POSTGRES_URI=postgresql://kontra@db/kontra \
--env KONTRA_CONFIG=/etc/kontra/config.yml \
-- uvx --from "kontra[mcp-postgres]" kontra-mcp
Codex CLI — add to ~/.codex/config.toml:
[mcp_servers.kontra]
command = "uvx"
args = ["--from", "kontra[mcp-postgres]", "kontra-mcp"]
[mcp_servers.kontra.env]
KONTRA_MCP_POSTGRES_URI = "postgresql://kontra@db/kontra"
KONTRA_CONFIG = "/etc/kontra/config.yml"
…or register it with the Codex CLI:
codex mcp add kontra \
--env KONTRA_MCP_POSTGRES_URI=postgresql://kontra@db/kontra \
--env KONTRA_CONFIG=/etc/kontra/config.yml \
-- uvx --from "kontra[mcp-postgres]" kontra-mcp
Tools and resources¶
The server exposes ten tools and three read-only resources. Every source-bearing argument must be a configured datasource name; contracts are relative names confined to the trusted contracts directory. Tool calls never accept a raw data path, connection URL, inline rule, or SQL statement.
| Tool | Arguments | Returns |
|---|---|---|
validate |
datasource, contract, env?, tally?, sample? |
Validation result; the run is persisted |
profile |
datasource, preset?, columns?, sample?, save? |
Dataset profile; persisted when save is set |
validation_history |
contract, limit?, since?, failed_only? |
Bounded run summaries, newest first |
validation_diff |
contract |
Diff of the two most recent runs |
get_validation_run |
contract, run_id? |
One persisted run (latest if no ID); dataset URIs and fingerprints removed |
measure_failure_samples |
datasource, contract, rule_id, n?, env? |
Example failing rows for one rule, measured now (not persisted) |
profile_history |
datasource, limit? |
Bounded profile history, newest first |
profile_diff |
datasource |
Diff of the two most recent persisted profiles |
compare_datasets |
before, after, key?/before_key?/after_key? |
Row and key deltas between two datasources |
profile_relationship |
left, right, on?/left_on?/right_on? |
Join cardinality and relational shape of two datasources |
| Resource | Returns |
|---|---|
kontra://health |
Server configuration/status metadata (backend type only, no credentials) |
kontra://rules |
Built-in measurement rules and their parameters |
kontra://datasources |
Configured datasource names and tables (no connection details) |
Transformation probes. compare_datasets and profile_relationship measure how two
datasources relate — row and key deltas, join cardinality — for reasoning about JOINs and
deduplication. They never emit raw rows and composite keys are capped at eight columns. Before
materializing, the server compares source metadata against a 100,000-row ceiling. Database row
counts may be catalog estimates, so this is a cost guardrail rather than a hard security boundary.
Override it with KONTRA_MCP_MAX_PROBE_ROWS.
Failure samples. measure_failure_samples runs a fresh measurement and returns example
failing rows for one rule, restricted to rule-relevant columns. Treat that output as live data
leaving your boundary. Persisted history keeps counts, not samples, so this tool always measures
against current data ("measurement": "current"). Setting sample > 0 on validate also
returns live failure samples in that immediate result.
Remote deployments¶
stdio is the default transport. For a remote deployment, use Streamable HTTP:
Kontra does not implement HTTP authentication itself. Non-loopback binds
therefore fail closed: a host other than 127.0.0.1/localhost is refused
unless you pass --allow-remote-unauthenticated. Use that override only when an
authenticating proxy or an isolated network supplies the access boundary.
PostgreSQL stores validation runs and profiles requested with persistence enabled. Current failure samples and transformation probes are transient. The server does not make policy decisions, edit contracts, write annotations, recommend fixes, or expose arbitrary SQL.
Credentials can also come from DATABASE_URL or the standard PostgreSQL PG*
environment variables. Server responses report only the backend type, never the
connection URI.
Core Functions¶
Validate Data¶
import kontra
from kontra import rules
result = kontra.validate(df, rules=[
rules.not_null("user_id"),
rules.unique("email"),
])
result.passed # bool
result.failed_count # int
result.total_rows # int
for rule in result.blocking_failures:
print(f"{rule.rule_id}: {rule.failed_count} violations")
Profile Data¶
profile = kontra.profile(df)
print(f"Rows: {profile.row_count}")
for col in profile.columns:
print(f"{col.name}: {col.dtype}, {col.null_rate:.0%} null")
Transformation Probes¶
Measure transformation effects before and after changes.
Compare (Before/After)¶
result = kontra.compare(before_df, after_df, key="user_id")
# Key metrics
result.row_delta # change in row count
result.duplicated_after # keys appearing >1x in after
result.dropped # keys lost in transformation
result.changed_rows # rows where values differ
Use key= when the identifying column has the same name on both sides. For a
FK→PK comparison where the sides name it differently, use before_key=/after_key=:
Profile Relationship (JOIN Structure)¶
profile = kontra.profile_relationship(left_df, right_df, on="customer_id")
# Key metrics
profile.right_duplicate_keys # keys appearing >1x in right
profile.right_key_multiplicity_max # max rows per key in right
profile.left_keys_without_match # left keys not in right
For differently named join keys, use left_on=/right_on= instead of on=
(same naming as pandas' merge):
See Transformation Probes for full schemas and all fields.
Token-Optimized Output¶
All result types have a .to_llm() method that returns a compact, token-efficient string:
# Validation result
result = kontra.validate("data.parquet", rules=[...])
print(result.to_llm())
# VALIDATION: my_contract PASSED
# PASSED: 5 rules
# With failures
# VALIDATION: my_contract FAILED
# BLOCKING: COL:email:not_null (523 nulls), COL:status:allowed_values (12 invalid)
# WARNING: COL:age:range (3 out of bounds)
# PASSED: 13 rules
# Profile
profile = kontra.profile("data.parquet")
print(profile.to_llm())
# DATASET: users.parquet (50K rows, 8 cols)
# COLS: user_id(int64,100%,unique), email(str,98%), status(str,100%,3vals), ...
# Compare
result = kontra.compare(before, after, key="order_id")
print(result.to_llm())
# COMPARE: 1000 → 1200 rows (+200)
# key: order_id
# keys: preserved=1000, dropped=0, added=0
# duplicated_keys: 50
# changes: 200 modified, 800 unchanged
# Diff
diff = kontra.diff("my_contract")
print(diff.to_llm())
# DIFF: my_contract 2024-01-10 -> 2024-01-12
# REGRESSION: COL:email:not_null (0 -> 523 nulls)
# RESOLVED: COL:age:range
Output Methods¶
| Method | Description |
|---|---|
.to_dict() |
Nested dictionary |
.to_json() |
JSON string |
.to_llm() |
Compact string for LLM context |
Available Rules¶
from kontra import rules
# Column checks
rules.not_null("column")
rules.unique("column")
rules.dtype("column", "int64")
rules.range("column", min=0, max=100)
rules.allowed_values("column", ["a", "b", "c"])
rules.regex("column", r"^[A-Z]{2}\d{4}$")
# Cross-column checks
rules.compare("end_date", "start_date", ">=")
rules.conditional_not_null("shipping_date", when="status == 'shipped'")
# Dataset checks
rules.min_rows(1000)
rules.max_rows(1000000)
Service Integration¶
Health Check¶
health = kontra.health()
# {
# "version": "0.x.x",
# "status": "ok",
# "config_found": True,
# "config_path": "/app/.kontra/config.yml",
# "rule_count": 18,
# "rules": ["not_null", "unique", "range", ...]
# }
if health["status"] == "ok":
print(f"Kontra {health['version']} ready")
Config Path Injection¶
Services that don't run from a project directory need explicit config:
# Set config path for service use
kontra.set_config("/etc/kontra/config.yml")
# All subsequent calls use this config
result = kontra.validate("prod_db.users", rules=[...])
# Check current setting
path = kontra.get_config_path()
# Reset to auto-discovery
kontra.set_config(None)
Datasource Resolution¶
# Resolve datasource name to URI
uri = kontra.resolve("users") # searches all datasources
uri = kontra.resolve("prod_db.users") # explicit datasource
# List available datasources
sources = kontra.list_datasources()
# {
# "prod_db": ["users", "orders", "products"],
# "local_data": ["events", "metrics"],
# }
Rule Discovery¶
rules_list = kontra.list_rules()
for rule in rules_list:
print(f"{rule['name']} ({rule['scope']})")
print(f" {rule['description']}")
print(f" Params: {rule['params']}")
# not_null (column)
# Fails where column contains NULL values
# Params: {'column': 'required', 'include_nan': 'optional'}
Suggested Rules¶
When an agent needs to generate validation rules from data:
profile = kontra.profile("data.parquet", preset="interrogate")
suggestions = kontra.draft(profile)
# Filter by confidence
high_confidence = suggestions.filter(min_confidence=0.9)
# Get as dict for validation
rules = high_confidence.to_dict()
result = kontra.validate("data.parquet", rules=rules)
Note: Suggested rules are heuristic. They reflect observed patterns in the data, not ground truth. Agents should present them as starting points, not authoritative contracts.
Error Handling¶
from kontra.errors import (
KontraError, # base class
ContractNotFoundError,
InvalidDataError,
ConnectionError,
)
try:
result = kontra.validate("data.parquet", "contract.yml")
except ContractNotFoundError as e:
return {"error": "contract_not_found", "message": str(e)}
except InvalidDataError as e:
return {"error": "invalid_data", "message": str(e)}
except RuntimeError as e:
# File access errors (missing files, permissions)
return {"error": "data_access_error", "message": str(e)}
except KontraError as e:
return {"error": "kontra_error", "message": str(e)}
Workflow Example¶
Transformation Pipeline¶
-
Profile relationship before writing JOIN:
-
Write transformation based on profile insights
-
Compare to measure transformation effects:
-
Validate final output:
Agent-Callable Function¶
def validate_data(data_source: str, contract: str) -> dict:
"""Agent-callable validation function."""
result = kontra.validate(data_source, contract=contract)
response = {
"passed": result.passed,
"total_rows": result.total_rows,
"summary": result.to_llm(),
}
if result.blocking_failures:
failure = result.blocking_failures[0]
response["status"] = "blocked"
response["worst_rule"] = {
"id": failure.rule_id,
"message": failure.message,
"failed_count": failure.failed_count,
"owner": failure.context.get("owner") if failure.context else None,
}
elif result.warnings:
response["status"] = "warnings"
else:
response["status"] = "passed"
return response
Contracts with Severity and Context¶
rules:
- name: not_null
params: { column: user_id }
severity: blocking
context:
owner: data_platform
fix_hint: User ID is required
- name: range
params: { column: age, min: 0 }
severity: warning
See Rule Context for details.