Reeflex MCP gateway -- a transparent MCP proxy that governs any MCP upstream: intercepts tools/call, normalizes it into a Reeflex Action Envelope, asks reeflex-core /v1/decide, and enforces the verdict. Zero LLM in the decision path.
Project description
reeflex-mcp
A transparent MCP gateway that governs any MCP upstream: it sits in the MCP
path, aggregates every configured upstream's tools (namespaced
<upstream>__<tool>), intercepts tools/call, normalizes it into a Reeflex
Action Envelope (reeflex-spec/SPEC.md section 2), asks reeflex-core's
POST /v1/decide, and (in enforce mode) applies the verdict. Everything
else passes through untouched. Zero LLM anywhere near the decision path.
See design/MCP-GATEWAY-DESIGN.md (v1 + addenda) for the full design.
Status: Track 5.1 (obligations -- conformance fix) of the design's roadmap
This build implements:
- The multi-upstream registry (
reeflex-mcp.yaml-- seereeflex-mcp.yaml.example), both stdio and streamable-HTTP upstreams. - Dynamic tool discovery + namespacing, zero hardcoded tool knowledge.
- Declarative per-server mappings (
reeflex_mcp/mappings/*.yaml, design doc section 8) -- per-toolverb/axesoverrides + an optionalmagnitude.from_argrule, resolved AHEAD of the heuristic. See "Declarative mappings" below. - A minimal, heuristic-only normalizer (name-prefix based -- see
reeflex_mcp/normalize.py's module docstring for the exact table) as the fallback for any tool a mapping doesn't name. - A fail-closed, stdlib-only
reeflex-coreclient (reeflex_mcp/core_client.py). observemode (default): everytools/callis normalized and submitted toreeflex-core(so the decision lands in core's audit trail), but the gateway always forwards -- observe must never break traffic.enforcemode (Track 3, full design section 9 mapping):allowforwards and tags core'sdecision_id(+parent_decision_idon an approved resubmission) on the result;denyblocks withrule+reason+decision_id;require_approvalblocks, surfaceshold_id+expires_ts, and the gateway remembers the pending hold (reeflex_mcp/holds_tracker.py, keyed by session + the same canonical action-hash core binds a hold to --reeflex_mcp/canonical.py) so a client's retry is recognized as a resubmission -- resolve the hold viareeflex-core's holds API, then retry the exact same call to have it execute.reeflex-coreunreachable fails closed. No gateway-side freeze logic -- core's ownreeflex.policy/frozendeny is relayed transparently.reeflex-mcp check: a fail-closed self-probe (mirrors reeflex-claude'scheck) -- launches a real gateway subprocess against an unreachable core in enforce mode and asserts a realtools/callis denied.- Lifecycle subcommands (design doc section 13):
setup/restore/add/import/doctor-- see "Lifecycle: setup / add / import / doctor" below. Client-config import + rewrite, live hot-add of an upstream into an already-running streamable-HTTP gateway (tools/list_changedpushed to already-open sessions -- proven live, not just unit-tested, see the adapter-builder's Track 5 report), and a drift check that runs automatically at every gateway startup. - Dual transport (stdio | streamable-HTTP), following the section 21
lifecycle findings (upstream connections are process-level, not
per-session; see
reeflex_mcp/gateway.py's module docstring for exactly how the streamable-HTTP front avoids the per-session reconnect bug). - Obligations, honored (design doc ADDENDUM v1.5 section 25; SPEC
section 5/7 minimum #5) --
decision["obligations"]is read on every decision, in both modes.enforce: a known obligation is applied via its registered handler and the call proceeds; an UNKNOWN one blocks the call before dispatch (isError, fail closed -- never silently forwarded past an obligation the gateway cannot honor).observe: every obligation is recorded ("would-honor", stderr) then forwarded regardless -- not applied (no side effect for a call that isn't really being allowed), but never silently dropped either. See "Obligations" below.
Track 6 (docs, per the design doc) is done: see
docs/mcp-gateway.md for the full operator guide
(architecture, deployment modes, config, mappings, obligations, lifecycle,
and the honest limits) and docs/why-reeflex.md
for the competitive positioning. Not yet built: PyPI publication (Track 7 —
gated on a human GO).
Declarative mappings (Track 4, design doc section 8)
Resolution order for one tools/call, highest precedence first:
- declarative mapping --
mappings/<target.system>.yamlhas an entry for this exact tool name. Source tag:mapping. - name-heuristic -- the tool name matches one of the
delete_*/send_*/get_*/etc. prefixes (seereeflex_mcp/normalize.py). Source tag:heuristic:<bucket>. - conservative default -- nothing above matched; axes are forced to the
restrictive floor (
irreversible/systemic/internal). Source tag:heuristic:default.
Every envelope carries which tier fired at context.classification_source,
and the gateway logs it to stderr on every call
([reeflex-mcp] classified '<tool>' via '<tier>' -> verb='<verb>') -- both
for the GIGO story below and for debugging a mapping that isn't matching the
way you expect.
GIGO honesty (design doc section 8, verbatim):
Mapping quality is adapter quality. A tool the gateway maps wrong is governed wrong. The starter mappings are a floor you can read and correct, not a guarantee -- the same candor we apply to what the base policy does not catch.
The 3 starter mappings ship with the package (reeflex_mcp/mappings/),
targeting real, verified tool names -- not invented ones:
| File | Targets (real server) | Covers |
|---|---|---|
filesystem.yaml |
@modelcontextprotocol/server-filesystem (the official reference filesystem MCP server) |
read vs. write/overwrite split. Honest limitation: the real server has NO delete tool at all -- write_file/edit_file are classified irreversible based on their own real destructiveHint: true MCP annotation, not a fictitious delete_file. |
github.yaml |
modelcontextprotocol/servers-archived GitHub server (the canonical, widely-mirrored reference tool surface) |
external write (externality: outbound) + the irreversible/broad case. Honest limitation: the real server has NO delete-anything tool either -- the irreversible+broad+outbound example is merge_pull_request (a PR merge has no "unmerge"), not a fictitious delete_repository. |
postgres.yaml |
crystaldba/postgres-mcp ("Postgres MCP Pro", a real, actively-maintained community server with explicit read/write modes) |
the single generic execute_sql(sql: str) write primitive. Honest limitation: no real Postgres MCP server (this one or the older read-only reference server) exposes row counts as a callable argument, so the "magnitude from args" case this file was originally meant to cover doesn't apply to it -- execute_sql is instead conservatively classified delete/irreversible/broad (the worst a raw, unparsed SQL string could be) so it correctly engages core's R5 session delete budget. |
The genuine "magnitude scales with a real list-typed argument" demonstration
lives in filesystem.yaml (read_multiple_files's paths) and github.yaml
(push_files's files) instead.
Every mapping choice above is commented in its own YAML file with the
real tool's own MCP annotations (destructiveHint, readOnlyHint, etc.)
where they exist -- read the files, don't just trust this table.
How to write your own mapping
Create mappings/<your-system-name>.yaml (matching the target.system you
gave that upstream in reeflex-mcp.yaml):
tools:
send_email:
verb: emit
axes: { reversibility: irreversible, blast_radius: single, externality: outbound }
list_drafts:
verb: read
axes: { reversibility: reversible, blast_radius: single, externality: internal }
bulk_archive:
verb: update
# axes.externality omitted on purpose -- see "partial axes" below
axes: { reversibility: recoverable, blast_radius: scoped }
# OPTIONAL -- count = len(arguments[<name>]) if it's a list, else 1.
# Omit this block entirely if no tool takes a structured/countable argument
# (see mappings/postgres.yaml for exactly that case, documented).
magnitude:
from_arg: message_ids
Partial axes are fine. Any axis you don't specify for a tool is filled
with reeflex-core's OWN restrictive default (irreversible/systemic/
physical -- reeflex_mcp/mappings.py's CORE_AXIS_DEFAULTS, verified to
match reeflex-core/app/envelope.py exactly) -- never a different,
gateway-invented guess. An axis you omit here ends up exactly where core
would have coerced it anyway if you had sent nothing at all.
Point reeflex-mcp.yaml's mappings_dir: (or REEFLEX_MCP_MAPPINGS_DIR) at
the directory containing your file, or drop it directly into this package's
own reeflex_mcp/mappings/ if you're running from a source checkout.
Obligations (Track 5.1, design doc ADDENDUM v1.5 section 25)
SPEC section 5: "obligations are mandatory side-effects (e.g. redact:pii,
rate_limit). An adapter that ignores an obligation is non-conformant."
Every reeflex-core Decision carries an obligations field (a list of
strings) alongside decision/reason/rule -- the gateway reads it on
every decision, in both modes.
Enforce mode: once the verdict itself is allow (original or an
approved resubmission), the gateway iterates obligations in order:
- known (a handler is registered for that exact string) -> the handler runs, then the loop continues.
- unknown -> the call is blocked right there, before any upstream
dispatch --
CallToolResult(isError=True), text:reeflex-mcp: unsupported obligation '<x>' -- cannot honor, failing closed decision_id=<...>. The gateway never silently forwards past an obligation it cannot honor -- an empty obligations list (or none at all) forwards normally, same as today.
Observe mode: every obligation on every decision is recorded (stderr:
observe mode -- would-honor obligation(s) for ...: [...]), never applied
(a handler's side effect should only fire for a call that is really being
allowed, which observe mode never actually decides) and never silently
dropped -- forwarding then proceeds exactly as before (observe must never
break traffic).
Dispatch is a deterministic string lookup only -- no LLM, no fuzzy/partial matching of an unrecognized obligation against a known one.
v1 known-set
Deliberately minimal (design doc section 25 explicitly allows "empty" for
v1) -- the base policy pack in this repo emits no obligations today, so real
coverage comes from tests with synthetic ones
(tests/test_gateway_obligations.py, tests/fixtures/e2e_obligations.py).
One example ships anyway, to prove the mechanism against a REAL string
rather than nothing:
| Obligation | Handler behavior |
|---|---|
audit:full |
Logs the full envelope to stderr, tagged with gateway_correlation_id. The exact obligation string SPEC section 5's own Decision example uses, and the one reeflex-spec/ADAPTER-EXAMPLES.md section C's shared Rego rule emits. Does not replace reeflex-core's own unconditional audit record (SPEC section 6) -- purely additive, gateway-side visibility. |
How to add an obligation handler
from reeflex_mcp import obligations
def _handle_rate_limit(ctx: obligations.ObligationContext) -> None:
# ctx.obligation, ctx.envelope, ctx.decision, ctx.gateway_correlation_id,
# ctx.upstream_name, ctx.tool_name are all available. A handler may do
# local I/O (e.g. logging, updating an in-memory counter) but must NEVER
# call an LLM, make its own network decision call, or otherwise re-decide
# anything -- SPEC's zero-LLM-in-the-decision-path invariant extends here.
print(f"[myext] rate_limit obligation seen for {ctx.upstream_name}__{ctx.tool_name}")
obligations.register("rate_limit", _handle_rate_limit)
Call obligations.register(...) from any module imported before the gateway
starts serving (e.g. at the top of a small extension module you import in
your own entry point, or by editing reeflex_mcp/obligations.py directly for
a source checkout). Last registration for a given string wins. An obligation
with NO registered handler is, by design, treated as unknown -- it blocks in
enforce mode and is recorded (not applied) in observe mode; there is no
"soft" middle option, matching SPEC's non-conformance language verbatim.
Lifecycle: setup / add / import / doctor (Track 5, design doc section 13)
SINGLE-PATH LIMIT -- read this first (design doc section 13, verbatim)
The gateway governs only what flows through it. A server added directly to the client is an ungoverned path --
doctordetects it, cannot prevent it. On hostile/multi-user machines, single-path must be enforced at the OS/network level. In service mode, single-path is enforced by network topology (upstreams reachable only from the gateway) -- the robust model.
Everything below is UX around that limit, not a way around it. setup and
import rewrite a client config so it launches ONLY the gateway; doctor
notices when that invariant has drifted. None of it can stop an operator (or
anyone with filesystem access to the client config) from adding a server
back directly five minutes later -- that is a structural property of "the
client decides what to launch," not a bug here.
reeflex-mcp setup
Reads mcpServers from the standard MCP client config locations:
| Client | Path |
|---|---|
| Claude Desktop | OS-specific claude_desktop_config.json (%APPDATA%\Claude\... / ~/Library/Application Support/Claude/... / ~/.config/Claude/...) |
| Claude Code (project) | ./.mcp.json |
| Claude Code settings | .claude/settings.json (project or global) |
For each server found there, it derives a reeflex-mcp.yaml upstream (the
server's own command/args or url; target.system defaults to the
server's own name -- so a server named filesystem/github/postgres
automatically picks up this package's bundled Track 4 mapping), then
backs up the client config (<path>.reeflex-mcp-backup -- never
overwritten by a later run, so it always holds the true pre-gateway state)
and rewrites it to a single reeflex-mcp entry. Idempotent: re-running
setup on an already-migrated config is a clean no-op; re-running it after
a NEW server was added directly imports just that one.
An inline secret on a remote server's Authorization header is never
copied into reeflex-mcp.yaml (secrets by-reference only) -- setup warns
and tells you to set auth: { token_env: ... } by hand instead.
reeflex-mcp setup # every standard location that exists
reeflex-mcp setup --client claude-desktop # just one
reeflex-mcp setup --path ./my-client-config.json # an explicit file
reeflex-mcp setup --environment staging # skip the per-server prompt
reeflex-mcp restore
Undoes setup/import's rewrite of a client config from the backup it made:
reeflex-mcp restore --client claude-desktop
reeflex-mcp add <name>
Registers a new upstream directly (not read from any client config) and, in
streamable-HTTP mode, hot-reloads an ALREADY-RUNNING gateway: it POSTs to the
gateway's /admin/reload route, which reconnects the new upstream and
broadcasts notifications/tools/list_changed to every currently-connected
front session (reusing the Track-2 FrontSessionRegistry) -- already-open
clients see the new tools without reconnecting. Native MCP clients require
a full restart to pick up ANY new server; this doesn't. (stdio front doesn't
need this: one client = one gateway process, and a client restart already
restarts the gateway.)
reeflex-mcp add filesystem --command npx -y @modelcontextprotocol/server-filesystem /data \
--environment staging
If no gateway is reachable at --gateway-url (default derived from
$REEFLEX_MCP_HOST/$REEFLEX_MCP_PORT), the config is still saved -- it
just takes effect on the next gateway start.
reeflex-mcp import <name>
The one-command fix doctor suggests: pulls ONE named server's definition
out of a client config where it was found registered directly (bypassing
the gateway) into reeflex-mcp.yaml, and removes just that one entry from the
client config -- surgical, unlike setup (which migrates everything). Any
OTHER foreign entry in the same file is left untouched.
reeflex-mcp import sneaky-slack --client claude-desktop
reeflex-mcp doctor
The client-config DRIFT check: compares each standard client config's
mcpServers against the single-gateway-entry invariant. Reports a
foreign_server finding for anything registered directly (an ungoverned
path) and a gateway_missing finding if the gateway entry itself is absent.
Runs automatically at every gateway startup (non-fatal -- see cmd_run
in cli.py) and on demand. No file-watching (YAGNI, design doc section
13): a stdio client restart already restarts the gateway, which is exactly
when a manually-edited client config matters -- there is nothing a
background watcher would catch that this doesn't already catch for free.
reeflex-mcp doctor # every standard location that exists
reeflex-mcp doctor --client claude-desktop
Exits 0 (clean) or 1 (findings) -- scriptable.
Install (development)
cd reeflex-mcp
python -m venv .venv
.venv/Scripts/pip install -e . # Windows
# .venv/bin/pip install -e . # Linux/macOS
Configure
Copy reeflex-mcp.yaml.example to reeflex-mcp.yaml and edit the
upstreams: list. See reeflex_mcp/registry.py's module docstring for the
full field reference.
Run
reeflex-mcp --config reeflex-mcp.yaml --transport stdio
reeflex-mcp --config reeflex-mcp.yaml --transport streamable-http --host 127.0.0.1 --port 8000
Or python -m reeflex_mcp ... (same flags).
reeflex-mcp check # fail-closed self-probe -- exit 0 = PASS, 1 = FAIL
Env vars
| Variable | Default | Purpose |
|---|---|---|
REEFLEX_CORE_URL |
http://127.0.0.1:8080 |
reeflex-core base URL |
REEFLEX_CORE_TOKEN |
unset | bearer token for reeflex-core (never logged) |
REEFLEX_MODE |
observe |
observe | enforce -- overrides the YAML mode: if set |
REEFLEX_VERIFY_SSL |
true |
set to 0/false/no/off to disable TLS verification (dev/self-signed only) |
REEFLEX_MCP_TIMEOUT |
10 |
seconds, HTTP timeout to reeflex-core |
REEFLEX_MCP_CONFIG |
./reeflex-mcp.yaml |
path to the registry file |
REEFLEX_MCP_TRANSPORT |
stdio |
stdio | streamable-http |
REEFLEX_MCP_HOST / REEFLEX_MCP_PORT |
127.0.0.1 / 8000 |
streamable-HTTP bind address |
REEFLEX_MCP_UPSTREAM_CONNECT_TIMEOUT |
10 |
seconds, per-upstream connect timeout at boot (fail-closed-at-boot) |
REEFLEX_MCP_CALL_TIMEOUT |
30 |
seconds, per-call dispatch timeout to an upstream |
REEFLEX_MCP_MAPPINGS_DIR |
unset | directory of declarative <system>.yaml mappings (Track 4) -- overrides the YAML mappings_dir: key; unset on both -> this package's own bundled starter mappings |
REEFLEX_MCP_ADMIN_TOKEN |
unset | optional shared-token gate on the /admin/reload hot-reload route (Track 5); unset -> no auth required (fine for a gateway bound to 127.0.0.1, the default -- your own risk to widen without setting this) |
Tests
.venv/Scripts/python -m pytest tests/ -q
tests/fixtures/ also contains manual, real end-to-end fixtures (two
throwaway upstream servers -- one stdio, one streamable-HTTP -- plus driver
scripts, including e2e_enforce_holds.py for the full hold -> resolve ->
resubmit -> execute round trip, and e2e_obligations.py +
stub_core_obligations.py for the obligations enforce-block/observe-record
proof against a real gateway subprocess and a real HTTP stand-in for
reeflex-core -- the real base policy pack emits no obligations today, so
this is the only way to drive a genuinely live obligations scenario without
touching reeflex-core itself) used to prove the full intercept -> normalize
-> decide -> audit -> dispatch path against a real local reeflex-core (or,
for obligations, its stub stand-in). These are not part of the pytest suite
(they need real processes already running) -- see the adapter-builder's
report for the exact commands and the raw transcript.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file reeflex_mcp-0.1.0.tar.gz.
File metadata
- Download URL: reeflex_mcp-0.1.0.tar.gz
- Upload date:
- Size: 103.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b472355ccf0e6d86bbff50e36e9823ad41d9e124bf6f291e52623601b97213fa
|
|
| MD5 |
cf9c28cb8ea9f93878aed296be71a375
|
|
| BLAKE2b-256 |
5d38d8754d98979791221e82acf46a0ad066f6434b4916b28b8e0d30f71a0b53
|
Provenance
The following attestation bundles were made for reeflex_mcp-0.1.0.tar.gz:
Publisher:
release.yml on Reeflex-io/reeflex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reeflex_mcp-0.1.0.tar.gz -
Subject digest:
b472355ccf0e6d86bbff50e36e9823ad41d9e124bf6f291e52623601b97213fa - Sigstore transparency entry: 2152075077
- Sigstore integration time:
-
Permalink:
Reeflex-io/reeflex@aeea7b7bc225263f89094e9f3062f3ab9a34f2c8 -
Branch / Tag:
refs/tags/v0.1.11 - Owner: https://github.com/Reeflex-io
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aeea7b7bc225263f89094e9f3062f3ab9a34f2c8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file reeflex_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: reeflex_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 79.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eac1cc507e059aa2ad3568ac6cee7b94c88f9a5e1d463ffebac172900085cdb5
|
|
| MD5 |
0a270fd7561dc2621c3d6135ad9f7f55
|
|
| BLAKE2b-256 |
c339e61f822d2cc3965cfde82bde3e76a21e8a64d6afb1b271c5be5cb913706f
|
Provenance
The following attestation bundles were made for reeflex_mcp-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Reeflex-io/reeflex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reeflex_mcp-0.1.0-py3-none-any.whl -
Subject digest:
eac1cc507e059aa2ad3568ac6cee7b94c88f9a5e1d463ffebac172900085cdb5 - Sigstore transparency entry: 2152075084
- Sigstore integration time:
-
Permalink:
Reeflex-io/reeflex@aeea7b7bc225263f89094e9f3062f3ab9a34f2c8 -
Branch / Tag:
refs/tags/v0.1.11 - Owner: https://github.com/Reeflex-io
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aeea7b7bc225263f89094e9f3062f3ab9a34f2c8 -
Trigger Event:
push
-
Statement type: