holon-sh 0.0.1.dev1__tar.gz → 0.0.1.dev2__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.
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/LICENSE +1 -1
- holon_sh-0.0.1.dev2/PKG-INFO +140 -0
- holon_sh-0.0.1.dev2/README.md +127 -0
- holon_sh-0.0.1.dev2/holon/binding.py +51 -0
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/holon/client.py +83 -34
- holon_sh-0.0.1.dev2/holon/view.py +55 -0
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/pyproject.toml +4 -2
- holon_sh-0.0.1.dev2/scripts/fixtures.sh +7 -0
- holon_sh-0.0.1.dev2/scripts/verify_distribution.py +37 -0
- holon_sh-0.0.1.dev2/tests/fixtures/caller/holon/apps/__init__.py +55 -0
- holon_sh-0.0.1.dev2/tests/fixtures/caller/requirements.txt +1 -0
- holon_sh-0.0.1.dev2/tests/fixtures/caller/server.json +5 -0
- holon_sh-0.0.1.dev2/tests/fixtures/orders/server.json +5 -0
- holon_sh-0.0.1.dev2/tests/fixtures/orders/server.py +14 -0
- holon_sh-0.0.1.dev2/tests/test_package.py +40 -0
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/tests/test_runtime.py +45 -109
- holon_sh-0.0.1.dev1/PKG-INFO +0 -11
- holon_sh-0.0.1.dev1/holon/binding.py +0 -30
- holon_sh-0.0.1.dev1/holon/view.py +0 -35
- holon_sh-0.0.1.dev1/scripts/verify_distribution.py +0 -91
- holon_sh-0.0.1.dev1/tests/test_package.py +0 -83
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/.gitignore +0 -0
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/holon/py.typed +0 -0
- {holon_sh-0.0.1.dev1 → holon_sh-0.0.1.dev2}/tests/test_view.py +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: holon-sh
|
|
3
|
+
Version: 0.0.1.dev2
|
|
4
|
+
Summary: Typed MCP clients, bindings, and app views
|
|
5
|
+
Project-URL: Repository, https://github.com/holon-sh/sdk-py
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: httpx2<3,>=2.10
|
|
10
|
+
Requires-Dist: jsonschema<5,>=4.26
|
|
11
|
+
Requires-Dist: mcp<3,>=2.3
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# holon-sh
|
|
15
|
+
|
|
16
|
+
The Python runtime for typed MCP clients. The [`holon` CLI](https://github.com/holon-sh/cli) reads the MCP servers your server calls and generates `holon.apps`, a typed client module, into your project. This package is what that module runs on: it connects over Streamable HTTP, validates arguments and results against each tool's JSON Schema, and raises on tool errors.
|
|
17
|
+
|
|
18
|
+
Start with the [CLI README](https://github.com/holon-sh/cli) for the walkthrough. This page is the Python API.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv add --prerelease allow holon-sh
|
|
24
|
+
uv add --dev mypy==2.4.0 # holon types check runs mypy from the app's .venv
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The distribution is `holon-sh`; the import package is `holon`, a namespace package shared with your generated module. Do not create `holon/__init__.py` in your app, and do not edit `holon/apps/__init__.py`: the CLI rewrites it on every run.
|
|
28
|
+
|
|
29
|
+
## The generated module
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from holon.apps import Inputs, Outputs, apps, create_apps
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Name | What it is |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `apps` | One client per dependency in `server.json` (`apps.orders`), bound through `<NAME>_MCP_URL`. |
|
|
38
|
+
| `create_apps(bindings)` | Clients bound explicitly, for only the dependencies you pass. |
|
|
39
|
+
| `Inputs.<dep>.<tool>` | The tool's argument type, a `TypedDict`. |
|
|
40
|
+
| `Outputs.<dep>.<tool>` | The tool's structured result type, a `TypedDict`. |
|
|
41
|
+
|
|
42
|
+
### Call a tool
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from holon.apps import apps
|
|
46
|
+
|
|
47
|
+
async def total(id: str) -> float:
|
|
48
|
+
order = await apps.orders.tools.lookup({"id": id}, timeout=5.0)
|
|
49
|
+
return order["total"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Arguments are one mapping, validated before sending; the result is validated against the output schema and returned. `timeout` is in seconds.
|
|
53
|
+
|
|
54
|
+
### Handle errors
|
|
55
|
+
|
|
56
|
+
`tools.<tool>` raises `ToolCallError` when the tool reports an error or asks for more input:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from holon.client import ToolCallError
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
order = await apps.orders.tools.lookup({"id": id})
|
|
63
|
+
except ToolCallError as error:
|
|
64
|
+
error.code # "tool_error" or "input_required"
|
|
65
|
+
error.result # the original MCP result
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Invalid arguments or a result that does not match the schema raise `ValueError`; transport failures propagate from the MCP client.
|
|
69
|
+
|
|
70
|
+
### Read the whole result
|
|
71
|
+
|
|
72
|
+
`call_tool` returns the full MCP result instead of raising:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
result = await apps.orders.call_tool("lookup", {"id": id})
|
|
76
|
+
if result.is_error:
|
|
77
|
+
... # inspect result.content
|
|
78
|
+
else:
|
|
79
|
+
result.structured_content # typed as Outputs.orders.lookup
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It also accepts `input_responses`, `request_state` and `meta`. A dependency the CLI could not obtain a contract for offers only `call_tool`, untyped.
|
|
83
|
+
|
|
84
|
+
## Bind a dependency
|
|
85
|
+
|
|
86
|
+
By default a client reads its endpoint from an environment variable named after the dependency, upper-cased, with `-` as `_`:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
ORDERS_MCP_URL=http://127.0.0.1:3001/mcp uv run python server.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For authentication or a custom HTTP client, bind explicitly. `http_client` is a factory called once per call; the client it returns is closed afterwards:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import os
|
|
96
|
+
import httpx2
|
|
97
|
+
from holon.apps import create_apps
|
|
98
|
+
|
|
99
|
+
apps = create_apps({
|
|
100
|
+
"orders": {
|
|
101
|
+
"endpoint": "https://orders.example.com/mcp",
|
|
102
|
+
"http_client": lambda: httpx2.AsyncClient(
|
|
103
|
+
headers={"Authorization": f"Bearer {os.environ['ORDERS_TOKEN']}"},
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Every call opens and closes its own session; there is nothing to close. `holon.binding.set_default_binding` exists for hosts that bind every app at startup; apps do not need it.
|
|
110
|
+
|
|
111
|
+
## Serve an MCP App view
|
|
112
|
+
|
|
113
|
+
`holon.view.app_view` registers a built HTML file as an MCP App resource:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from holon.view import app_view
|
|
117
|
+
|
|
118
|
+
server.add_resource(app_view(
|
|
119
|
+
"ui://checkout/index.html", "Checkout", "dist/index.html",
|
|
120
|
+
connect_domains=["https://orders.example.com"],
|
|
121
|
+
))
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The file is read on first request and cached. `connect_domains` and `resource_domains` become the view's content security policy. The page itself uses [`holon-ui`](https://github.com/holon-sh/sdk-ui).
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
The package test uses committed clients in `tests/fixtures/caller/holon/apps`.
|
|
129
|
+
To refresh them, install the [holon CLI](https://github.com/holon-sh/cli) and run:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
./scripts/fixtures.sh
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
This runs `holon types generate ../orders` from the caller fixture. The CLI
|
|
136
|
+
extracts `tests/fixtures/orders/server.py` without starting the server.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# holon-sh
|
|
2
|
+
|
|
3
|
+
The Python runtime for typed MCP clients. The [`holon` CLI](https://github.com/holon-sh/cli) reads the MCP servers your server calls and generates `holon.apps`, a typed client module, into your project. This package is what that module runs on: it connects over Streamable HTTP, validates arguments and results against each tool's JSON Schema, and raises on tool errors.
|
|
4
|
+
|
|
5
|
+
Start with the [CLI README](https://github.com/holon-sh/cli) for the walkthrough. This page is the Python API.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
uv add --prerelease allow holon-sh
|
|
11
|
+
uv add --dev mypy==2.4.0 # holon types check runs mypy from the app's .venv
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The distribution is `holon-sh`; the import package is `holon`, a namespace package shared with your generated module. Do not create `holon/__init__.py` in your app, and do not edit `holon/apps/__init__.py`: the CLI rewrites it on every run.
|
|
15
|
+
|
|
16
|
+
## The generated module
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from holon.apps import Inputs, Outputs, apps, create_apps
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Name | What it is |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `apps` | One client per dependency in `server.json` (`apps.orders`), bound through `<NAME>_MCP_URL`. |
|
|
25
|
+
| `create_apps(bindings)` | Clients bound explicitly, for only the dependencies you pass. |
|
|
26
|
+
| `Inputs.<dep>.<tool>` | The tool's argument type, a `TypedDict`. |
|
|
27
|
+
| `Outputs.<dep>.<tool>` | The tool's structured result type, a `TypedDict`. |
|
|
28
|
+
|
|
29
|
+
### Call a tool
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from holon.apps import apps
|
|
33
|
+
|
|
34
|
+
async def total(id: str) -> float:
|
|
35
|
+
order = await apps.orders.tools.lookup({"id": id}, timeout=5.0)
|
|
36
|
+
return order["total"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Arguments are one mapping, validated before sending; the result is validated against the output schema and returned. `timeout` is in seconds.
|
|
40
|
+
|
|
41
|
+
### Handle errors
|
|
42
|
+
|
|
43
|
+
`tools.<tool>` raises `ToolCallError` when the tool reports an error or asks for more input:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from holon.client import ToolCallError
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
order = await apps.orders.tools.lookup({"id": id})
|
|
50
|
+
except ToolCallError as error:
|
|
51
|
+
error.code # "tool_error" or "input_required"
|
|
52
|
+
error.result # the original MCP result
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Invalid arguments or a result that does not match the schema raise `ValueError`; transport failures propagate from the MCP client.
|
|
56
|
+
|
|
57
|
+
### Read the whole result
|
|
58
|
+
|
|
59
|
+
`call_tool` returns the full MCP result instead of raising:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
result = await apps.orders.call_tool("lookup", {"id": id})
|
|
63
|
+
if result.is_error:
|
|
64
|
+
... # inspect result.content
|
|
65
|
+
else:
|
|
66
|
+
result.structured_content # typed as Outputs.orders.lookup
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It also accepts `input_responses`, `request_state` and `meta`. A dependency the CLI could not obtain a contract for offers only `call_tool`, untyped.
|
|
70
|
+
|
|
71
|
+
## Bind a dependency
|
|
72
|
+
|
|
73
|
+
By default a client reads its endpoint from an environment variable named after the dependency, upper-cased, with `-` as `_`:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
ORDERS_MCP_URL=http://127.0.0.1:3001/mcp uv run python server.py
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For authentication or a custom HTTP client, bind explicitly. `http_client` is a factory called once per call; the client it returns is closed afterwards:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import os
|
|
83
|
+
import httpx2
|
|
84
|
+
from holon.apps import create_apps
|
|
85
|
+
|
|
86
|
+
apps = create_apps({
|
|
87
|
+
"orders": {
|
|
88
|
+
"endpoint": "https://orders.example.com/mcp",
|
|
89
|
+
"http_client": lambda: httpx2.AsyncClient(
|
|
90
|
+
headers={"Authorization": f"Bearer {os.environ['ORDERS_TOKEN']}"},
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Every call opens and closes its own session; there is nothing to close. `holon.binding.set_default_binding` exists for hosts that bind every app at startup; apps do not need it.
|
|
97
|
+
|
|
98
|
+
## Serve an MCP App view
|
|
99
|
+
|
|
100
|
+
`holon.view.app_view` registers a built HTML file as an MCP App resource:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from holon.view import app_view
|
|
104
|
+
|
|
105
|
+
server.add_resource(app_view(
|
|
106
|
+
"ui://checkout/index.html", "Checkout", "dist/index.html",
|
|
107
|
+
connect_domains=["https://orders.example.com"],
|
|
108
|
+
))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The file is read on first request and cached. `connect_domains` and `resource_domains` become the view's content security policy. The page itself uses [`holon-ui`](https://github.com/holon-sh/sdk-ui).
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
The package test uses committed clients in `tests/fixtures/caller/holon/apps`.
|
|
116
|
+
To refresh them, install the [holon CLI](https://github.com/holon-sh/cli) and run:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
./scripts/fixtures.sh
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This runs `holon types generate ../orders` from the caller fixture. The CLI
|
|
123
|
+
extracts `tests/fixtures/orders/server.py` without starting the server.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
import httpx2
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Binding(TypedDict):
|
|
10
|
+
"""Connection settings for an MCP dependency.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
endpoint: Streamable HTTP MCP URL.
|
|
14
|
+
http_client: Factory returning a fresh ``httpx2.AsyncClient`` for each
|
|
15
|
+
call. Configure authentication headers or a custom transport here.
|
|
16
|
+
The SDK closes the returned client when the call finishes.
|
|
17
|
+
scoped: Compatibility setting. Python always opens and closes a session
|
|
18
|
+
per call, regardless of this value.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
endpoint: str
|
|
22
|
+
http_client: NotRequired[Callable[[], httpx2.AsyncClient]]
|
|
23
|
+
# Python clients open one session per call, so every binding already behaves as scoped.
|
|
24
|
+
scoped: NotRequired[bool]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_resolver: Callable[[str], Binding | None] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_default_binding(resolve: Callable[[str], Binding | None]) -> None:
|
|
31
|
+
"""Register the default binding resolver for app clients.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
resolve: Process-wide callback receiving the dependency name. Return a
|
|
35
|
+
binding or ``None`` to use the environment fallback. Explicit
|
|
36
|
+
bindings take precedence. Register during application startup.
|
|
37
|
+
"""
|
|
38
|
+
global _resolver
|
|
39
|
+
_resolver = resolve
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_default_binding(app: str) -> Binding | None:
|
|
43
|
+
"""Return the registered resolver's binding for an app.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
app: Dependency name passed unchanged to the resolver.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The resolved binding, or ``None`` if none is available.
|
|
50
|
+
"""
|
|
51
|
+
return _resolver(app) if _resolver is not None else None
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from collections.abc import Mapping
|
|
5
|
+
from functools import partial
|
|
6
|
+
from types import SimpleNamespace
|
|
5
7
|
from typing import Any, Generic, Literal, Protocol, TypeAlias, TypeVar
|
|
6
8
|
|
|
7
9
|
import httpx2
|
|
@@ -30,6 +32,8 @@ Output = TypeVar("Output")
|
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
class CompleteResult(Protocol, Generic[Output]):
|
|
35
|
+
"""Successful tool result with contract-typed structured content."""
|
|
36
|
+
|
|
33
37
|
result_type: Literal["complete"]
|
|
34
38
|
is_error: Literal[False]
|
|
35
39
|
structured_content: Output
|
|
@@ -38,6 +42,8 @@ class CompleteResult(Protocol, Generic[Output]):
|
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
class ErrorResult(Protocol):
|
|
45
|
+
"""Completed tool result reporting an application error."""
|
|
46
|
+
|
|
41
47
|
result_type: Literal["complete"]
|
|
42
48
|
is_error: Literal[True]
|
|
43
49
|
structured_content: object
|
|
@@ -51,6 +57,16 @@ _result = TypeAdapter(CallToolResult | InputRequiredResult)
|
|
|
51
57
|
|
|
52
58
|
|
|
53
59
|
class ToolCallError(RuntimeError):
|
|
60
|
+
"""Raised by ``tools.<name>`` for tool errors or additional input requests.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
app: Dependency name.
|
|
64
|
+
tool: Requested tool name.
|
|
65
|
+
result: Original MCP error or input-required result.
|
|
66
|
+
code: ``tool_error`` or ``input_required``. Use ``call_tool`` when you
|
|
67
|
+
need to inspect these results and submit additional input.
|
|
68
|
+
"""
|
|
69
|
+
|
|
54
70
|
def __init__(
|
|
55
71
|
self, app: str, tool: str, result: CallToolResult | InputRequiredResult
|
|
56
72
|
) -> None:
|
|
@@ -94,7 +110,13 @@ class _App:
|
|
|
94
110
|
if contract is None
|
|
95
111
|
else {tool["name"]: tool for tool in contract["tools"]}
|
|
96
112
|
)
|
|
97
|
-
self.tools =
|
|
113
|
+
self.tools = SimpleNamespace(
|
|
114
|
+
**{
|
|
115
|
+
name: partial(self._call_tool, name)
|
|
116
|
+
for name in self._definitions or ()
|
|
117
|
+
if not name.startswith("_")
|
|
118
|
+
}
|
|
119
|
+
)
|
|
98
120
|
|
|
99
121
|
async def call_tool(
|
|
100
122
|
self,
|
|
@@ -106,6 +128,27 @@ class _App:
|
|
|
106
128
|
request_state: str | None = None,
|
|
107
129
|
meta: RequestParamsMeta | None = None,
|
|
108
130
|
) -> CallToolResult | InputRequiredResult:
|
|
131
|
+
"""Call a dependency tool and return its full MCP result.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
name: Tool name. Contracted dependencies reject unknown tools.
|
|
135
|
+
arguments: Tool arguments, validated against the contract if present.
|
|
136
|
+
timeout: Request read timeout in seconds, or the MCP client default.
|
|
137
|
+
input_responses: Responses to a previous input-required result.
|
|
138
|
+
request_state: Continuation state returned by the server.
|
|
139
|
+
meta: Optional MCP request metadata.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A complete or input-required result. Tool error results are returned
|
|
143
|
+
unchanged; successful structured output is contract-validated.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: Unknown tool, invalid input/output, or undeclared dependency.
|
|
147
|
+
RuntimeError: Endpoint resolution failed.
|
|
148
|
+
|
|
149
|
+
Transport and protocol exceptions propagate. Each call closes its HTTP
|
|
150
|
+
client and MCP session, including when an exception occurs.
|
|
151
|
+
"""
|
|
109
152
|
definition = None
|
|
110
153
|
if self._definitions is not None:
|
|
111
154
|
definition = self._definitions.get(name)
|
|
@@ -127,12 +170,10 @@ class _App:
|
|
|
127
170
|
raise RuntimeError(
|
|
128
171
|
f"Dependency {self._name} is unavailable ({variable}). Set the endpoint or supply a binding."
|
|
129
172
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
135
|
-
async with http:
|
|
173
|
+
http_client = partial(httpx2.AsyncClient, trust_env=False)
|
|
174
|
+
async with (
|
|
175
|
+
binding.get("http_client", http_client) if binding else http_client
|
|
176
|
+
)() as http:
|
|
136
177
|
transport = streamable_http_client(endpoint, http_client=http)
|
|
137
178
|
async with Client(
|
|
138
179
|
transport,
|
|
@@ -167,28 +208,41 @@ class _App:
|
|
|
167
208
|
)
|
|
168
209
|
return result
|
|
169
210
|
|
|
211
|
+
async def _call_tool(
|
|
212
|
+
self,
|
|
213
|
+
name: str,
|
|
214
|
+
arguments: Mapping[str, object],
|
|
215
|
+
*,
|
|
216
|
+
timeout: float | None = None,
|
|
217
|
+
) -> object:
|
|
218
|
+
result = await self.call_tool(name, arguments, timeout=timeout)
|
|
219
|
+
if isinstance(result, InputRequiredResult) or result.is_error:
|
|
220
|
+
raise ToolCallError(self._name, name, result)
|
|
221
|
+
return result.structured_content
|
|
170
222
|
|
|
171
|
-
class _Tools:
|
|
172
|
-
def __init__(self, client: _App) -> None:
|
|
173
|
-
self._client = client
|
|
174
223
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if name.startswith("_") or definitions is None or name not in definitions:
|
|
178
|
-
raise AttributeError(name)
|
|
224
|
+
class Apps:
|
|
225
|
+
"""Create the dependency collection used by generated Python clients.
|
|
179
226
|
|
|
180
|
-
|
|
181
|
-
arguments: Mapping[str, object], *, timeout: float | None = None
|
|
182
|
-
) -> object:
|
|
183
|
-
result = await self._client.call_tool(name, arguments, timeout=timeout)
|
|
184
|
-
if isinstance(result, InputRequiredResult) or result.is_error:
|
|
185
|
-
raise ToolCallError(self._client._name, name, result)
|
|
186
|
-
return result.structured_content
|
|
227
|
+
Applications typically use ``apps`` or ``create_apps`` from ``holon.apps``.
|
|
187
228
|
|
|
188
|
-
|
|
229
|
+
Args:
|
|
230
|
+
identity: Application name, version, and declared ``requires`` mapping.
|
|
231
|
+
contracts: Dependency contracts with a ``tools`` list, or ``None`` for
|
|
232
|
+
untyped dependencies.
|
|
233
|
+
bindings: Explicit bindings. When supplied, only these dependencies are
|
|
234
|
+
included; an empty mapping selects none. Otherwise all declared
|
|
235
|
+
dependencies and contracts are available.
|
|
189
236
|
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: A binding names an unknown dependency.
|
|
239
|
+
|
|
240
|
+
Access a client with ``clients[name]`` or ``clients.name``. Its
|
|
241
|
+
``tools.<name>(arguments, timeout=seconds)`` methods return structured output
|
|
242
|
+
and raise :class:`ToolCallError` for tool errors or additional input requests.
|
|
243
|
+
Use its ``call_tool`` method for the full result envelope.
|
|
244
|
+
"""
|
|
190
245
|
|
|
191
|
-
class Apps:
|
|
192
246
|
def __init__(
|
|
193
247
|
self,
|
|
194
248
|
identity: dict[str, Any],
|
|
@@ -196,19 +250,14 @@ class Apps:
|
|
|
196
250
|
bindings: Mapping[str, Binding] | None = None,
|
|
197
251
|
) -> None:
|
|
198
252
|
# Every declared dependency gets a client; those without a contract are untyped.
|
|
199
|
-
available
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
available.update(contracts)
|
|
203
|
-
selected = available if bindings is None else {}
|
|
204
|
-
for name in bindings or ():
|
|
253
|
+
available = dict.fromkeys(identity["requires"]) | contracts
|
|
254
|
+
self._apps = {}
|
|
255
|
+
for name in available if bindings is None else bindings:
|
|
205
256
|
if name not in available:
|
|
206
257
|
raise ValueError(f"Unknown app binding: {name}")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
for name, contract in selected.items()
|
|
211
|
-
}
|
|
258
|
+
self._apps[name] = _App(
|
|
259
|
+
name, identity, available[name], bindings[name] if bindings else None
|
|
260
|
+
)
|
|
212
261
|
|
|
213
262
|
def __getitem__(self, name: str) -> _App:
|
|
214
263
|
return self._apps[name]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mcp.server.apps import APP_MIME_TYPE
|
|
6
|
+
from mcp.server.mcpserver.resources import FunctionResource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def app_view(
|
|
10
|
+
uri: str,
|
|
11
|
+
name: str,
|
|
12
|
+
file: Path | str,
|
|
13
|
+
*,
|
|
14
|
+
connect_domains: list[str] | None = None,
|
|
15
|
+
resource_domains: list[str] | None = None,
|
|
16
|
+
) -> FunctionResource:
|
|
17
|
+
"""Create a server resource serving a built MCP App HTML file.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
uri: Resource URI, normally starting with ``ui://``.
|
|
21
|
+
name: Human-readable resource name.
|
|
22
|
+
file: HTML file to read lazily on the first resource request.
|
|
23
|
+
connect_domains: Domains allowed for network connections by the view.
|
|
24
|
+
resource_domains: Domains allowed for external view assets.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A ``FunctionResource`` ready for the MCP server's resource manager.
|
|
28
|
+
Successful reads are cached for the lifetime of this resource instance.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
OSError: Reading the HTML file failed.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
``resource = app_view("ui://app/index.html", "App", "dist/index.html")``
|
|
35
|
+
"""
|
|
36
|
+
page = Path(file)
|
|
37
|
+
csp = {
|
|
38
|
+
"connectDomains": connect_domains or [],
|
|
39
|
+
"resourceDomains": resource_domains or [],
|
|
40
|
+
}
|
|
41
|
+
built: list[str] = []
|
|
42
|
+
|
|
43
|
+
def read() -> str:
|
|
44
|
+
# Concurrent first reads must all return the first cached build.
|
|
45
|
+
if not built:
|
|
46
|
+
built.append(page.read_text())
|
|
47
|
+
return built[0]
|
|
48
|
+
|
|
49
|
+
return FunctionResource.from_function(
|
|
50
|
+
read,
|
|
51
|
+
uri,
|
|
52
|
+
name=name,
|
|
53
|
+
mime_type=APP_MIME_TYPE,
|
|
54
|
+
meta={"ui": {"csp": csp}},
|
|
55
|
+
)
|
|
@@ -4,8 +4,9 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "holon-sh"
|
|
7
|
-
version = "0.0.1.
|
|
7
|
+
version = "0.0.1.dev2"
|
|
8
8
|
description = "Typed MCP clients, bindings, and app views"
|
|
9
|
+
readme = "README.md"
|
|
9
10
|
license = "MIT"
|
|
10
11
|
license-files = ["LICENSE"]
|
|
11
12
|
requires-python = ">=3.11"
|
|
@@ -25,6 +26,7 @@ testpaths = ["tests"]
|
|
|
25
26
|
|
|
26
27
|
[tool.ruff]
|
|
27
28
|
target-version = "py311"
|
|
29
|
+
extend-exclude = ["tests/fixtures"]
|
|
28
30
|
|
|
29
31
|
[dependency-groups]
|
|
30
|
-
dev = ["
|
|
32
|
+
dev = ["mypy==2.4.0", "pytest==9.1.1", "ruff==0.16.8", "types-jsonschema", "hatchling>=1.27,<2"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
# Run from any directory with the holon CLI on PATH. The orders server is
|
|
5
|
+
# statically extracted; no running server or installed fixture dependencies are needed.
|
|
6
|
+
cd "$(dirname "$0")/../tests/fixtures/caller"
|
|
7
|
+
exec holon types generate ../orders
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Install and test each built distribution in an isolated uv environment."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
root = Path(__file__).resolve().parents[1]
|
|
9
|
+
artifacts = sorted(root.glob("dist/holon_sh-*.whl")) + sorted(
|
|
10
|
+
root.glob("dist/holon_sh-*.tar.gz")
|
|
11
|
+
)
|
|
12
|
+
if len(artifacts) != 2:
|
|
13
|
+
raise RuntimeError("Build exactly one wheel and one sdist before verification.")
|
|
14
|
+
for artifact in artifacts:
|
|
15
|
+
subprocess.run(
|
|
16
|
+
[
|
|
17
|
+
"uv",
|
|
18
|
+
"run",
|
|
19
|
+
"--isolated",
|
|
20
|
+
"--no-project",
|
|
21
|
+
"--no-config",
|
|
22
|
+
"--python",
|
|
23
|
+
sys.executable,
|
|
24
|
+
"--with",
|
|
25
|
+
str(artifact),
|
|
26
|
+
"--with",
|
|
27
|
+
f"pytest=={version('pytest')}",
|
|
28
|
+
"--with",
|
|
29
|
+
f"mypy=={version('mypy')}",
|
|
30
|
+
"python",
|
|
31
|
+
"-m",
|
|
32
|
+
"pytest",
|
|
33
|
+
str(root / "tests/test_package.py"),
|
|
34
|
+
],
|
|
35
|
+
cwd=root / "dist",
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# holon-generated-apps-v1 app=caller
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json as _json
|
|
5
|
+
|
|
6
|
+
from mcp.types import InputResponses as _InputResponses
|
|
7
|
+
from mcp.types import RequestParamsMeta as _RequestParamsMeta
|
|
8
|
+
|
|
9
|
+
from holon.client import Apps as _Apps
|
|
10
|
+
from holon.client import Binding as _Binding
|
|
11
|
+
from holon.client import ToolResult as _ToolResult
|
|
12
|
+
|
|
13
|
+
import builtins as _builtins
|
|
14
|
+
import typing as _typing
|
|
15
|
+
|
|
16
|
+
_Shape0 = _typing.TypedDict("_Shape0", {"id": _builtins.str})
|
|
17
|
+
|
|
18
|
+
_Shape1 = _typing.TypedDict("_Shape1", {"total": _builtins.float})
|
|
19
|
+
|
|
20
|
+
class Inputs:
|
|
21
|
+
class orders:
|
|
22
|
+
read: _typing.TypeAlias = _Shape0
|
|
23
|
+
|
|
24
|
+
class Outputs:
|
|
25
|
+
class orders:
|
|
26
|
+
read: _typing.TypeAlias = _Shape1
|
|
27
|
+
|
|
28
|
+
class InputTypes:
|
|
29
|
+
class orders:
|
|
30
|
+
class read:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
class OutputTypes:
|
|
34
|
+
class orders:
|
|
35
|
+
class read:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
class _Tools0(_typing.Protocol):
|
|
39
|
+
async def read(self, arguments: _Shape0, *, timeout: _builtins.float | None = ...) -> _Shape1: ...
|
|
40
|
+
|
|
41
|
+
class _App0(_typing.Protocol):
|
|
42
|
+
tools: _Tools0
|
|
43
|
+
async def call_tool(self, name: _typing.Literal["read"], arguments: _Shape0, *, timeout: _builtins.float | None = ..., input_responses: _InputResponses | None = ..., request_state: _builtins.str | None = ..., meta: _RequestParamsMeta | None = ...) -> _ToolResult[_Shape1]: ...
|
|
44
|
+
|
|
45
|
+
class _Clients(_typing.Protocol):
|
|
46
|
+
orders: _App0
|
|
47
|
+
def __getitem__(self, name: _typing.Literal["orders"]) -> _App0: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_identity, _contracts = _json.loads('[{"name":"fixtures/caller","requires":{"orders":{"name":"fixtures/orders","version":"1"}},"version":"1"},{"orders":{"tools":[{"name":"read","inputSchema":{"properties":{"id":{"type":"string"}},"required":["id"],"type":"object"},"outputSchema":{"properties":{"total":{"type":"number"}},"required":["total"],"type":"object"}}]}}]')
|
|
51
|
+
|
|
52
|
+
apps = _typing.cast(_Clients, _Apps(_identity, _contracts))
|
|
53
|
+
|
|
54
|
+
def create_apps(bindings: _typing.Mapping[str, _Binding]) -> _Clients:
|
|
55
|
+
return _typing.cast(_Clients, _Apps(_identity, _contracts, bindings))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
holon-sh
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Consumer contracts at the generated namespace boundary."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_generated_namespace_combines_app_types_with_installed_runtime(tmp_path):
|
|
11
|
+
# Refresh with scripts/fixtures.sh; generation is tested in holon-sh/cli.
|
|
12
|
+
shutil.copytree(Path(__file__).parent / "fixtures/caller", tmp_path, dirs_exist_ok=True)
|
|
13
|
+
(tmp_path / "check.py").write_text("""from pathlib import Path
|
|
14
|
+
import holon
|
|
15
|
+
import holon.client
|
|
16
|
+
from holon.apps import apps, Inputs, Outputs
|
|
17
|
+
|
|
18
|
+
async def run() -> float:
|
|
19
|
+
arguments: Inputs.orders.read = {"id": "order-1"}
|
|
20
|
+
output: Outputs.orders.read = await apps.orders.tools.read(arguments)
|
|
21
|
+
return output["total"]
|
|
22
|
+
|
|
23
|
+
async def invalid() -> None:
|
|
24
|
+
await apps.orders.tools.read({"id": 1}) # type: ignore
|
|
25
|
+
await apps.orders.tools.read({}) # type: ignore
|
|
26
|
+
apps.missing # type: ignore
|
|
27
|
+
|
|
28
|
+
assert holon.__file__ is None
|
|
29
|
+
assert Path(holon.client.__file__).parent != Path(__file__).parent / "holon"
|
|
30
|
+
""")
|
|
31
|
+
# Runtime and typing must agree across the two namespace portions. No
|
|
32
|
+
# MYPYPATH/PYTHONPATH override is allowed to hide packaging problems.
|
|
33
|
+
env = os.environ.copy()
|
|
34
|
+
env.pop("MYPYPATH", None)
|
|
35
|
+
env.pop("PYTHONPATH", None)
|
|
36
|
+
for command in [
|
|
37
|
+
[sys.executable, "-m", "mypy", "--strict", "--no-incremental", "check.py"],
|
|
38
|
+
[sys.executable, "check.py"],
|
|
39
|
+
]:
|
|
40
|
+
subprocess.run(command, cwd=tmp_path, env=env, check=True)
|
|
@@ -10,6 +10,28 @@ import holon.binding
|
|
|
10
10
|
from holon.binding import set_default_binding
|
|
11
11
|
from holon.client import Apps, ToolCallError
|
|
12
12
|
|
|
13
|
+
CONTRACT = {
|
|
14
|
+
"tools": [
|
|
15
|
+
{
|
|
16
|
+
"name": "read",
|
|
17
|
+
"inputSchema": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"id": {"type": "string"},
|
|
21
|
+
"limit": {"type": "integer", "default": 5},
|
|
22
|
+
},
|
|
23
|
+
"required": ["id"],
|
|
24
|
+
"additionalProperties": False,
|
|
25
|
+
},
|
|
26
|
+
"outputSchema": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {"total": {"type": "number"}},
|
|
29
|
+
"required": ["total"],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
|
|
14
36
|
def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
15
37
|
async def exercise():
|
|
@@ -44,14 +66,17 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
44
66
|
app = server.streamable_http_app(stateless_http=True)
|
|
45
67
|
requests = []
|
|
46
68
|
clients = []
|
|
69
|
+
headers = []
|
|
47
70
|
|
|
48
71
|
async def record(request):
|
|
72
|
+
headers.append(request.headers.get("authorization"))
|
|
49
73
|
if request.method == "POST":
|
|
50
74
|
requests.append(json.loads(await request.aread()))
|
|
51
75
|
|
|
52
76
|
def http_client():
|
|
53
77
|
client = httpx2.AsyncClient(
|
|
54
78
|
transport=httpx2.ASGITransport(app=app),
|
|
79
|
+
headers={"Authorization": "Bearer external"},
|
|
55
80
|
event_hooks={"request": [record]},
|
|
56
81
|
)
|
|
57
82
|
clients.append(client)
|
|
@@ -66,41 +91,15 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
66
91
|
else None
|
|
67
92
|
)
|
|
68
93
|
)
|
|
69
|
-
contract = {
|
|
70
|
-
"tools": [
|
|
71
|
-
{
|
|
72
|
-
"name": "read",
|
|
73
|
-
"inputSchema": {
|
|
74
|
-
"type": "object",
|
|
75
|
-
"properties": {
|
|
76
|
-
"id": {"type": "string"},
|
|
77
|
-
"limit": {"type": "integer", "default": 5},
|
|
78
|
-
},
|
|
79
|
-
"required": ["id"],
|
|
80
|
-
"additionalProperties": False,
|
|
81
|
-
},
|
|
82
|
-
"outputSchema": {
|
|
83
|
-
"type": "object",
|
|
84
|
-
"properties": {"total": {"type": "number"}},
|
|
85
|
-
"required": ["total"],
|
|
86
|
-
},
|
|
87
|
-
}
|
|
88
|
-
]
|
|
89
|
-
}
|
|
90
94
|
identity = {"name": "checkout", "version": "1", "requires": {"orders": {}}}
|
|
91
|
-
apps = Apps(identity, {"orders":
|
|
92
|
-
|
|
93
|
-
repr(apps.orders.tools)
|
|
94
|
-
with pytest.raises(TypeError):
|
|
95
|
-
await apps.orders
|
|
96
|
-
with pytest.raises(TypeError):
|
|
97
|
-
json.dumps(apps.orders)
|
|
95
|
+
apps = Apps(identity, {"orders": CONTRACT})
|
|
96
|
+
other = Apps({**identity, "name": "other"}, {"orders": CONTRACT})
|
|
98
97
|
assert not requests
|
|
99
98
|
async with app.router.lifespan_context(app):
|
|
100
99
|
arguments = {"id": "good"}
|
|
101
100
|
output, raw = await asyncio.gather(
|
|
102
101
|
apps.orders.tools.read(arguments),
|
|
103
|
-
|
|
102
|
+
other.orders.call_tool("read", {"id": "good", "limit": 2}),
|
|
104
103
|
)
|
|
105
104
|
assert output == {"total": 5}
|
|
106
105
|
assert arguments == {"id": "good"}
|
|
@@ -108,10 +107,6 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
108
107
|
assert raw.content[0].text == "Order"
|
|
109
108
|
assert raw.meta["preserved"] is True
|
|
110
109
|
assert not any(request["method"] == "tools/list" for request in requests)
|
|
111
|
-
other = Apps({**identity, "name": "other"}, {"orders": contract})
|
|
112
|
-
await asyncio.gather(
|
|
113
|
-
apps.orders.tools.read(arguments), other.orders.tools.read(arguments)
|
|
114
|
-
)
|
|
115
110
|
callers = [
|
|
116
111
|
request["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"]
|
|
117
112
|
for request in requests
|
|
@@ -149,84 +144,25 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
149
144
|
assert not hasattr(untyped.orders.tools, "read")
|
|
150
145
|
with pytest.raises(RuntimeError, match=r"unavailable \(MISSING_MCP_URL\)"):
|
|
151
146
|
await untyped.missing.call_tool("read", {})
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
async with asyncio.timeout(15):
|
|
156
|
-
await exercise()
|
|
157
|
-
|
|
158
|
-
asyncio.run(bounded())
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def test_endpoint_bindings_call_without_the_runtime(monkeypatch):
|
|
162
|
-
async def exercise():
|
|
163
|
-
server = MCPServer("orders")
|
|
164
|
-
headers = []
|
|
165
|
-
|
|
166
|
-
@server.tool()
|
|
167
|
-
async def read(id: str) -> CallToolResult:
|
|
168
|
-
return CallToolResult(
|
|
169
|
-
content=[TextContent(type="text", text=id)],
|
|
170
|
-
structured_content={"total": 1},
|
|
147
|
+
# Explicit bindings work without a declaration and override the resolver.
|
|
148
|
+
set_default_binding(
|
|
149
|
+
lambda _: {"endpoint": "http://unavailable.invalid/mcp"}
|
|
171
150
|
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
event_hooks={"request": [record]},
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
monkeypatch.delenv("ORDERS_MCP_URL", raising=False)
|
|
186
|
-
monkeypatch.setattr(holon.binding, "_resolver", None)
|
|
187
|
-
contract = {
|
|
188
|
-
"tools": [
|
|
189
|
-
{
|
|
190
|
-
"name": "read",
|
|
191
|
-
"inputSchema": {
|
|
192
|
-
"type": "object",
|
|
193
|
-
"properties": {"id": {"type": "string"}},
|
|
194
|
-
"required": ["id"],
|
|
195
|
-
"additionalProperties": False,
|
|
196
|
-
},
|
|
197
|
-
"outputSchema": {
|
|
198
|
-
"type": "object",
|
|
199
|
-
"properties": {"total": {"type": "number"}},
|
|
200
|
-
"required": ["total"],
|
|
201
|
-
},
|
|
202
|
-
}
|
|
203
|
-
]
|
|
204
|
-
}
|
|
205
|
-
identity = {"name": "script", "version": "1", "requires": {}}
|
|
206
|
-
with pytest.raises(ValueError, match="Unknown app binding"):
|
|
207
|
-
Apps(identity, {"orders": contract}, {"missing": {"endpoint": "http://x"}})
|
|
208
|
-
bound = Apps(
|
|
209
|
-
identity,
|
|
210
|
-
{"orders": contract},
|
|
211
|
-
{
|
|
212
|
-
"orders": {
|
|
213
|
-
"endpoint": "http://127.0.0.1:8000/mcp",
|
|
214
|
-
"http_client": http_client,
|
|
215
|
-
}
|
|
216
|
-
},
|
|
217
|
-
)
|
|
218
|
-
async with app.router.lifespan_context(app):
|
|
219
|
-
assert await bound.orders.tools.read({"id": "a"}) == {"total": 1}
|
|
220
|
-
with pytest.raises(ValueError, match="arguments"):
|
|
221
|
-
await bound.orders.tools.read({"id": 1})
|
|
151
|
+
script = {**identity, "requires": {}}
|
|
152
|
+
binding = {
|
|
153
|
+
"endpoint": "http://127.0.0.1:8000/mcp",
|
|
154
|
+
"http_client": http_client,
|
|
155
|
+
}
|
|
156
|
+
with pytest.raises(ValueError, match="Unknown app binding"):
|
|
157
|
+
Apps(script, {"orders": CONTRACT}, {"missing": binding})
|
|
158
|
+
bound = Apps(script, {"orders": CONTRACT}, {"orders": binding})
|
|
159
|
+
assert await bound.orders.tools.read({"id": "good"}) == {"total": 5}
|
|
160
|
+
set_default_binding(lambda _: None)
|
|
222
161
|
with pytest.raises(ValueError, match="not declared"):
|
|
223
|
-
await Apps(
|
|
224
|
-
{"id": "
|
|
162
|
+
await Apps(script, {"orders": CONTRACT}).orders.tools.read(
|
|
163
|
+
{"id": "good"}
|
|
225
164
|
)
|
|
226
165
|
assert headers and all(value == "Bearer external" for value in headers)
|
|
166
|
+
assert all(client.is_closed for client in clients)
|
|
227
167
|
|
|
228
|
-
|
|
229
|
-
async with asyncio.timeout(15):
|
|
230
|
-
await exercise()
|
|
231
|
-
|
|
232
|
-
asyncio.run(bounded())
|
|
168
|
+
asyncio.run(asyncio.wait_for(exercise(), timeout=15))
|
holon_sh-0.0.1.dev1/PKG-INFO
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: holon-sh
|
|
3
|
-
Version: 0.0.1.dev1
|
|
4
|
-
Summary: Typed MCP clients, bindings, and app views
|
|
5
|
-
Project-URL: Repository, https://github.com/holon-sh/sdk-py
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.11
|
|
9
|
-
Requires-Dist: httpx2<3,>=2.10
|
|
10
|
-
Requires-Dist: jsonschema<5,>=4.26
|
|
11
|
-
Requires-Dist: mcp<3,>=2.3
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from typing import NotRequired, TypedDict
|
|
5
|
-
|
|
6
|
-
import httpx2
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Binding(TypedDict):
|
|
10
|
-
endpoint: str
|
|
11
|
-
http_client: NotRequired[Callable[[], httpx2.AsyncClient]]
|
|
12
|
-
# Python clients open one session per call, so every binding already behaves as scoped.
|
|
13
|
-
scoped: NotRequired[bool]
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
_resolver: Callable[[str], Binding | None] | None = None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def set_default_binding(resolve: Callable[[str], Binding | None]) -> None:
|
|
20
|
-
"""Resolve bindings for app clients that were not bound with ``create_apps``.
|
|
21
|
-
|
|
22
|
-
Without a resolver, or when it returns ``None``, a client falls back to its
|
|
23
|
-
``<NAME>_MCP_URL`` environment variable.
|
|
24
|
-
"""
|
|
25
|
-
global _resolver
|
|
26
|
-
_resolver = resolve
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def resolve_default_binding(app: str) -> Binding | None:
|
|
30
|
-
return _resolver(app) if _resolver is not None else None
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from mcp.server.apps import APP_MIME_TYPE
|
|
6
|
-
from mcp.server.mcpserver.resources import FunctionResource
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def app_view(
|
|
10
|
-
uri: str,
|
|
11
|
-
name: str,
|
|
12
|
-
file: Path | str,
|
|
13
|
-
*,
|
|
14
|
-
connect_domains: list[str] | None = None,
|
|
15
|
-
resource_domains: list[str] | None = None,
|
|
16
|
-
) -> FunctionResource:
|
|
17
|
-
page = Path(file)
|
|
18
|
-
csp = {
|
|
19
|
-
"connectDomains": connect_domains or [],
|
|
20
|
-
"resourceDomains": resource_domains or [],
|
|
21
|
-
}
|
|
22
|
-
built: list[str] = []
|
|
23
|
-
|
|
24
|
-
def read() -> str:
|
|
25
|
-
if not built:
|
|
26
|
-
built.append(page.read_text())
|
|
27
|
-
return built[0]
|
|
28
|
-
|
|
29
|
-
return FunctionResource.from_function(
|
|
30
|
-
read,
|
|
31
|
-
uri,
|
|
32
|
-
name=name,
|
|
33
|
-
mime_type=APP_MIME_TYPE,
|
|
34
|
-
meta={"ui": {"csp": csp}},
|
|
35
|
-
)
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""Install each built distribution in an isolated environment and test it.
|
|
2
|
-
|
|
3
|
-
Dependency packages are copied from the invoking development environment to
|
|
4
|
-
avoid network access. The holon namespace is excluded so only the installed
|
|
5
|
-
artifact can satisfy the runtime imports.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import sysconfig
|
|
14
|
-
import tempfile
|
|
15
|
-
import venv
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def verify(root: Path) -> None:
|
|
20
|
-
artifacts = sorted(root.glob("dist/holon_sh-*.whl")) + sorted(
|
|
21
|
-
root.glob("dist/holon_sh-*.tar.gz")
|
|
22
|
-
)
|
|
23
|
-
if len(artifacts) != 2:
|
|
24
|
-
raise RuntimeError("Build exactly one wheel and one sdist before verification.")
|
|
25
|
-
for artifact in artifacts:
|
|
26
|
-
with tempfile.TemporaryDirectory(prefix="holon-sh-consumer-") as temporary:
|
|
27
|
-
directory = Path(temporary)
|
|
28
|
-
environment = directory / "venv"
|
|
29
|
-
venv.create(environment, with_pip=False)
|
|
30
|
-
python = environment / (
|
|
31
|
-
"Scripts/python.exe" if sys.platform == "win32" else "bin/python"
|
|
32
|
-
)
|
|
33
|
-
installed = Path(
|
|
34
|
-
subprocess.check_output(
|
|
35
|
-
[
|
|
36
|
-
str(python),
|
|
37
|
-
"-c",
|
|
38
|
-
"import sysconfig; print(sysconfig.get_path('purelib'))",
|
|
39
|
-
],
|
|
40
|
-
text=True,
|
|
41
|
-
).strip()
|
|
42
|
-
)
|
|
43
|
-
for source in Path(sysconfig.get_path("purelib")).iterdir():
|
|
44
|
-
if (
|
|
45
|
-
source.name.startswith(("holon", "_holon"))
|
|
46
|
-
or source.suffix == ".pth"
|
|
47
|
-
):
|
|
48
|
-
continue
|
|
49
|
-
target = installed / source.name
|
|
50
|
-
if source.is_dir():
|
|
51
|
-
shutil.copytree(source, target, dirs_exist_ok=True)
|
|
52
|
-
else:
|
|
53
|
-
shutil.copy2(source, target)
|
|
54
|
-
subprocess.run(
|
|
55
|
-
[
|
|
56
|
-
"uv",
|
|
57
|
-
"pip",
|
|
58
|
-
"install",
|
|
59
|
-
"--python",
|
|
60
|
-
str(python),
|
|
61
|
-
"--no-deps",
|
|
62
|
-
"--no-build-isolation",
|
|
63
|
-
str(artifact),
|
|
64
|
-
],
|
|
65
|
-
check=True,
|
|
66
|
-
)
|
|
67
|
-
subprocess.run(
|
|
68
|
-
[
|
|
69
|
-
str(python),
|
|
70
|
-
"-c",
|
|
71
|
-
"import holon, holon.client; assert holon.__file__ is None; print(holon.client.__file__)",
|
|
72
|
-
],
|
|
73
|
-
cwd=directory,
|
|
74
|
-
check=True,
|
|
75
|
-
)
|
|
76
|
-
subprocess.run(
|
|
77
|
-
[
|
|
78
|
-
str(python),
|
|
79
|
-
"-m",
|
|
80
|
-
"pytest",
|
|
81
|
-
str(root / "tests/test_package.py"),
|
|
82
|
-
"-k",
|
|
83
|
-
"generated_namespace",
|
|
84
|
-
],
|
|
85
|
-
cwd=directory,
|
|
86
|
-
check=True,
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if __name__ == "__main__":
|
|
91
|
-
verify(Path(__file__).resolve().parents[1])
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Consumer contracts at the generated namespace boundary."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
from mcptype.compiler import generate
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_generated_namespace_combines_app_types_with_installed_runtime(tmp_path):
|
|
11
|
-
identity = {"name": "caller", "version": "1"}
|
|
12
|
-
contract = {
|
|
13
|
-
"tools": [
|
|
14
|
-
{
|
|
15
|
-
"name": "read",
|
|
16
|
-
"inputSchema": {
|
|
17
|
-
"type": "object",
|
|
18
|
-
"properties": {"id": {"type": "string"}},
|
|
19
|
-
"required": ["id"],
|
|
20
|
-
"additionalProperties": False,
|
|
21
|
-
},
|
|
22
|
-
"outputSchema": {
|
|
23
|
-
"type": "object",
|
|
24
|
-
"properties": {"total": {"type": "number"}},
|
|
25
|
-
"required": ["total"],
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
]
|
|
29
|
-
}
|
|
30
|
-
result = generate(
|
|
31
|
-
{
|
|
32
|
-
"target": "python",
|
|
33
|
-
"identity": identity,
|
|
34
|
-
"requires": {"orders": {"name": "orders", "version": "1"}},
|
|
35
|
-
"contracts": {"orders": contract},
|
|
36
|
-
"python_module": "holon/apps/__init__.py",
|
|
37
|
-
"python_runtime_module": "holon.client",
|
|
38
|
-
"banner": "holon-generated-apps-v1 app=caller",
|
|
39
|
-
}
|
|
40
|
-
)
|
|
41
|
-
for name, content in result["files"].items():
|
|
42
|
-
path = tmp_path / name
|
|
43
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
path.write_bytes(content)
|
|
45
|
-
assert not (tmp_path / "holon/__init__.py").exists()
|
|
46
|
-
(tmp_path / "check.py").write_text("""from pathlib import Path
|
|
47
|
-
import holon
|
|
48
|
-
import holon.client
|
|
49
|
-
from holon.apps import apps, Inputs, Outputs
|
|
50
|
-
from holon.binding import Binding, set_default_binding
|
|
51
|
-
|
|
52
|
-
binding: Binding = {"endpoint": "https://example.com/mcp"}
|
|
53
|
-
set_default_binding(lambda name: binding)
|
|
54
|
-
|
|
55
|
-
async def run() -> float:
|
|
56
|
-
arguments: Inputs.orders.read = {"id": "order-1"}
|
|
57
|
-
output: Outputs.orders.read = await apps.orders.tools.read(arguments)
|
|
58
|
-
return output["total"]
|
|
59
|
-
|
|
60
|
-
async def invalid() -> None:
|
|
61
|
-
await apps.orders.tools.read({"id": 1}) # type: ignore
|
|
62
|
-
await apps.orders.tools.read({}) # type: ignore
|
|
63
|
-
apps.missing # type: ignore
|
|
64
|
-
|
|
65
|
-
assert holon.__file__ is None
|
|
66
|
-
assert not (Path(__file__).parent / "holon/client.py").exists()
|
|
67
|
-
assert Path(holon.client.__file__).parent != Path(__file__).parent / "holon"
|
|
68
|
-
""")
|
|
69
|
-
# Runtime and typing must agree across the two namespace portions. No
|
|
70
|
-
# MYPYPATH/PYTHONPATH override is allowed to hide packaging problems.
|
|
71
|
-
env = {
|
|
72
|
-
key: value
|
|
73
|
-
for key, value in os.environ.items()
|
|
74
|
-
if key not in {"MYPYPATH", "PYTHONPATH"}
|
|
75
|
-
}
|
|
76
|
-
for command in [
|
|
77
|
-
[sys.executable, "-m", "mypy", "--strict", "--no-incremental", "check.py"],
|
|
78
|
-
[sys.executable, "check.py"],
|
|
79
|
-
]:
|
|
80
|
-
checked = subprocess.run(
|
|
81
|
-
command, cwd=tmp_path, env=env, text=True, capture_output=True, check=False
|
|
82
|
-
)
|
|
83
|
-
assert checked.returncode == 0, checked.stdout + checked.stderr
|
|
File without changes
|
|
File without changes
|
|
File without changes
|