MCP server exposing reeflex-core's Human-in-the-Loop (HIL) holds API -- list, inspect, and resolve Reeflex governance holds from any MCP client.
Project description
reeflex-holds
An MCP server that turns Reeflex's Human-in-the-Loop (HIL) holds queue into a socket any MCP client can talk to. Any MCP-capable client (Claude Desktop, a coding agent, a custom bot) can list pending governance holds, inspect one, and approve or reject it -- without a bespoke integration per client.
What it is: a thin MCP wrapper around three reeflex-core HTTP endpoints
(GET /v1/holds, GET /v1/holds/{id}, POST /v1/holds/{id}/resolve), plus a
best-effort reachability probe.
What it is NOT: it does not decide, enforce, or execute anything. Every
governance rule -- who may resolve which hold, whether the resolving identity
is allowed to act, whether a hold has expired -- is enforced by reeflex-core
(OPA/Rego + classical logic), exactly as it is for every other adapter. This
package forwards HTTP calls and relays reeflex-core's response, success or
error, verbatim. A rejection from core (409, 403, 404, ...) is never retried,
softened, or overridden here -- it surfaces to the MCP client as a real tool
error.
Tools
| Tool | Arguments | Calls |
|---|---|---|
list_holds |
status? (pending|approved|rejected|expired|consumed) |
GET /v1/holds?status= |
get_hold |
id |
GET /v1/holds/{id} |
resolve_hold |
id, decision (approve|reject), reason? |
POST /v1/holds/{id}/resolve |
get_freeze_status |
(none) | GET /healthz (best-effort; see below) |
resolve_hold never accepts a principal argument. The resolving identity
always comes from this server's own REEFLEX_PRINCIPAL configuration -- an
MCP client cannot resolve a hold "as" an arbitrary identity by simply asking
to. reeflex-core still independently enforces the operator's resolution
policy (which principal types may resolve which rule), the R3/systemic
immunity guard (irreversible_systemic_prod holds can never be resolved by
anyone), and the actor-is-approver check (the agent whose action raised the
hold can never resolve it, on any surface, including this one). None of that
enforcement lives in this package -- it lives in reeflex-core/app/server.py.
Honest two-step reality for WordPress (and other adapter) holds
Resolving a hold via MCP marks it approved IN REEFLEX-CORE ONLY. It does not execute anything. The underlying action still has to run on the adapter that raised it:
- For the WordPress adapter: the hold's
approvedstatus in core does not, by itself, delete the 50 posts (or whatever the held action was). The WordPress action completes WordPress-side, either via the wp-admin "run approved" button, or automatically the next time the adapter resubmits the same envelope (e.g. on the admin's next matching request) and core sees a matching, approved, unconsumed hold. - The same is true for any other adapter:
reeflex-holdsis a read/resolve console for the hold record, not an execution engine. It has no way to reach into WordPress, a CI pipeline, or anything else and run the action.
If you approve a hold here and the "thing" doesn't visibly happen, that is expected -- check the originating adapter for its own resubmission / "run approved" mechanism.
Config (env)
| Variable | Required | Default | Purpose |
|---|---|---|---|
REEFLEX_CORE_URL |
no | http://127.0.0.1:8080 |
reeflex-core base URL |
REEFLEX_TOKEN |
no | unset | optional bearer token; adds Authorization: Bearer <token> to every request. Never logged. Holds the same reeflex-core bearer token the other adapters call REEFLEX_CORE_TOKEN — see the naming note below. |
REEFLEX_PRINCIPAL |
only for resolve_hold |
unset | "type:id" of the resolving identity, e.g. human:leo or agent:triage-bot. Split on the first colon (an id may itself contain colons). list_holds, get_hold, and get_freeze_status do not need it. |
REEFLEX_VERIFY_SSL |
no | true (full TLS verification) |
set to 0/false/no/off (case-insensitive) to disable TLS certificate verification -- dev/self-signed endpoints only, at the operator's own risk. Same env name and semantics as reeflex-claude and the WordPress adapter, per the project's standing TLS-verify-opt-out rule. |
REEFLEX_HOLDS_TIMEOUT |
no | 10 (seconds) |
hard socket timeout for every HTTP request to core; this package never issues an unbounded request |
Naming note.
REEFLEX_CORE_TOKENis the project-wide name for thereeflex-corebearer token, used by thereeflex-claudeandreeflex-wordpressadapters.reeflex-holdsis the one exception: it reads the same bearer token, but fromREEFLEX_TOKENinstead, per its own original brief. Same credential, same purpose, just a different env var name for this one package — setREEFLEX_TOKENhere to whatever value the other adapters put inREEFLEX_CORE_TOKEN. A non-breaking unification (acceptingREEFLEX_CORE_TOKENwithREEFLEX_TOKENas a fallback) is a candidate for a future 0.1.1, not implemented here.
Why the mcp SDK
This package's only dependency is the official
MCP Python SDK (mcp.server.fastmcp.FastMCP).
reeflex-core itself stays dependency-free by contract (stdlib + OPA
subprocess only), and the other two adapters (reeflex-claude,
reeflex-wordpress) are also zero/near-zero-dependency by design -- but this
package's entire job is to speak the MCP protocol correctly to arbitrary MCP
clients, and hand-rolling that protocol (JSON-RPC framing, capability
negotiation, tool schema generation, notification handling, the streaming and
stdio transport edge cases) is exactly the kind of thing a widely-used
official SDK exists to get right once. Depending on mcp here is the
proportional choice for a thin, single-purpose MCP surface -- this is not a
change to reeflex-core's or the other adapters' dependency posture.
Install
pip install reeflex-holds
reeflex-holds is published on PyPI (reeflex-holds==0.1.0). To work from a
local checkout instead (for development, or to track main):
git clone https://github.com/Reeflex-io/reeflex.git
cd reeflex/reeflex-holds
pip install -e .
Running it directly
export REEFLEX_CORE_URL=http://127.0.0.1:8080
export REEFLEX_PRINCIPAL=human:leo
python -m reeflex_holds
This starts the stdio MCP server and blocks, waiting for a client to speak the protocol on stdin/stdout. You normally do not run it manually -- an MCP client (below) launches it as a subprocess.
Claude Desktop demo
Add this to Claude Desktop's claude_desktop_config.json (Settings ->
Developer -> Edit Config), using the absolute path to your checkout:
{
"mcpServers": {
"reeflex-holds": {
"command": "python",
"args": ["-m", "reeflex_holds"],
"env": {
"REEFLEX_CORE_URL": "http://127.0.0.1:8080",
"REEFLEX_TOKEN": "",
"REEFLEX_PRINCIPAL": "human:leo",
"REEFLEX_VERIFY_SSL": "true"
}
}
}
}
Notes:
commandmust resolve to a Python that has this package installed (pip install reeflex-holds, orpip install -e .from a local checkout, as above) -- use an absolute interpreter path (e.g."C:\\path\\to\\venv\\Scripts\\python.exe"or/path/to/venv/bin/python) ifpythonis not reliably on Claude Desktop'sPATH.- Leave
REEFLEX_TOKENempty (or omit it) if your core has noREEFLEX_AUTH_TOKENconfigured. - Set
REEFLEX_VERIFY_SSLtofalseonly against a dev/self-signed core endpoint (e.g. a staging deployment) -- never in production. - Restart Claude Desktop after editing the config.
Expected transcript
Assume reeflex-core is running locally with a pending hold already in its
queue (e.g. a WordPress adapter submitted a 50-post bulk-delete that scored
require_approval under rule reeflex.policy/irreversible_broad_prod).
You type: "List pending Reeflex holds"
What happens: Claude recognizes this maps to the list_holds tool,
calls it with {"status": "pending"}, and gets back something like:
{
"items": [
{
"id": "279ac798cf8f40eb85b5ebbdecafec70",
"status": "pending",
"rule_id": "reeflex.policy/irreversible_broad_prod",
"created_ts": "2026-07-04T19:53:26Z",
"expires_ts": "2026-07-04T23:53:26Z",
"envelope": {
"action": {"namespace": "wordpress", "verb": "delete", "ability": "wordpress/delete-post"},
"axes": {"reversibility": "irreversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 50}
}
}
],
"count": 1
}
Claude responds (paraphrasing the JSON, e.g.): "There is 1 pending hold:
a WordPress bulk-delete of 50 posts (irreversible, broad, in production),
created a few minutes ago, expiring in about 4 hours. Hold id
279ac798.... Would you like to approve or reject it?"
You type: "Approve hold 279ac798cf8f40eb85b5ebbdecafec70"
What happens: Claude calls resolve_hold with
{"id": "279ac798cf8f40eb85b5ebbdecafec70", "decision": "approve"}. The
tool never asks for or sends a principal -- it resolves as whatever
REEFLEX_PRINCIPAL was configured in the server's env (human:leo above).
The response:
{
"id": "279ac798cf8f40eb85b5ebbdecafec70",
"status": "approved",
"decided_by": "human:leo",
"decided_ts": "2026-07-04T19:58:40Z",
"reason": null
}
Claude responds: "Hold 279ac798... is now approved (by human:leo).
Note: this marks it approved in Reeflex core only -- the actual WordPress
bulk-delete still needs the adapter to resubmit or run it (e.g. the
wp-admin 'run approved' button), it does not happen automatically from this
approval alone."
If the agent that raised the hold and REEFLEX_PRINCIPAL are the same
identity, or the hold has already been resolved/expired, or the resolution
policy does not allow this principal type for this rule, resolve_hold
comes back as a genuine MCP tool error carrying core's exact reason (e.g.
actor_is_approver, not_resolvable, principal_type_not_allowed) --
Claude will surface that error text, not a fabricated success.
get_freeze_status -- an honest limitation
reeflex-core has no dedicated freeze-status endpoint. The operator
kill-switch (REEFLEX_FREEZE) is an environment variable read fresh on every
/v1/decide call inside core (see reeflex-core/app/decide.py), and it is
never exposed via the HTTP API. Per this package's brief, we do not
invent a core endpoint to answer this question.
get_freeze_status therefore does the only honest thing available from
outside core: a GET /healthz reachability probe (the one universally
unauthenticated, side-effect-free endpoint core exposes). It always returns:
{
"core_reachable": true,
"freeze_state": "unknown",
"note": "reeflex-core has no dedicated freeze-status endpoint; REEFLEX_FREEZE is an operator-side environment variable re-read on every /v1/decide call ... This is a best-effort GET /healthz reachability probe only -- it cannot report the actual REEFLEX_FREEZE value. To infer freeze state: ask the operator directly, or watch for repeated 'reeflex.policy/frozen' denials in /v1/decide responses or the audit log."
}
freeze_state is always "unknown" -- this tool cannot and does not claim
otherwise. Upgrade path: if/when reeflex-core ships a real freeze-status
endpoint, this function should call it directly and drop the /healthz
fallback.
Running the tests
cd reeflex-holds
pip install -e .
python -m unittest discover -s tests -v
test_config.py and most of test_client.py / test_server.py need no
network (pure parsing, or a local stub HTTP server standing in for
reeflex-core on an ephemeral port). Every test that talks HTTP applies a
hard timeout -- this suite cannot hang.
Live smoke (owned by a separate task, T7): the tests above mock
reeflex-core. A full live smoke test -- a real local reeflex-core
instance (with OPA configured), a genuine bulk-delete envelope raising a
real hold, and a real MCP client (mcp.client.stdio.stdio_client +
ClientSession) driving python -m reeflex_holds as a subprocess against
it -- was run manually during implementation to validate the end-to-end
wiring (see the implementer's report for the transcript). That full
live-smoke harness is expected to live in reeflex-verify or an equivalent
T7 conformance step, not in this package's unit test suite.
Limits / upgrade paths
- No pagination exposed.
reeflex-core'sGET /v1/holdssupportslimit/cursor, butlist_holdshere only exposesstatus(per this package's brief). UPGRADE: add optionallimit/cursorarguments tolist_holdsif a queue ever exceeds core's page size (currently 100). get_freeze_statusis best-effort only -- see the section above. UPGRADE: call a real freeze-status endpoint oncereeflex-coreships one.REEFLEX_TOKEN(this package) vsREEFLEX_CORE_TOKEN(reeflex-claude,reeflex-wordpress) -- same bearer token, different env var name for this package only; see the naming note in Config above. UPGRADE: acceptREEFLEX_CORE_TOKENwithREEFLEX_TOKENas a fallback in a future non-breaking release.- stdio transport only. FastMCP also supports
sseandstreamable-http; this package only wires upstdio(matching the brief and the primary Claude Desktop use case). UPGRADE: exposetransportas a CLI flag or env var if a hosted/remote MCP surface is ever needed.
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_holds-0.1.1.tar.gz.
File metadata
- Download URL: reeflex_holds-0.1.1.tar.gz
- Upload date:
- Size: 25.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9ace912120d55a9d795680cd31012902bcbbac658addd46785c9a25bcaeaa63
|
|
| MD5 |
25e11b30c0365342755487c138910c88
|
|
| BLAKE2b-256 |
f2ba59f1cb16bacea3e7055a36fee985e1713ba23aeb8edbe0053beb025d26b4
|
Provenance
The following attestation bundles were made for reeflex_holds-0.1.1.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_holds-0.1.1.tar.gz -
Subject digest:
a9ace912120d55a9d795680cd31012902bcbbac658addd46785c9a25bcaeaa63 - Sigstore transparency entry: 2087781312
- Sigstore integration time:
-
Permalink:
Reeflex-io/reeflex@93d7f28e51faa01e3941ff9f05454f8d89bbaef5 -
Branch / Tag:
refs/tags/v0.1.10 - Owner: https://github.com/Reeflex-io
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@93d7f28e51faa01e3941ff9f05454f8d89bbaef5 -
Trigger Event:
push
-
Statement type:
File details
Details for the file reeflex_holds-0.1.1-py3-none-any.whl.
File metadata
- Download URL: reeflex_holds-0.1.1-py3-none-any.whl
- Upload date:
- Size: 16.9 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 |
44196454c243e9d58cc46accb93ca0b98ab8b49a8794c7ae54c1a240fee05900
|
|
| MD5 |
9e461c2f61909d508841945a9719cd96
|
|
| BLAKE2b-256 |
5b0a0af141821efbd970cc29f72a161e11457f68cdca9a8505aba825935c9822
|
Provenance
The following attestation bundles were made for reeflex_holds-0.1.1-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_holds-0.1.1-py3-none-any.whl -
Subject digest:
44196454c243e9d58cc46accb93ca0b98ab8b49a8794c7ae54c1a240fee05900 - Sigstore transparency entry: 2087781516
- Sigstore integration time:
-
Permalink:
Reeflex-io/reeflex@93d7f28e51faa01e3941ff9f05454f8d89bbaef5 -
Branch / Tag:
refs/tags/v0.1.10 - Owner: https://github.com/Reeflex-io
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@93d7f28e51faa01e3941ff9f05454f8d89bbaef5 -
Trigger Event:
push
-
Statement type: