-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvault_client.py
More file actions
159 lines (134 loc) · 5.62 KB
/
Copy pathvault_client.py
File metadata and controls
159 lines (134 loc) · 5.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""
vault_client.py — MCP client wrapper for the Perseus Vault CLI binary.
Maintains a persistent MCP stdio connection to the vault binary.
Uses the MCP JSON-RPC protocol for tool calls.
"""
import asyncio
import json
import logging
import os
import subprocess
import time
from typing import Any
logger = logging.getLogger("perseus_cloud.vault")
PERSEUS_VAULT_BINARY = os.getenv(
"PERSEUS_VAULT_BINARY_PATH",
"/opt/data/webui/minions/.minions-data/vault/vault",
)
PERSEUS_VAULT_DB = os.getenv("PERSEUS_VAULT_DB_PATH", "/opt/data/webui/minions/.minions-data/vault/vault.db")
PERSEUS_VAULT_ENCRYPTION_KEY = os.getenv("PERSEUS_VAULT_ENCRYPTION_KEY", "")
class VaultClient:
"""Async wrapper around the Perseus Vault MCP binary via stdio subprocess."""
def __init__(self):
self._process: subprocess.Popen | None = None
self._request_id = 0
self._lock = asyncio.Lock()
self._connected = False
async def start(self) -> None:
"""Start the Perseus Vault MCP process and perform handshake."""
cmd = [PERSEUS_VAULT_BINARY, "serve", "--db", PERSEUS_VAULT_DB]
if PERSEUS_VAULT_ENCRYPTION_KEY:
cmd.extend(["--encryption-key", PERSEUS_VAULT_ENCRYPTION_KEY])
safe_cmd = [PERSEUS_VAULT_BINARY, "serve", "--db", PERSEUS_VAULT_DB]
if PERSEUS_VAULT_ENCRYPTION_KEY:
safe_cmd.extend(["--encryption-key", "<redacted>"])
logger.info("Starting Perseus Vault process", extra={"cmd": safe_cmd})
self._process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# MCP initialize handshake
init_result, err = await self._call("initialize", {
"protocolVersion": "2025-06-18",
"clientInfo": {"name": "perseus-cloud-api", "version": "1.1.0"},
"capabilities": {},
})
if err or not init_result:
raise RuntimeError(f"Perseus Vault handshake failed: {err}")
# Send initialized notification
self._send_notification("notifications/initialized", {})
self._connected = True
logger.info("Perseus Vault connected successfully")
async def stop(self) -> None:
"""Stop the Perseus Vault process."""
if self._process:
try:
self._process.stdin.close()
self._process.stdout.close()
self._process.terminate()
self._process.wait(timeout=5)
except Exception:
try:
self._process.kill()
except Exception:
pass
self._process = None
self._connected = False
async def call_tool(self, tool_name: str, arguments: dict) -> tuple[Any, str | None]:
"""Call an MCP tool and return (result, error_string)."""
async with self._lock:
result, err = await self._call("tools/call", {
"name": tool_name,
"arguments": arguments,
})
if err:
return None, err
if result is None:
return None, "no result"
# MCP tool result wraps content in result.content[0].text (JSON string)
content = result.get("content", [])
if content and isinstance(content, list):
first = content[0]
if isinstance(first, dict) and "text" in first:
try:
return json.loads(first["text"]), None
except (json.JSONDecodeError, TypeError):
return {"text": first["text"]}, None
return result, None
async def _call(self, method: str, params: dict) -> tuple[dict | None, str | None]:
"""Send a JSON-RPC request and return the result."""
if not self._process or self._process.poll() is not None:
return None, "MCP process not running"
self._request_id += 1
req_id = self._request_id
request = json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
})
loop = asyncio.get_event_loop()
try:
self._process.stdin.write(request + "\n")
self._process.stdin.flush()
except (BrokenPipeError, OSError) as e:
return None, f"MCP write failed: {e}"
# Read response line (run blocking read in executor)
try:
line = await loop.run_in_executor(None, self._process.stdout.readline)
if not line:
return None, "MCP EOF (process may have crashed)"
response = json.loads(line)
except (json.JSONDecodeError, Exception) as e:
return None, f"MCP read/parse failed: {e}"
if "error" in response:
err = response["error"]
return None, f"MCP error {err.get('code', '')}: {err.get('message', str(err))}"
return response.get("result"), None
def _send_notification(self, method: str, params: dict) -> None:
"""Send a JSON-RPC notification (no response expected)."""
msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
if self._process and self._process.stdin:
try:
self._process.stdin.write(msg + "\n")
self._process.stdin.flush()
except Exception:
pass
@property
def is_connected(self) -> bool:
return self._connected and self._process is not None and self._process.poll() is None
# Global singleton
vault_client = VaultClient()