hx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/mcp/client.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""MCP client - stdio and streamable HTTP transports.
|
|
2
|
+
|
|
3
|
+
Implements the subset HX needs: ``initialize``, ``tools/list``, ``tools/call``.
|
|
4
|
+
JSON-RPC 2.0 over either transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import abc
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from hx import __version__
|
|
22
|
+
|
|
23
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
24
|
+
CLIENT_INFO = {"name": "hx", "version": __version__}
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class MCPToolDef:
|
|
31
|
+
name: str
|
|
32
|
+
description: str
|
|
33
|
+
input_schema: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class ServerCapabilities:
|
|
38
|
+
tools: bool = False
|
|
39
|
+
prompts: bool = False
|
|
40
|
+
resources: bool = False
|
|
41
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Transport(abc.ABC):
|
|
45
|
+
@abc.abstractmethod
|
|
46
|
+
async def connect(self) -> None: ...
|
|
47
|
+
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
async def send(self, message: dict[str, Any]) -> None: ...
|
|
50
|
+
|
|
51
|
+
@abc.abstractmethod
|
|
52
|
+
def receive(self) -> AsyncIterator[dict[str, Any]]: ...
|
|
53
|
+
|
|
54
|
+
@abc.abstractmethod
|
|
55
|
+
async def close(self) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StdioTransport(Transport):
|
|
59
|
+
"""Newline-delimited JSON-RPC over a subprocess's stdio.
|
|
60
|
+
|
|
61
|
+
The server's stderr is drained to the log rather than the terminal; a chatty
|
|
62
|
+
server would otherwise corrupt the TUI render.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
command: str,
|
|
68
|
+
args: list[str],
|
|
69
|
+
env: dict[str, str] | None = None,
|
|
70
|
+
cwd: Path | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.command = command
|
|
73
|
+
self.args = list(args)
|
|
74
|
+
self.env = env
|
|
75
|
+
self.cwd = cwd
|
|
76
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
77
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
78
|
+
|
|
79
|
+
async def connect(self) -> None:
|
|
80
|
+
import os
|
|
81
|
+
|
|
82
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
83
|
+
self.command,
|
|
84
|
+
*self.args,
|
|
85
|
+
stdin=asyncio.subprocess.PIPE,
|
|
86
|
+
stdout=asyncio.subprocess.PIPE,
|
|
87
|
+
stderr=asyncio.subprocess.PIPE,
|
|
88
|
+
env={**os.environ, **(self.env or {})},
|
|
89
|
+
cwd=str(self.cwd) if self.cwd else None,
|
|
90
|
+
)
|
|
91
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
92
|
+
|
|
93
|
+
async def _drain_stderr(self) -> None:
|
|
94
|
+
assert self._process is not None
|
|
95
|
+
if self._process.stderr is None:
|
|
96
|
+
return
|
|
97
|
+
async for line in self._process.stderr:
|
|
98
|
+
log.debug("mcp[%s] %s", self.command, line.decode(errors="replace").rstrip())
|
|
99
|
+
|
|
100
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
101
|
+
if self._process is None or self._process.stdin is None:
|
|
102
|
+
raise MCPError("transport is not connected")
|
|
103
|
+
self._process.stdin.write((json.dumps(message) + "\n").encode())
|
|
104
|
+
await self._process.stdin.drain()
|
|
105
|
+
|
|
106
|
+
async def receive(self) -> AsyncIterator[dict[str, Any]]:
|
|
107
|
+
if self._process is None or self._process.stdout is None:
|
|
108
|
+
raise MCPError("transport is not connected")
|
|
109
|
+
async for raw in self._process.stdout:
|
|
110
|
+
line = raw.decode(errors="replace").strip()
|
|
111
|
+
if not line:
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
yield json.loads(line)
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
# A server printing plain text on stdout is a server bug, not a
|
|
117
|
+
# reason to tear down the session.
|
|
118
|
+
log.warning("mcp[%s] non-JSON on stdout: %s", self.command, line[:200])
|
|
119
|
+
|
|
120
|
+
async def close(self) -> None:
|
|
121
|
+
if self._stderr_task is not None:
|
|
122
|
+
self._stderr_task.cancel()
|
|
123
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
124
|
+
await self._stderr_task
|
|
125
|
+
if self._process is None:
|
|
126
|
+
return
|
|
127
|
+
with contextlib.suppress(ProcessLookupError):
|
|
128
|
+
self._process.terminate()
|
|
129
|
+
with contextlib.suppress(TimeoutError, ProcessLookupError):
|
|
130
|
+
await asyncio.wait_for(self._process.wait(), timeout=5)
|
|
131
|
+
self._process = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HTTPTransport(Transport):
|
|
135
|
+
"""Streamable HTTP transport. Responses arrive as JSON or as an SSE stream."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, url: str, headers: dict[str, str] | None = None) -> None:
|
|
138
|
+
self.url = url
|
|
139
|
+
self.headers = {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"Accept": "application/json, text/event-stream",
|
|
142
|
+
**(headers or {}),
|
|
143
|
+
}
|
|
144
|
+
self._client: httpx.AsyncClient | None = None
|
|
145
|
+
self._session_id: str | None = None
|
|
146
|
+
self._inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
147
|
+
|
|
148
|
+
async def connect(self) -> None:
|
|
149
|
+
self._client = httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0))
|
|
150
|
+
|
|
151
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
152
|
+
if self._client is None:
|
|
153
|
+
raise MCPError("transport is not connected")
|
|
154
|
+
headers = dict(self.headers)
|
|
155
|
+
if self._session_id:
|
|
156
|
+
headers["Mcp-Session-Id"] = self._session_id
|
|
157
|
+
|
|
158
|
+
response = await self._client.post(self.url, json=message, headers=headers)
|
|
159
|
+
if response.status_code >= 400:
|
|
160
|
+
raise MCPError(f"{self.url} returned {response.status_code}: {response.text[:200]}")
|
|
161
|
+
if session_id := response.headers.get("Mcp-Session-Id"):
|
|
162
|
+
self._session_id = session_id
|
|
163
|
+
|
|
164
|
+
if response.status_code == 202 or not response.content:
|
|
165
|
+
return
|
|
166
|
+
for payload in _decode_http_body(response):
|
|
167
|
+
await self._inbox.put(payload)
|
|
168
|
+
|
|
169
|
+
async def receive(self) -> AsyncIterator[dict[str, Any]]:
|
|
170
|
+
while True:
|
|
171
|
+
yield await self._inbox.get()
|
|
172
|
+
|
|
173
|
+
async def close(self) -> None:
|
|
174
|
+
if self._client is not None:
|
|
175
|
+
await self._client.aclose()
|
|
176
|
+
self._client = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _decode_http_body(response: httpx.Response) -> list[dict[str, Any]]:
|
|
180
|
+
content_type = response.headers.get("content-type", "")
|
|
181
|
+
if "text/event-stream" in content_type:
|
|
182
|
+
payloads = []
|
|
183
|
+
for line in response.text.splitlines():
|
|
184
|
+
if line.startswith("data:"):
|
|
185
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
186
|
+
payloads.append(json.loads(line[5:].strip()))
|
|
187
|
+
return payloads
|
|
188
|
+
try:
|
|
189
|
+
body = response.json()
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
raise MCPError(f"invalid JSON from {response.url}: {exc}") from exc
|
|
192
|
+
return body if isinstance(body, list) else [body]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class MCPClient:
|
|
196
|
+
"""One connected server."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, name: str, transport: Transport, timeout: float = 30.0) -> None:
|
|
199
|
+
self.name = name
|
|
200
|
+
self.transport = transport
|
|
201
|
+
self.timeout = timeout
|
|
202
|
+
self.capabilities = ServerCapabilities()
|
|
203
|
+
self._next_id = 0
|
|
204
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
205
|
+
self._reader: asyncio.Task[None] | None = None
|
|
206
|
+
|
|
207
|
+
async def initialize(self) -> ServerCapabilities:
|
|
208
|
+
await self.transport.connect()
|
|
209
|
+
self._reader = asyncio.create_task(self._read_loop())
|
|
210
|
+
|
|
211
|
+
result = await self._request(
|
|
212
|
+
"initialize",
|
|
213
|
+
{
|
|
214
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
215
|
+
"capabilities": {},
|
|
216
|
+
"clientInfo": CLIENT_INFO,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
raw = result.get("capabilities") or {}
|
|
220
|
+
self.capabilities = ServerCapabilities(
|
|
221
|
+
tools="tools" in raw,
|
|
222
|
+
prompts="prompts" in raw,
|
|
223
|
+
resources="resources" in raw,
|
|
224
|
+
raw=raw,
|
|
225
|
+
)
|
|
226
|
+
await self._notify("notifications/initialized", {})
|
|
227
|
+
return self.capabilities
|
|
228
|
+
|
|
229
|
+
async def list_tools(self) -> list[MCPToolDef]:
|
|
230
|
+
if not self.capabilities.tools:
|
|
231
|
+
return []
|
|
232
|
+
result = await self._request("tools/list", {})
|
|
233
|
+
return [
|
|
234
|
+
MCPToolDef(
|
|
235
|
+
name=str(item["name"]),
|
|
236
|
+
description=str(item.get("description") or ""),
|
|
237
|
+
input_schema=item.get("inputSchema") or {"type": "object", "properties": {}},
|
|
238
|
+
)
|
|
239
|
+
for item in result.get("tools", [])
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
|
|
243
|
+
"""Call a tool and flatten the content blocks to text.
|
|
244
|
+
|
|
245
|
+
Tool results are data from a third-party server. They are returned to the
|
|
246
|
+
model as tool output and must never be treated as instructions to HX.
|
|
247
|
+
"""
|
|
248
|
+
result = await self._request("tools/call", {"name": name, "arguments": arguments})
|
|
249
|
+
parts = [
|
|
250
|
+
str(block.get("text", ""))
|
|
251
|
+
for block in result.get("content", [])
|
|
252
|
+
if block.get("type") == "text"
|
|
253
|
+
]
|
|
254
|
+
text = "\n".join(part for part in parts if part)
|
|
255
|
+
if result.get("isError"):
|
|
256
|
+
raise MCPToolError(text or f"{name} failed")
|
|
257
|
+
return text or "(no content)"
|
|
258
|
+
|
|
259
|
+
async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
260
|
+
self._next_id += 1
|
|
261
|
+
request_id = self._next_id
|
|
262
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
263
|
+
self._pending[request_id] = future
|
|
264
|
+
|
|
265
|
+
await self.transport.send(
|
|
266
|
+
{"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
message = await asyncio.wait_for(future, timeout=self.timeout)
|
|
270
|
+
except TimeoutError as exc:
|
|
271
|
+
raise MCPError(f"{self.name}: {method} timed out after {self.timeout:.0f}s") from exc
|
|
272
|
+
finally:
|
|
273
|
+
self._pending.pop(request_id, None)
|
|
274
|
+
|
|
275
|
+
if error := message.get("error"):
|
|
276
|
+
raise MCPError(f"{self.name}: {error.get('message', error)}")
|
|
277
|
+
result = message.get("result")
|
|
278
|
+
return result if isinstance(result, dict) else {}
|
|
279
|
+
|
|
280
|
+
async def _notify(self, method: str, params: dict[str, Any]) -> None:
|
|
281
|
+
await self.transport.send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
282
|
+
|
|
283
|
+
async def _read_loop(self) -> None:
|
|
284
|
+
try:
|
|
285
|
+
async for message in self.transport.receive():
|
|
286
|
+
request_id = message.get("id")
|
|
287
|
+
future = self._pending.get(request_id) if request_id is not None else None
|
|
288
|
+
if future is not None and not future.done():
|
|
289
|
+
future.set_result(message)
|
|
290
|
+
except asyncio.CancelledError:
|
|
291
|
+
raise
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
self._fail_pending(f"transport failed: {exc}")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# The stream ended: the server exited. Fail the waiters now rather than
|
|
297
|
+
# letting each one burn its full timeout against a process that is gone.
|
|
298
|
+
self._fail_pending("the server exited")
|
|
299
|
+
|
|
300
|
+
def _fail_pending(self, reason: str) -> None:
|
|
301
|
+
for future in self._pending.values():
|
|
302
|
+
if not future.done():
|
|
303
|
+
future.set_exception(MCPError(f"{self.name}: {reason}"))
|
|
304
|
+
|
|
305
|
+
async def close(self) -> None:
|
|
306
|
+
if self._reader is not None:
|
|
307
|
+
self._reader.cancel()
|
|
308
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
309
|
+
await self._reader
|
|
310
|
+
self._reader = None
|
|
311
|
+
await self.transport.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class MCPError(Exception):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class MCPToolError(MCPError):
|
|
319
|
+
"""The server ran the tool and it failed - distinct from a protocol failure."""
|
hx/mcp/manager.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""MCP server lifecycle and tool registration.
|
|
2
|
+
|
|
3
|
+
Config lives in ``<cwd>/.hx/mcp.json`` and ``~/.hx/mcp.json``, project last.
|
|
4
|
+
Servers connect concurrently at startup with a per-server timeout; a failure
|
|
5
|
+
logs a warning and drops that server, never kills the session.
|
|
6
|
+
|
|
7
|
+
Tools are namespaced ``mcp__<server>__<tool>`` and registered into the same
|
|
8
|
+
:class:`~hx.tools.registry.ToolRegistry` as builtins, sorted deterministically -
|
|
9
|
+
a server whose tool order varies between runs would invalidate the cache prefix.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from hx.mcp.client import MCPClient, MCPError, MCPToolDef, StdioTransport
|
|
22
|
+
from hx.tools.base import Tool, ToolContext, ToolError, ToolResult
|
|
23
|
+
from hx.tools.output import cap_output, summarize_for_ui
|
|
24
|
+
|
|
25
|
+
NAMESPACE_TEMPLATE = "mcp__{server}__{tool}"
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class MCPServerConfig:
|
|
32
|
+
name: str
|
|
33
|
+
transport: str
|
|
34
|
+
"""``stdio`` or ``http``."""
|
|
35
|
+
command: str | None = None
|
|
36
|
+
args: tuple[str, ...] = ()
|
|
37
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
url: str | None = None
|
|
39
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
timeout: float = 30.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ServerStatus:
|
|
46
|
+
name: str
|
|
47
|
+
connected: bool
|
|
48
|
+
tool_count: int
|
|
49
|
+
error: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MCPTool(Tool):
|
|
53
|
+
"""Adapter presenting an MCP tool through the normal :class:`Tool` interface."""
|
|
54
|
+
|
|
55
|
+
#: MCP servers are third-party code; assume a call can have side effects
|
|
56
|
+
#: unless the schema says otherwise. Concurrency and prompting follow from
|
|
57
|
+
#: this, so guessing "read-only" would be the unsafe guess.
|
|
58
|
+
mutating = True
|
|
59
|
+
|
|
60
|
+
def __init__(self, client: MCPClient, server: str, definition: MCPToolDef) -> None:
|
|
61
|
+
self.client = client
|
|
62
|
+
self.server = server
|
|
63
|
+
self.definition = definition
|
|
64
|
+
self.name = NAMESPACE_TEMPLATE.format(server=server, tool=definition.name)
|
|
65
|
+
self.description = definition.description or f"{definition.name} (from {server})"
|
|
66
|
+
|
|
67
|
+
def schema(self) -> dict[str, Any]:
|
|
68
|
+
return self.definition.input_schema
|
|
69
|
+
|
|
70
|
+
def permission_specifier(self, params: dict[str, Any]) -> str | None:
|
|
71
|
+
return self.definition.name
|
|
72
|
+
|
|
73
|
+
async def run(self, params: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
74
|
+
try:
|
|
75
|
+
output = await self.client.call_tool(self.definition.name, params)
|
|
76
|
+
except MCPError as exc:
|
|
77
|
+
raise ToolError(str(exc)) from exc
|
|
78
|
+
|
|
79
|
+
capped = cap_output(
|
|
80
|
+
output,
|
|
81
|
+
session_id=ctx.session_id,
|
|
82
|
+
tool_use_id=ctx.tool_use_id,
|
|
83
|
+
char_cap=ctx.settings.context.tool_output_char_cap,
|
|
84
|
+
line_cap=ctx.settings.context.tool_output_line_cap,
|
|
85
|
+
)
|
|
86
|
+
return ToolResult(
|
|
87
|
+
content=capped.text,
|
|
88
|
+
spilled_path=capped.spilled_path,
|
|
89
|
+
summary=summarize_for_ui(output),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MCPManager:
|
|
94
|
+
def __init__(self, configs: list[MCPServerConfig]) -> None:
|
|
95
|
+
self.configs = [config for config in configs if config.enabled]
|
|
96
|
+
self.clients: dict[str, MCPClient] = {}
|
|
97
|
+
self._status: dict[str, ServerStatus] = {}
|
|
98
|
+
self._tools: dict[str, list[MCPToolDef]] = {}
|
|
99
|
+
|
|
100
|
+
async def connect_all(self) -> list[ServerStatus]:
|
|
101
|
+
"""Connect concurrently. Never raises for a single server's failure."""
|
|
102
|
+
results = await asyncio.gather(
|
|
103
|
+
*(self._connect(config) for config in self.configs),
|
|
104
|
+
return_exceptions=True,
|
|
105
|
+
)
|
|
106
|
+
for config, result in zip(self.configs, results, strict=True):
|
|
107
|
+
if isinstance(result, BaseException):
|
|
108
|
+
self._status[config.name] = ServerStatus(config.name, False, 0, str(result))
|
|
109
|
+
log.warning("mcp server %s failed to start: %s", config.name, result)
|
|
110
|
+
return self.status()
|
|
111
|
+
|
|
112
|
+
async def _connect(self, config: MCPServerConfig) -> None:
|
|
113
|
+
client = MCPClient(config.name, _build_transport(config), timeout=config.timeout)
|
|
114
|
+
try:
|
|
115
|
+
await asyncio.wait_for(client.initialize(), timeout=config.timeout)
|
|
116
|
+
definitions = await client.list_tools()
|
|
117
|
+
except (TimeoutError, MCPError, OSError) as exc:
|
|
118
|
+
await client.close()
|
|
119
|
+
# One bad server must not cost the user their whole session.
|
|
120
|
+
# asyncio.TimeoutError stringifies to nothing, so say what happened.
|
|
121
|
+
reason = (
|
|
122
|
+
f"did not respond within {config.timeout:.0f}s"
|
|
123
|
+
if isinstance(exc, TimeoutError)
|
|
124
|
+
else str(exc) or type(exc).__name__
|
|
125
|
+
)
|
|
126
|
+
self._status[config.name] = ServerStatus(config.name, False, 0, reason)
|
|
127
|
+
log.warning("mcp server %s unavailable: %s", config.name, reason)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self.clients[config.name] = client
|
|
131
|
+
self._tools[config.name] = definitions
|
|
132
|
+
self._status[config.name] = ServerStatus(config.name, True, len(definitions))
|
|
133
|
+
|
|
134
|
+
async def register_tools(self, registry: Any) -> None:
|
|
135
|
+
"""Register every connected server's tools under its namespace.
|
|
136
|
+
|
|
137
|
+
Servers are visited in sorted order and their tools sorted by name, so
|
|
138
|
+
the schema block is byte-identical between runs even if a server
|
|
139
|
+
returns its tools in a different order.
|
|
140
|
+
"""
|
|
141
|
+
for server in sorted(self.clients):
|
|
142
|
+
client = self.clients[server]
|
|
143
|
+
for definition in sorted(self._tools[server], key=lambda item: item.name):
|
|
144
|
+
tool = MCPTool(client, server, definition)
|
|
145
|
+
if registry.has(tool.name):
|
|
146
|
+
log.warning("mcp tool %s already registered; skipping", tool.name)
|
|
147
|
+
continue
|
|
148
|
+
registry.register(tool)
|
|
149
|
+
|
|
150
|
+
def status(self) -> list[ServerStatus]:
|
|
151
|
+
"""Backs ``/mcp`` and ``hx mcp list``."""
|
|
152
|
+
return [
|
|
153
|
+
self._status.get(config.name, ServerStatus(config.name, False, 0, "not started"))
|
|
154
|
+
for config in self.configs
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
async def close_all(self) -> None:
|
|
158
|
+
await asyncio.gather(
|
|
159
|
+
*(client.close() for client in self.clients.values()), return_exceptions=True
|
|
160
|
+
)
|
|
161
|
+
self.clients.clear()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _build_transport(config: MCPServerConfig) -> Any:
|
|
165
|
+
if config.transport == "stdio":
|
|
166
|
+
if not config.command:
|
|
167
|
+
raise MCPError(f"{config.name}: stdio transport needs a command")
|
|
168
|
+
return StdioTransport(config.command, list(config.args), config.env)
|
|
169
|
+
if config.transport in {"http", "sse"}:
|
|
170
|
+
from hx.mcp.client import HTTPTransport
|
|
171
|
+
|
|
172
|
+
if not config.url:
|
|
173
|
+
raise MCPError(f"{config.name}: http transport needs a url")
|
|
174
|
+
return HTTPTransport(config.url, config.headers)
|
|
175
|
+
raise MCPError(f"{config.name}: unknown transport {config.transport!r}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_configs(cwd: Path) -> list[MCPServerConfig]:
|
|
179
|
+
"""Read and merge user and project ``mcp.json``. Project entries win by name."""
|
|
180
|
+
from hx.paths import project_mcp_file, user_home
|
|
181
|
+
|
|
182
|
+
merged: dict[str, MCPServerConfig] = {}
|
|
183
|
+
for path in (user_home() / "mcp.json", project_mcp_file(cwd)):
|
|
184
|
+
for config in _read_file(path):
|
|
185
|
+
merged[config.name] = config
|
|
186
|
+
return [merged[name] for name in sorted(merged)]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _read_file(path: Path) -> list[MCPServerConfig]:
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
return []
|
|
192
|
+
try:
|
|
193
|
+
data = json.loads(path.read_text())
|
|
194
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
195
|
+
log.warning("ignoring %s: %s", path, exc)
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
servers = data.get("mcpServers") or data.get("servers") or {}
|
|
199
|
+
configs: list[MCPServerConfig] = []
|
|
200
|
+
for name, raw in servers.items():
|
|
201
|
+
if not isinstance(raw, dict):
|
|
202
|
+
continue
|
|
203
|
+
transport = str(raw.get("type") or ("http" if raw.get("url") else "stdio"))
|
|
204
|
+
configs.append(
|
|
205
|
+
MCPServerConfig(
|
|
206
|
+
name=str(name),
|
|
207
|
+
transport=transport,
|
|
208
|
+
command=raw.get("command"),
|
|
209
|
+
args=tuple(str(arg) for arg in raw.get("args") or ()),
|
|
210
|
+
env={str(k): str(v) for k, v in (raw.get("env") or {}).items()},
|
|
211
|
+
url=raw.get("url"),
|
|
212
|
+
headers={str(k): str(v) for k, v in (raw.get("headers") or {}).items()},
|
|
213
|
+
enabled=bool(raw.get("enabled", True)),
|
|
214
|
+
timeout=float(raw.get("timeout", 30.0)),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
return configs
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def save_config(config: MCPServerConfig, cwd: Path, user_level: bool = False) -> Path:
|
|
221
|
+
"""Backs ``hx mcp add``."""
|
|
222
|
+
from hx.paths import project_mcp_file, user_home
|
|
223
|
+
|
|
224
|
+
path = (user_home() / "mcp.json") if user_level else project_mcp_file(cwd)
|
|
225
|
+
data: dict[str, Any] = {}
|
|
226
|
+
if path.is_file():
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(path.read_text())
|
|
229
|
+
except (OSError, json.JSONDecodeError):
|
|
230
|
+
data = {}
|
|
231
|
+
|
|
232
|
+
servers = data.setdefault("mcpServers", {})
|
|
233
|
+
entry: dict[str, Any] = {"type": config.transport}
|
|
234
|
+
if config.command:
|
|
235
|
+
entry["command"] = config.command
|
|
236
|
+
if config.args:
|
|
237
|
+
entry["args"] = list(config.args)
|
|
238
|
+
if config.env:
|
|
239
|
+
entry["env"] = config.env
|
|
240
|
+
if config.url:
|
|
241
|
+
entry["url"] = config.url
|
|
242
|
+
servers[config.name] = entry
|
|
243
|
+
|
|
244
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
246
|
+
return path
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def remove_config(name: str, cwd: Path, user_level: bool = False) -> bool:
|
|
250
|
+
from hx.paths import project_mcp_file, user_home
|
|
251
|
+
|
|
252
|
+
path = (user_home() / "mcp.json") if user_level else project_mcp_file(cwd)
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
return False
|
|
255
|
+
try:
|
|
256
|
+
data = json.loads(path.read_text())
|
|
257
|
+
except (OSError, json.JSONDecodeError):
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
servers = data.get("mcpServers") or {}
|
|
261
|
+
if name not in servers:
|
|
262
|
+
return False
|
|
263
|
+
del servers[name]
|
|
264
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
265
|
+
return True
|
hx/paths.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Filesystem layout for HX state and configuration.
|
|
2
|
+
|
|
3
|
+
User-level state lives under ``$HX_HOME`` (default ``~/.hx``). Project-level
|
|
4
|
+
overrides live in ``<cwd>/.hx``. Nothing here touches the network or mutates
|
|
5
|
+
state on import; call :func:`ensure_user_dirs` explicitly at startup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
HX_HOME_ENV = "HX_HOME"
|
|
14
|
+
PROJECT_DIR_NAME = ".hx"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def user_home() -> Path:
|
|
18
|
+
"""Root of user-level HX state (``$HX_HOME`` or ``~/.hx``)."""
|
|
19
|
+
override = os.environ.get(HX_HOME_ENV)
|
|
20
|
+
return Path(override).expanduser() if override else Path.home() / ".hx"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def user_settings_file() -> Path:
|
|
24
|
+
return user_home() / "settings.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth_file() -> Path:
|
|
28
|
+
"""Credential store. Created with mode 0600 by the auth layer."""
|
|
29
|
+
return user_home() / "auth.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def models_cache_file() -> Path:
|
|
33
|
+
"""Cached OpenRouter model catalogue."""
|
|
34
|
+
return user_home() / "models.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sessions_dir() -> Path:
|
|
38
|
+
return user_home() / "sessions"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def session_dir(session_id: str) -> Path:
|
|
42
|
+
return sessions_dir() / session_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def session_transcript_file(session_id: str) -> Path:
|
|
46
|
+
return session_dir(session_id) / "transcript.jsonl"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def session_outputs_dir(session_id: str) -> Path:
|
|
50
|
+
"""Where oversized tool outputs are spilled (see ``hx.tools.output``)."""
|
|
51
|
+
return session_dir(session_id) / "outputs"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_skills_dir() -> Path:
|
|
55
|
+
return user_home() / "skills"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def user_agents_dir() -> Path:
|
|
59
|
+
return user_home() / "agents"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def logs_dir() -> Path:
|
|
63
|
+
return user_home() / "logs"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def project_dir(cwd: Path | None = None) -> Path:
|
|
67
|
+
"""Project-level config directory (``<cwd>/.hx``)."""
|
|
68
|
+
return (cwd or Path.cwd()) / PROJECT_DIR_NAME
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def project_settings_file(cwd: Path | None = None) -> Path:
|
|
72
|
+
return project_dir(cwd) / "settings.json"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def project_mcp_file(cwd: Path | None = None) -> Path:
|
|
76
|
+
return project_dir(cwd) / "mcp.json"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def project_skills_dir(cwd: Path | None = None) -> Path:
|
|
80
|
+
return project_dir(cwd) / "skills"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def project_agents_dir(cwd: Path | None = None) -> Path:
|
|
84
|
+
return project_dir(cwd) / "agents"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_user_dirs() -> None:
|
|
88
|
+
"""Create the user-level directory skeleton if it does not exist."""
|
|
89
|
+
for path in (user_home(), sessions_dir(), user_skills_dir(), user_agents_dir(), logs_dir()):
|
|
90
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Two independent safety layers: a rule engine and an OS sandbox.
|
|
2
|
+
|
|
3
|
+
Both must pass. The rule engine decides intent (may this be attempted?); the
|
|
4
|
+
sandbox enforces reality (what can the process actually touch?). Neither is
|
|
5
|
+
trusted to be sufficient alone - a mis-parsed command escapes the engine, and a
|
|
6
|
+
sandbox cannot tell a wanted `rm` from an unwanted one.
|
|
7
|
+
"""
|