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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vrymdor-node
3
- Version: 1.1.0
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
- Test the agent first (it prints "Connected: ..." or the reason it is not):
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
- Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
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
- Test the agent first (it prints "Connected: ..." or the reason it is not):
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
- Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
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.0"
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.0
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
- Test the agent first (it prints "Connected: ..." or the reason it is not):
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
- Claude Desktop (`claude_desktop_config.json`) and Cursor (`mcp.json`):
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
 
@@ -1,4 +1,5 @@
1
1
  httpx<1,>=0.27
2
+ truststore>=0.10
2
3
 
3
4
  [langchain]
4
5
  langchain-core>=0.3.49
@@ -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