vrymdor-node 1.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.
- vrymdor_node-1.1.0/LICENSE +21 -0
- vrymdor_node-1.1.0/PKG-INFO +117 -0
- vrymdor_node-1.1.0/README.md +86 -0
- vrymdor_node-1.1.0/pyproject.toml +43 -0
- vrymdor_node-1.1.0/setup.cfg +4 -0
- vrymdor_node-1.1.0/vrymdor_mcp.py +235 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/PKG-INFO +117 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/SOURCES.txt +11 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/dependency_links.txt +1 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/entry_points.txt +3 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/requires.txt +8 -0
- vrymdor_node-1.1.0/vrymdor_node.egg-info/top_level.txt +2 -0
- vrymdor_node-1.1.0/vrymnox_node.py +144 -0
|
@@ -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,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,86 @@
|
|
|
1
|
+
# vrymdor-node
|
|
2
|
+
|
|
3
|
+
The Python client for the Vrymdor AI Node by Vrymnox Systems LLC. Every call goes through your node's
|
|
4
|
+
checks: the agent's identity and status, your policy, removal of PII (personally identifiable information)
|
|
5
|
+
and secrets, a signed request and a metadata-only audit record. One module, one dependency (httpx).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vrymdor-node # the client
|
|
11
|
+
pip install "vrymdor-node[langchain]" # plus LangChain tools
|
|
12
|
+
pip install "vrymdor-node[llamaindex]" # plus LlamaIndex tools
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
You need your node's address and an agent registered in the Vrymdor app (AI agents, Register an agent).
|
|
18
|
+
The agent secret is shown once.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from vrymnox_node import VrymnoxNode
|
|
22
|
+
|
|
23
|
+
node = VrymnoxNode("http://vrymdor-node:8080", agent_id="agt_...", agent_secret="vrym_agent_...")
|
|
24
|
+
|
|
25
|
+
node.sanitize("Customer jane@acme.io called") # "Customer [REDACTED:EMAIL] called"
|
|
26
|
+
node.tool("github", "create_issue", {"title": "Login fails"})
|
|
27
|
+
node.llm("anthropic", "v1/messages", {"model": "...", "messages": [...]})
|
|
28
|
+
|
|
29
|
+
tools = node.langchain_tools() # one LangChain StructuredTool per tool your policy allows
|
|
30
|
+
tools = node.llamaindex_tools() # the same, as LlamaIndex FunctionTools
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A refusal raises `VrymnoxError` with a stable `code` (for example `tool_not_allowed`, `rate_limited`,
|
|
34
|
+
`agent_stopped`) and `retryable`. An action your policy holds raises `VrymnoxHeld` (code
|
|
35
|
+
`held_by_policy`): it did not run, and a held action is never released. Change the policy to allow it.
|
|
36
|
+
|
|
37
|
+
Documentation: https://docs.vrymdor.com/ai-node.html
|
|
38
|
+
|
|
39
|
+
## MCP: Claude Desktop, Cursor and VS Code, no Docker
|
|
40
|
+
|
|
41
|
+
<!-- mcp-name: io.github.ernesthausmann15/vrymdor-mcp -->
|
|
42
|
+
|
|
43
|
+
The package includes `vrymdor-mcp`, the bridge desktop MCP clients start as a local command. It relays each MCP
|
|
44
|
+
message to your node and renews the agent's token by itself. The easiest way to run it is `uvx`, which also fetches
|
|
45
|
+
a matching Python for you: install uv once (macOS and Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`;
|
|
46
|
+
Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`).
|
|
47
|
+
|
|
48
|
+
Test the agent first (it prints "Connected: ..." or the reason it is not):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 VRYMNOX_AGENT_ID=agt_... \
|
|
52
|
+
VRYMNOX_AGENT_SECRET=vrym_agent_... uvx --from vrymdor-node vrymdor-mcp --check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"vrymdor": {
|
|
61
|
+
"command": "uvx",
|
|
62
|
+
"args": ["--from", "vrymdor-node", "vrymdor-mcp"],
|
|
63
|
+
"env": {
|
|
64
|
+
"VRYMNOX_NODE_URL": "https://node.vrymdor.com/t/abc123def456",
|
|
65
|
+
"VRYMNOX_AGENT_ID": "agt_...",
|
|
66
|
+
"VRYMNOX_AGENT_SECRET": "vrym_agent_..."
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
VS Code (`.vscode/mcp.json`): the same block under `"servers"`, with `"type": "stdio"` added.
|
|
74
|
+
|
|
75
|
+
If the client says it cannot find `uvx` (Claude Desktop on macOS starts commands without your shell's PATH), put
|
|
76
|
+
the full path in `command`: the output of `which uvx` (macOS, Linux) or `where uvx` (Windows).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT. The Vrymdor AI Node itself is licensed separately under your Vrymnox agreement.
|
|
81
|
+
|
|
82
|
+
## License and terms
|
|
83
|
+
|
|
84
|
+
Copyright (c) 2026 Vrymnox Systems LLC. This client is open source under the MIT License (LICENSE). Using the
|
|
85
|
+
Vrymdor AI Node service it connects to is governed by the Vrymdor Master Subscription Agreement
|
|
86
|
+
(https://vrymdor.com/msa) and the Acceptable Use Policy in the agreement set you accept when you sign up.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vrymdor-node"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "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."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Vrymnox Systems LLC", email = "support@vrymdor.com" }]
|
|
14
|
+
keywords = ["ai", "agents", "mcp", "governance", "pii", "langchain", "llamaindex", "vrymdor"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
vrymdor-mcp = "vrymdor_mcp:main"
|
|
30
|
+
vrymdor-node = "vrymdor_mcp:main" # the same bridge: MCP clients installing from the MCP Registry run uvx vrymdor-node
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
langchain = ["langchain-core>=0.3.49"] # 0.3.49 is the first that takes a JSON Schema dict as args_schema
|
|
34
|
+
llamaindex = ["llama-index-core>=0.12", "pydantic>=2"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://vrymdor.com"
|
|
38
|
+
Documentation = "https://docs.vrymdor.com/ai-node.html"
|
|
39
|
+
Source = "https://github.com/ernesthausmann15/vrymdor-node-python"
|
|
40
|
+
Issues = "https://github.com/ernesthausmann15/vrymdor-node-python/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
py-modules = ["vrymnox_node", "vrymdor_mcp"]
|
|
@@ -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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
vrymdor_mcp.py
|
|
5
|
+
vrymnox_node.py
|
|
6
|
+
vrymdor_node.egg-info/PKG-INFO
|
|
7
|
+
vrymdor_node.egg-info/SOURCES.txt
|
|
8
|
+
vrymdor_node.egg-info/dependency_links.txt
|
|
9
|
+
vrymdor_node.egg-info/entry_points.txt
|
|
10
|
+
vrymdor_node.egg-info/requires.txt
|
|
11
|
+
vrymdor_node.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|