holon-sh 0.0.1.dev1__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.
@@ -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,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
+ [![CI](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml)
17
+ [![PyPI](https://img.shields.io/pypi/v/holon-sh)](https://pypi.org/project/holon-sh/)
18
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/)
19
+ [![MIT License](https://img.shields.io/badge/license-MIT-green)](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
+ [![CI](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/holon-sh/sdk-py/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/holon-sh)](https://pypi.org/project/holon-sh/)
5
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-green)](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).
@@ -0,0 +1,47 @@
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
+ """
18
+
19
+ endpoint: str
20
+ http_client: NotRequired[Callable[[], httpx2.AsyncClient]]
21
+
22
+
23
+ _resolver: Callable[[str], Binding | None] | None = None
24
+
25
+
26
+ def set_default_binding(resolve: Callable[[str], Binding | None]) -> None:
27
+ """Register the default binding resolver for app clients.
28
+
29
+ Args:
30
+ resolve: Process-wide callback receiving the dependency name. Return a
31
+ binding or ``None`` to use the environment fallback. Explicit
32
+ bindings take precedence. Register during application startup.
33
+ """
34
+ global _resolver
35
+ _resolver = resolve
36
+
37
+
38
+ def resolve_default_binding(app: str) -> Binding | None:
39
+ """Return the registered resolver's binding for an app.
40
+
41
+ Args:
42
+ app: Dependency name passed unchanged to the resolver.
43
+
44
+ Returns:
45
+ The resolved binding, or ``None`` if none is available.
46
+ """
47
+ return _resolver(app) if _resolver is not None else None
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  from collections.abc import Mapping
5
- from typing import Any, Generic, Literal, Protocol, TypeAlias, TypeVar
5
+ from functools import partial
6
+ from types import SimpleNamespace
7
+ from typing import Any, Generic, Literal, Protocol, TypeAlias, TypedDict, TypeVar
6
8
 
7
9
  import httpx2
8
10
  import jsonschema # type: ignore[import-untyped,unused-ignore]
@@ -24,12 +26,14 @@ from referencing import Registry
24
26
  from holon.binding import Binding, resolve_default_binding
25
27
 
26
28
  # Binding is re-exported for the generated holon.apps module.
27
- __all__ = ["Apps", "Binding", "ToolCallError", "ToolResult"]
29
+ __all__ = ["Apps", "Binding", "CallOptions", "ToolCallError", "ToolResult"]
28
30
 
29
31
  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
@@ -50,7 +56,26 @@ ToolResult: TypeAlias = CompleteResult[Output] | ErrorResult | InputRequiredResu
50
56
  _result = TypeAdapter(CallToolResult | InputRequiredResult)
51
57
 
52
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
+
53
68
  class ToolCallError(RuntimeError):
69
+ """Raised by ``tools.<name>`` for tool errors or additional input requests.
70
+
71
+ Attributes:
72
+ app: Dependency name.
73
+ tool: Requested tool name.
74
+ result: Original MCP error or input-required result.
75
+ code: ``tool_error`` or ``input_required``. Use ``call_tool`` when you
76
+ need to inspect these results and submit additional input.
77
+ """
78
+
54
79
  def __init__(
55
80
  self, app: str, tool: str, result: CallToolResult | InputRequiredResult
56
81
  ) -> None:
@@ -94,18 +119,41 @@ class _App:
94
119
  if contract is None
95
120
  else {tool["name"]: tool for tool in contract["tools"]}
96
121
  )
97
- self.tools = _Tools(self)
122
+ self.tools = SimpleNamespace(
123
+ **{
124
+ name: partial(self._call_tool, name)
125
+ for name in self._definitions or ()
126
+ if not name.startswith("_")
127
+ }
128
+ )
98
129
 
99
130
  async def call_tool(
100
131
  self,
101
132
  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,
133
+ options: CallOptions | None = None,
134
+ /,
135
+ **arguments: object,
108
136
  ) -> CallToolResult | InputRequiredResult:
137
+ """Call a dependency tool and return its full MCP result.
138
+
139
+ Args:
140
+ name: Tool name. Contracted dependencies reject unknown tools.
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.
144
+
145
+ Returns:
146
+ A complete or input-required result. Tool error results are returned
147
+ unchanged; successful structured output is contract-validated.
148
+
149
+ Raises:
150
+ ValueError: Unknown tool, invalid input/output, or undeclared dependency.
151
+ RuntimeError: Endpoint resolution failed.
152
+
153
+ Transport and protocol exceptions propagate. Each call closes its HTTP
154
+ client and MCP session, including when an exception occurs.
155
+ """
156
+ options = options or {}
109
157
  definition = None
110
158
  if self._definitions is not None:
111
159
  definition = self._definitions.get(name)
@@ -127,12 +175,10 @@ class _App:
127
175
  raise RuntimeError(
128
176
  f"Dependency {self._name} is unavailable ({variable}). Set the endpoint or supply a binding."
129
177
  )
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:
178
+ http_client = partial(httpx2.AsyncClient, trust_env=False)
179
+ async with (
180
+ binding.get("http_client", http_client) if binding else http_client
181
+ )() as http:
136
182
  transport = streamable_http_client(endpoint, http_client=http)
137
183
  async with Client(
138
184
  transport,
@@ -145,14 +191,14 @@ class _App:
145
191
  CallToolRequest(
146
192
  params=CallToolRequestParams(
147
193
  name=name,
148
- arguments=dict(arguments),
149
- input_responses=input_responses,
150
- request_state=request_state,
151
- _meta=meta,
194
+ arguments=arguments,
195
+ input_responses=options.get("input_responses"),
196
+ request_state=options.get("request_state"),
197
+ _meta=options.get("meta"),
152
198
  )
153
199
  ),
154
200
  _result,
155
- request_read_timeout_seconds=timeout,
201
+ request_read_timeout_seconds=options.get("timeout"),
156
202
  )
157
203
  if (
158
204
  definition is not None
@@ -167,28 +213,41 @@ class _App:
167
213
  )
168
214
  return result
169
215
 
216
+ async def _call_tool(
217
+ self,
218
+ name: str,
219
+ options: CallOptions | None = None,
220
+ /,
221
+ **arguments: object,
222
+ ) -> object:
223
+ result = await self.call_tool(name, options, **arguments)
224
+ if isinstance(result, InputRequiredResult) or result.is_error:
225
+ raise ToolCallError(self._name, name, result)
226
+ return result.structured_content
227
+
170
228
 
171
- class _Tools:
172
- def __init__(self, client: _App) -> None:
173
- self._client = client
229
+ class Apps:
230
+ """Create the dependency collection used by generated Python clients.
174
231
 
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)
232
+ Applications typically use ``apps`` or ``create_apps`` from ``holon.apps``.
179
233
 
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
234
+ Args:
235
+ identity: Application name, version, and declared ``requires`` mapping.
236
+ contracts: Dependency contracts with a ``tools`` list, or ``None`` for
237
+ untyped dependencies.
238
+ bindings: Explicit bindings. When supplied, only these dependencies are
239
+ included; an empty mapping selects none. Otherwise all declared
240
+ dependencies and contracts are available.
187
241
 
188
- return call
242
+ Raises:
243
+ ValueError: A binding names an unknown dependency.
189
244
 
245
+ Access a client with ``clients[name]`` or ``clients.name``. Its
246
+ ``tools.<name>(**arguments)`` methods return structured output
247
+ and raise :class:`ToolCallError` for tool errors or additional input requests.
248
+ Use its ``call_tool`` method for the full result envelope.
249
+ """
190
250
 
191
- class Apps:
192
251
  def __init__(
193
252
  self,
194
253
  identity: dict[str, Any],
@@ -196,19 +255,14 @@ class Apps:
196
255
  bindings: Mapping[str, Binding] | None = None,
197
256
  ) -> None:
198
257
  # 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 ():
258
+ available = dict.fromkeys(identity["requires"]) | contracts
259
+ self._apps = {}
260
+ for name in available if bindings is None else bindings:
205
261
  if name not in available:
206
262
  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
- }
263
+ self._apps[name] = _App(
264
+ name, identity, available[name], bindings[name] if bindings else None
265
+ )
212
266
 
213
267
  def __getitem__(self, name: str) -> _App:
214
268
  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.dev3"
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 -s ../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
+ )