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.
- raposa_mcp-0.1.0/.gitignore +6 -0
- raposa_mcp-0.1.0/LICENSE +21 -0
- raposa_mcp-0.1.0/PKG-INFO +82 -0
- raposa_mcp-0.1.0/README.md +60 -0
- raposa_mcp-0.1.0/pyproject.toml +33 -0
- raposa_mcp-0.1.0/raposa_mcp/__init__.py +2 -0
- raposa_mcp-0.1.0/raposa_mcp/server.py +145 -0
- raposa_mcp-0.1.0/tests/test_server.py +142 -0
raposa_mcp-0.1.0/LICENSE
ADDED
|
@@ -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,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))
|