holon-sh 0.0.1.dev2__tar.gz → 0.0.1.dev3__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.dev3/PKG-INFO +104 -0
- holon_sh-0.0.1.dev3/README.md +91 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/holon/binding.py +0 -4
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/holon/client.py +28 -23
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/pyproject.toml +1 -1
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/scripts/fixtures.sh +1 -1
- holon_sh-0.0.1.dev3/tests/fixtures/caller/holon/apps/__init__.py +106 -0
- holon_sh-0.0.1.dev3/tests/fixtures/orders/server.py +57 -0
- holon_sh-0.0.1.dev3/tests/test_package.py +67 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/tests/test_runtime.py +46 -16
- holon_sh-0.0.1.dev2/PKG-INFO +0 -140
- holon_sh-0.0.1.dev2/README.md +0 -127
- holon_sh-0.0.1.dev2/tests/fixtures/caller/holon/apps/__init__.py +0 -55
- holon_sh-0.0.1.dev2/tests/fixtures/orders/server.py +0 -14
- holon_sh-0.0.1.dev2/tests/test_package.py +0 -40
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/.gitignore +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/LICENSE +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/holon/py.typed +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/holon/view.py +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/scripts/verify_distribution.py +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/tests/fixtures/caller/requirements.txt +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/tests/fixtures/caller/server.json +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/tests/fixtures/orders/server.json +0 -0
- {holon_sh-0.0.1.dev2 → holon_sh-0.0.1.dev3}/tests/test_view.py +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: holon-sh
|
|
3
|
+
Version: 0.0.1.dev3
|
|
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 Python SDK
|
|
15
|
+
|
|
16
|
+
[](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml)
|
|
17
|
+
[](https://pypi.org/project/holon-sh/)
|
|
18
|
+
[](https://www.python.org/downloads/)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
|
|
21
|
+
This repository contains the Python runtime for [`holon`](https://github.com/holon-sh/cli).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv add --prerelease allow holon-sh
|
|
27
|
+
uv add --dev holon-types
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`holon-types` is the `holon` CLI; it brings mypy, which `holon types check` runs from the project's environment.
|
|
31
|
+
|
|
32
|
+
## Invoking MCP Servers
|
|
33
|
+
|
|
34
|
+
The Holon Python SDK allows generation of type-safe MCP clients. See [Example](https://github.com/holon-sh/cli#example) for an explanation, or [Dependencies](https://github.com/holon-sh/cli#dependencies) to understand dependency resolution.
|
|
35
|
+
|
|
36
|
+
### Type-checking MCP invocations
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv run holon types check
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
<details open>
|
|
43
|
+
<summary><h3>Call a tool</h3></summary>
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from holon.apps import apps
|
|
47
|
+
|
|
48
|
+
async def greet(name: str) -> str:
|
|
49
|
+
greeting = await apps.bar.tools.greet({"timeout": 5.0}, name=name)
|
|
50
|
+
return greeting["result"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Arguments are keywords, validated before sending; the result is validated against the output schema and returned.
|
|
54
|
+
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<details open>
|
|
58
|
+
<summary><h3>Handle errors</h3></summary>
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from holon.client import ToolCallError
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
await apps.bar.tools.greet(name="world")
|
|
65
|
+
except ToolCallError as error:
|
|
66
|
+
error.code # "tool_error" or "input_required"
|
|
67
|
+
error.result # the original MCP result
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
<details open>
|
|
73
|
+
<summary><h3>Read the whole result</h3></summary>
|
|
74
|
+
|
|
75
|
+
`call_tool` returns the full MCP result instead of raising:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
result = await apps.bar.call_tool("greet", name="world")
|
|
79
|
+
if result.result_type == "input_required":
|
|
80
|
+
... # call again with input_responses and request_state
|
|
81
|
+
elif result.is_error:
|
|
82
|
+
... # inspect result.content
|
|
83
|
+
else:
|
|
84
|
+
result.structured_content # typed as Outputs.bar.greet
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The options dict also accepts `input_responses`, `request_state` and `meta`.
|
|
88
|
+
|
|
89
|
+
</details>
|
|
90
|
+
|
|
91
|
+
## Serve an [MCP App](https://modelcontextprotocol.io/extensions/apps/overview) view
|
|
92
|
+
|
|
93
|
+
`holon.view.app_view` registers a built HTML file as an MCP App resource:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from holon.view import app_view
|
|
97
|
+
|
|
98
|
+
server.add_resource(app_view(
|
|
99
|
+
"ui://foo/index.html", "Foo", "dist/index.html",
|
|
100
|
+
connect_domains=["https://bar.example.com"],
|
|
101
|
+
))
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The page itself uses [`holon-ui`](https://github.com/holon-sh/sdk-ui).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Holon Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/holon-sh/)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
This repository contains the Python runtime for [`holon`](https://github.com/holon-sh/cli).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv add --prerelease allow holon-sh
|
|
14
|
+
uv add --dev holon-types
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`holon-types` is the `holon` CLI; it brings mypy, which `holon types check` runs from the project's environment.
|
|
18
|
+
|
|
19
|
+
## Invoking MCP Servers
|
|
20
|
+
|
|
21
|
+
The Holon Python SDK allows generation of type-safe MCP clients. See [Example](https://github.com/holon-sh/cli#example) for an explanation, or [Dependencies](https://github.com/holon-sh/cli#dependencies) to understand dependency resolution.
|
|
22
|
+
|
|
23
|
+
### Type-checking MCP invocations
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv run holon types check
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
<details open>
|
|
30
|
+
<summary><h3>Call a tool</h3></summary>
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from holon.apps import apps
|
|
34
|
+
|
|
35
|
+
async def greet(name: str) -> str:
|
|
36
|
+
greeting = await apps.bar.tools.greet({"timeout": 5.0}, name=name)
|
|
37
|
+
return greeting["result"]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Arguments are keywords, validated before sending; the result is validated against the output schema and returned.
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
<details open>
|
|
45
|
+
<summary><h3>Handle errors</h3></summary>
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from holon.client import ToolCallError
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
await apps.bar.tools.greet(name="world")
|
|
52
|
+
except ToolCallError as error:
|
|
53
|
+
error.code # "tool_error" or "input_required"
|
|
54
|
+
error.result # the original MCP result
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
</details>
|
|
58
|
+
|
|
59
|
+
<details open>
|
|
60
|
+
<summary><h3>Read the whole result</h3></summary>
|
|
61
|
+
|
|
62
|
+
`call_tool` returns the full MCP result instead of raising:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
result = await apps.bar.call_tool("greet", name="world")
|
|
66
|
+
if result.result_type == "input_required":
|
|
67
|
+
... # call again with input_responses and request_state
|
|
68
|
+
elif result.is_error:
|
|
69
|
+
... # inspect result.content
|
|
70
|
+
else:
|
|
71
|
+
result.structured_content # typed as Outputs.bar.greet
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The options dict also accepts `input_responses`, `request_state` and `meta`.
|
|
75
|
+
|
|
76
|
+
</details>
|
|
77
|
+
|
|
78
|
+
## Serve an [MCP App](https://modelcontextprotocol.io/extensions/apps/overview) view
|
|
79
|
+
|
|
80
|
+
`holon.view.app_view` registers a built HTML file as an MCP App resource:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from holon.view import app_view
|
|
84
|
+
|
|
85
|
+
server.add_resource(app_view(
|
|
86
|
+
"ui://foo/index.html", "Foo", "dist/index.html",
|
|
87
|
+
connect_domains=["https://bar.example.com"],
|
|
88
|
+
))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The page itself uses [`holon-ui`](https://github.com/holon-sh/sdk-ui).
|
|
@@ -14,14 +14,10 @@ class Binding(TypedDict):
|
|
|
14
14
|
http_client: Factory returning a fresh ``httpx2.AsyncClient`` for each
|
|
15
15
|
call. Configure authentication headers or a custom transport here.
|
|
16
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
17
|
"""
|
|
20
18
|
|
|
21
19
|
endpoint: str
|
|
22
20
|
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
21
|
|
|
26
22
|
|
|
27
23
|
_resolver: Callable[[str], Binding | None] | None = None
|
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
from collections.abc import Mapping
|
|
5
5
|
from functools import partial
|
|
6
6
|
from types import SimpleNamespace
|
|
7
|
-
from typing import Any, Generic, Literal, Protocol, TypeAlias, TypeVar
|
|
7
|
+
from typing import Any, Generic, Literal, Protocol, TypeAlias, TypedDict, TypeVar
|
|
8
8
|
|
|
9
9
|
import httpx2
|
|
10
10
|
import jsonschema # type: ignore[import-untyped,unused-ignore]
|
|
@@ -26,7 +26,7 @@ from referencing import Registry
|
|
|
26
26
|
from holon.binding import Binding, resolve_default_binding
|
|
27
27
|
|
|
28
28
|
# Binding is re-exported for the generated holon.apps module.
|
|
29
|
-
__all__ = ["Apps", "Binding", "ToolCallError", "ToolResult"]
|
|
29
|
+
__all__ = ["Apps", "Binding", "CallOptions", "ToolCallError", "ToolResult"]
|
|
30
30
|
|
|
31
31
|
Output = TypeVar("Output")
|
|
32
32
|
|
|
@@ -56,6 +56,15 @@ ToolResult: TypeAlias = CompleteResult[Output] | ErrorResult | InputRequiredResu
|
|
|
56
56
|
_result = TypeAdapter(CallToolResult | InputRequiredResult)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class CallOptions(TypedDict, total=False):
|
|
60
|
+
"""Request controls, passed positionally to keep tool keywords available."""
|
|
61
|
+
|
|
62
|
+
timeout: float | None
|
|
63
|
+
input_responses: InputResponses | None
|
|
64
|
+
request_state: str | None
|
|
65
|
+
meta: RequestParamsMeta | None
|
|
66
|
+
|
|
67
|
+
|
|
59
68
|
class ToolCallError(RuntimeError):
|
|
60
69
|
"""Raised by ``tools.<name>`` for tool errors or additional input requests.
|
|
61
70
|
|
|
@@ -121,22 +130,17 @@ class _App:
|
|
|
121
130
|
async def call_tool(
|
|
122
131
|
self,
|
|
123
132
|
name: str,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
input_responses: InputResponses | None = None,
|
|
128
|
-
request_state: str | None = None,
|
|
129
|
-
meta: RequestParamsMeta | None = None,
|
|
133
|
+
options: CallOptions | None = None,
|
|
134
|
+
/,
|
|
135
|
+
**arguments: object,
|
|
130
136
|
) -> CallToolResult | InputRequiredResult:
|
|
131
137
|
"""Call a dependency tool and return its full MCP result.
|
|
132
138
|
|
|
133
139
|
Args:
|
|
134
140
|
name: Tool name. Contracted dependencies reject unknown tools.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
request_state: Continuation state returned by the server.
|
|
139
|
-
meta: Optional MCP request metadata.
|
|
141
|
+
options: Request timeout in seconds, input responses, continuation
|
|
142
|
+
state, and metadata. Passed positionally, separate from tool input.
|
|
143
|
+
**arguments: Tool input, validated against the contract if present.
|
|
140
144
|
|
|
141
145
|
Returns:
|
|
142
146
|
A complete or input-required result. Tool error results are returned
|
|
@@ -149,6 +153,7 @@ class _App:
|
|
|
149
153
|
Transport and protocol exceptions propagate. Each call closes its HTTP
|
|
150
154
|
client and MCP session, including when an exception occurs.
|
|
151
155
|
"""
|
|
156
|
+
options = options or {}
|
|
152
157
|
definition = None
|
|
153
158
|
if self._definitions is not None:
|
|
154
159
|
definition = self._definitions.get(name)
|
|
@@ -186,14 +191,14 @@ class _App:
|
|
|
186
191
|
CallToolRequest(
|
|
187
192
|
params=CallToolRequestParams(
|
|
188
193
|
name=name,
|
|
189
|
-
arguments=
|
|
190
|
-
input_responses=input_responses,
|
|
191
|
-
request_state=request_state,
|
|
192
|
-
_meta=meta,
|
|
194
|
+
arguments=arguments,
|
|
195
|
+
input_responses=options.get("input_responses"),
|
|
196
|
+
request_state=options.get("request_state"),
|
|
197
|
+
_meta=options.get("meta"),
|
|
193
198
|
)
|
|
194
199
|
),
|
|
195
200
|
_result,
|
|
196
|
-
request_read_timeout_seconds=timeout,
|
|
201
|
+
request_read_timeout_seconds=options.get("timeout"),
|
|
197
202
|
)
|
|
198
203
|
if (
|
|
199
204
|
definition is not None
|
|
@@ -211,11 +216,11 @@ class _App:
|
|
|
211
216
|
async def _call_tool(
|
|
212
217
|
self,
|
|
213
218
|
name: str,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
219
|
+
options: CallOptions | None = None,
|
|
220
|
+
/,
|
|
221
|
+
**arguments: object,
|
|
217
222
|
) -> object:
|
|
218
|
-
result = await self.call_tool(name,
|
|
223
|
+
result = await self.call_tool(name, options, **arguments)
|
|
219
224
|
if isinstance(result, InputRequiredResult) or result.is_error:
|
|
220
225
|
raise ToolCallError(self._name, name, result)
|
|
221
226
|
return result.structured_content
|
|
@@ -238,7 +243,7 @@ class Apps:
|
|
|
238
243
|
ValueError: A binding names an unknown dependency.
|
|
239
244
|
|
|
240
245
|
Access a client with ``clients[name]`` or ``clients.name``. Its
|
|
241
|
-
``tools.<name>(arguments
|
|
246
|
+
``tools.<name>(**arguments)`` methods return structured output
|
|
242
247
|
and raise :class:`ToolCallError` for tool errors or additional input requests.
|
|
243
248
|
Use its ``call_tool`` method for the full result envelope.
|
|
244
249
|
"""
|
|
@@ -4,4 +4,4 @@ set -eu
|
|
|
4
4
|
# Run from any directory with the holon CLI on PATH. The orders server is
|
|
5
5
|
# statically extracted; no running server or installed fixture dependencies are needed.
|
|
6
6
|
cd "$(dirname "$0")/../tests/fixtures/caller"
|
|
7
|
-
exec holon types generate ../orders
|
|
7
|
+
exec holon types generate -s ../orders
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# holon-generated-apps-v1 app=caller
|
|
2
|
+
# Input alternatives share a result type; redundant overloads are harmless.
|
|
3
|
+
# mypy: disable-error-code=overload-cannot-match
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json as _json
|
|
7
|
+
|
|
8
|
+
from holon.client import Apps as _Apps
|
|
9
|
+
from holon.client import Binding as _Binding
|
|
10
|
+
from holon.client import CallOptions as _CallOptions
|
|
11
|
+
from holon.client import ToolResult as _ToolResult
|
|
12
|
+
|
|
13
|
+
import builtins as _builtins
|
|
14
|
+
import typing as _typing
|
|
15
|
+
|
|
16
|
+
OrdersFindInput = _typing.TypedDict("OrdersFindInput", {"arguments": _typing.NotRequired[_builtins.str], "class": _typing.NotRequired[_builtins.str], "id": _builtins.str, "limit": _typing.NotRequired[_builtins.int], "name": _typing.NotRequired[_builtins.str], "options": _typing.NotRequired[_builtins.str], "order-id": _typing.NotRequired[_builtins.str], "self": _typing.NotRequired[_builtins.str], "timeout": _typing.NotRequired[_builtins.str]})
|
|
17
|
+
|
|
18
|
+
OrdersFindInput2 = _typing.TypedDict("OrdersFindInput2", {"arguments": _typing.NotRequired[_builtins.str], "class": _typing.NotRequired[_builtins.str], "id": _builtins.str, "limit": _typing.NotRequired[_builtins.int], "name": _typing.NotRequired[_builtins.str], "options": _typing.NotRequired[_builtins.str], "order-id": _typing.NotRequired[_builtins.str], "self": _typing.NotRequired[_builtins.str], "timeout": _typing.NotRequired[_builtins.str]})
|
|
19
|
+
|
|
20
|
+
OrdersFindInput3 = _typing.TypedDict("OrdersFindInput3", {"email": _builtins.str})
|
|
21
|
+
|
|
22
|
+
OrdersGetOrderInput = _typing.TypedDict("OrdersGetOrderInput", {"arguments": _typing.NotRequired[_builtins.str], "class": _typing.NotRequired[_builtins.str], "id": _builtins.str, "limit": _typing.NotRequired[_builtins.int], "name": _typing.NotRequired[_builtins.str], "options": _typing.NotRequired[_builtins.str], "order-id": _typing.NotRequired[_builtins.str], "self": _typing.NotRequired[_builtins.str], "timeout": _typing.NotRequired[_builtins.str]})
|
|
23
|
+
|
|
24
|
+
OrdersGetOrderOutput = _typing.TypedDict("OrdersGetOrderOutput", {"total": _builtins.float})
|
|
25
|
+
|
|
26
|
+
OrdersPingInput = _typing.TypedDict("OrdersPingInput", {})
|
|
27
|
+
|
|
28
|
+
OrdersReadInput = _typing.TypedDict("OrdersReadInput", {"arguments": _typing.NotRequired[_builtins.str], "class": _typing.NotRequired[_builtins.str], "id": _builtins.str, "limit": _typing.NotRequired[_builtins.int], "name": _typing.NotRequired[_builtins.str], "options": _typing.NotRequired[_builtins.str], "order-id": _typing.NotRequired[_builtins.str], "self": _typing.NotRequired[_builtins.str], "timeout": _typing.NotRequired[_builtins.str]})
|
|
29
|
+
|
|
30
|
+
OrdersReadOutput = _typing.TypedDict("OrdersReadOutput", {"total": _builtins.float})
|
|
31
|
+
|
|
32
|
+
class Inputs:
|
|
33
|
+
class orders:
|
|
34
|
+
counts: _typing.TypeAlias = _builtins.dict[_builtins.str, _builtins.int]
|
|
35
|
+
find: _typing.TypeAlias = OrdersFindInput | OrdersFindInput2 | OrdersFindInput3
|
|
36
|
+
ping: _typing.TypeAlias = OrdersPingInput
|
|
37
|
+
read: _typing.TypeAlias = OrdersReadInput
|
|
38
|
+
|
|
39
|
+
class Outputs:
|
|
40
|
+
class orders:
|
|
41
|
+
counts: _typing.TypeAlias = _builtins.object
|
|
42
|
+
find: _typing.TypeAlias = _builtins.object
|
|
43
|
+
ping: _typing.TypeAlias = _builtins.object
|
|
44
|
+
read: _typing.TypeAlias = OrdersReadOutput
|
|
45
|
+
|
|
46
|
+
class InputTypes:
|
|
47
|
+
class orders:
|
|
48
|
+
class counts:
|
|
49
|
+
pass
|
|
50
|
+
class find:
|
|
51
|
+
pass
|
|
52
|
+
class ping:
|
|
53
|
+
pass
|
|
54
|
+
class read:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
class OutputTypes:
|
|
58
|
+
class orders:
|
|
59
|
+
class counts:
|
|
60
|
+
pass
|
|
61
|
+
class find:
|
|
62
|
+
pass
|
|
63
|
+
class ping:
|
|
64
|
+
pass
|
|
65
|
+
class read:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
class OrdersTools(_typing.Protocol):
|
|
69
|
+
async def counts(self, options: _CallOptions | None = ..., /, **arguments: _builtins.int) -> _builtins.object: ...
|
|
70
|
+
@_typing.overload
|
|
71
|
+
async def find(self, options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput]) -> _builtins.object: ...
|
|
72
|
+
@_typing.overload
|
|
73
|
+
async def find(self, options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput2]) -> _builtins.object: ...
|
|
74
|
+
@_typing.overload
|
|
75
|
+
async def find(self, options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput3]) -> _builtins.object: ...
|
|
76
|
+
async def ping(self, options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersPingInput]) -> _builtins.object: ...
|
|
77
|
+
async def read(self, options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersReadInput]) -> OrdersReadOutput: ...
|
|
78
|
+
|
|
79
|
+
class OrdersApp(_typing.Protocol):
|
|
80
|
+
tools: OrdersTools
|
|
81
|
+
@_typing.overload
|
|
82
|
+
async def call_tool(self, name: _typing.Literal["counts"], options: _CallOptions | None = ..., /, **arguments: _builtins.int) -> _ToolResult[_builtins.object]: ...
|
|
83
|
+
@_typing.overload
|
|
84
|
+
async def call_tool(self, name: _typing.Literal["find"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput]) -> _ToolResult[_builtins.object]: ...
|
|
85
|
+
@_typing.overload
|
|
86
|
+
async def call_tool(self, name: _typing.Literal["find"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput2]) -> _ToolResult[_builtins.object]: ...
|
|
87
|
+
@_typing.overload
|
|
88
|
+
async def call_tool(self, name: _typing.Literal["find"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersFindInput3]) -> _ToolResult[_builtins.object]: ...
|
|
89
|
+
@_typing.overload
|
|
90
|
+
async def call_tool(self, name: _typing.Literal["get-order"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersGetOrderInput]) -> _ToolResult[OrdersGetOrderOutput]: ...
|
|
91
|
+
@_typing.overload
|
|
92
|
+
async def call_tool(self, name: _typing.Literal["ping"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersPingInput]) -> _ToolResult[_builtins.object]: ...
|
|
93
|
+
@_typing.overload
|
|
94
|
+
async def call_tool(self, name: _typing.Literal["read"], options: _CallOptions | None = ..., /, **arguments: _typing.Unpack[OrdersReadInput]) -> _ToolResult[OrdersReadOutput]: ...
|
|
95
|
+
|
|
96
|
+
class _Clients(_typing.Protocol):
|
|
97
|
+
orders: OrdersApp
|
|
98
|
+
def __getitem__(self, name: _typing.Literal["orders"]) -> OrdersApp: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_identity, _contracts = _json.loads("[{\"name\":\"fixtures/caller\",\"requires\":{\"orders\":{\"name\":\"fixtures/orders\",\"version\":\"1\"}},\"version\":\"1\"},{\"orders\":{\"tools\":[{\"inputSchema\":{\"additionalProperties\":{\"type\":\"integer\"},\"type\":\"object\"},\"name\":\"counts\"},{\"inputSchema\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"arguments\":{\"type\":\"string\"},\"class\":{\"type\":\"string\"},\"id\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"},\"name\":{\"type\":\"string\"},\"options\":{\"type\":\"string\"},\"order-id\":{\"type\":\"string\"},\"self\":{\"type\":\"string\"},\"timeout\":{\"type\":\"string\"}},\"required\":[\"id\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"arguments\":{\"type\":\"string\"},\"class\":{\"type\":\"string\"},\"id\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"},\"name\":{\"type\":\"string\"},\"options\":{\"type\":\"string\"},\"order-id\":{\"type\":\"string\"},\"self\":{\"type\":\"string\"},\"timeout\":{\"type\":\"string\"}},\"required\":[\"id\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"email\":{\"type\":\"string\"}},\"required\":[\"email\"],\"type\":\"object\"}],\"type\":\"object\"},\"name\":\"find\"},{\"inputSchema\":{\"additionalProperties\":false,\"properties\":{\"arguments\":{\"type\":\"string\"},\"class\":{\"type\":\"string\"},\"id\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"},\"name\":{\"type\":\"string\"},\"options\":{\"type\":\"string\"},\"order-id\":{\"type\":\"string\"},\"self\":{\"type\":\"string\"},\"timeout\":{\"type\":\"string\"}},\"required\":[\"id\"],\"type\":\"object\"},\"name\":\"get-order\",\"outputSchema\":{\"properties\":{\"total\":{\"type\":\"number\"}},\"required\":[\"total\"],\"type\":\"object\"}},{\"inputSchema\":{\"additionalProperties\":false,\"type\":\"object\"},\"name\":\"ping\"},{\"inputSchema\":{\"additionalProperties\":false,\"properties\":{\"arguments\":{\"type\":\"string\"},\"class\":{\"type\":\"string\"},\"id\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"},\"name\":{\"type\":\"string\"},\"options\":{\"type\":\"string\"},\"order-id\":{\"type\":\"string\"},\"self\":{\"type\":\"string\"},\"timeout\":{\"type\":\"string\"}},\"required\":[\"id\"],\"type\":\"object\"},\"name\":\"read\",\"outputSchema\":{\"properties\":{\"total\":{\"type\":\"number\"}},\"required\":[\"total\"],\"type\":\"object\"}}]}}]")
|
|
102
|
+
|
|
103
|
+
apps = _typing.cast(_Clients, _Apps(_identity, _contracts))
|
|
104
|
+
|
|
105
|
+
def create_apps(bindings: _typing.Mapping[str, _Binding]) -> _Clients:
|
|
106
|
+
return _typing.cast(_Clients, _Apps(_identity, _contracts, bindings))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Static tool catalog for generated-client typing regressions."""
|
|
2
|
+
|
|
3
|
+
from mcp.server import Server
|
|
4
|
+
from mcp.types import Tool
|
|
5
|
+
|
|
6
|
+
server = Server("orders")
|
|
7
|
+
|
|
8
|
+
READ_INPUT = {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
"id": {"type": "string"},
|
|
12
|
+
"limit": {"type": "integer"},
|
|
13
|
+
"name": {"type": "string"},
|
|
14
|
+
"timeout": {"type": "string"},
|
|
15
|
+
"options": {"type": "string"},
|
|
16
|
+
"self": {"type": "string"},
|
|
17
|
+
"arguments": {"type": "string"},
|
|
18
|
+
"class": {"type": "string"},
|
|
19
|
+
"order-id": {"type": "string"},
|
|
20
|
+
},
|
|
21
|
+
"required": ["id"],
|
|
22
|
+
"additionalProperties": False,
|
|
23
|
+
}
|
|
24
|
+
READ_OUTPUT = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {"total": {"type": "number"}},
|
|
27
|
+
"required": ["total"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@server.list_tools()
|
|
32
|
+
async def tools() -> list[Tool]:
|
|
33
|
+
return [
|
|
34
|
+
Tool(name="read", input_schema=READ_INPUT, output_schema=READ_OUTPUT),
|
|
35
|
+
Tool(name="get-order", input_schema=READ_INPUT, output_schema=READ_OUTPUT),
|
|
36
|
+
Tool(name="ping", input_schema={"type": "object", "additionalProperties": False}),
|
|
37
|
+
Tool(
|
|
38
|
+
name="counts",
|
|
39
|
+
input_schema={"type": "object", "additionalProperties": {"type": "integer"}},
|
|
40
|
+
),
|
|
41
|
+
Tool(
|
|
42
|
+
name="find",
|
|
43
|
+
input_schema={
|
|
44
|
+
"type": "object",
|
|
45
|
+
"anyOf": [
|
|
46
|
+
READ_INPUT,
|
|
47
|
+
READ_INPUT,
|
|
48
|
+
{
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {"email": {"type": "string"}},
|
|
51
|
+
"required": ["email"],
|
|
52
|
+
"additionalProperties": False,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
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(
|
|
13
|
+
Path(__file__).parent / "fixtures/caller", tmp_path, dirs_exist_ok=True
|
|
14
|
+
)
|
|
15
|
+
(tmp_path / "check.py").write_text("""from pathlib import Path
|
|
16
|
+
import holon
|
|
17
|
+
import holon.client
|
|
18
|
+
from holon.apps import apps, Inputs, Outputs
|
|
19
|
+
|
|
20
|
+
async def run() -> float:
|
|
21
|
+
arguments: Inputs.orders.read = {
|
|
22
|
+
"id": "order-1", "limit": 2, "name": "n", "timeout": "t", "options": "o",
|
|
23
|
+
"self": "s", "arguments": "a", "class": "c", "order-id": "id",
|
|
24
|
+
}
|
|
25
|
+
output: Outputs.orders.read = await apps.orders.tools.read(**arguments)
|
|
26
|
+
await apps.orders.call_tool("read", {"timeout": 5}, **arguments)
|
|
27
|
+
await apps.orders.call_tool("get-order", id="order-1")
|
|
28
|
+
await apps.orders.tools.ping()
|
|
29
|
+
await apps.orders.tools.counts(apples=2, oranges=3)
|
|
30
|
+
await apps.orders.tools.find(id="order-1")
|
|
31
|
+
await apps.orders.call_tool("find", email="user@example.com")
|
|
32
|
+
return output["total"]
|
|
33
|
+
|
|
34
|
+
async def invalid() -> None:
|
|
35
|
+
await apps.orders.tools.read(id=1) # type: ignore
|
|
36
|
+
await apps.orders.tools.read() # type: ignore
|
|
37
|
+
await apps.orders.call_tool("read", id=1) # type: ignore
|
|
38
|
+
await apps.orders.call_tool("read") # type: ignore
|
|
39
|
+
await apps.orders.tools.read({"id": "old form"}) # type: ignore
|
|
40
|
+
await apps.orders.tools.ping(unexpected=True) # type: ignore
|
|
41
|
+
await apps.orders.tools.counts(apples="two") # type: ignore
|
|
42
|
+
await apps.orders.tools.find() # type: ignore
|
|
43
|
+
await apps.orders.call_tool("find", id=1) # type: ignore
|
|
44
|
+
await apps.orders.tools.find(id="x", email="user@example.com") # type: ignore
|
|
45
|
+
apps.missing # type: ignore
|
|
46
|
+
apps.orders.missing
|
|
47
|
+
|
|
48
|
+
assert holon.__file__ is None
|
|
49
|
+
assert Path(holon.client.__file__).parent != Path(__file__).parent / "holon"
|
|
50
|
+
""")
|
|
51
|
+
# Runtime and typing must agree across the two namespace portions. No
|
|
52
|
+
# MYPYPATH/PYTHONPATH override is allowed to hide packaging problems.
|
|
53
|
+
env = os.environ.copy()
|
|
54
|
+
env.pop("MYPYPATH", None)
|
|
55
|
+
env.pop("PYTHONPATH", None)
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[sys.executable, "-m", "mypy", "--strict", "--no-error-summary", "check.py"],
|
|
58
|
+
cwd=tmp_path,
|
|
59
|
+
env=env,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
assert result.returncode == 1
|
|
65
|
+
assert len(result.stdout.splitlines()) == 1, result.stdout
|
|
66
|
+
assert '"OrdersApp" has no attribute "missing"' in result.stdout
|
|
67
|
+
subprocess.run([sys.executable, "check.py"], cwd=tmp_path, env=env, check=True)
|
|
@@ -19,6 +19,8 @@ CONTRACT = {
|
|
|
19
19
|
"properties": {
|
|
20
20
|
"id": {"type": "string"},
|
|
21
21
|
"limit": {"type": "integer", "default": 5},
|
|
22
|
+
"name": {"type": "string"},
|
|
23
|
+
"timeout": {"type": "integer"},
|
|
22
24
|
},
|
|
23
25
|
"required": ["id"],
|
|
24
26
|
"additionalProperties": False,
|
|
@@ -40,7 +42,9 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
40
42
|
server = MCPServer("orders")
|
|
41
43
|
|
|
42
44
|
@server.tool()
|
|
43
|
-
async def read(
|
|
45
|
+
async def read(
|
|
46
|
+
id: str, limit: int = 5, name: str = "", timeout: int = 0
|
|
47
|
+
) -> CallToolResult | InputRequiredResult:
|
|
44
48
|
if id == "error":
|
|
45
49
|
return CallToolResult(
|
|
46
50
|
is_error=True,
|
|
@@ -96,16 +100,37 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
96
100
|
other = Apps({**identity, "name": "other"}, {"orders": CONTRACT})
|
|
97
101
|
assert not requests
|
|
98
102
|
async with app.router.lifespan_context(app):
|
|
99
|
-
arguments = {"id": "good"}
|
|
100
103
|
output, raw = await asyncio.gather(
|
|
101
|
-
apps.orders.tools.read(
|
|
102
|
-
other.orders.call_tool(
|
|
104
|
+
apps.orders.tools.read(id="good"),
|
|
105
|
+
other.orders.call_tool(
|
|
106
|
+
"read",
|
|
107
|
+
{
|
|
108
|
+
"timeout": 5,
|
|
109
|
+
"meta": {"trace": "control"},
|
|
110
|
+
},
|
|
111
|
+
id="good",
|
|
112
|
+
limit=2,
|
|
113
|
+
name="field",
|
|
114
|
+
timeout=7,
|
|
115
|
+
),
|
|
103
116
|
)
|
|
104
117
|
assert output == {"total": 5}
|
|
105
|
-
assert arguments == {"id": "good"}
|
|
106
118
|
assert raw.structured_content == {"total": 2}
|
|
107
119
|
assert raw.content[0].text == "Order"
|
|
108
120
|
assert raw.meta["preserved"] is True
|
|
121
|
+
request = next(
|
|
122
|
+
request["params"]
|
|
123
|
+
for request in requests
|
|
124
|
+
if request["method"] == "tools/call"
|
|
125
|
+
and request["params"]["arguments"].get("name") == "field"
|
|
126
|
+
)
|
|
127
|
+
assert request["arguments"] == {
|
|
128
|
+
"id": "good",
|
|
129
|
+
"limit": 2,
|
|
130
|
+
"name": "field",
|
|
131
|
+
"timeout": 7,
|
|
132
|
+
}
|
|
133
|
+
assert request["_meta"]["trace"] == "control"
|
|
109
134
|
assert not any(request["method"] == "tools/list" for request in requests)
|
|
110
135
|
callers = [
|
|
111
136
|
request["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"]
|
|
@@ -114,9 +139,9 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
114
139
|
]
|
|
115
140
|
assert set(callers) == {"checkout", "other"}
|
|
116
141
|
for value, code in [("error", "tool_error"), ("input", "input_required")]:
|
|
117
|
-
raw = await apps.orders.call_tool("read",
|
|
142
|
+
raw = await apps.orders.call_tool("read", id=value)
|
|
118
143
|
with pytest.raises(ToolCallError) as error:
|
|
119
|
-
await apps.orders.tools.read(
|
|
144
|
+
await apps.orders.tools.read(id=value)
|
|
120
145
|
assert error.value.code == code
|
|
121
146
|
assert error.value.app == "orders"
|
|
122
147
|
assert error.value.tool == "read"
|
|
@@ -124,26 +149,33 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
124
149
|
assert error.value.result.meta["preserved"] is True
|
|
125
150
|
if isinstance(raw, InputRequiredResult):
|
|
126
151
|
assert error.value.result.request_state
|
|
152
|
+
await apps.orders.call_tool(
|
|
153
|
+
"read",
|
|
154
|
+
{"request_state": raw.request_state, "input_responses": {}},
|
|
155
|
+
id=value,
|
|
156
|
+
)
|
|
157
|
+
assert requests[-1]["params"]["requestState"] == raw.request_state
|
|
158
|
+
assert requests[-1]["params"]["inputResponses"] == {}
|
|
127
159
|
else:
|
|
128
160
|
assert error.value.result == raw
|
|
129
161
|
before = len(requests)
|
|
130
162
|
with pytest.raises(ValueError, match="arguments"):
|
|
131
|
-
await apps.orders.tools.read(
|
|
163
|
+
await apps.orders.tools.read(id=1)
|
|
132
164
|
assert len(requests) == before
|
|
133
165
|
with pytest.raises(ValueError, match="structured output"):
|
|
134
|
-
await apps.orders.tools.read(
|
|
135
|
-
task = asyncio.create_task(apps.orders.tools.read(
|
|
166
|
+
await apps.orders.tools.read(id="bad")
|
|
167
|
+
task = asyncio.create_task(apps.orders.tools.read(id="slow"))
|
|
136
168
|
await started.wait()
|
|
137
169
|
task.cancel()
|
|
138
170
|
with pytest.raises(asyncio.CancelledError):
|
|
139
171
|
await task
|
|
140
172
|
await cancelled.wait()
|
|
141
173
|
untyped = Apps({**identity, "requires": {"orders": {}, "missing": {}}}, {})
|
|
142
|
-
raw = await untyped.orders.call_tool("read",
|
|
174
|
+
raw = await untyped.orders.call_tool("read", id="good")
|
|
143
175
|
assert raw.structured_content == {"total": 5}
|
|
144
176
|
assert not hasattr(untyped.orders.tools, "read")
|
|
145
177
|
with pytest.raises(RuntimeError, match=r"unavailable \(MISSING_MCP_URL\)"):
|
|
146
|
-
await untyped.missing.call_tool("read"
|
|
178
|
+
await untyped.missing.call_tool("read")
|
|
147
179
|
# Explicit bindings work without a declaration and override the resolver.
|
|
148
180
|
set_default_binding(
|
|
149
181
|
lambda _: {"endpoint": "http://unavailable.invalid/mcp"}
|
|
@@ -156,12 +188,10 @@ def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
|
156
188
|
with pytest.raises(ValueError, match="Unknown app binding"):
|
|
157
189
|
Apps(script, {"orders": CONTRACT}, {"missing": binding})
|
|
158
190
|
bound = Apps(script, {"orders": CONTRACT}, {"orders": binding})
|
|
159
|
-
assert await bound.orders.tools.read(
|
|
191
|
+
assert await bound.orders.tools.read(id="good") == {"total": 5}
|
|
160
192
|
set_default_binding(lambda _: None)
|
|
161
193
|
with pytest.raises(ValueError, match="not declared"):
|
|
162
|
-
await Apps(script, {"orders": CONTRACT}).orders.tools.read(
|
|
163
|
-
{"id": "good"}
|
|
164
|
-
)
|
|
194
|
+
await Apps(script, {"orders": CONTRACT}).orders.tools.read(id="good")
|
|
165
195
|
assert headers and all(value == "Bearer external" for value in headers)
|
|
166
196
|
assert all(client.is_closed for client in clients)
|
|
167
197
|
|
holon_sh-0.0.1.dev2/PKG-INFO
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
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
|
holon_sh-0.0.1.dev2/README.md
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
|
|
@@ -1,55 +0,0 @@
|
|
|
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))
|
|
@@ -1,40 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|