raposa-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DC ESCRYPT SL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: raposa-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Raposa Aval: give your agent a request_human_approval tool with a signed audit trail.
5
+ Project-URL: Homepage, https://raposa.group
6
+ Project-URL: Documentation, https://raposa.group/docs/
7
+ Project-URL: Source, https://github.com/agentlabbusiness/raposa-mcp
8
+ Author-email: DC ESCRYPT SL <contact@raposa.group>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,approval,audit,human-in-the-loop,mcp
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: mcp<2,>=1.2
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == 'test'
20
+ Requires-Dist: pytest-asyncio; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # raposa-mcp
24
+
25
+ An MCP server that gives your agent one honest tool: **ask a human**.
26
+
27
+ ```
28
+ request_human_approval(action, context, risk) -> { approved: true|false, decided_by, ... }
29
+ ```
30
+
31
+ `approved` is `true` only when a named person pressed Approve. A timeout, an expiry or a rejection all
32
+ return `approved: false` — silence is not consent. Every decision is sealed in Raposa's hash-chained
33
+ audit log, exportable for an auditor. EU-hosted, DPA available.
34
+
35
+ ## Install
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "raposa": {
41
+ "command": "uvx",
42
+ "args": ["raposa-mcp"],
43
+ "env": { "RAPOSA_API_KEY": "<your key>" }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Claude Code: `claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx raposa-mcp`
50
+
51
+ Until the PyPI release lands, install straight from GitHub (identical code):
52
+
53
+ ```bash
54
+ claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx --from git+https://github.com/agentlabbusiness/raposa-mcp raposa-mcp
55
+ ```
56
+
57
+ Get a free sandbox key (100 approvals a month) at https://raposa.group/start/ — it arrives by email in a minute.
58
+
59
+ ## Tools
60
+
61
+ | Tool | What it does |
62
+ |---|---|
63
+ | `request_human_approval(action, context, risk, requested_by?, wait_sec=600, expires_in_sec?)` | creates the request and waits for a human; returns `approved` |
64
+ | `create_approval(action, context, risk, requested_by?, expires_in_sec?, webhook_url?)` | returns the id at once; decision arrives on the HMAC-signed webhook or via `get_approval` |
65
+ | `get_approval(approval_id)` | reads one approval |
66
+
67
+ `risk` is `low | medium | high`. The API key is read from `RAPOSA_API_KEY` only and never appears in tool
68
+ inputs or outputs. Base URL override: `RAPOSA_API_BASE` (default `https://dcescrypt.com/api`).
69
+
70
+ ## Who approves
71
+
72
+ The people on your team who hold the approve button get a scoped console login and, if you give us their
73
+ email, every request also reaches them as a mail with signed Approve / Reject links. Their name is recorded on
74
+ the decision. Docs: https://raposa.group/docs/#approvers
75
+
76
+ ## Develop
77
+
78
+ ```bash
79
+ pip install -e ".[test]" && pytest -q
80
+ ```
81
+
82
+ MIT · DC ESCRYPT SL · contact@raposa.group
@@ -0,0 +1,60 @@
1
+ # raposa-mcp
2
+
3
+ An MCP server that gives your agent one honest tool: **ask a human**.
4
+
5
+ ```
6
+ request_human_approval(action, context, risk) -> { approved: true|false, decided_by, ... }
7
+ ```
8
+
9
+ `approved` is `true` only when a named person pressed Approve. A timeout, an expiry or a rejection all
10
+ return `approved: false` — silence is not consent. Every decision is sealed in Raposa's hash-chained
11
+ audit log, exportable for an auditor. EU-hosted, DPA available.
12
+
13
+ ## Install
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "raposa": {
19
+ "command": "uvx",
20
+ "args": ["raposa-mcp"],
21
+ "env": { "RAPOSA_API_KEY": "<your key>" }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Claude Code: `claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx raposa-mcp`
28
+
29
+ Until the PyPI release lands, install straight from GitHub (identical code):
30
+
31
+ ```bash
32
+ claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx --from git+https://github.com/agentlabbusiness/raposa-mcp raposa-mcp
33
+ ```
34
+
35
+ Get a free sandbox key (100 approvals a month) at https://raposa.group/start/ — it arrives by email in a minute.
36
+
37
+ ## Tools
38
+
39
+ | Tool | What it does |
40
+ |---|---|
41
+ | `request_human_approval(action, context, risk, requested_by?, wait_sec=600, expires_in_sec?)` | creates the request and waits for a human; returns `approved` |
42
+ | `create_approval(action, context, risk, requested_by?, expires_in_sec?, webhook_url?)` | returns the id at once; decision arrives on the HMAC-signed webhook or via `get_approval` |
43
+ | `get_approval(approval_id)` | reads one approval |
44
+
45
+ `risk` is `low | medium | high`. The API key is read from `RAPOSA_API_KEY` only and never appears in tool
46
+ inputs or outputs. Base URL override: `RAPOSA_API_BASE` (default `https://dcescrypt.com/api`).
47
+
48
+ ## Who approves
49
+
50
+ The people on your team who hold the approve button get a scoped console login and, if you give us their
51
+ email, every request also reaches them as a mail with signed Approve / Reject links. Their name is recorded on
52
+ the decision. Docs: https://raposa.group/docs/#approvers
53
+
54
+ ## Develop
55
+
56
+ ```bash
57
+ pip install -e ".[test]" && pytest -q
58
+ ```
59
+
60
+ MIT · DC ESCRYPT SL · contact@raposa.group
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "raposa-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for Raposa Aval: give your agent a request_human_approval tool with a signed audit trail."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "DC ESCRYPT SL", email = "contact@raposa.group" }]
13
+ keywords = ["mcp", "human-in-the-loop", "approval", "ai-agents", "audit"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Libraries",
18
+ ]
19
+ dependencies = ["mcp>=1.2,<2", "httpx>=0.27"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://raposa.group"
23
+ Documentation = "https://raposa.group/docs/"
24
+ Source = "https://github.com/agentlabbusiness/raposa-mcp"
25
+
26
+ [project.scripts]
27
+ raposa-mcp = "raposa_mcp.server:main"
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest", "pytest-asyncio"]
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
@@ -0,0 +1,2 @@
1
+ """raposa-mcp: MCP server for Raposa Aval human approvals."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,145 @@
1
+ """raposa-mcp — an MCP server that gives an agent one honest tool: ask a human.
2
+
3
+ request_human_approval(action, context, risk) -> {approved: bool, ...}
4
+
5
+ `approved` is True only when a named human approved. A timeout, an expiry or
6
+ a rejection all come back with approved=False — silence is not consent.
7
+ The API key lives in RAPOSA_API_KEY and is never part of tool inputs or
8
+ outputs. Spec: raposa/docs/specs/R6-mcp-server.md
9
+ """
10
+ import asyncio
11
+ import json
12
+ import os
13
+
14
+ import httpx
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ DEFAULT_BASE = "https://dcescrypt.com/api"
18
+ POLL_SEC = 5.0
19
+ RISKS = ("low", "medium", "high")
20
+
21
+ mcp = FastMCP(
22
+ "raposa",
23
+ instructions=("Raposa Aval: pause a high-stakes action until an authorised human approves it. "
24
+ "Call request_human_approval before payments, deploys, deletions or customer-facing "
25
+ "commitments. Proceed only when the result has approved=true."),
26
+ )
27
+
28
+ _transport = None # tests inject an httpx.MockTransport
29
+
30
+
31
+ def configure(transport=None):
32
+ global _transport
33
+ _transport = transport
34
+
35
+
36
+ class ToolError(Exception):
37
+ pass
38
+
39
+
40
+ def _client() -> httpx.AsyncClient:
41
+ key = os.environ.get("RAPOSA_API_KEY", "").strip()
42
+ if not key:
43
+ raise ToolError("RAPOSA_API_KEY is not set. Get a free sandbox key at https://raposa.group/start/ "
44
+ "and put it in the MCP server's environment.")
45
+ base = os.environ.get("RAPOSA_API_BASE", DEFAULT_BASE).rstrip("/")
46
+ return httpx.AsyncClient(base_url=base, transport=_transport, timeout=20.0,
47
+ headers={"Authorization": f"Bearer {key}",
48
+ "User-Agent": "raposa-mcp/0.1 (+https://raposa.group)"})
49
+
50
+
51
+ async def _request(method: str, path: str, **kw) -> dict:
52
+ async with _client() as c:
53
+ r = await c.request(method, path, **kw)
54
+ if r.status_code == 429:
55
+ await asyncio.sleep(min(float(r.headers.get("Retry-After", "1") or 1), 10))
56
+ r = await c.request(method, path, **kw)
57
+ if r.status_code >= 400:
58
+ raise ToolError(f"Raposa API {r.status_code} on {method} {path}: {r.text[:300]}")
59
+ return r.json()
60
+
61
+
62
+ def _shape(a: dict) -> dict:
63
+ status = a.get("status")
64
+ return {"id": a.get("id"), "status": status, "approved": status == "approved",
65
+ "decided_by": a.get("decided_by") or None, "decided_at": a.get("decided_at"),
66
+ "comment": a.get("decision_comment"),
67
+ "message": {"approved": "Approved by a human — proceed.",
68
+ "rejected": "Rejected by a human — do not proceed.",
69
+ "expired": "Not approved — the request expired before anyone decided.",
70
+ "pending": "Still waiting for a human."}.get(status, "Not approved.")}
71
+
72
+
73
+ def _validate(action: str, context: str, risk: str):
74
+ if risk not in RISKS:
75
+ raise ToolError(f"risk must be one of {RISKS}, got {risk!r}")
76
+ if not action or not context:
77
+ raise ToolError("action and context are required")
78
+
79
+
80
+ def _create_body(action, context, risk, requested_by, expires_in_sec, webhook_url):
81
+ body = {"action": action[:200], "context": context[:4000], "risk": risk,
82
+ "requested_by": (requested_by or "mcp-agent")[:200]}
83
+ if expires_in_sec:
84
+ body["expires_in_sec"] = int(expires_in_sec)
85
+ if webhook_url:
86
+ body["webhook_url"] = webhook_url
87
+ return body
88
+
89
+
90
+ @mcp.tool()
91
+ async def create_approval(action: str, context: str, risk: str, requested_by: str = "mcp-agent",
92
+ expires_in_sec: int | None = None, webhook_url: str | None = None) -> str:
93
+ """Create an approval request and return its id immediately (no waiting).
94
+ Use with a webhook_url, or poll with get_approval. risk: low | medium | high."""
95
+ try:
96
+ _validate(action, context, risk)
97
+ out = await _request("POST", "/v1/approvals",
98
+ json=_create_body(action, context, risk, requested_by, expires_in_sec, webhook_url))
99
+ return json.dumps({"id": out.get("id"), "status": out.get("status")})
100
+ except ToolError as exc:
101
+ raise RuntimeError(str(exc))
102
+
103
+
104
+ @mcp.tool()
105
+ async def get_approval(approval_id: str) -> str:
106
+ """Read an approval by id. Look at `approved` — it is true only when a human approved."""
107
+ try:
108
+ return json.dumps(_shape(await _request("GET", f"/v1/approvals/{approval_id}")))
109
+ except ToolError as exc:
110
+ raise RuntimeError(str(exc))
111
+
112
+
113
+ @mcp.tool()
114
+ async def request_human_approval(action: str, context: str, risk: str, requested_by: str = "mcp-agent",
115
+ wait_sec: int = 600, expires_in_sec: int | None = None) -> str:
116
+ """Ask an authorised human to approve an action and wait for the answer.
117
+ Returns {approved: bool, status, decided_by, ...}. Proceed ONLY if approved is true —
118
+ a timeout, an expiry or a rejection means do not proceed. risk: low | medium | high."""
119
+ try:
120
+ _validate(action, context, risk)
121
+ created = await _request("POST", "/v1/approvals",
122
+ json=_create_body(action, context, risk, requested_by, expires_in_sec, None))
123
+ aid = created.get("id")
124
+ loop = asyncio.get_event_loop()
125
+ deadline = loop.time() + max(0, int(wait_sec))
126
+ while True:
127
+ a = await _request("GET", f"/v1/approvals/{aid}")
128
+ if a.get("status") != "pending":
129
+ return json.dumps(_shape(a))
130
+ if loop.time() >= deadline:
131
+ return json.dumps({"id": aid, "status": "timeout", "approved": False,
132
+ "decided_by": None, "decided_at": None, "comment": None,
133
+ "message": f"Not approved — nobody answered in {wait_sec}s. "
134
+ "The request is still open; check later with get_approval."})
135
+ await asyncio.sleep(POLL_SEC)
136
+ except ToolError as exc:
137
+ raise RuntimeError(str(exc))
138
+
139
+
140
+ def main():
141
+ mcp.run()
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -0,0 +1,142 @@
1
+ """R6 — contract tests for the raposa-mcp server. Spec: raposa/docs/specs/R6-mcp-server.md
2
+
3
+ Written before the implementation. The Raposa API is faked with an httpx
4
+ MockTransport; the MCP side is exercised through the SDK's in-memory
5
+ client/server session, i.e. exactly what a real MCP client would see.
6
+ """
7
+ import json
8
+
9
+ import httpx
10
+ import pytest
11
+ from mcp.shared.memory import create_connected_server_and_client_session as client_session
12
+
13
+ from raposa_mcp import server as srv
14
+
15
+
16
+ class FakeApi:
17
+ """Minimal Raposa API: create → pending; decided after `decide_after` reads."""
18
+
19
+ def __init__(self, decide_after=1, final="approved", status_code=200):
20
+ self.decide_after, self.final, self.status_code = decide_after, final, status_code
21
+ self.reads, self.calls, self.auth = 0, [], []
22
+
23
+ def handler(self, request: httpx.Request) -> httpx.Response:
24
+ self.calls.append((request.method, request.url.path))
25
+ self.auth.append(request.headers.get("authorization"))
26
+ if self.status_code != 200:
27
+ return httpx.Response(self.status_code, json={"detail": f"faked {self.status_code}"})
28
+ if request.method == "POST" and request.url.path.endswith("/v1/approvals"):
29
+ body = json.loads(request.content)
30
+ assert body["risk"] in ("low", "medium", "high")
31
+ return httpx.Response(200, json={"id": "ap-1", "status": "pending"})
32
+ if request.method == "GET" and "/v1/approvals/ap-1" in request.url.path:
33
+ self.reads += 1
34
+ decided = self.reads >= self.decide_after
35
+ return httpx.Response(200, json={
36
+ "id": "ap-1", "action": "payout", "status": self.final if decided else "pending",
37
+ "decided_by": "approver:finance-lead" if decided else "",
38
+ "decided_at": "2026-09-03T10:00:00.000000Z" if decided else None,
39
+ "decision_comment": "ok" if decided else None})
40
+ return httpx.Response(404, json={"detail": "no"})
41
+
42
+
43
+ @pytest.fixture
44
+ def api(monkeypatch):
45
+ fake = FakeApi()
46
+ monkeypatch.setenv("RAPOSA_API_KEY", "test-key-value")
47
+ monkeypatch.setattr(srv, "POLL_SEC", 0) # no real sleeping in tests
48
+ srv.configure(transport=httpx.MockTransport(fake.handler))
49
+ return fake
50
+
51
+
52
+ async def _call(name, args):
53
+ async with client_session(srv.mcp._mcp_server) as client:
54
+ return await client.call_tool(name, args)
55
+
56
+
57
+ def _payload(result):
58
+ text = "".join(c.text for c in result.content if getattr(c, "text", None))
59
+ try:
60
+ return json.loads(text)
61
+ except ValueError:
62
+ return {"_text": text}
63
+
64
+
65
+ async def test_tools_are_listed_with_schemas(api):
66
+ async with client_session(srv.mcp._mcp_server) as client:
67
+ tools = {t.name: t for t in (await client.list_tools()).tools}
68
+ assert set(tools) >= {"request_human_approval", "create_approval", "get_approval"}
69
+ props = tools["request_human_approval"].inputSchema["properties"]
70
+ assert {"action", "context", "risk"} <= set(props)
71
+ assert "api_key" not in props and "key" not in props
72
+
73
+
74
+ async def test_request_waits_and_returns_approved(api):
75
+ api.decide_after = 3
76
+ r = await _call("request_human_approval",
77
+ {"action": "payout", "context": "invoice 7", "risk": "high", "wait_sec": 30})
78
+ out = _payload(r)
79
+ assert out["status"] == "approved" and out["approved"] is True
80
+ assert out["decided_by"] == "approver:finance-lead" and out["id"] == "ap-1"
81
+ assert api.reads == 3
82
+
83
+
84
+ async def test_reject_is_not_approved(api):
85
+ api.final = "rejected"
86
+ out = _payload(await _call("request_human_approval",
87
+ {"action": "payout", "context": "x", "risk": "low"}))
88
+ assert out["status"] == "rejected" and out["approved"] is False
89
+
90
+
91
+ async def test_timeout_is_not_approval(api):
92
+ api.decide_after = 10 ** 6
93
+ out = _payload(await _call("request_human_approval",
94
+ {"action": "payout", "context": "x", "risk": "low", "wait_sec": 0}))
95
+ assert out["status"] == "timeout" and out["approved"] is False
96
+ assert "not approved" in out["message"].lower()
97
+
98
+
99
+ async def test_expired_is_not_approval(api):
100
+ api.final = "expired"
101
+ out = _payload(await _call("request_human_approval",
102
+ {"action": "payout", "context": "x", "risk": "low"}))
103
+ assert out["status"] == "expired" and out["approved"] is False
104
+
105
+
106
+ async def test_create_returns_id_without_waiting(api):
107
+ out = _payload(await _call("create_approval", {"action": "deploy", "context": "v2", "risk": "medium"}))
108
+ assert out == {"id": "ap-1", "status": "pending"}
109
+ assert api.reads == 0
110
+
111
+
112
+ async def test_get_approval(api):
113
+ api.decide_after = 1
114
+ out = _payload(await _call("get_approval", {"approval_id": "ap-1"}))
115
+ assert out["status"] == "approved" and out["approved"] is True
116
+
117
+
118
+ async def test_api_error_is_reported_not_swallowed(api):
119
+ api.status_code = 401
120
+ r = await _call("create_approval", {"action": "x", "context": "y", "risk": "low"})
121
+ assert r.isError
122
+ assert "401" in _payload(r).get("_text", "")
123
+
124
+
125
+ async def test_bad_risk_never_hits_the_api(api):
126
+ r = await _call("create_approval", {"action": "x", "context": "y", "risk": "critical"})
127
+ assert r.isError and api.calls == []
128
+
129
+
130
+ async def test_missing_key_is_a_clear_error_and_server_still_lists_tools(monkeypatch):
131
+ monkeypatch.delenv("RAPOSA_API_KEY", raising=False)
132
+ srv.configure(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
133
+ async with client_session(srv.mcp._mcp_server) as client:
134
+ assert (await client.list_tools()).tools
135
+ r = await client.call_tool("create_approval", {"action": "x", "context": "y", "risk": "low"})
136
+ assert r.isError and "RAPOSA_API_KEY" in _payload(r).get("_text", "")
137
+
138
+
139
+ async def test_key_is_sent_as_bearer_and_never_returned(api):
140
+ r = await _call("create_approval", {"action": "x", "context": "y", "risk": "low"})
141
+ assert api.auth[-1] == "Bearer test-key-value"
142
+ assert "test-key-value" not in json.dumps(_payload(r))