vrymdor-node 1.1.0__py3-none-any.whl

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.
vrymdor_mcp.py ADDED
@@ -0,0 +1,235 @@
1
+ """vrymdor-mcp: connect Claude Desktop, Cursor, VS Code or any MCP client to your Vrymdor AI Node, no Docker needed.
2
+
3
+ Desktop MCP clients start a local command and talk to it over standard input and output. This command relays each
4
+ message to your node's MCP endpoint (POST /mcp) and writes the node's answer back. It trades the Agent ID and Agent
5
+ Secret for a short-lived token and renews it a minute before it expires, so the client never handles a token.
6
+
7
+ uvx --from vrymdor-node vrymdor-mcp # what the MCP client runs
8
+ uvx --from vrymdor-node vrymdor-mcp --check # one-time test: does the node accept this agent?
9
+
10
+ Settings come from the environment (the MCP client's "env" block):
11
+
12
+ VRYMNOX_NODE_URL your Node Host URL, for example https://node.vrymdor.com/t/abc123def456
13
+ or http://localhost:8080 for a node on this computer
14
+ VRYMNOX_AGENT_ID agt_...
15
+ VRYMNOX_AGENT_SECRET vrym_agent_...
16
+
17
+ The VRYMDOR_ spellings (VRYMDOR_NODE_URL and so on, as in the GitHub Action) work too. Standard output carries only
18
+ MCP messages; anything for a person goes to standard error.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from typing import TextIO
28
+
29
+ import httpx
30
+
31
+ NAMES = {"url": ("VRYMNOX_NODE_URL", "VRYMDOR_NODE_URL"),
32
+ "id": ("VRYMNOX_AGENT_ID", "VRYMDOR_AGENT_ID"),
33
+ "secret": ("VRYMNOX_AGENT_SECRET", "VRYMDOR_AGENT_SECRET")}
34
+
35
+
36
+ def version() -> str:
37
+ try:
38
+ from importlib.metadata import version as _v
39
+
40
+ return _v("vrymdor-node")
41
+ except Exception: # noqa: BLE001 (running from a source checkout)
42
+ return "0"
43
+
44
+
45
+ def user_agent() -> str:
46
+ # Our own name: a stock library name (Python-urllib, python-httpx) can be refused by Cloudflare's browser check in
47
+ # front of a hosted node.
48
+ return f"vrymdor-mcp/{version()}"
49
+
50
+
51
+ def clean_url(raw: str) -> str:
52
+ """Forgive the usual paste mistakes: spaces, quotes, a slash at the end, or the Token URL ending in /v1/token."""
53
+ url = raw.strip().strip("\"'").strip().rstrip("/")
54
+ if url.endswith("/v1/token"):
55
+ url = url[: -len("/v1/token")].rstrip("/")
56
+ return url
57
+
58
+
59
+ def setting(key: str, env: dict | None = None) -> str:
60
+ env = os.environ if env is None else env
61
+ for name in NAMES[key]:
62
+ if env.get(name, "").strip():
63
+ return env[name].strip().strip("\"'").strip()
64
+ return ""
65
+
66
+
67
+ def refusal(status: int, raw: bytes) -> str:
68
+ """Why the token was refused, in words: the node's own reason, or that something in front of it answered."""
69
+ text = (raw or b"")[:2000].decode("utf-8", "replace")
70
+ try:
71
+ doc = json.loads(text)
72
+ detail = doc.get("detail") or doc.get("error") if isinstance(doc, dict) else None
73
+ except ValueError:
74
+ detail = None
75
+ if isinstance(detail, dict):
76
+ detail = detail.get("message") or detail.get("code")
77
+ if detail:
78
+ return f"the node refused the agent (HTTP {status}): {str(detail)[:200]}"
79
+ low = text.lower()
80
+ if "cloudflare" in low or "error code: 10" in low:
81
+ return (f"the request was blocked before it reached the node (HTTP {status}, Cloudflare). Check the Node Host "
82
+ "URL is the one from your Vrymdor dashboard")
83
+ if status == 401:
84
+ return "the node refused the agent (HTTP 401): the Agent ID or Agent Secret is wrong"
85
+ if status == 404:
86
+ return "the node answered 404: the Node Host URL is wrong. Copy it again from your Vrymdor dashboard"
87
+ if status in (502, 503, 504):
88
+ return f"the node is not answering (HTTP {status}). If it is a hosted node, turn it on in Home base"
89
+ return f"the node refused the agent (HTTP {status})"
90
+
91
+
92
+ class Bridge:
93
+ def __init__(self, url: str, agent_id: str, secret: str, *, http: httpx.Client | None = None) -> None:
94
+ self.url, self.agent_id, self._secret = clean_url(url), agent_id, secret
95
+ self._http = http or httpx.Client(timeout=120.0, headers={"User-Agent": user_agent()})
96
+ self._token, self._expires = "", 0.0
97
+ self._session = ""
98
+
99
+ def token(self) -> str:
100
+ if self._token and time.time() < self._expires - 60:
101
+ return self._token
102
+ r = self._http.post(f"{self.url}/v1/token", json={"agent_id": self.agent_id, "agent_secret": self._secret})
103
+ if r.status_code != 200:
104
+ raise RuntimeError(refusal(r.status_code, r.content))
105
+ doc = r.json()
106
+ self._token, self._expires = doc["access_token"], time.time() + int(doc.get("expires_in", 600))
107
+ return self._token
108
+
109
+ def _send(self, line: str) -> httpx.Response:
110
+ def post() -> httpx.Response:
111
+ headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
112
+ "Authorization": f"Bearer {self.token()}"}
113
+ if self._session:
114
+ headers["Mcp-Session-Id"] = self._session
115
+ return self._http.post(f"{self.url}/mcp", content=line.encode("utf-8"), headers=headers)
116
+
117
+ r = post()
118
+ if r.status_code == 401: # the token was revoked or the node restarted: get a fresh one and try once more
119
+ self._token, self._expires = "", 0.0
120
+ r = post()
121
+ if r.headers.get("mcp-session-id"):
122
+ self._session = r.headers["mcp-session-id"]
123
+ return r
124
+
125
+ def handle(self, line: str, err: TextIO = sys.stderr) -> str | None:
126
+ try:
127
+ msg = json.loads(line)
128
+ except ValueError:
129
+ return json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}})
130
+ has_id = isinstance(msg, dict) and "id" in msg
131
+ try:
132
+ r = self._send(line)
133
+ except (httpx.HTTPError, RuntimeError, ValueError, KeyError) as exc:
134
+ reason = why(exc, self.url)
135
+ print(f"vrymdor-mcp: {reason}", file=err, flush=True)
136
+ if has_id:
137
+ return json.dumps({"jsonrpc": "2.0", "id": msg.get("id"),
138
+ "error": {"code": -32603, "message": f"Your Vrymdor AI Node could not be reached: {reason}"}})
139
+ return None
140
+ if r.status_code == 202 or not r.content:
141
+ return None # a notification
142
+ text = r.content.decode("utf-8")
143
+ if r.headers.get("content-type", "").split(";")[0].strip().lower() == "text/event-stream":
144
+ return sse_messages(text) or None
145
+ return text
146
+
147
+
148
+ def sse_messages(text: str) -> str:
149
+ """A streamed answer (text/event-stream): each event's data lines joined, one JSON message per output line."""
150
+ out, data = [], []
151
+ for raw in text.replace("\r\n", "\n").split("\n"):
152
+ if raw.startswith("data:"):
153
+ data.append(raw[5:].lstrip(" "))
154
+ elif raw == "" and data:
155
+ out.append("".join(data))
156
+ data = []
157
+ if data:
158
+ out.append("".join(data))
159
+ return "\n".join(m for m in out if m.strip())
160
+
161
+
162
+ def why(exc: Exception, url: str) -> str:
163
+ """An exception in words: no connection is the usual case, and it is the address or a node that is off."""
164
+ if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
165
+ return f"could not reach {url} (check the Node Host URL, and that your node is on)"
166
+ if isinstance(exc, httpx.TimeoutException):
167
+ return f"{url} did not answer in time"
168
+ return str(exc)
169
+
170
+
171
+ def _utf8(stream: TextIO) -> None:
172
+ # MCP messages are UTF-8 with \n line endings; a Windows console default (cp1252, \r\n) would damage them.
173
+ try:
174
+ stream.reconfigure(encoding="utf-8", newline="\n") # type: ignore[attr-defined]
175
+ except (AttributeError, ValueError):
176
+ pass
177
+
178
+
179
+ def check(bridge: Bridge, out: TextIO = sys.stdout) -> int:
180
+ """--check: get a token and list the tools, then say in words whether it works."""
181
+ try:
182
+ bridge.token()
183
+ doc = json.loads(bridge.handle(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
184
+ err=io.StringIO()) or "{}")
185
+ if doc.get("error"):
186
+ raise RuntimeError(str(doc["error"].get("message") or doc["error"]).rstrip("."))
187
+ tools = [t.get("name") for t in doc.get("result", {}).get("tools", [])]
188
+ except (httpx.HTTPError, RuntimeError, ValueError, KeyError) as exc:
189
+ print(f"Not connected: {why(exc, bridge.url)}.", file=out)
190
+ return 1
191
+ if not tools:
192
+ print("Connected, but the node listed no tools for this agent. Check the agent's role in your policy.", file=out)
193
+ return 1
194
+ first = sorted(tools, key=lambda n: (n != "vrymnox_sanitize", not str(n).startswith("vrymnox_"), str(n)))[:4]
195
+ print(f"Connected: your node accepted this agent and offers {len(tools)} tools, including "
196
+ f"{', '.join(map(str, first))}.", file=out)
197
+ return 0
198
+
199
+
200
+ def main(argv: list[str] | None = None) -> int:
201
+ args = sys.argv[1:] if argv is None else argv
202
+ if "--version" in args:
203
+ print(f"vrymdor-mcp {version()}")
204
+ return 0
205
+ if "-h" in args or "--help" in args:
206
+ print(__doc__)
207
+ return 0
208
+ missing = [NAMES[k][0] for k in ("url", "id", "secret") if not setting(k)]
209
+ if missing:
210
+ print(f"vrymdor-mcp: set {', '.join(missing)} (in your MCP client's env block)", file=sys.stderr)
211
+ return 2
212
+ url = setting("url")
213
+ if not url.startswith(("http://", "https://")):
214
+ print("vrymdor-mcp: VRYMNOX_NODE_URL must start with https:// (or http:// for a node on this computer)",
215
+ file=sys.stderr)
216
+ return 2
217
+ bridge = Bridge(url, setting("id"), setting("secret"))
218
+ if "--check" in args:
219
+ return check(bridge)
220
+ _utf8(sys.stdin)
221
+ _utf8(sys.stdout)
222
+ try:
223
+ for line in sys.stdin:
224
+ if line.strip():
225
+ out = bridge.handle(line.strip())
226
+ if out is not None:
227
+ sys.stdout.write(out + "\n")
228
+ sys.stdout.flush()
229
+ except KeyboardInterrupt:
230
+ pass
231
+ return 0
232
+
233
+
234
+ if __name__ == "__main__":
235
+ raise SystemExit(main())
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: vrymdor-node
3
+ Version: 1.1.0
4
+ Summary: Python client for the Vrymdor AI Node: governed tool, model and webhook calls, LangChain and LlamaIndex tools, and vrymdor-mcp, the MCP bridge for Claude Desktop, Cursor and VS Code.
5
+ Author-email: Vrymnox Systems LLC <support@vrymdor.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://vrymdor.com
8
+ Project-URL: Documentation, https://docs.vrymdor.com/ai-node.html
9
+ Project-URL: Source, https://github.com/ernesthausmann15/vrymdor-node-python
10
+ Project-URL: Issues, https://github.com/ernesthausmann15/vrymdor-node-python/issues
11
+ Keywords: ai,agents,mcp,governance,pii,langchain,llamaindex,vrymdor
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: httpx<1,>=0.27
25
+ Provides-Extra: langchain
26
+ Requires-Dist: langchain-core>=0.3.49; extra == "langchain"
27
+ Provides-Extra: llamaindex
28
+ Requires-Dist: llama-index-core>=0.12; extra == "llamaindex"
29
+ Requires-Dist: pydantic>=2; extra == "llamaindex"
30
+ Dynamic: license-file
31
+
32
+ # vrymdor-node
33
+
34
+ The Python client for the Vrymdor AI Node by Vrymnox Systems LLC. Every call goes through your node's
35
+ checks: the agent's identity and status, your policy, removal of PII (personally identifiable information)
36
+ and secrets, a signed request and a metadata-only audit record. One module, one dependency (httpx).
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install vrymdor-node # the client
42
+ pip install "vrymdor-node[langchain]" # plus LangChain tools
43
+ pip install "vrymdor-node[llamaindex]" # plus LlamaIndex tools
44
+ ```
45
+
46
+ ## Use
47
+
48
+ You need your node's address and an agent registered in the Vrymdor app (AI agents, Register an agent).
49
+ The agent secret is shown once.
50
+
51
+ ```python
52
+ from vrymnox_node import VrymnoxNode
53
+
54
+ node = VrymnoxNode("http://vrymdor-node:8080", agent_id="agt_...", agent_secret="vrym_agent_...")
55
+
56
+ node.sanitize("Customer jane@acme.io called") # "Customer [REDACTED:EMAIL] called"
57
+ node.tool("github", "create_issue", {"title": "Login fails"})
58
+ node.llm("anthropic", "v1/messages", {"model": "...", "messages": [...]})
59
+
60
+ tools = node.langchain_tools() # one LangChain StructuredTool per tool your policy allows
61
+ tools = node.llamaindex_tools() # the same, as LlamaIndex FunctionTools
62
+ ```
63
+
64
+ A refusal raises `VrymnoxError` with a stable `code` (for example `tool_not_allowed`, `rate_limited`,
65
+ `agent_stopped`) and `retryable`. An action your policy holds raises `VrymnoxHeld` (code
66
+ `held_by_policy`): it did not run, and a held action is never released. Change the policy to allow it.
67
+
68
+ Documentation: https://docs.vrymdor.com/ai-node.html
69
+
70
+ ## MCP: Claude Desktop, Cursor and VS Code, no Docker
71
+
72
+ <!-- mcp-name: io.github.ernesthausmann15/vrymdor-mcp -->
73
+
74
+ The package includes `vrymdor-mcp`, the bridge desktop MCP clients start as a local command. It relays each MCP
75
+ message to your node and renews the agent's token by itself. The easiest way to run it is `uvx`, which also fetches
76
+ a matching Python for you: install uv once (macOS and Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`;
77
+ Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`).
78
+
79
+ Test the agent first (it prints "Connected: ..." or the reason it is not):
80
+
81
+ ```bash
82
+ VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 VRYMNOX_AGENT_ID=agt_... \
83
+ VRYMNOX_AGENT_SECRET=vrym_agent_... uvx --from vrymdor-node vrymdor-mcp --check
84
+ ```
85
+
86
+ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "vrymdor": {
92
+ "command": "uvx",
93
+ "args": ["--from", "vrymdor-node", "vrymdor-mcp"],
94
+ "env": {
95
+ "VRYMNOX_NODE_URL": "https://node.vrymdor.com/t/abc123def456",
96
+ "VRYMNOX_AGENT_ID": "agt_...",
97
+ "VRYMNOX_AGENT_SECRET": "vrym_agent_..."
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ VS Code (`.vscode/mcp.json`): the same block under `"servers"`, with `"type": "stdio"` added.
105
+
106
+ If the client says it cannot find `uvx` (Claude Desktop on macOS starts commands without your shell's PATH), put
107
+ the full path in `command`: the output of `which uvx` (macOS, Linux) or `where uvx` (Windows).
108
+
109
+ ## License
110
+
111
+ MIT. The Vrymdor AI Node itself is licensed separately under your Vrymnox agreement.
112
+
113
+ ## License and terms
114
+
115
+ Copyright (c) 2026 Vrymnox Systems LLC. This client is open source under the MIT License (LICENSE). Using the
116
+ Vrymdor AI Node service it connects to is governed by the Vrymdor Master Subscription Agreement
117
+ (https://vrymdor.com/msa) and the Acceptable Use Policy in the agreement set you accept when you sign up.
@@ -0,0 +1,8 @@
1
+ vrymdor_mcp.py,sha256=IYc0QBfKR4jp3GP0xuyZzaFra4w8Ufismrsx73JCkvI,10072
2
+ vrymnox_node.py,sha256=nHeMGwvGlqzNFdxJfPfUY8A5McZTdZdl6kpGvEATtR4,7390
3
+ vrymdor_node-1.1.0.dist-info/licenses/LICENSE,sha256=9xeKa3_q9zpsJG_jIQRTv4Cznxll2hyWsVBDsiFrE8U,1076
4
+ vrymdor_node-1.1.0.dist-info/METADATA,sha256=lEfBDi_nUW0dNldXZfGddKJ9Jh-c647ldCg_xtMi1lM,4989
5
+ vrymdor_node-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ vrymdor_node-1.1.0.dist-info/entry_points.txt,sha256=-N-KATg8_a0MyGBahIalDF2LD5eymDTUNdRItrWbN7o,81
7
+ vrymdor_node-1.1.0.dist-info/top_level.txt,sha256=Vue8oZRFU28dYjLTcgZ0716EUvs7LBsNd7QBs4DDDhM,25
8
+ vrymdor_node-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vrymdor-mcp = vrymdor_mcp:main
3
+ vrymdor-node = vrymdor_mcp:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vrymnox Systems LLC
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,2 @@
1
+ vrymdor_mcp
2
+ vrymnox_node
vrymnox_node.py ADDED
@@ -0,0 +1,144 @@
1
+ """Vrymnox AI Node client for Python apps and agent frameworks (one file; needs only httpx).
2
+
3
+ from vrymnox_node import VrymnoxNode
4
+ node = VrymnoxNode("http://vrymnox-gateway:8080", agent_id="agt_...", agent_secret="vrym_agent_...")
5
+ node.sanitize("Customer jane@acme.io called") # -> "Customer [REDACTED:EMAIL] called"
6
+ node.tool("github", "create_issue", {"title": "Login fails"})
7
+ node.llm("anthropic", "v1/messages", {"model": "...", "messages": [...]})
8
+
9
+ # LangChain: every tool the agent's policy allows, as governed LangChain tools
10
+ tools = node.langchain_tools()
11
+ # LlamaIndex: the same, as FunctionTools
12
+ tools = node.llamaindex_tools()
13
+
14
+ Every call goes through the gateway's checks (identity, policy, redaction, audit). A refusal raises
15
+ VrymnoxError with a stable `code` (tool_not_allowed, rate_limited, agent_stopped, ...) and `retryable`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ import uuid
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+
26
+ class VrymnoxError(Exception):
27
+ def __init__(self, code: str, message: str, retryable: bool = False, response: dict | None = None) -> None:
28
+ super().__init__(f"{code}: {message}")
29
+ self.code, self.message, self.retryable, self.response = code, message, retryable, response or {}
30
+
31
+
32
+ class VrymnoxHeld(VrymnoxError):
33
+ """Your policy held the action, so it did not run. Holds are final; `hold_id` is kept for the audit."""
34
+
35
+ def __init__(self, hold_id: str, response: dict) -> None:
36
+ super().__init__("held_by_policy", "Your policy holds this action, so it did not run.", False, response)
37
+ self.hold_id = hold_id
38
+
39
+
40
+ class VrymnoxNode:
41
+ def __init__(self, url: str, *, agent_id: str, agent_secret: str, http: httpx.Client | None = None,
42
+ timeout: float = 120.0) -> None:
43
+ self.url, self.agent_id, self._secret = url.rstrip("/"), agent_id, agent_secret
44
+ # Our own name: Cloudflare's browser check in front of a hosted node can refuse stock library names.
45
+ self._http = http or httpx.Client(timeout=timeout, headers={"User-Agent": "vrymdor-node-python"})
46
+ self._token, self._expires = "", 0.0
47
+
48
+ # ------------------------------------------------------------ plumbing
49
+ def _auth(self) -> dict:
50
+ if time.time() >= self._expires - 60:
51
+ r = self._http.post(f"{self.url}/v1/token", json={"agent_id": self.agent_id, "agent_secret": self._secret})
52
+ if r.status_code != 200:
53
+ raise VrymnoxError("unauthenticated", "The gateway refused this agent's credential.")
54
+ doc = r.json()
55
+ self._token, self._expires = doc["access_token"], time.time() + int(doc.get("expires_in", 600))
56
+ return {"Authorization": f"Bearer {self._token}"}
57
+
58
+ def run(self, action: dict, input: Any = None, *, context: dict | None = None, dry_run: bool = False,
59
+ idempotency_key: str | None = None) -> dict:
60
+ """The raw NodeRequest call. Returns the NodeResponse; raises on refusal or hold."""
61
+ body = {"node_version": "1", "action": action, "input": input, "context": context or {},
62
+ "params": {"dry_run": dry_run}, "idempotency_key": idempotency_key or uuid.uuid4().hex}
63
+ out = self._http.post(f"{self.url}/v1/node/run", json=body, headers=self._auth()).json()
64
+ return self._result(out)
65
+
66
+ @staticmethod
67
+ def _result(out: dict) -> dict:
68
+ if out.get("decision") == "hold":
69
+ raise VrymnoxHeld(out["hold_id"], out)
70
+ if not out.get("ok"):
71
+ err = out.get("error") or {}
72
+ raise VrymnoxError(err.get("code", "error"), err.get("message", "The action was refused."),
73
+ bool(err.get("retryable")), out)
74
+ return out
75
+
76
+ def wait_for(self, hold_id: str, *, timeout: float = 900, every: float = 5) -> dict:
77
+ """The final answer for a held action. Holds are final, so it answers at once: the action did not run
78
+ (VrymnoxError held_by_policy). Kept so existing code keeps working."""
79
+ end = time.time() + timeout
80
+ while time.time() < end:
81
+ r = self._http.get(f"{self.url}/v1/node/holds/{hold_id}", headers=self._auth())
82
+ if r.status_code != 202:
83
+ return self._result(r.json())
84
+ time.sleep(every)
85
+ raise VrymnoxError("held_by_policy", "Your policy holds this action, so it did not run.", False)
86
+
87
+ # ------------------------------------------------------------ the four actions
88
+ def sanitize(self, value: Any) -> Any:
89
+ return self.run({"type": "sanitize"}, value)["output"]
90
+
91
+ def tool(self, upstream: str, tool: str, arguments: dict | None = None, **kw) -> Any:
92
+ return self.run({"type": "tool", "upstream": upstream, "tool": tool}, arguments or {}, **kw)["output"]
93
+
94
+ def llm(self, upstream: str, path: str, body: dict, **kw) -> Any:
95
+ return self.run({"type": "llm", "upstream": upstream, "path": path}, body, **kw)["output"]
96
+
97
+ def webhook(self, upstream: str, payload: Any, **kw) -> Any:
98
+ return self.run({"type": "webhook", "upstream": upstream}, payload, **kw)["output"]
99
+
100
+ # ------------------------------------------------------------ framework adapters
101
+ def governed_tools(self) -> list[dict]:
102
+ """The tools this agent may call, from the gateway's MCP tool list (policy decides; nothing is guessed)."""
103
+ msg = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
104
+ tools = self._http.post(f"{self.url}/mcp", json=msg, headers=self._auth()).json()["result"]["tools"]
105
+ return [t for t in tools if "__" in t["name"]]
106
+
107
+ def _caller(self, name: str):
108
+ upstream, tool = name.split("__", 1)
109
+
110
+ def call(**arguments: Any) -> Any:
111
+ return self.tool(upstream, tool, arguments)
112
+ return call
113
+
114
+ def langchain_tools(self) -> list:
115
+ from langchain_core.tools import StructuredTool # optional dependency
116
+
117
+ return [StructuredTool.from_function(func=self._caller(t["name"]), name=t["name"], description=t["description"],
118
+ args_schema=t["inputSchema"]) for t in self.governed_tools()]
119
+
120
+ def llamaindex_tools(self) -> list:
121
+ from llama_index.core.tools import FunctionTool # optional dependency
122
+
123
+ return [FunctionTool.from_defaults(fn=self._caller(t["name"]), name=t["name"], description=t["description"],
124
+ fn_schema=_json_schema_model(t["name"], t.get("inputSchema") or {}))
125
+ for t in self.governed_tools()]
126
+
127
+
128
+ def _json_schema_model(name: str, schema: dict):
129
+ """A pydantic model that reports the gateway's JSON Schema as is. LlamaIndex reads a tool's arguments from
130
+ fn_schema; without it the model would only see the caller's generic **arguments. Extra fields are allowed
131
+ because the gateway, not this model, validates the arguments (tool_schema rule)."""
132
+ from pydantic import BaseModel, ConfigDict
133
+
134
+ published = {"type": "object", "properties": {}, **schema}
135
+
136
+ class ToolArguments(BaseModel):
137
+ model_config = ConfigDict(extra="allow")
138
+
139
+ @classmethod
140
+ def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict:
141
+ return dict(published)
142
+
143
+ ToolArguments.__name__ = ToolArguments.__qualname__ = "".join(p.title() for p in name.split("_") if p) or "Tool"
144
+ return ToolArguments