daimon-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- daimon_sdk-0.1.0/.github/workflows/publish.yml +35 -0
- daimon_sdk-0.1.0/.gitignore +7 -0
- daimon_sdk-0.1.0/PKG-INFO +116 -0
- daimon_sdk-0.1.0/README.md +102 -0
- daimon_sdk-0.1.0/examples/auth.py +16 -0
- daimon_sdk-0.1.0/examples/exec_session.py +17 -0
- daimon_sdk-0.1.0/examples/files.py +19 -0
- daimon_sdk-0.1.0/examples/runtime_and_web.py +17 -0
- daimon_sdk-0.1.0/pyproject.toml +32 -0
- daimon_sdk-0.1.0/src/daimon_sdk/__init__.py +35 -0
- daimon_sdk-0.1.0/src/daimon_sdk/_transport.py +136 -0
- daimon_sdk-0.1.0/src/daimon_sdk/client.py +437 -0
- daimon_sdk-0.1.0/src/daimon_sdk/exceptions.py +25 -0
- daimon_sdk-0.1.0/src/daimon_sdk/models.py +242 -0
- daimon_sdk-0.1.0/tests/conftest.py +125 -0
- daimon_sdk-0.1.0/tests/test_e2e.py +129 -0
- daimon_sdk-0.1.0/tests/test_unit.py +60 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- name: Check out repository
|
|
18
|
+
uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
|
|
25
|
+
- name: Install package and test dependencies
|
|
26
|
+
run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" build
|
|
27
|
+
|
|
28
|
+
- name: Run unit tests
|
|
29
|
+
run: python -m pytest tests/test_unit.py -q
|
|
30
|
+
|
|
31
|
+
- name: Build distributions
|
|
32
|
+
run: python -m build
|
|
33
|
+
|
|
34
|
+
- name: Publish to PyPI
|
|
35
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daimon-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed async Python SDK for daimon MCP services.
|
|
5
|
+
Author: processd contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: fastmcp<4,>=3.1.1
|
|
9
|
+
Requires-Dist: httpx<1,>=0.28
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest<9,>=8.3; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# daimon-sdk
|
|
16
|
+
|
|
17
|
+
Typed async Python SDK for `processd-mcp`.
|
|
18
|
+
|
|
19
|
+
`daimon-sdk` wraps the raw MCP tool surface exposed by `processd-mcp` and presents it as grouped Python APIs such as `client.files.read()` and `client.exec.start_session()`. The SDK keeps `processd-standalone` as the contract source of truth and focuses on:
|
|
20
|
+
|
|
21
|
+
- connection and token wiring
|
|
22
|
+
- typed request/response handling
|
|
23
|
+
- structured tool error mapping
|
|
24
|
+
- interactive session helpers
|
|
25
|
+
- compatibility tests against a real `processd-mcp` binary
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install daimon-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For local development:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
from daimon_sdk import DaimonClient
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def main() -> None:
|
|
48
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
|
|
49
|
+
runtime = await client.runtime.get_context()
|
|
50
|
+
print(runtime.base_workdir)
|
|
51
|
+
|
|
52
|
+
result = await client.files.glob("**/*.rs", path=runtime.base_workdir)
|
|
53
|
+
print(result.filenames[:5])
|
|
54
|
+
|
|
55
|
+
bash = await client.exec.bash("printf 'hello from processd\\n'")
|
|
56
|
+
print(bash.stdout)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Raw MCP vs SDK
|
|
63
|
+
|
|
64
|
+
Raw MCP:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
payload = await mcp_client.call_tool("Read", {"file_path": "/tmp/demo.txt"})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
SDK:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
read = await client.files.read("/tmp/demo.txt")
|
|
74
|
+
print(read.file.content)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## API Overview
|
|
78
|
+
|
|
79
|
+
- `DaimonClient(base_url, access_token=None, timeout_s=30.0)`
|
|
80
|
+
- `await client.connect()` / `await client.close()`
|
|
81
|
+
- `async with DaimonClient(...) as client`
|
|
82
|
+
- `client.runtime.get_context()`
|
|
83
|
+
- `client.files.read() / write() / edit() / glob() / grep()`
|
|
84
|
+
- `client.exec.bash() / start_session()`
|
|
85
|
+
- `SessionHandle.write() / poll() / wait_for_exit() / close()`
|
|
86
|
+
- `client.web.fetch()`
|
|
87
|
+
- `client.raw.call_tool()`
|
|
88
|
+
|
|
89
|
+
## Local Testing
|
|
90
|
+
|
|
91
|
+
The SDK compatibility tests expect a sibling checkout of `processd-standalone`:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
e2b-project/
|
|
95
|
+
processd-standalone/
|
|
96
|
+
processd-sdk/
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run tests with an environment that already has the dev dependencies installed:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
PYTHONPATH=src python -m pytest -q
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The E2E suite builds and launches `../processd-standalone/target/debug/processd-mcp`.
|
|
106
|
+
|
|
107
|
+
## Release
|
|
108
|
+
|
|
109
|
+
Releases are published from GitHub Actions when a tag matching `v*` is pushed.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git tag v0.1.0
|
|
113
|
+
git push origin v0.1.0
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The tag version must match `pyproject.toml`'s project version.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# daimon-sdk
|
|
2
|
+
|
|
3
|
+
Typed async Python SDK for `processd-mcp`.
|
|
4
|
+
|
|
5
|
+
`daimon-sdk` wraps the raw MCP tool surface exposed by `processd-mcp` and presents it as grouped Python APIs such as `client.files.read()` and `client.exec.start_session()`. The SDK keeps `processd-standalone` as the contract source of truth and focuses on:
|
|
6
|
+
|
|
7
|
+
- connection and token wiring
|
|
8
|
+
- typed request/response handling
|
|
9
|
+
- structured tool error mapping
|
|
10
|
+
- interactive session helpers
|
|
11
|
+
- compatibility tests against a real `processd-mcp` binary
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install daimon-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
from daimon_sdk import DaimonClient
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def main() -> None:
|
|
34
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
|
|
35
|
+
runtime = await client.runtime.get_context()
|
|
36
|
+
print(runtime.base_workdir)
|
|
37
|
+
|
|
38
|
+
result = await client.files.glob("**/*.rs", path=runtime.base_workdir)
|
|
39
|
+
print(result.filenames[:5])
|
|
40
|
+
|
|
41
|
+
bash = await client.exec.bash("printf 'hello from processd\\n'")
|
|
42
|
+
print(bash.stdout)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Raw MCP vs SDK
|
|
49
|
+
|
|
50
|
+
Raw MCP:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
payload = await mcp_client.call_tool("Read", {"file_path": "/tmp/demo.txt"})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
SDK:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
read = await client.files.read("/tmp/demo.txt")
|
|
60
|
+
print(read.file.content)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API Overview
|
|
64
|
+
|
|
65
|
+
- `DaimonClient(base_url, access_token=None, timeout_s=30.0)`
|
|
66
|
+
- `await client.connect()` / `await client.close()`
|
|
67
|
+
- `async with DaimonClient(...) as client`
|
|
68
|
+
- `client.runtime.get_context()`
|
|
69
|
+
- `client.files.read() / write() / edit() / glob() / grep()`
|
|
70
|
+
- `client.exec.bash() / start_session()`
|
|
71
|
+
- `SessionHandle.write() / poll() / wait_for_exit() / close()`
|
|
72
|
+
- `client.web.fetch()`
|
|
73
|
+
- `client.raw.call_tool()`
|
|
74
|
+
|
|
75
|
+
## Local Testing
|
|
76
|
+
|
|
77
|
+
The SDK compatibility tests expect a sibling checkout of `processd-standalone`:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
e2b-project/
|
|
81
|
+
processd-standalone/
|
|
82
|
+
processd-sdk/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run tests with an environment that already has the dev dependencies installed:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
PYTHONPATH=src python -m pytest -q
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The E2E suite builds and launches `../processd-standalone/target/debug/processd-mcp`.
|
|
92
|
+
|
|
93
|
+
## Release
|
|
94
|
+
|
|
95
|
+
Releases are published from GitHub Actions when a tag matching `v*` is pushed.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git tag v0.1.0
|
|
99
|
+
git push origin v0.1.0
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The tag version must match `pyproject.toml`'s project version.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from daimon_sdk import DaimonClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
token = os.environ["PROCESSD_TOKEN"]
|
|
11
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp", access_token=token) as client:
|
|
12
|
+
result = await client.exec.exec_command("echo secure", yield_time_ms=200)
|
|
13
|
+
print(result.output.strip())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from daimon_sdk import DaimonClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def main() -> None:
|
|
9
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
|
|
10
|
+
session = await client.exec.start_session("/bin/cat", tty=True, yield_time_ms=100)
|
|
11
|
+
echoed = await session.write("hello session\n", yield_time_ms=100)
|
|
12
|
+
print(echoed.output)
|
|
13
|
+
exited = await session.close(exit_payload="\u0004", yield_time_ms=500)
|
|
14
|
+
print(exited.exit_code)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from daimon_sdk import DaimonClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
target = Path("/tmp/daimon-sdk-demo.txt")
|
|
11
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
|
|
12
|
+
await client.files.edit(str(target), old_string="", new_string="hello from sdk\n")
|
|
13
|
+
read = await client.files.read(str(target))
|
|
14
|
+
print(read.file.content)
|
|
15
|
+
glob = await client.files.glob("*.txt", path=str(target.parent))
|
|
16
|
+
print(glob.filenames)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from daimon_sdk import DaimonClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def main() -> None:
|
|
9
|
+
async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
|
|
10
|
+
runtime = await client.runtime.get_context()
|
|
11
|
+
print(runtime.summary)
|
|
12
|
+
|
|
13
|
+
page = await client.web.fetch("https://example.com")
|
|
14
|
+
print(page.status_code, page.result_type)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daimon-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed async Python SDK for daimon MCP services."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "processd contributors" },
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastmcp>=3.1.1,<4",
|
|
17
|
+
"httpx>=0.28,<1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.3,<9",
|
|
23
|
+
"pytest-asyncio>=0.24,<1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/daimon_sdk"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
python_files = ["test_*.py"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .client import DaimonClient
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
DaimonConnectionError,
|
|
4
|
+
DaimonError,
|
|
5
|
+
DaimonProtocolError,
|
|
6
|
+
DaimonToolError,
|
|
7
|
+
)
|
|
8
|
+
from .models import (
|
|
9
|
+
BashResult,
|
|
10
|
+
EditResult,
|
|
11
|
+
ExecResult,
|
|
12
|
+
GlobResult,
|
|
13
|
+
GrepResult,
|
|
14
|
+
RuntimeContextResult,
|
|
15
|
+
SessionHandle,
|
|
16
|
+
WebFetchResult,
|
|
17
|
+
WriteResult,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BashResult",
|
|
22
|
+
"EditResult",
|
|
23
|
+
"ExecResult",
|
|
24
|
+
"GlobResult",
|
|
25
|
+
"GrepResult",
|
|
26
|
+
"DaimonClient",
|
|
27
|
+
"DaimonConnectionError",
|
|
28
|
+
"DaimonError",
|
|
29
|
+
"DaimonProtocolError",
|
|
30
|
+
"DaimonToolError",
|
|
31
|
+
"RuntimeContextResult",
|
|
32
|
+
"SessionHandle",
|
|
33
|
+
"WebFetchResult",
|
|
34
|
+
"WriteResult",
|
|
35
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import Client
|
|
9
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
10
|
+
|
|
11
|
+
from .exceptions import DaimonConnectionError, DaimonProtocolError, DaimonToolError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ToolCallEnvelope:
|
|
16
|
+
tool_name: str
|
|
17
|
+
payload: dict[str, Any]
|
|
18
|
+
content_blocks: list[dict[str, Any]]
|
|
19
|
+
raw_result: Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _content_block_to_dict(block: Any) -> dict[str, Any]:
|
|
23
|
+
if isinstance(block, dict):
|
|
24
|
+
return dict(block)
|
|
25
|
+
data: dict[str, Any] = {}
|
|
26
|
+
for key in ("type", "text", "data", "mimeType", "mime_type", "annotations"):
|
|
27
|
+
value = getattr(block, key, None)
|
|
28
|
+
if value is not None:
|
|
29
|
+
data[key] = value
|
|
30
|
+
if not data and hasattr(block, "model_dump"):
|
|
31
|
+
dumped = block.model_dump()
|
|
32
|
+
if isinstance(dumped, dict):
|
|
33
|
+
data = dumped
|
|
34
|
+
if not data and hasattr(block, "__dict__"):
|
|
35
|
+
data = {
|
|
36
|
+
key: value
|
|
37
|
+
for key, value in vars(block).items()
|
|
38
|
+
if not key.startswith("_")
|
|
39
|
+
}
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode_tool_result(result: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
44
|
+
if isinstance(getattr(result, "structured_content", None), dict):
|
|
45
|
+
payload = dict(result.structured_content)
|
|
46
|
+
content = [_content_block_to_dict(block) for block in getattr(result, "content", []) or []]
|
|
47
|
+
return payload, content
|
|
48
|
+
if getattr(result, "data", None) is not None:
|
|
49
|
+
data = result.data
|
|
50
|
+
if isinstance(data, dict):
|
|
51
|
+
payload = dict(data)
|
|
52
|
+
elif isinstance(data, str):
|
|
53
|
+
try:
|
|
54
|
+
payload = json.loads(data)
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
raise DaimonProtocolError(f"tool response data was not valid JSON: {data}") from exc
|
|
57
|
+
else:
|
|
58
|
+
raise DaimonProtocolError(f"unsupported tool response data type: {type(data)!r}")
|
|
59
|
+
content = [_content_block_to_dict(block) for block in getattr(result, "content", []) or []]
|
|
60
|
+
return payload, content
|
|
61
|
+
content = getattr(result, "content", None) or []
|
|
62
|
+
if content:
|
|
63
|
+
text = getattr(content[0], "text", None)
|
|
64
|
+
if isinstance(text, str):
|
|
65
|
+
try:
|
|
66
|
+
payload = json.loads(text)
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise DaimonProtocolError(f"tool response text was not valid JSON: {text}") from exc
|
|
69
|
+
return payload, [_content_block_to_dict(block) for block in content]
|
|
70
|
+
raise DaimonProtocolError(f"unable to decode tool result: {result!r}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FastMCPTransportAdapter:
|
|
74
|
+
def __init__(self, base_url: str, *, access_token: str | None, timeout_s: float) -> None:
|
|
75
|
+
self.base_url = base_url
|
|
76
|
+
self.access_token = access_token
|
|
77
|
+
self.timeout_s = timeout_s
|
|
78
|
+
self._client: Client | None = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def client(self) -> Client:
|
|
82
|
+
if self._client is None:
|
|
83
|
+
raise DaimonConnectionError("client is not connected")
|
|
84
|
+
return self._client
|
|
85
|
+
|
|
86
|
+
async def connect(self) -> None:
|
|
87
|
+
if self._client is not None:
|
|
88
|
+
return
|
|
89
|
+
headers = {"X-Access-Token": self.access_token} if self.access_token else None
|
|
90
|
+
transport = StreamableHttpTransport(
|
|
91
|
+
self.base_url,
|
|
92
|
+
headers=headers,
|
|
93
|
+
httpx_client_factory=self._httpx_client_factory,
|
|
94
|
+
)
|
|
95
|
+
client = Client(transport, timeout=self.timeout_s)
|
|
96
|
+
try:
|
|
97
|
+
await client.__aenter__()
|
|
98
|
+
except Exception as exc: # pragma: no cover - fastmcp exception types vary
|
|
99
|
+
raise DaimonConnectionError(str(exc)) from exc
|
|
100
|
+
self._client = client
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
if self._client is None:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
await self._client.__aexit__(None, None, None)
|
|
107
|
+
finally:
|
|
108
|
+
self._client = None
|
|
109
|
+
|
|
110
|
+
async def call_tool(
|
|
111
|
+
self,
|
|
112
|
+
tool_name: str,
|
|
113
|
+
arguments: dict[str, Any],
|
|
114
|
+
*,
|
|
115
|
+
raise_on_error: bool = True,
|
|
116
|
+
) -> ToolCallEnvelope:
|
|
117
|
+
await self.connect()
|
|
118
|
+
try:
|
|
119
|
+
result = await self.client.call_tool(tool_name, arguments, raise_on_error=False)
|
|
120
|
+
except Exception as exc: # pragma: no cover - transport-side failures vary
|
|
121
|
+
raise DaimonConnectionError(str(exc)) from exc
|
|
122
|
+
payload, content_blocks = decode_tool_result(result)
|
|
123
|
+
if raise_on_error and isinstance(payload.get("error"), str):
|
|
124
|
+
raise DaimonToolError(payload["error"], tool_name=tool_name, payload=payload)
|
|
125
|
+
return ToolCallEnvelope(
|
|
126
|
+
tool_name=tool_name,
|
|
127
|
+
payload=payload,
|
|
128
|
+
content_blocks=content_blocks,
|
|
129
|
+
raw_result=result,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _httpx_client_factory(self, **kwargs: Any) -> httpx.AsyncClient:
|
|
133
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
134
|
+
kwargs.setdefault("timeout", self.timeout_s)
|
|
135
|
+
kwargs.setdefault("follow_redirects", True)
|
|
136
|
+
return httpx.AsyncClient(headers=headers, **kwargs)
|