vrymdor-node 1.1.0__tar.gz → 1.2.1__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 → vrymdor_node-1.2.1}/PKG-INFO +39 -5
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/README.md +37 -4
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/pyproject.toml +2 -2
- vrymdor_node-1.2.1/vrymdor_mcp.py +507 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/PKG-INFO +39 -5
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/requires.txt +1 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymnox_node.py +20 -1
- vrymdor_node-1.1.0/vrymdor_mcp.py +0 -235
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/LICENSE +0 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/setup.cfg +0 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/SOURCES.txt +0 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/dependency_links.txt +0 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/entry_points.txt +0 -0
- {vrymdor_node-1.1.0 → vrymdor_node-1.2.1}/vrymdor_node.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vrymdor-node
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
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
5
|
Author-email: Vrymnox Systems LLC <support@vrymdor.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Requires-Python: >=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: httpx<1,>=0.27
|
|
25
|
+
Requires-Dist: truststore>=0.10
|
|
25
26
|
Provides-Extra: langchain
|
|
26
27
|
Requires-Dist: langchain-core>=0.3.49; extra == "langchain"
|
|
27
28
|
Provides-Extra: llamaindex
|
|
@@ -76,14 +77,43 @@ message to your node and renews the agent's token by itself. The easiest way to
|
|
|
76
77
|
a matching Python for you: install uv once (macOS and Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`;
|
|
77
78
|
Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`).
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
### Claude Desktop in one command
|
|
81
|
+
|
|
82
|
+
It tests your agent, writes Claude Desktop's settings in the right place (keeping a backup of the old file) and
|
|
83
|
+
restarts Claude Desktop. Use the three values from AI agents, Connect your AI Node.
|
|
84
|
+
|
|
85
|
+
Windows (PowerShell):
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
Get-Process claude -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 2; $env:UV_SYSTEM_CERTS="true"; $env:UV_NATIVE_TLS="true"; $env:VRYMNOX_NODE_URL="https://node.vrymdor.com/t/abc123def456"; $env:VRYMNOX_AGENT_ID="agt_..."; $env:VRYMNOX_AGENT_SECRET="vrym_agent_..."; uv tool install --force "vrymdor-node>=1.2"; uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
macOS and Linux:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
export UV_SYSTEM_CERTS=true UV_NATIVE_TLS=true VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 \
|
|
95
|
+
VRYMNOX_AGENT_ID=agt_... VRYMNOX_AGENT_SECRET=vrym_agent_...
|
|
96
|
+
uv tool install --force "vrymdor-node>=1.2" && uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then in Claude Desktop click **+** next to the message box, then **Connectors**: **vrymdor** is listed. Ask Claude
|
|
100
|
+
"run vrymdor_status" to confirm: it answers Connected, with your node and agent, or says exactly what to fix.
|
|
101
|
+
|
|
102
|
+
`UV_SYSTEM_CERTS` and `UV_NATIVE_TLS` make uv download with the computer's own certificates, which antivirus HTTPS
|
|
103
|
+
scanning (Norton, Avast, AVG) and company proxies need. The bridge itself always trusts the computer's certificates.
|
|
104
|
+
|
|
105
|
+
### Test the agent
|
|
106
|
+
|
|
107
|
+
It prints "Connected: ..." or the reason it is not:
|
|
80
108
|
|
|
81
109
|
```bash
|
|
82
110
|
VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 VRYMNOX_AGENT_ID=agt_... \
|
|
83
111
|
VRYMNOX_AGENT_SECRET=vrym_agent_... uvx --from vrymdor-node vrymdor-mcp --check
|
|
84
112
|
```
|
|
85
113
|
|
|
86
|
-
|
|
114
|
+
### Settings by hand: Cursor, VS Code, or Claude Desktop
|
|
115
|
+
|
|
116
|
+
Cursor (`mcp.json`) and Claude Desktop (`claude_desktop_config.json`):
|
|
87
117
|
|
|
88
118
|
```json
|
|
89
119
|
{
|
|
@@ -94,7 +124,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
94
124
|
"env": {
|
|
95
125
|
"VRYMNOX_NODE_URL": "https://node.vrymdor.com/t/abc123def456",
|
|
96
126
|
"VRYMNOX_AGENT_ID": "agt_...",
|
|
97
|
-
"VRYMNOX_AGENT_SECRET": "vrym_agent_..."
|
|
127
|
+
"VRYMNOX_AGENT_SECRET": "vrym_agent_...",
|
|
128
|
+
"UV_SYSTEM_CERTS": "true",
|
|
129
|
+
"UV_NATIVE_TLS": "true"
|
|
98
130
|
}
|
|
99
131
|
}
|
|
100
132
|
}
|
|
@@ -104,7 +136,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
104
136
|
VS Code (`.vscode/mcp.json`): the same block under `"servers"`, with `"type": "stdio"` added.
|
|
105
137
|
|
|
106
138
|
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).
|
|
139
|
+
the full path in `command`: the output of `which uvx` (macOS, Linux) or `where.exe uvx` (Windows).
|
|
140
|
+
|
|
141
|
+
The client always lists **vrymdor**, even when the node cannot be reached: its `vrymdor_status` tool then says why.
|
|
108
142
|
|
|
109
143
|
## License
|
|
110
144
|
|
|
@@ -45,14 +45,43 @@ message to your node and renews the agent's token by itself. The easiest way to
|
|
|
45
45
|
a matching Python for you: install uv once (macOS and Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`;
|
|
46
46
|
Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`).
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
### Claude Desktop in one command
|
|
49
|
+
|
|
50
|
+
It tests your agent, writes Claude Desktop's settings in the right place (keeping a backup of the old file) and
|
|
51
|
+
restarts Claude Desktop. Use the three values from AI agents, Connect your AI Node.
|
|
52
|
+
|
|
53
|
+
Windows (PowerShell):
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
Get-Process claude -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 2; $env:UV_SYSTEM_CERTS="true"; $env:UV_NATIVE_TLS="true"; $env:VRYMNOX_NODE_URL="https://node.vrymdor.com/t/abc123def456"; $env:VRYMNOX_AGENT_ID="agt_..."; $env:VRYMNOX_AGENT_SECRET="vrym_agent_..."; uv tool install --force "vrymdor-node>=1.2"; uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
macOS and Linux:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export UV_SYSTEM_CERTS=true UV_NATIVE_TLS=true VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 \
|
|
63
|
+
VRYMNOX_AGENT_ID=agt_... VRYMNOX_AGENT_SECRET=vrym_agent_...
|
|
64
|
+
uv tool install --force "vrymdor-node>=1.2" && uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then in Claude Desktop click **+** next to the message box, then **Connectors**: **vrymdor** is listed. Ask Claude
|
|
68
|
+
"run vrymdor_status" to confirm: it answers Connected, with your node and agent, or says exactly what to fix.
|
|
69
|
+
|
|
70
|
+
`UV_SYSTEM_CERTS` and `UV_NATIVE_TLS` make uv download with the computer's own certificates, which antivirus HTTPS
|
|
71
|
+
scanning (Norton, Avast, AVG) and company proxies need. The bridge itself always trusts the computer's certificates.
|
|
72
|
+
|
|
73
|
+
### Test the agent
|
|
74
|
+
|
|
75
|
+
It prints "Connected: ..." or the reason it is not:
|
|
49
76
|
|
|
50
77
|
```bash
|
|
51
78
|
VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 VRYMNOX_AGENT_ID=agt_... \
|
|
52
79
|
VRYMNOX_AGENT_SECRET=vrym_agent_... uvx --from vrymdor-node vrymdor-mcp --check
|
|
53
80
|
```
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
### Settings by hand: Cursor, VS Code, or Claude Desktop
|
|
83
|
+
|
|
84
|
+
Cursor (`mcp.json`) and Claude Desktop (`claude_desktop_config.json`):
|
|
56
85
|
|
|
57
86
|
```json
|
|
58
87
|
{
|
|
@@ -63,7 +92,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
63
92
|
"env": {
|
|
64
93
|
"VRYMNOX_NODE_URL": "https://node.vrymdor.com/t/abc123def456",
|
|
65
94
|
"VRYMNOX_AGENT_ID": "agt_...",
|
|
66
|
-
"VRYMNOX_AGENT_SECRET": "vrym_agent_..."
|
|
95
|
+
"VRYMNOX_AGENT_SECRET": "vrym_agent_...",
|
|
96
|
+
"UV_SYSTEM_CERTS": "true",
|
|
97
|
+
"UV_NATIVE_TLS": "true"
|
|
67
98
|
}
|
|
68
99
|
}
|
|
69
100
|
}
|
|
@@ -73,7 +104,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
73
104
|
VS Code (`.vscode/mcp.json`): the same block under `"servers"`, with `"type": "stdio"` added.
|
|
74
105
|
|
|
75
106
|
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).
|
|
107
|
+
the full path in `command`: the output of `which uvx` (macOS, Linux) or `where.exe uvx` (Windows).
|
|
108
|
+
|
|
109
|
+
The client always lists **vrymdor**, even when the node cannot be reached: its `vrymdor_status` tool then says why.
|
|
77
110
|
|
|
78
111
|
## License
|
|
79
112
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vrymdor-node"
|
|
7
|
-
version = "1.1
|
|
7
|
+
version = "1.2.1"
|
|
8
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
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Security",
|
|
24
24
|
"Topic :: Software Development :: Libraries",
|
|
25
25
|
]
|
|
26
|
-
dependencies = ["httpx>=0.27,<1"]
|
|
26
|
+
dependencies = ["httpx>=0.27,<1", "truststore>=0.10"] # truststore: the computer's own certificates (antivirus HTTPS scanning, company proxies)
|
|
27
27
|
|
|
28
28
|
[project.scripts]
|
|
29
29
|
vrymdor-mcp = "vrymdor_mcp:main"
|
|
@@ -0,0 +1,507 @@
|
|
|
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
|
+
vrymdor-mcp --install-claude # set up Claude Desktop in one step: tests the agent, writes Claude's settings
|
|
8
|
+
vrymdor-mcp --check # one-time test: does the node accept this agent?
|
|
9
|
+
vrymdor-mcp # what the MCP client runs
|
|
10
|
+
|
|
11
|
+
Settings come from the environment (the MCP client's "env" block); --install-claude asks for any that are missing:
|
|
12
|
+
|
|
13
|
+
VRYMNOX_NODE_URL your Node Host URL, for example https://node.vrymdor.com/t/abc123def456
|
|
14
|
+
or http://localhost:8080 for a node on this computer
|
|
15
|
+
VRYMNOX_AGENT_ID agt_...
|
|
16
|
+
VRYMNOX_AGENT_SECRET vrym_agent_...
|
|
17
|
+
|
|
18
|
+
The VRYMDOR_ spellings (VRYMDOR_NODE_URL and so on, as in the GitHub Action) work too. Standard output carries only
|
|
19
|
+
MCP messages; anything for a person goes to standard error.
|
|
20
|
+
|
|
21
|
+
It trusts the certificates this computer trusts, so antivirus HTTPS scanning (Norton, Avast, AVG) and company
|
|
22
|
+
proxies work without any change. In the client it always shows as "vrymdor", even when the node cannot be reached,
|
|
23
|
+
with a vrymdor_status tool that says whether it is connected and, if not, what to fix.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import io
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import ssl
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TextIO
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from vrymnox_node import system_ssl_context
|
|
43
|
+
except ImportError: # pragma: no cover (running this file on its own)
|
|
44
|
+
def system_ssl_context(): # type: ignore[misc]
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
NAMES = {"url": ("VRYMNOX_NODE_URL", "VRYMDOR_NODE_URL"),
|
|
48
|
+
"id": ("VRYMNOX_AGENT_ID", "VRYMDOR_AGENT_ID"),
|
|
49
|
+
"secret": ("VRYMNOX_AGENT_SECRET", "VRYMDOR_AGENT_SECRET")}
|
|
50
|
+
|
|
51
|
+
STATUS_TOOL = "vrymdor_status"
|
|
52
|
+
SERVER_KEY = "vrymdor"
|
|
53
|
+
PROTOCOL = "2025-06-18"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def version() -> str:
|
|
57
|
+
try:
|
|
58
|
+
from importlib.metadata import version as _v
|
|
59
|
+
|
|
60
|
+
return _v("vrymdor-node")
|
|
61
|
+
except Exception: # noqa: BLE001 (running from a source checkout)
|
|
62
|
+
return "0"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def user_agent() -> str:
|
|
66
|
+
# Our own name: a stock library name (Python-urllib, python-httpx) can be refused by Cloudflare's browser check in
|
|
67
|
+
# front of a hosted node.
|
|
68
|
+
return f"vrymdor-mcp/{version()}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clean_url(raw: str) -> str:
|
|
72
|
+
"""Forgive the usual paste mistakes: spaces, quotes, a slash at the end, or the Token URL ending in /v1/token."""
|
|
73
|
+
url = raw.strip().strip("\"'").strip().rstrip("/")
|
|
74
|
+
if url.endswith("/v1/token"):
|
|
75
|
+
url = url[: -len("/v1/token")].rstrip("/")
|
|
76
|
+
return url
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def setting(key: str, env: dict | None = None) -> str:
|
|
80
|
+
env = os.environ if env is None else env
|
|
81
|
+
for name in NAMES[key]:
|
|
82
|
+
if env.get(name, "").strip():
|
|
83
|
+
return env[name].strip().strip("\"'").strip()
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def refusal(status: int, raw: bytes) -> str:
|
|
88
|
+
"""Why the token was refused, in words: the node's own reason, or that something in front of it answered."""
|
|
89
|
+
text = (raw or b"")[:2000].decode("utf-8", "replace")
|
|
90
|
+
try:
|
|
91
|
+
doc = json.loads(text)
|
|
92
|
+
detail = doc.get("detail") or doc.get("error") if isinstance(doc, dict) else None
|
|
93
|
+
except ValueError:
|
|
94
|
+
detail = None
|
|
95
|
+
if isinstance(detail, dict):
|
|
96
|
+
detail = detail.get("message") or detail.get("code")
|
|
97
|
+
if detail:
|
|
98
|
+
hint = ""
|
|
99
|
+
if "may not receive tokens" in str(detail):
|
|
100
|
+
hint = (". This agent is stopped, paused or expired: on AI agents, resume or extend it, or add a new agent "
|
|
101
|
+
"and use its Agent ID and Agent Secret")
|
|
102
|
+
return f"the node refused the agent (HTTP {status}): {str(detail)[:200].rstrip('.')}{hint}"
|
|
103
|
+
low = text.lower()
|
|
104
|
+
if "cloudflare" in low or "error code: 10" in low:
|
|
105
|
+
return (f"the request was blocked before it reached the node (HTTP {status}, Cloudflare). Check the Node Host "
|
|
106
|
+
"URL is the one from your Vrymdor dashboard")
|
|
107
|
+
if status == 401:
|
|
108
|
+
return "the node refused the agent (HTTP 401): the Agent ID or Agent Secret is wrong"
|
|
109
|
+
if status == 404:
|
|
110
|
+
return "the node answered 404: the Node Host URL is wrong. Copy it again from your Vrymdor dashboard"
|
|
111
|
+
if status in (502, 503, 504):
|
|
112
|
+
return f"the node is not answering (HTTP {status}). If it is a hosted node, turn it on in Home base"
|
|
113
|
+
return f"the node refused the agent (HTTP {status})"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def certificate_problem(exc: BaseException) -> bool:
|
|
117
|
+
"""True when the connection failed because the node's certificate was not trusted (not a wrong address)."""
|
|
118
|
+
seen: set[int] = set()
|
|
119
|
+
e: BaseException | None = exc
|
|
120
|
+
while e is not None and id(e) not in seen:
|
|
121
|
+
seen.add(id(e))
|
|
122
|
+
if isinstance(e, ssl.SSLCertVerificationError) or "CERTIFICATE_VERIFY_FAILED" in str(e):
|
|
123
|
+
return True
|
|
124
|
+
e = e.__cause__ or e.__context__
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def why(exc: Exception, url: str) -> str:
|
|
129
|
+
"""An exception in words, naming the real cause: an untrusted certificate, no connection, or a slow answer."""
|
|
130
|
+
if isinstance(exc, httpx.ConnectError) and certificate_problem(exc):
|
|
131
|
+
return (f"the connection to {url} was not trusted: something on this computer replaced the node's certificate "
|
|
132
|
+
"(antivirus HTTPS scanning such as Norton, Avast or AVG, or a company proxy) with one this computer does "
|
|
133
|
+
"not trust either. Exclude the node's address from that program's HTTPS scanning, or set SSL_CERT_FILE "
|
|
134
|
+
"to its certificate")
|
|
135
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
136
|
+
return f"could not reach {url} (check the Node Host URL, and that your node is on)"
|
|
137
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
138
|
+
return f"{url} did not answer in time"
|
|
139
|
+
return str(exc)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def status_tool(problem: str = "") -> dict:
|
|
143
|
+
text = ("Checks the connection to your Vrymdor AI Node: says whether it is connected, with the node address, the "
|
|
144
|
+
"agent and the tools it offers, or exactly what to fix.")
|
|
145
|
+
if problem:
|
|
146
|
+
text = f"Not connected to your Vrymdor AI Node yet: {problem}. Run this tool to check again. " + text
|
|
147
|
+
return {"name": STATUS_TOOL, "title": "Vrymdor connection status", "description": text,
|
|
148
|
+
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
149
|
+
"annotations": {"title": "Vrymdor connection status", "readOnlyHint": True, "openWorldHint": False}}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _reply(id_, result: dict) -> str:
|
|
153
|
+
return json.dumps({"jsonrpc": "2.0", "id": id_, "result": result})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
LIST_CHANGED = json.dumps({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Bridge:
|
|
160
|
+
def __init__(self, url: str, agent_id: str, secret: str, *, http: httpx.Client | None = None) -> None:
|
|
161
|
+
self.url, self.agent_id, self._secret = clean_url(url), agent_id, secret
|
|
162
|
+
self._http = http or httpx.Client(timeout=120.0, headers={"User-Agent": user_agent()},
|
|
163
|
+
verify=system_ssl_context())
|
|
164
|
+
self._token, self._expires = "", 0.0
|
|
165
|
+
self._session = ""
|
|
166
|
+
self.problem = "" # why the node could not be reached last time; "" while it works
|
|
167
|
+
|
|
168
|
+
def token(self) -> str:
|
|
169
|
+
if self._token and time.time() < self._expires - 60:
|
|
170
|
+
return self._token
|
|
171
|
+
r = self._http.post(f"{self.url}/v1/token", json={"agent_id": self.agent_id, "agent_secret": self._secret})
|
|
172
|
+
if r.status_code != 200:
|
|
173
|
+
raise RuntimeError(refusal(r.status_code, r.content))
|
|
174
|
+
doc = r.json()
|
|
175
|
+
self._token, self._expires = doc["access_token"], time.time() + int(doc.get("expires_in", 600))
|
|
176
|
+
return self._token
|
|
177
|
+
|
|
178
|
+
def _send(self, line: str) -> httpx.Response:
|
|
179
|
+
def post() -> httpx.Response:
|
|
180
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
|
181
|
+
"Authorization": f"Bearer {self.token()}"}
|
|
182
|
+
if self._session:
|
|
183
|
+
headers["Mcp-Session-Id"] = self._session
|
|
184
|
+
return self._http.post(f"{self.url}/mcp", content=line.encode("utf-8"), headers=headers)
|
|
185
|
+
|
|
186
|
+
r = post()
|
|
187
|
+
if r.status_code == 401: # the token was revoked or the node restarted: get a fresh one and try once more
|
|
188
|
+
self._token, self._expires = "", 0.0
|
|
189
|
+
r = post()
|
|
190
|
+
if r.headers.get("mcp-session-id"):
|
|
191
|
+
self._session = r.headers["mcp-session-id"]
|
|
192
|
+
return r
|
|
193
|
+
|
|
194
|
+
def _node_tools(self) -> list[dict]:
|
|
195
|
+
"""The node's own tool list (raises on any failure, in words)."""
|
|
196
|
+
r = self._send(json.dumps({"jsonrpc": "2.0", "id": "vrymdor-status", "method": "tools/list"}))
|
|
197
|
+
text = r.content.decode("utf-8")
|
|
198
|
+
if r.headers.get("content-type", "").split(";")[0].strip().lower() == "text/event-stream":
|
|
199
|
+
text = sse_messages(text).split("\n")[0]
|
|
200
|
+
doc = json.loads(text or "{}")
|
|
201
|
+
if doc.get("error"):
|
|
202
|
+
raise RuntimeError(str(doc["error"].get("message") or doc["error"]).rstrip("."))
|
|
203
|
+
return [t for t in doc.get("result", {}).get("tools", []) if isinstance(t, dict)]
|
|
204
|
+
|
|
205
|
+
def status(self) -> tuple[bool, str, list[str]]:
|
|
206
|
+
"""Connected or not, in a sentence for a person, and the node's tool names."""
|
|
207
|
+
try:
|
|
208
|
+
self.token()
|
|
209
|
+
names = [str(t.get("name")) for t in self._node_tools()]
|
|
210
|
+
except (httpx.HTTPError, RuntimeError, ValueError, KeyError) as exc:
|
|
211
|
+
self.problem = why(exc, self.url)
|
|
212
|
+
return False, f"Not connected: {self.problem}.", []
|
|
213
|
+
self.problem = ""
|
|
214
|
+
if not names:
|
|
215
|
+
return False, ("Connected, but the node listed no tools for this agent. Check the agent's role in your "
|
|
216
|
+
"policy."), []
|
|
217
|
+
first = sorted(names, key=lambda n: (n != "vrymnox_sanitize", not n.startswith("vrymnox_"), n))[:4]
|
|
218
|
+
return True, (f"Connected: your node at {self.url} accepted agent {self.agent_id} and offers {len(names)} "
|
|
219
|
+
f"tools, including {', '.join(first)}. Bridge vrymdor-mcp {version()}."), names
|
|
220
|
+
|
|
221
|
+
def handle(self, line: str, err: TextIO = sys.stderr) -> str | None:
|
|
222
|
+
try:
|
|
223
|
+
msg = json.loads(line)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}})
|
|
226
|
+
if not isinstance(msg, dict):
|
|
227
|
+
return json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid request"}})
|
|
228
|
+
has_id, method = "id" in msg, msg.get("method")
|
|
229
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
230
|
+
if method == "tools/call" and params.get("name") == STATUS_TOOL:
|
|
231
|
+
was_down = bool(self.problem)
|
|
232
|
+
ok, text, _ = self.status()
|
|
233
|
+
out = _reply(msg.get("id"), {"content": [{"type": "text", "text": text}], "isError": not ok})
|
|
234
|
+
return f"{LIST_CHANGED}\n{out}" if ok and was_down else out
|
|
235
|
+
try:
|
|
236
|
+
r = self._send(line)
|
|
237
|
+
except (httpx.HTTPError, RuntimeError, ValueError, KeyError) as exc:
|
|
238
|
+
self.problem = why(exc, self.url)
|
|
239
|
+
print(f"vrymdor-mcp: {self.problem}", file=err, flush=True)
|
|
240
|
+
return self._offline(msg, method, params, has_id)
|
|
241
|
+
self.problem = ""
|
|
242
|
+
if r.status_code == 202 or not r.content:
|
|
243
|
+
return None # a notification
|
|
244
|
+
text = r.content.decode("utf-8")
|
|
245
|
+
if r.headers.get("content-type", "").split(";")[0].strip().lower() == "text/event-stream":
|
|
246
|
+
text = sse_messages(text)
|
|
247
|
+
if method == "tools/list" and has_id:
|
|
248
|
+
text = _with_status_tool(text, msg.get("id"))
|
|
249
|
+
return text or None
|
|
250
|
+
|
|
251
|
+
def _offline(self, msg: dict, method, params: dict, has_id: bool) -> str | None:
|
|
252
|
+
"""The node could not be reached: still start, so the client lists "vrymdor" with a tool that says why."""
|
|
253
|
+
if not has_id:
|
|
254
|
+
return None
|
|
255
|
+
id_ = msg.get("id")
|
|
256
|
+
if method == "initialize":
|
|
257
|
+
return _reply(id_, {"protocolVersion": str(params.get("protocolVersion") or PROTOCOL),
|
|
258
|
+
"capabilities": {"tools": {"listChanged": True}},
|
|
259
|
+
"serverInfo": {"name": SERVER_KEY, "title": "Vrymdor AI Node", "version": version()},
|
|
260
|
+
"instructions": (f"Not connected to the Vrymdor AI Node yet: {self.problem}. The "
|
|
261
|
+
f"{STATUS_TOOL} tool checks again and says what to fix.")})
|
|
262
|
+
if method == "tools/list":
|
|
263
|
+
return _reply(id_, {"tools": [status_tool(self.problem)]})
|
|
264
|
+
if method == "ping":
|
|
265
|
+
return _reply(id_, {})
|
|
266
|
+
if method in ("resources/list", "prompts/list"):
|
|
267
|
+
return _reply(id_, {method.split("/")[0]: []})
|
|
268
|
+
return json.dumps({"jsonrpc": "2.0", "id": id_,
|
|
269
|
+
"error": {"code": -32603, "message": f"Your Vrymdor AI Node could not be reached: {self.problem}"}})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _with_status_tool(text: str, id_) -> str:
|
|
273
|
+
"""Add the vrymdor_status tool to the node's tool list answer (each line is one JSON message)."""
|
|
274
|
+
out = []
|
|
275
|
+
for raw in text.split("\n"):
|
|
276
|
+
try:
|
|
277
|
+
doc = json.loads(raw)
|
|
278
|
+
except ValueError:
|
|
279
|
+
out.append(raw)
|
|
280
|
+
continue
|
|
281
|
+
tools = doc.get("result", {}).get("tools") if isinstance(doc, dict) and doc.get("id") == id_ else None
|
|
282
|
+
if isinstance(tools, list) and not any(isinstance(t, dict) and t.get("name") == STATUS_TOOL for t in tools):
|
|
283
|
+
tools.append(status_tool())
|
|
284
|
+
raw = json.dumps(doc)
|
|
285
|
+
out.append(raw)
|
|
286
|
+
return "\n".join(out)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def sse_messages(text: str) -> str:
|
|
290
|
+
"""A streamed answer (text/event-stream): each event's data lines joined, one JSON message per output line."""
|
|
291
|
+
out, data = [], []
|
|
292
|
+
for raw in text.replace("\r\n", "\n").split("\n"):
|
|
293
|
+
if raw.startswith("data:"):
|
|
294
|
+
data.append(raw[5:].lstrip(" "))
|
|
295
|
+
elif raw == "" and data:
|
|
296
|
+
out.append("".join(data))
|
|
297
|
+
data = []
|
|
298
|
+
if data:
|
|
299
|
+
out.append("".join(data))
|
|
300
|
+
return "\n".join(m for m in out if m.strip())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _utf8(stream: TextIO) -> None:
|
|
304
|
+
# MCP messages are UTF-8 with \n line endings; a Windows console default (cp1252, \r\n) would damage them.
|
|
305
|
+
try:
|
|
306
|
+
stream.reconfigure(encoding="utf-8", newline="\n") # type: ignore[attr-defined]
|
|
307
|
+
except (AttributeError, ValueError):
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check(bridge: Bridge, out: TextIO = sys.stdout) -> int:
|
|
312
|
+
"""--check: get a token and list the tools, then say in words whether it works."""
|
|
313
|
+
ok, text, _ = bridge.status()
|
|
314
|
+
print(text, file=out)
|
|
315
|
+
return 0 if ok else 1
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------- Claude Desktop setup
|
|
319
|
+
def claude_config_paths(env: dict | None = None, platform: str | None = None, home: Path | None = None) -> list[Path]:
|
|
320
|
+
"""Every place Claude Desktop may read its settings from on this computer (the first one is always written).
|
|
321
|
+
|
|
322
|
+
Windows: %APPDATA%\\Claude, and for the packaged (Microsoft Store style) install also its own folder under
|
|
323
|
+
%LOCALAPPDATA%\\Packages. macOS: ~/Library/Application Support/Claude. Linux: ~/.config/Claude."""
|
|
324
|
+
env = os.environ if env is None else env
|
|
325
|
+
platform = platform or sys.platform
|
|
326
|
+
home = home or Path.home()
|
|
327
|
+
name = "claude_desktop_config.json"
|
|
328
|
+
if platform.startswith("win"):
|
|
329
|
+
out = [Path(env.get("APPDATA") or home / "AppData" / "Roaming") / "Claude" / name]
|
|
330
|
+
packages = Path(env.get("LOCALAPPDATA") or home / "AppData" / "Local") / "Packages"
|
|
331
|
+
if packages.is_dir():
|
|
332
|
+
out += [d / "LocalCache" / "Roaming" / "Claude" / name for d in sorted(packages.glob("Claude_*")) if d.is_dir()]
|
|
333
|
+
return out
|
|
334
|
+
if platform == "darwin":
|
|
335
|
+
return [home / "Library" / "Application Support" / "Claude" / name]
|
|
336
|
+
return [Path(env.get("XDG_CONFIG_HOME") or home / ".config") / "Claude" / name]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _ephemeral(path: str) -> bool:
|
|
340
|
+
"""A command inside uv's cache (what uvx runs) moves on the next update: never write that into Claude's settings."""
|
|
341
|
+
parts = [p.lower() for p in path.replace("\\", "/").split("/")]
|
|
342
|
+
return any(p in ("cache", ".cache") or p.startswith("archive-v") for p in parts)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def installed_command(env: dict | None = None, home: Path | None = None) -> str:
|
|
346
|
+
"""The lasting vrymdor-mcp command (uv tool install vrymdor-node puts it in ~/.local/bin), or "" when there is none."""
|
|
347
|
+
env = os.environ if env is None else env
|
|
348
|
+
home = home or Path.home()
|
|
349
|
+
exe = "vrymdor-mcp.exe" if os.name == "nt" else "vrymdor-mcp"
|
|
350
|
+
candidates = [env.get("UV_TOOL_BIN_DIR", ""), env.get("XDG_BIN_HOME", ""), str(home / ".local" / "bin")]
|
|
351
|
+
found = [shutil.which("vrymdor-mcp") or ""] + [str(Path(d) / exe) for d in candidates if d]
|
|
352
|
+
for c in found:
|
|
353
|
+
if c and Path(c).is_file() and not _ephemeral(c):
|
|
354
|
+
return str(Path(c))
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def server_entry(url: str, agent_id: str, secret: str, command: str = "") -> dict:
|
|
359
|
+
"""Claude Desktop's settings for this server: the installed command, or uvx told to use the computer's certificates."""
|
|
360
|
+
env = {"VRYMNOX_NODE_URL": clean_url(url), "VRYMNOX_AGENT_ID": agent_id, "VRYMNOX_AGENT_SECRET": secret}
|
|
361
|
+
if command:
|
|
362
|
+
return {"command": command, "args": [], "env": env}
|
|
363
|
+
uvx = shutil.which("uvx") or "uvx"
|
|
364
|
+
# uv downloads with its own certificate list unless told otherwise: UV_SYSTEM_CERTS (uv 0.11 and later) and
|
|
365
|
+
# UV_NATIVE_TLS (earlier versions) make it use the computer's, as antivirus HTTPS scanning needs.
|
|
366
|
+
return {"command": uvx, "args": ["--from", "vrymdor-node", "vrymdor-mcp"],
|
|
367
|
+
"env": {**env, "UV_SYSTEM_CERTS": "true", "UV_NATIVE_TLS": "true"}}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_claude_config(path: Path, entry: dict) -> Path | None:
|
|
371
|
+
"""Put the vrymdor server into one settings file, keeping everything else in it. Returns the backup, if any."""
|
|
372
|
+
doc: dict = {}
|
|
373
|
+
backup = None
|
|
374
|
+
if path.is_file():
|
|
375
|
+
raw = path.read_text(encoding="utf-8-sig")
|
|
376
|
+
backup = path.with_name(f"{path.name}.{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak")
|
|
377
|
+
backup.write_text(raw, encoding="utf-8")
|
|
378
|
+
try:
|
|
379
|
+
loaded = json.loads(raw) if raw.strip() else {}
|
|
380
|
+
doc = loaded if isinstance(loaded, dict) else {}
|
|
381
|
+
except ValueError:
|
|
382
|
+
doc = {} # damaged: the backup keeps the old text, the new file starts clean
|
|
383
|
+
servers = doc.get("mcpServers") if isinstance(doc.get("mcpServers"), dict) else {}
|
|
384
|
+
servers[SERVER_KEY] = entry
|
|
385
|
+
doc["mcpServers"] = servers
|
|
386
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") # UTF-8 without a byte order mark
|
|
388
|
+
return backup
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def restart_claude(platform: str | None = None) -> str:
|
|
392
|
+
"""Close Claude Desktop and open it again so it reads the new settings. Returns what happened, in words."""
|
|
393
|
+
platform = platform or sys.platform
|
|
394
|
+
try:
|
|
395
|
+
if platform.startswith("win"):
|
|
396
|
+
# Close it if it runs, then open it from its Start menu entry (works for the installer and the packaged
|
|
397
|
+
# install alike); the path of the running copy is the fallback.
|
|
398
|
+
ps = ("$p = Get-Process claude -ErrorAction SilentlyContinue; $path = ($p | Where-Object Path | "
|
|
399
|
+
"Select-Object -First 1).Path; if ($p) { $p | Stop-Process -Force; Start-Sleep -Seconds 2 }; "
|
|
400
|
+
"$app = Get-StartApps -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq 'Claude' } | "
|
|
401
|
+
"Select-Object -First 1; if ($app) { Start-Process \"shell:AppsFolder\\$($app.AppID)\"; 'restarted' } "
|
|
402
|
+
"elseif ($path) { Start-Process $path; 'restarted' } elseif ($p) { 'closed' } else { 'not running' }")
|
|
403
|
+
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True,
|
|
404
|
+
timeout=30).stdout.strip().splitlines()
|
|
405
|
+
return out[-1] if out else "closed"
|
|
406
|
+
if platform == "darwin":
|
|
407
|
+
running = subprocess.run(["pgrep", "-x", "Claude"], capture_output=True).returncode == 0
|
|
408
|
+
if not running:
|
|
409
|
+
return "not running"
|
|
410
|
+
subprocess.run(["osascript", "-e", 'quit app "Claude"'], capture_output=True, timeout=30)
|
|
411
|
+
time.sleep(2)
|
|
412
|
+
subprocess.run(["open", "-a", "Claude"], capture_output=True, timeout=30)
|
|
413
|
+
return "restarted"
|
|
414
|
+
except (OSError, subprocess.SubprocessError):
|
|
415
|
+
return "closed"
|
|
416
|
+
return "not running"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _ask(prompt: str, secret: bool = False) -> str:
|
|
420
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
421
|
+
return ""
|
|
422
|
+
if secret:
|
|
423
|
+
import getpass
|
|
424
|
+
|
|
425
|
+
return getpass.getpass(prompt).strip()
|
|
426
|
+
return input(prompt).strip()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def install_claude(args: list[str], out: TextIO = sys.stdout, bridge: Bridge | None = None) -> int:
|
|
430
|
+
"""--install-claude: test the agent, then write Claude Desktop's settings and restart it."""
|
|
431
|
+
url = setting("url") or _ask("Node Host URL (from AI agents, Connect your AI Node): ")
|
|
432
|
+
agent_id = setting("id") or _ask("Agent ID (agt_...): ")
|
|
433
|
+
secret = setting("secret") or _ask("Agent Secret (vrym_agent_..., hidden as you type): ", secret=True)
|
|
434
|
+
if not (url and agent_id and secret):
|
|
435
|
+
print("Not set up: give the Node Host URL, the Agent ID and the Agent Secret (VRYMNOX_NODE_URL, "
|
|
436
|
+
"VRYMNOX_AGENT_ID, VRYMNOX_AGENT_SECRET).", file=out)
|
|
437
|
+
return 2
|
|
438
|
+
url = clean_url(url)
|
|
439
|
+
if not url.startswith(("http://", "https://")):
|
|
440
|
+
print("Not set up: the Node Host URL must start with https:// (or http:// for a node on this computer).", file=out)
|
|
441
|
+
return 2
|
|
442
|
+
ok, text, _ = (bridge or Bridge(url, agent_id, secret)).status()
|
|
443
|
+
print(f"1. Testing your agent. {text}", file=out)
|
|
444
|
+
if not ok and "--force" not in args:
|
|
445
|
+
print("Claude Desktop was not changed. Fix the line above and run this again.", file=out)
|
|
446
|
+
return 1
|
|
447
|
+
command = installed_command()
|
|
448
|
+
entry = server_entry(url, agent_id, secret, command)
|
|
449
|
+
paths = claude_config_paths()
|
|
450
|
+
for path in paths:
|
|
451
|
+
backup = write_claude_config(path, entry)
|
|
452
|
+
print(f"2. Claude Desktop settings written: {path}" + (f" (your old file is kept as {backup.name})" if backup else ""),
|
|
453
|
+
file=out)
|
|
454
|
+
if not command:
|
|
455
|
+
print(" It starts the bridge with uvx. For a faster start that never downloads, run "
|
|
456
|
+
"'uv tool install vrymdor-node' and then this command again.", file=out)
|
|
457
|
+
state = "skipped" if "--no-restart" in args else restart_claude()
|
|
458
|
+
if state == "restarted":
|
|
459
|
+
print("3. Claude Desktop was restarted so it reads the new settings.", file=out)
|
|
460
|
+
elif state == "closed":
|
|
461
|
+
print("3. Claude Desktop was closed. Open it again.", file=out)
|
|
462
|
+
else:
|
|
463
|
+
print("3. Open Claude Desktop (if it is open, quit it completely from its icon by the clock, then open it).",
|
|
464
|
+
file=out)
|
|
465
|
+
print("Done. In Claude Desktop, click + next to the message box, then Connectors: vrymdor is listed. "
|
|
466
|
+
"To confirm, ask Claude: \"run vrymdor_status\". It answers Connected, with your node and agent.", file=out)
|
|
467
|
+
return 0
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def main(argv: list[str] | None = None) -> int:
|
|
471
|
+
args = sys.argv[1:] if argv is None else argv
|
|
472
|
+
if "--version" in args:
|
|
473
|
+
print(f"vrymdor-mcp {version()}")
|
|
474
|
+
return 0
|
|
475
|
+
if "-h" in args or "--help" in args:
|
|
476
|
+
print(__doc__)
|
|
477
|
+
return 0
|
|
478
|
+
if "--install-claude" in args or "setup" in args:
|
|
479
|
+
return install_claude(args)
|
|
480
|
+
missing = [NAMES[k][0] for k in ("url", "id", "secret") if not setting(k)]
|
|
481
|
+
if missing:
|
|
482
|
+
print(f"vrymdor-mcp: set {', '.join(missing)} (in your MCP client's env block)", file=sys.stderr)
|
|
483
|
+
return 2
|
|
484
|
+
url = setting("url")
|
|
485
|
+
if not url.startswith(("http://", "https://")):
|
|
486
|
+
print("vrymdor-mcp: VRYMNOX_NODE_URL must start with https:// (or http:// for a node on this computer)",
|
|
487
|
+
file=sys.stderr)
|
|
488
|
+
return 2
|
|
489
|
+
bridge = Bridge(url, setting("id"), setting("secret"))
|
|
490
|
+
if "--check" in args:
|
|
491
|
+
return check(bridge)
|
|
492
|
+
_utf8(sys.stdin)
|
|
493
|
+
_utf8(sys.stdout)
|
|
494
|
+
try:
|
|
495
|
+
for line in sys.stdin:
|
|
496
|
+
if line.strip():
|
|
497
|
+
out = bridge.handle(line.strip())
|
|
498
|
+
if out is not None:
|
|
499
|
+
sys.stdout.write(out + "\n")
|
|
500
|
+
sys.stdout.flush()
|
|
501
|
+
except KeyboardInterrupt:
|
|
502
|
+
pass
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
if __name__ == "__main__":
|
|
507
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vrymdor-node
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
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
5
|
Author-email: Vrymnox Systems LLC <support@vrymdor.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Requires-Python: >=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: httpx<1,>=0.27
|
|
25
|
+
Requires-Dist: truststore>=0.10
|
|
25
26
|
Provides-Extra: langchain
|
|
26
27
|
Requires-Dist: langchain-core>=0.3.49; extra == "langchain"
|
|
27
28
|
Provides-Extra: llamaindex
|
|
@@ -76,14 +77,43 @@ message to your node and renews the agent's token by itself. The easiest way to
|
|
|
76
77
|
a matching Python for you: install uv once (macOS and Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`;
|
|
77
78
|
Windows PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`).
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
### Claude Desktop in one command
|
|
81
|
+
|
|
82
|
+
It tests your agent, writes Claude Desktop's settings in the right place (keeping a backup of the old file) and
|
|
83
|
+
restarts Claude Desktop. Use the three values from AI agents, Connect your AI Node.
|
|
84
|
+
|
|
85
|
+
Windows (PowerShell):
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
Get-Process claude -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 2; $env:UV_SYSTEM_CERTS="true"; $env:UV_NATIVE_TLS="true"; $env:VRYMNOX_NODE_URL="https://node.vrymdor.com/t/abc123def456"; $env:VRYMNOX_AGENT_ID="agt_..."; $env:VRYMNOX_AGENT_SECRET="vrym_agent_..."; uv tool install --force "vrymdor-node>=1.2"; uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
macOS and Linux:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
export UV_SYSTEM_CERTS=true UV_NATIVE_TLS=true VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 \
|
|
95
|
+
VRYMNOX_AGENT_ID=agt_... VRYMNOX_AGENT_SECRET=vrym_agent_...
|
|
96
|
+
uv tool install --force "vrymdor-node>=1.2" && uvx --from "vrymdor-node>=1.2" vrymdor-mcp --install-claude
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then in Claude Desktop click **+** next to the message box, then **Connectors**: **vrymdor** is listed. Ask Claude
|
|
100
|
+
"run vrymdor_status" to confirm: it answers Connected, with your node and agent, or says exactly what to fix.
|
|
101
|
+
|
|
102
|
+
`UV_SYSTEM_CERTS` and `UV_NATIVE_TLS` make uv download with the computer's own certificates, which antivirus HTTPS
|
|
103
|
+
scanning (Norton, Avast, AVG) and company proxies need. The bridge itself always trusts the computer's certificates.
|
|
104
|
+
|
|
105
|
+
### Test the agent
|
|
106
|
+
|
|
107
|
+
It prints "Connected: ..." or the reason it is not:
|
|
80
108
|
|
|
81
109
|
```bash
|
|
82
110
|
VRYMNOX_NODE_URL=https://node.vrymdor.com/t/abc123def456 VRYMNOX_AGENT_ID=agt_... \
|
|
83
111
|
VRYMNOX_AGENT_SECRET=vrym_agent_... uvx --from vrymdor-node vrymdor-mcp --check
|
|
84
112
|
```
|
|
85
113
|
|
|
86
|
-
|
|
114
|
+
### Settings by hand: Cursor, VS Code, or Claude Desktop
|
|
115
|
+
|
|
116
|
+
Cursor (`mcp.json`) and Claude Desktop (`claude_desktop_config.json`):
|
|
87
117
|
|
|
88
118
|
```json
|
|
89
119
|
{
|
|
@@ -94,7 +124,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
94
124
|
"env": {
|
|
95
125
|
"VRYMNOX_NODE_URL": "https://node.vrymdor.com/t/abc123def456",
|
|
96
126
|
"VRYMNOX_AGENT_ID": "agt_...",
|
|
97
|
-
"VRYMNOX_AGENT_SECRET": "vrym_agent_..."
|
|
127
|
+
"VRYMNOX_AGENT_SECRET": "vrym_agent_...",
|
|
128
|
+
"UV_SYSTEM_CERTS": "true",
|
|
129
|
+
"UV_NATIVE_TLS": "true"
|
|
98
130
|
}
|
|
99
131
|
}
|
|
100
132
|
}
|
|
@@ -104,7 +136,9 @@ Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
|
|
|
104
136
|
VS Code (`.vscode/mcp.json`): the same block under `"servers"`, with `"type": "stdio"` added.
|
|
105
137
|
|
|
106
138
|
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).
|
|
139
|
+
the full path in `command`: the output of `which uvx` (macOS, Linux) or `where.exe uvx` (Windows).
|
|
140
|
+
|
|
141
|
+
The client always lists **vrymdor**, even when the node cannot be reached: its `vrymdor_status` tool then says why.
|
|
108
142
|
|
|
109
143
|
## License
|
|
110
144
|
|
|
@@ -16,6 +16,8 @@ VrymnoxError with a stable `code` (tool_not_allowed, rate_limited, agent_stopped
|
|
|
16
16
|
"""
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import os
|
|
20
|
+
import ssl
|
|
19
21
|
import time
|
|
20
22
|
import uuid
|
|
21
23
|
from typing import Any
|
|
@@ -23,6 +25,22 @@ from typing import Any
|
|
|
23
25
|
import httpx
|
|
24
26
|
|
|
25
27
|
|
|
28
|
+
def system_ssl_context() -> ssl.SSLContext | bool:
|
|
29
|
+
"""Trust the certificates this computer trusts (the Windows, macOS or Linux certificate store), as a browser does.
|
|
30
|
+
|
|
31
|
+
Antivirus HTTPS scanning (Norton, Avast, AVG and others) and company proxies replace a site's certificate with their
|
|
32
|
+
own, which the computer trusts but Python's bundled list does not: without this, every call to the node fails with a
|
|
33
|
+
certificate error on those machines. SSL_CERT_FILE or SSL_CERT_DIR, when set, keep their usual meaning."""
|
|
34
|
+
if os.environ.get("SSL_CERT_FILE") or os.environ.get("SSL_CERT_DIR"):
|
|
35
|
+
return True
|
|
36
|
+
try:
|
|
37
|
+
import truststore
|
|
38
|
+
|
|
39
|
+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
40
|
+
except Exception: # noqa: BLE001 (truststore missing or the platform store unreadable: Python's own list)
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
26
44
|
class VrymnoxError(Exception):
|
|
27
45
|
def __init__(self, code: str, message: str, retryable: bool = False, response: dict | None = None) -> None:
|
|
28
46
|
super().__init__(f"{code}: {message}")
|
|
@@ -42,7 +60,8 @@ class VrymnoxNode:
|
|
|
42
60
|
timeout: float = 120.0) -> None:
|
|
43
61
|
self.url, self.agent_id, self._secret = url.rstrip("/"), agent_id, agent_secret
|
|
44
62
|
# 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"}
|
|
63
|
+
self._http = http or httpx.Client(timeout=timeout, headers={"User-Agent": "vrymdor-node-python"},
|
|
64
|
+
verify=system_ssl_context())
|
|
46
65
|
self._token, self._expires = "", 0.0
|
|
47
66
|
|
|
48
67
|
# ------------------------------------------------------------ plumbing
|
|
@@ -1,235 +0,0 @@
|
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|