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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Holon Labs
3
+ Copyright (c) 2026 Holon Systems, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -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 = _Tools(self)
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
- 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:
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
- 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)
224
+ class Apps:
225
+ """Create the dependency collection used by generated Python clients.
179
226
 
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
227
+ Applications typically use ``apps`` or ``create_apps`` from ``holon.apps``.
187
228
 
188
- return call
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: 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 ():
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
- 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
- }
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.dev1"
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 = ["mcptype==0.0.6", "mypy==2.4.0", "pytest==9.1.1", "ruff==0.16.8", "types-jsonschema", "hatchling>=1.27,<2"]
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,5 @@
1
+ {
2
+ "name": "fixtures/caller",
3
+ "version": "1",
4
+ "description": "Consumer of the orders package fixture."
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "fixtures/orders",
3
+ "version": "1",
4
+ "description": "Orders contract for the Python package fixture."
5
+ }
@@ -0,0 +1,14 @@
1
+ from typing import TypedDict
2
+
3
+ from mcp.server import MCPServer
4
+
5
+ server = MCPServer("orders")
6
+
7
+
8
+ class Order(TypedDict):
9
+ total: float
10
+
11
+
12
+ @server.tool()
13
+ def read(id: str) -> Order:
14
+ return {"total": 42.0}
@@ -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": 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)
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
- apps.orders.call_tool("read", {"id": "good", "limit": 2}),
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
- 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},
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
- 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})
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(identity, {"orders": contract}).orders.tools.read(
224
- {"id": "a"}
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
- async def bounded():
229
- async with asyncio.timeout(15):
230
- await exercise()
231
-
232
- asyncio.run(bounded())
168
+ asyncio.run(asyncio.wait_for(exercise(), timeout=15))
@@ -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