praxiom
An MCP gateway that fronts several MCP servers behind one connection and checks every tool call against a formal policy before forwarding it.
The client sees one server. Tools keep their upstream as a prefix — github's
create_issue arrives as github__create_issue.
The policy is not a list of forbidden tools. It is a state machine checked by an SMT solver, so it can forbid a sequence whose steps are each unremarkable:
ALLOW fs__read_file "path"="…/README.md" tainted=False
ALLOW github__create_pull_request writes=1
ALLOW fs__read_file "path"="…/.env" tainted=True
DENY github__create_pull_request fails guard 'publish_to_github'
DENY docs__query-docs fails guard 'query_third_party_docs'
The secret was read through one server and the exits closed on two others. No per-server config can express that — no single server sees both halves.
Implements ePCA (Executable Proof-Constrained Action) from Wu et al., Provably Secure Agent Guardrail. A policy file is a deterministic transition system
$$\Sigma = \langle S_{ver},\ A,\ \delta,\ s_0,\ \Phi_{safe} \rangle$$
— a state space of security-relevant attributes, a finite set of actions, a transition $\delta : S_{ver} \times A \to S_{ver}$, an initial state, and the invariants. Each call becomes a typed payload $j$, is translated into first-order logic by a fixed table, and Z3 decides the joint formula
$$C = s \ \wedge\ [[j]]{SMT} \ \wedge\ \Phi{safe}(s') \qquad s' = \delta(s, a)$$
against the state the call would produce. SAT forwards it and commits the transition; UNSAT is the paper's algebraic deadlock — blocked, state unchanged. No language model takes part in the decision.
Install
uv tool install praxiom # or: pip install praxiom
Python 3.12+.
Run
Two files, both required, each from its flag or its environment variable. There are no default locations: running unverified should be something you asked for.
praxiom --config praxiom.yaml --policy praxiom-policy.yaml
| Flag | Environment | |
|---|---|---|
--config PATH |
PRAXIOM_CONFIG |
Upstream servers |
--policy PATH |
PRAXIOM_POLICY |
ePCA policy |
--env-file PATH |
PRAXIOM_ENV_FILE |
KEY=value lines loaded before the config |
--http [HOST:]PORT |
Serve over streamable HTTP instead of stdio | |
--log-arguments |
none, redacted (default), full |
|
--log-level |
Default INFO; logs go to stderr |
|
--color |
auto (default), always, never |
You do not start it yourself: the client spawns it, and it spawns its own local upstreams in turn.
Server config
Each key under servers is that server's tool-name prefix. A server is either a
local subprocess (command) or a remote endpoint (url) — exactly one.
servers:
fs: # local, stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "${HOME}/work"]
# env: {TOKEN: "${TOKEN}"}, cwd: /some/dir
github: # remote, bearer token
url: https://api.githubcopilot.com/mcp/
token_env: GITHUB_MCP_TOKEN
internal: # remote, other headers
url: https://mcp.internal.example.com/mcp
headers:
X-Api-Key: ${INTERNAL_API_KEY}
docs: https://mcp.context7.com/mcp # no credentials
Credentials in order: headers, token (a literal — prefer the others),
token_env, then PRAXIOM_TOKEN_<NAME>. ${VAR} expands from the
environment anywhere in the file and is strict — an unset variable stops startup
rather than connecting without the credential. Header values never reach the
log, only their names. Unknown keys are rejected, because a misspelled headers
would otherwise mean a silently unauthenticated upstream.
Policy
default: allow # unmatched tools are forwarded, logged as PASS
patterns: # named substring lists for matches()
secret_paths: ["/.ssh", "/.env", "id_rsa", ".pem", ".aws/"]
state: # S_ver and the initial state s₀
tainted: {type: bool, init: false}
secret_reads: {type: int, init: 0}
invariants: # Φ_safe — must hold in every reachable state
- name: secret_read_budget
expr: secret_reads <= 5
actions: # A and δ; `match` globs the prefixed tool name
- name: read_files
match: [fs__read_file, fs__read_text_file]
effect:
tainted: tainted or matches(payload, secret_paths)
secret_reads: secret_reads + ite(matches(payload, secret_paths), 1, 0)
- name: no_writes_after_secret_read
match: [fs__write_file, fs__edit_file, fs__move_file]
requires: not tainted
Invariants are checked against the state a call would produce, and that state persists between calls — that is what catches the split attack. Every action whose glob matches contributes; guards are conjoined, effects merged.
A guard only ever sees what a call asked for, and that is not always enough: a
search whose pattern names nothing forbidden can still hand back the path to a
key. on_result is a second transition, applied once the call has returned, with
the answer in scope as result:
- name: secrets_in_the_answer
match: [fs__read_file, fs__search_files]
on_result:
tainted: tainted or matches(result, secret_paths)
It cannot refuse — the call has already run — so it only records. If what came
back leaves the state outside Φ_safe, that is committed and logged rather than
rolled back, and every later call is then unsatisfiable: the deadlock arrives one
step late instead of being lost. Only the first 64 KB of a response is examined.
In scope for requires and effect: state variables, arguments declared under
args, and always payload (the arguments as JSON), tool, upstream.
Matching payload makes a rule work without knowing a server's argument names.
Invariants see only the state. A guard of "false" never holds — that is how
you refuse a tool outright.
| literals | 1, -2, true, false, "text" |
| arithmetic | + - * // % |
| comparison | < <= > >= == !=, chainable |
| boolean | and, or, not |
| functions | implies(a,b), ite(c,a,b), startswith, endswith, contains, matches(s, patterns), member(x, [..]) |
Expressions are parsed by Python's parser and walked into Z3 through a strict
node whitelist — eval is never used, and anything outside the grammar
fails at startup. Denials name the axiom from Z3's unsat core and reach the
client as is_error=True, so the model can adapt:
praxiom blocked 'fs__write_file': fails guard 'no_writes_after_secret_read'
[state: tainted=True, secret_reads=1]
Claude Code
claude mcp add praxiom \
--env PRAXIOM_CONFIG=/path/to/praxiom.yaml \
--env PRAXIOM_POLICY=/path/to/praxiom-policy.yaml \
-- uv run --directory /path/to/praxiom praxiom
Remove the direct entries for any server you put behind the gateway — two paths
to the same data means the policy is decoration. The same applies to the
built-in Bash, Read and Write tools: they bypass the gateway, and asking
the model to prefer the MCP tools is a request, not a control. Close them with
permissions.deny or a PreToolUse hook if the guarantee is meant to be real.
Pydantic AI
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset, StdioTransport
praxiom = MCPToolset(
StdioTransport(
command="uv",
args=["run", "--directory", "/path/to/praxiom", "praxiom"],
env={"PRAXIOM_CONFIG": "…", "PRAXIOM_POLICY": "…"},
),
tool_error_behavior="failed",
)
agent = Agent("anthropic:claude-sonnet-4-6", toolsets=[praxiom])
async with agent:
result = await agent.run("read the config and open a PR")
tool_error_behavior="failed" matters: the default retries a denied call until
the run dies instead of letting the model see the refusal. An agent you build
yourself is where mediation is actually complete — its tool list is whatever you
give it, so don't hand it a shell alongside.
In your own code
When the tools are functions in your own program rather than MCP servers, the same policy runs without the gateway:
from praxiom import Guard, PolicyDenied
guard = Guard.from_file("policy.yaml", namespace="fs")
with guard.session() as session: # one agent run, its own state
verdict = session.check("read_file", {"path": path})
if not verdict.allowed:
print(verdict.reason) # names the axiom that failed
check returns a verdict, enforce raises PolicyDenied instead — that is the
one to wire into whatever single tool-call hook your framework gives you. Prefer
that over decorating each tool: a decorator you forget to apply is a hole that
looks like a policy.
Where the functions are yours, the decorator is less typing:
@guard.tool # or @guard.tool("fs__read_file")
def read_file(path: str) -> str: ...
It works on sync and async functions, binds arguments through the signature — so
f(path) and f(path=path) produce the same payload — and takes
on_deny="return" when the refusal should reach the model as a tool result
rather than an exception.
Two things worth knowing. Give every run its own session(): compiling the
policy is the expensive part and is shared, while state is not, so a Guard
reused across runs carries one run's taint into the next. And a bare tool name
needs a namespace — policies match prefixed names, so read_file with no
namespace raises rather than silently matching no rule and passing unverified.
Audit log
One line per call: verdict, tool, arguments, the rule that fired, the state after, upstream latency. Logs go to stderr; stdout belongs to the MCP transport.
ALLOW fs__read_file "path"="…/notes.md" [read_files] tainted=False 7ms
ALLOW fs__write_file "path"="…/api.txt", "token"="***" [never_write_secret_paths] 8ms
DENY fs__write_file "path"="…/copy.txt" fails guard 'no_writes_after_secret_read'
PASS fs__list_directory no policy action covers this tool 5ms
--log-arguments redacted (default) blanks credential-shaped keys and shortens
long values from the middle, keeping both ends. full writes them verbatim.
Transports
stdio is the right answer almost always: it works for local and remote upstreams
alike, gives each client its own state, and opens no port. --http makes the
gateway a standalone shared process — but all clients then share one verification
state, there is no authentication, and path-based tools break, since a path is a
string interpreted on the far side.
Examples
Tool names in these were taken from live connections, not documentation — the two differ.
| examples/filesystem | Local npx server. Reading a secret taints; writes close after it. |
| examples/github | Remote, 44 tools. Read-here-publish-there, review comments included. |
| examples/etherscan | Remote, all 20 tools read-only — a policy with little to forbid, so budgets and an audit trail. |
| examples/mixed | All of the above plus a public server, 80 tools. Cross-server taint. |
Metadata
Release files for praxiom 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| praxiom-0.2.0.tar.gz | 38.2 kB | Without provenance | Details for praxiom-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| praxiom-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for praxiom-0.2.0-py3-none-any.whl |
Total release size: 79.6 kB
Release files / praxiom-0.2.0.tar.gz
| Download URL | praxiom-0.2.0.tar.gz |
|---|---|
| Size | 38.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b48f03fdfdf6f2fd84c9cca596531b40d9cb72baaa0c7fb41fa382b08a0940ed
|
|
BLAKE2b-256 checksum How to use checksums |
2fe621c8b19c7edbba98b1f03647acbb73c05b28aaed41979eec5ea8dd578103
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / praxiom-0.2.0-py3-none-any.whl
| Download URL | praxiom-0.2.0-py3-none-any.whl |
|---|---|
| Size | 41.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
16ef073a0eb882b526634a5edf11f4f07345bbe347c3ebe45d45b04b1db0cbe0
|
|
BLAKE2b-256 checksum How to use checksums |
a6e5c2a5728b694363c8372240578ab7bf8b03a0b8d5b0743f94e248f78e127f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for praxiom 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.