holon-sh 0.0.1.dev1__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/.gitignore +7 -0
- holon_sh-0.0.1.dev1/LICENSE +21 -0
- holon_sh-0.0.1.dev1/PKG-INFO +11 -0
- holon_sh-0.0.1.dev1/holon/binding.py +30 -0
- holon_sh-0.0.1.dev1/holon/client.py +219 -0
- holon_sh-0.0.1.dev1/holon/py.typed +0 -0
- holon_sh-0.0.1.dev1/holon/view.py +35 -0
- holon_sh-0.0.1.dev1/pyproject.toml +30 -0
- holon_sh-0.0.1.dev1/scripts/verify_distribution.py +91 -0
- holon_sh-0.0.1.dev1/tests/test_package.py +83 -0
- holon_sh-0.0.1.dev1/tests/test_runtime.py +232 -0
- holon_sh-0.0.1.dev1/tests/test_view.py +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Holon Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any, Generic, Literal, Protocol, TypeAlias, TypeVar
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
import jsonschema # type: ignore[import-untyped,unused-ignore]
|
|
9
|
+
from mcp import Client
|
|
10
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
11
|
+
from mcp.types import (
|
|
12
|
+
CallToolRequest,
|
|
13
|
+
CallToolRequestParams,
|
|
14
|
+
CallToolResult,
|
|
15
|
+
ContentBlock,
|
|
16
|
+
Implementation,
|
|
17
|
+
InputRequiredResult,
|
|
18
|
+
InputResponses,
|
|
19
|
+
RequestParamsMeta,
|
|
20
|
+
)
|
|
21
|
+
from pydantic import TypeAdapter
|
|
22
|
+
from referencing import Registry
|
|
23
|
+
|
|
24
|
+
from holon.binding import Binding, resolve_default_binding
|
|
25
|
+
|
|
26
|
+
# Binding is re-exported for the generated holon.apps module.
|
|
27
|
+
__all__ = ["Apps", "Binding", "ToolCallError", "ToolResult"]
|
|
28
|
+
|
|
29
|
+
Output = TypeVar("Output")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CompleteResult(Protocol, Generic[Output]):
|
|
33
|
+
result_type: Literal["complete"]
|
|
34
|
+
is_error: Literal[False]
|
|
35
|
+
structured_content: Output
|
|
36
|
+
content: list[ContentBlock]
|
|
37
|
+
meta: dict[str, object] | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ErrorResult(Protocol):
|
|
41
|
+
result_type: Literal["complete"]
|
|
42
|
+
is_error: Literal[True]
|
|
43
|
+
structured_content: object
|
|
44
|
+
content: list[ContentBlock]
|
|
45
|
+
meta: dict[str, object] | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ToolResult: TypeAlias = CompleteResult[Output] | ErrorResult | InputRequiredResult
|
|
49
|
+
|
|
50
|
+
_result = TypeAdapter(CallToolResult | InputRequiredResult)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ToolCallError(RuntimeError):
|
|
54
|
+
def __init__(
|
|
55
|
+
self, app: str, tool: str, result: CallToolResult | InputRequiredResult
|
|
56
|
+
) -> None:
|
|
57
|
+
self.app = app
|
|
58
|
+
self.tool = tool
|
|
59
|
+
self.result = result
|
|
60
|
+
self.code: Literal["tool_error", "input_required"]
|
|
61
|
+
if isinstance(result, InputRequiredResult):
|
|
62
|
+
self.code, message = "input_required", "additional input required"
|
|
63
|
+
else:
|
|
64
|
+
self.code, message = "tool_error", "tool failed"
|
|
65
|
+
super().__init__(f"{app}.{tool}: {message}.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate(schema: dict[str, Any], value: object, label: str) -> None:
|
|
69
|
+
try:
|
|
70
|
+
jsonschema.validate(
|
|
71
|
+
value,
|
|
72
|
+
schema,
|
|
73
|
+
registry=Registry(),
|
|
74
|
+
format_checker=jsonschema.FormatChecker(),
|
|
75
|
+
)
|
|
76
|
+
except jsonschema.ValidationError as error:
|
|
77
|
+
raise ValueError(f"Invalid {label}: {error.message}") from error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _App:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
name: str,
|
|
84
|
+
identity: dict[str, Any],
|
|
85
|
+
contract: dict[str, Any] | None,
|
|
86
|
+
binding: Binding | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._name = name
|
|
89
|
+
self._identity = identity
|
|
90
|
+
self._binding = binding
|
|
91
|
+
# None for a dependency without a contract: any tool, unvalidated.
|
|
92
|
+
self._definitions = (
|
|
93
|
+
None
|
|
94
|
+
if contract is None
|
|
95
|
+
else {tool["name"]: tool for tool in contract["tools"]}
|
|
96
|
+
)
|
|
97
|
+
self.tools = _Tools(self)
|
|
98
|
+
|
|
99
|
+
async def call_tool(
|
|
100
|
+
self,
|
|
101
|
+
name: str,
|
|
102
|
+
arguments: Mapping[str, object],
|
|
103
|
+
*,
|
|
104
|
+
timeout: float | None = None,
|
|
105
|
+
input_responses: InputResponses | None = None,
|
|
106
|
+
request_state: str | None = None,
|
|
107
|
+
meta: RequestParamsMeta | None = None,
|
|
108
|
+
) -> CallToolResult | InputRequiredResult:
|
|
109
|
+
definition = None
|
|
110
|
+
if self._definitions is not None:
|
|
111
|
+
definition = self._definitions.get(name)
|
|
112
|
+
if definition is None:
|
|
113
|
+
raise ValueError(f"Unknown tool {self._name}.{name}.")
|
|
114
|
+
validate(
|
|
115
|
+
definition["inputSchema"],
|
|
116
|
+
arguments,
|
|
117
|
+
f"arguments for {self._name}.{name}",
|
|
118
|
+
)
|
|
119
|
+
binding = self._binding or resolve_default_binding(self._name)
|
|
120
|
+
if binding is None and self._name not in self._identity["requires"]:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Dependency {self._name} is not declared by this application."
|
|
123
|
+
)
|
|
124
|
+
variable = self._name.upper().replace("-", "_") + "_MCP_URL"
|
|
125
|
+
endpoint = binding["endpoint"] if binding else os.environ.get(variable)
|
|
126
|
+
if not endpoint:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"Dependency {self._name} is unavailable ({variable}). Set the endpoint or supply a binding."
|
|
129
|
+
)
|
|
130
|
+
http = (
|
|
131
|
+
binding["http_client"]()
|
|
132
|
+
if binding and "http_client" in binding
|
|
133
|
+
else httpx2.AsyncClient(trust_env=False)
|
|
134
|
+
)
|
|
135
|
+
async with http:
|
|
136
|
+
transport = streamable_http_client(endpoint, http_client=http)
|
|
137
|
+
async with Client(
|
|
138
|
+
transport,
|
|
139
|
+
client_info=Implementation(
|
|
140
|
+
name=self._identity["name"], version=self._identity["version"]
|
|
141
|
+
),
|
|
142
|
+
mode="2026-07-28",
|
|
143
|
+
) as client:
|
|
144
|
+
result = await client.session.send_request(
|
|
145
|
+
CallToolRequest(
|
|
146
|
+
params=CallToolRequestParams(
|
|
147
|
+
name=name,
|
|
148
|
+
arguments=dict(arguments),
|
|
149
|
+
input_responses=input_responses,
|
|
150
|
+
request_state=request_state,
|
|
151
|
+
_meta=meta,
|
|
152
|
+
)
|
|
153
|
+
),
|
|
154
|
+
_result,
|
|
155
|
+
request_read_timeout_seconds=timeout,
|
|
156
|
+
)
|
|
157
|
+
if (
|
|
158
|
+
definition is not None
|
|
159
|
+
and isinstance(result, CallToolResult)
|
|
160
|
+
and not result.is_error
|
|
161
|
+
and "outputSchema" in definition
|
|
162
|
+
):
|
|
163
|
+
validate(
|
|
164
|
+
definition["outputSchema"],
|
|
165
|
+
result.structured_content,
|
|
166
|
+
f"structured output from {self._name}.{name}",
|
|
167
|
+
)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _Tools:
|
|
172
|
+
def __init__(self, client: _App) -> None:
|
|
173
|
+
self._client = client
|
|
174
|
+
|
|
175
|
+
def __getattr__(self, name: str) -> Any:
|
|
176
|
+
definitions = self._client._definitions
|
|
177
|
+
if name.startswith("_") or definitions is None or name not in definitions:
|
|
178
|
+
raise AttributeError(name)
|
|
179
|
+
|
|
180
|
+
async def call(
|
|
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
|
|
187
|
+
|
|
188
|
+
return call
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Apps:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
identity: dict[str, Any],
|
|
195
|
+
contracts: dict[str, Any],
|
|
196
|
+
bindings: Mapping[str, Binding] | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
# Every declared dependency gets a client; those without a contract are untyped.
|
|
199
|
+
available: dict[str, dict[str, Any] | None] = {
|
|
200
|
+
name: None for name in identity["requires"]
|
|
201
|
+
}
|
|
202
|
+
available.update(contracts)
|
|
203
|
+
selected = available if bindings is None else {}
|
|
204
|
+
for name in bindings or ():
|
|
205
|
+
if name not in available:
|
|
206
|
+
raise ValueError(f"Unknown app binding: {name}")
|
|
207
|
+
selected[name] = available[name]
|
|
208
|
+
self._apps = {
|
|
209
|
+
name: _App(name, identity, contract, bindings[name] if bindings else None)
|
|
210
|
+
for name, contract in selected.items()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def __getitem__(self, name: str) -> _App:
|
|
214
|
+
return self._apps[name]
|
|
215
|
+
|
|
216
|
+
def __getattr__(self, name: str) -> _App:
|
|
217
|
+
if name.startswith("_") or name not in self._apps:
|
|
218
|
+
raise AttributeError(name)
|
|
219
|
+
return self._apps[name]
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "holon-sh"
|
|
7
|
+
version = "0.0.1.dev1"
|
|
8
|
+
description = "Typed MCP clients, bindings, and app views"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["mcp>=2.3,<3", "jsonschema>=4.26,<5", "httpx2>=2.10,<3"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/holon-sh/sdk-py"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
only-include = ["holon"]
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.sdist]
|
|
21
|
+
include = ["holon", "tests", "scripts", "pyproject.toml"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
target-version = "py311"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["mcptype==0.0.6", "mypy==2.4.0", "pytest==9.1.1", "ruff==0.16.8", "types-jsonschema", "hatchling>=1.27,<2"]
|
|
@@ -0,0 +1,91 @@
|
|
|
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])
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx2
|
|
5
|
+
import pytest
|
|
6
|
+
from mcp.server import MCPServer
|
|
7
|
+
from mcp.types import CallToolResult, InputRequiredResult, TextContent
|
|
8
|
+
|
|
9
|
+
import holon.binding
|
|
10
|
+
from holon.binding import set_default_binding
|
|
11
|
+
from holon.client import Apps, ToolCallError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_clients_preserve_mcp_results_validate_and_cancel(monkeypatch):
|
|
15
|
+
async def exercise():
|
|
16
|
+
started = asyncio.Event()
|
|
17
|
+
cancelled = asyncio.Event()
|
|
18
|
+
server = MCPServer("orders")
|
|
19
|
+
|
|
20
|
+
@server.tool()
|
|
21
|
+
async def read(id: str, limit: int = 5) -> CallToolResult | InputRequiredResult:
|
|
22
|
+
if id == "error":
|
|
23
|
+
return CallToolResult(
|
|
24
|
+
is_error=True,
|
|
25
|
+
content=[TextContent(type="text", text="Unavailable")],
|
|
26
|
+
_meta={"preserved": True},
|
|
27
|
+
)
|
|
28
|
+
if id == "input":
|
|
29
|
+
return InputRequiredResult(
|
|
30
|
+
request_state="opaque", _meta={"preserved": True}
|
|
31
|
+
)
|
|
32
|
+
if id == "slow":
|
|
33
|
+
started.set()
|
|
34
|
+
try:
|
|
35
|
+
await asyncio.Event().wait()
|
|
36
|
+
finally:
|
|
37
|
+
cancelled.set()
|
|
38
|
+
return CallToolResult(
|
|
39
|
+
content=[TextContent(type="text", text="Order")],
|
|
40
|
+
structured_content={"total": "bad" if id == "bad" else limit},
|
|
41
|
+
_meta={"preserved": True},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
app = server.streamable_http_app(stateless_http=True)
|
|
45
|
+
requests = []
|
|
46
|
+
clients = []
|
|
47
|
+
|
|
48
|
+
async def record(request):
|
|
49
|
+
if request.method == "POST":
|
|
50
|
+
requests.append(json.loads(await request.aread()))
|
|
51
|
+
|
|
52
|
+
def http_client():
|
|
53
|
+
client = httpx2.AsyncClient(
|
|
54
|
+
transport=httpx2.ASGITransport(app=app),
|
|
55
|
+
event_hooks={"request": [record]},
|
|
56
|
+
)
|
|
57
|
+
clients.append(client)
|
|
58
|
+
return client
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(holon.binding, "_resolver", None)
|
|
61
|
+
monkeypatch.delenv("MISSING_MCP_URL", raising=False)
|
|
62
|
+
set_default_binding(
|
|
63
|
+
lambda app: (
|
|
64
|
+
{"endpoint": "http://127.0.0.1:8000/mcp", "http_client": http_client}
|
|
65
|
+
if app == "orders"
|
|
66
|
+
else None
|
|
67
|
+
)
|
|
68
|
+
)
|
|
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
|
+
identity = {"name": "checkout", "version": "1", "requires": {"orders": {}}}
|
|
91
|
+
apps = Apps(identity, {"orders": contract})
|
|
92
|
+
repr(apps)
|
|
93
|
+
repr(apps.orders.tools)
|
|
94
|
+
with pytest.raises(TypeError):
|
|
95
|
+
await apps.orders
|
|
96
|
+
with pytest.raises(TypeError):
|
|
97
|
+
json.dumps(apps.orders)
|
|
98
|
+
assert not requests
|
|
99
|
+
async with app.router.lifespan_context(app):
|
|
100
|
+
arguments = {"id": "good"}
|
|
101
|
+
output, raw = await asyncio.gather(
|
|
102
|
+
apps.orders.tools.read(arguments),
|
|
103
|
+
apps.orders.call_tool("read", {"id": "good", "limit": 2}),
|
|
104
|
+
)
|
|
105
|
+
assert output == {"total": 5}
|
|
106
|
+
assert arguments == {"id": "good"}
|
|
107
|
+
assert raw.structured_content == {"total": 2}
|
|
108
|
+
assert raw.content[0].text == "Order"
|
|
109
|
+
assert raw.meta["preserved"] is True
|
|
110
|
+
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
|
+
callers = [
|
|
116
|
+
request["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"]
|
|
117
|
+
for request in requests
|
|
118
|
+
if request["method"] == "tools/call"
|
|
119
|
+
]
|
|
120
|
+
assert set(callers) == {"checkout", "other"}
|
|
121
|
+
for value, code in [("error", "tool_error"), ("input", "input_required")]:
|
|
122
|
+
raw = await apps.orders.call_tool("read", {"id": value})
|
|
123
|
+
with pytest.raises(ToolCallError) as error:
|
|
124
|
+
await apps.orders.tools.read({"id": value})
|
|
125
|
+
assert error.value.code == code
|
|
126
|
+
assert error.value.app == "orders"
|
|
127
|
+
assert error.value.tool == "read"
|
|
128
|
+
assert type(error.value.result) is type(raw)
|
|
129
|
+
assert error.value.result.meta["preserved"] is True
|
|
130
|
+
if isinstance(raw, InputRequiredResult):
|
|
131
|
+
assert error.value.result.request_state
|
|
132
|
+
else:
|
|
133
|
+
assert error.value.result == raw
|
|
134
|
+
before = len(requests)
|
|
135
|
+
with pytest.raises(ValueError, match="arguments"):
|
|
136
|
+
await apps.orders.tools.read({"id": 1})
|
|
137
|
+
assert len(requests) == before
|
|
138
|
+
with pytest.raises(ValueError, match="structured output"):
|
|
139
|
+
await apps.orders.tools.read({"id": "bad"})
|
|
140
|
+
task = asyncio.create_task(apps.orders.tools.read({"id": "slow"}))
|
|
141
|
+
await started.wait()
|
|
142
|
+
task.cancel()
|
|
143
|
+
with pytest.raises(asyncio.CancelledError):
|
|
144
|
+
await task
|
|
145
|
+
await cancelled.wait()
|
|
146
|
+
untyped = Apps({**identity, "requires": {"orders": {}, "missing": {}}}, {})
|
|
147
|
+
raw = await untyped.orders.call_tool("read", {"id": "good"})
|
|
148
|
+
assert raw.structured_content == {"total": 5}
|
|
149
|
+
assert not hasattr(untyped.orders.tools, "read")
|
|
150
|
+
with pytest.raises(RuntimeError, match=r"unavailable \(MISSING_MCP_URL\)"):
|
|
151
|
+
await untyped.missing.call_tool("read", {})
|
|
152
|
+
assert all(client.is_closed for client in clients)
|
|
153
|
+
|
|
154
|
+
async def bounded():
|
|
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},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
app = server.streamable_http_app(stateless_http=True)
|
|
174
|
+
|
|
175
|
+
async def record(request):
|
|
176
|
+
headers.append(request.headers.get("authorization"))
|
|
177
|
+
|
|
178
|
+
def http_client():
|
|
179
|
+
return httpx2.AsyncClient(
|
|
180
|
+
transport=httpx2.ASGITransport(app=app),
|
|
181
|
+
headers={"Authorization": "Bearer external"},
|
|
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})
|
|
222
|
+
with pytest.raises(ValueError, match="not declared"):
|
|
223
|
+
await Apps(identity, {"orders": contract}).orders.tools.read(
|
|
224
|
+
{"id": "a"}
|
|
225
|
+
)
|
|
226
|
+
assert headers and all(value == "Bearer external" for value in headers)
|
|
227
|
+
|
|
228
|
+
async def bounded():
|
|
229
|
+
async with asyncio.timeout(15):
|
|
230
|
+
await exercise()
|
|
231
|
+
|
|
232
|
+
asyncio.run(bounded())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from mcp import Client
|
|
4
|
+
from mcp.server import MCPServer
|
|
5
|
+
from mcp.server.apps import APP_MIME_TYPE
|
|
6
|
+
|
|
7
|
+
from holon.view import app_view
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_app_view_serves_one_build_for_the_life_of_the_process(tmp_path):
|
|
11
|
+
page = tmp_path / "index.html"
|
|
12
|
+
page.write_text("<html>one</html>")
|
|
13
|
+
server = MCPServer("site")
|
|
14
|
+
server.add_resource(
|
|
15
|
+
app_view(
|
|
16
|
+
"ui://site/index.html",
|
|
17
|
+
"Site",
|
|
18
|
+
page,
|
|
19
|
+
connect_domains=["https://api.example"],
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
async def exercise():
|
|
24
|
+
async with Client(server, mode="2026-07-28") as client:
|
|
25
|
+
listed = await client.list_resources()
|
|
26
|
+
resource = listed.resources[0]
|
|
27
|
+
assert (str(resource.uri), resource.name, resource.mime_type) == (
|
|
28
|
+
"ui://site/index.html",
|
|
29
|
+
"Site",
|
|
30
|
+
APP_MIME_TYPE,
|
|
31
|
+
)
|
|
32
|
+
assert resource.meta == {
|
|
33
|
+
"ui": {
|
|
34
|
+
"csp": {
|
|
35
|
+
"connectDomains": ["https://api.example"],
|
|
36
|
+
"resourceDomains": [],
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
first = await client.read_resource("ui://site/index.html")
|
|
41
|
+
assert first.contents[0].text == "<html>one</html>"
|
|
42
|
+
assert first.contents[0].mime_type == APP_MIME_TYPE
|
|
43
|
+
page.write_text("<html>two</html>")
|
|
44
|
+
second = await client.read_resource("ui://site/index.html")
|
|
45
|
+
assert second.contents[0].text == "<html>one</html>"
|
|
46
|
+
|
|
47
|
+
asyncio.run(exercise())
|