scxp 0.1.0__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.
Files changed (44) hide show
  1. scxp-0.1.0/.gitignore +1 -0
  2. scxp-0.1.0/PKG-INFO +91 -0
  3. scxp-0.1.0/README.md +67 -0
  4. scxp-0.1.0/docs/consumer-guide.md +174 -0
  5. scxp-0.1.0/docs/provider-guide.md +224 -0
  6. scxp-0.1.0/examples/websocket_channel.py +52 -0
  7. scxp-0.1.0/examples/websocket_example.py +86 -0
  8. scxp-0.1.0/pyproject.toml +49 -0
  9. scxp-0.1.0/src/scxp/__init__.py +92 -0
  10. scxp-0.1.0/src/scxp/channel.py +41 -0
  11. scxp-0.1.0/src/scxp/codec.py +54 -0
  12. scxp-0.1.0/src/scxp/connection.py +176 -0
  13. scxp-0.1.0/src/scxp/consumer.py +138 -0
  14. scxp-0.1.0/src/scxp/contract_builder.py +82 -0
  15. scxp-0.1.0/src/scxp/errors.py +105 -0
  16. scxp-0.1.0/src/scxp/handshake.py +208 -0
  17. scxp-0.1.0/src/scxp/protocol_version.py +19 -0
  18. scxp-0.1.0/src/scxp/provider.py +74 -0
  19. scxp-0.1.0/src/scxp/provider_catalog.py +156 -0
  20. scxp-0.1.0/src/scxp/tool.py +74 -0
  21. scxp-0.1.0/src/scxp/tool_invoker.py +167 -0
  22. scxp-0.1.0/src/scxp/tool_schema.py +154 -0
  23. scxp-0.1.0/src/scxp/wire/__init__.py +67 -0
  24. scxp-0.1.0/src/scxp/wire/call_tool.py +84 -0
  25. scxp-0.1.0/src/scxp/wire/envelope.py +71 -0
  26. scxp-0.1.0/src/scxp/wire/initialize.py +73 -0
  27. scxp-0.1.0/src/scxp/wire/manifest.py +65 -0
  28. scxp-0.1.0/tests/__init__.py +0 -0
  29. scxp-0.1.0/tests/conformance_support.py +159 -0
  30. scxp-0.1.0/tests/conftest.py +1 -0
  31. scxp-0.1.0/tests/in_memory_channel.py +51 -0
  32. scxp-0.1.0/tests/test_codec.py +80 -0
  33. scxp-0.1.0/tests/test_conformance_contract.py +31 -0
  34. scxp-0.1.0/tests/test_conformance_schema.py +39 -0
  35. scxp-0.1.0/tests/test_connection.py +86 -0
  36. scxp-0.1.0/tests/test_connection_robustness.py +137 -0
  37. scxp-0.1.0/tests/test_handshake.py +142 -0
  38. scxp-0.1.0/tests/test_in_memory_channel.py +69 -0
  39. scxp-0.1.0/tests/test_protocol_version.py +42 -0
  40. scxp-0.1.0/tests/test_provider.py +83 -0
  41. scxp-0.1.0/tests/test_provider_catalog.py +130 -0
  42. scxp-0.1.0/tests/test_surface_e2e.py +88 -0
  43. scxp-0.1.0/tests/test_tool_invoker.py +151 -0
  44. scxp-0.1.0/tests/test_wire.py +138 -0
scxp-0.1.0/.gitignore ADDED
@@ -0,0 +1 @@
1
+ __pycache__/
scxp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: scxp
3
+ Version: 0.1.0
4
+ Summary: Symmetric Context Exchange Protocol (SCXP) SDK for Python: consumer + provider surface, handshake, id correlation/dispatch, and Gen A tool-schema generation. Transport-agnostic.
5
+ Author: SCXP contributors
6
+ License-Expression: MIT
7
+ Keywords: agents,handshake,llm,protocol,scxp,tools
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: pydantic>=2
19
+ Provides-Extra: dev
20
+ Requires-Dist: jsonschema>=4; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # scxp
26
+
27
+ Python SDK for the **Symmetric Context Exchange Protocol (SCXP)** — a protocol for
28
+ exchanging tool catalogs and tool calls between a *consumer* (which discovers and
29
+ invokes tools) and a *provider* (which registers and serves them).
30
+
31
+ The SDK is **transport-agnostic**: you bring the transport (WebSocket, TCP, stdio,
32
+ ...) by implementing a small `Channel`, and the SDK handles the protocol —
33
+ handshake, id correlation, batching, and tool-schema generation.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install scxp
39
+ ```
40
+
41
+ Requires Python 3.11+.
42
+
43
+ ## Provider — expose tools
44
+
45
+ ```python
46
+ from scxp import Provider, ProviderCatalog, tool, Info
47
+
48
+ class Tools:
49
+ @tool(name="add", domain="com.example.math", description="Add two integers.")
50
+ def add(self, a: int, b: int) -> int:
51
+ return a + b
52
+
53
+ catalog = ProviderCatalog.from_object(Tools(), Info(name="my-provider", version="1.0.0"))
54
+
55
+ # `channel` is your Channel implementation, already connected.
56
+ provider = await Provider.create(channel, catalog)
57
+ # The provider now serves tools/list and tools/call.
58
+ ```
59
+
60
+ ## Consumer — discover and invoke tools
61
+
62
+ ```python
63
+ from scxp import Consumer, ConsumerConfig, Info
64
+
65
+ # `channel` is your Channel implementation, already connected.
66
+ consumer = await Consumer.create(
67
+ channel, ConsumerConfig(info=Info(name="my-app", version="1.0.0"))
68
+ )
69
+
70
+ manifest = await consumer.list_tools() # discover
71
+ result = await consumer.call_tool("add", {"a": 2, "b": 3}) # invoke
72
+ print(result.content[0].text) # "5"
73
+ ```
74
+
75
+ ## Bring your own transport
76
+
77
+ Implement `Channel` (send bytes, receive bytes, observe close). A runnable
78
+ WebSocket example lives under `examples/`.
79
+
80
+ ```python
81
+ from scxp import Channel # a typing.Protocol with: send / on_message / on_close
82
+ ```
83
+
84
+ ## Docs
85
+
86
+ - Consumer and provider integration guides: `docs/`
87
+ - Protocol spec and JSON Schemas: the SCXP repository
88
+
89
+ ## License
90
+
91
+ MIT.
scxp-0.1.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # scxp
2
+
3
+ Python SDK for the **Symmetric Context Exchange Protocol (SCXP)** — a protocol for
4
+ exchanging tool catalogs and tool calls between a *consumer* (which discovers and
5
+ invokes tools) and a *provider* (which registers and serves them).
6
+
7
+ The SDK is **transport-agnostic**: you bring the transport (WebSocket, TCP, stdio,
8
+ ...) by implementing a small `Channel`, and the SDK handles the protocol —
9
+ handshake, id correlation, batching, and tool-schema generation.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install scxp
15
+ ```
16
+
17
+ Requires Python 3.11+.
18
+
19
+ ## Provider — expose tools
20
+
21
+ ```python
22
+ from scxp import Provider, ProviderCatalog, tool, Info
23
+
24
+ class Tools:
25
+ @tool(name="add", domain="com.example.math", description="Add two integers.")
26
+ def add(self, a: int, b: int) -> int:
27
+ return a + b
28
+
29
+ catalog = ProviderCatalog.from_object(Tools(), Info(name="my-provider", version="1.0.0"))
30
+
31
+ # `channel` is your Channel implementation, already connected.
32
+ provider = await Provider.create(channel, catalog)
33
+ # The provider now serves tools/list and tools/call.
34
+ ```
35
+
36
+ ## Consumer — discover and invoke tools
37
+
38
+ ```python
39
+ from scxp import Consumer, ConsumerConfig, Info
40
+
41
+ # `channel` is your Channel implementation, already connected.
42
+ consumer = await Consumer.create(
43
+ channel, ConsumerConfig(info=Info(name="my-app", version="1.0.0"))
44
+ )
45
+
46
+ manifest = await consumer.list_tools() # discover
47
+ result = await consumer.call_tool("add", {"a": 2, "b": 3}) # invoke
48
+ print(result.content[0].text) # "5"
49
+ ```
50
+
51
+ ## Bring your own transport
52
+
53
+ Implement `Channel` (send bytes, receive bytes, observe close). A runnable
54
+ WebSocket example lives under `examples/`.
55
+
56
+ ```python
57
+ from scxp import Channel # a typing.Protocol with: send / on_message / on_close
58
+ ```
59
+
60
+ ## Docs
61
+
62
+ - Consumer and provider integration guides: `docs/`
63
+ - Protocol spec and JSON Schemas: the SCXP repository
64
+
65
+ ## License
66
+
67
+ MIT.
@@ -0,0 +1,174 @@
1
+ # Consumer Guide (Python)
2
+
3
+ > Audience: a developer who wants to **consume tools** exposed by an SCXP provider.
4
+
5
+ ---
6
+
7
+ ## What is a Consumer?
8
+
9
+ A Consumer discovers and invokes tools. It connects to a Provider over any
10
+ transport wrapped in a `Channel`, runs the handshake automatically, and then
11
+ exposes two operations: `list_tools()` (`tools/list`) and `call_tools()` /
12
+ `call_tool()` (`tools/call`).
13
+
14
+ You don't build envelopes, correlate ids, or negotiate versions — the SDK does.
15
+
16
+ ---
17
+
18
+ ## Step 1: Implement a `Channel`
19
+
20
+ `Channel` is a `typing.Protocol` with three members (structural — no inheritance
21
+ required):
22
+
23
+ ```python
24
+ class Channel(Protocol):
25
+ async def send(self, message: bytes) -> None: ...
26
+ def on_message(self, handler: Callable[[bytes], Awaitable[None]]) -> None: ...
27
+ def on_close(self, handler: Callable[[Exception | None], None]) -> None: ...
28
+ ```
29
+
30
+ Each message is one complete Envelope (JSON bytes). If your transport is
31
+ message-framed (WebSocket), one frame = one Envelope. A ready-to-use WebSocket
32
+ implementation lives in `examples/websocket_channel.py`.
33
+
34
+ The SDK never opens connections — you bring the `Channel` already connected.
35
+
36
+ ---
37
+
38
+ ## Step 2: Create the Consumer
39
+
40
+ ```python
41
+ from scxp import Consumer, ConsumerConfig, Info
42
+
43
+ # `channel` is your Channel implementation, already connected.
44
+ consumer = await Consumer.create(
45
+ channel,
46
+ ConsumerConfig(info=Info(name="my-app", version="1.0.0")),
47
+ )
48
+
49
+ print(consumer.session.protocol_version) # agreed version
50
+ print(consumer.session.remote_info.name) # the provider's name
51
+ ```
52
+
53
+ ### `ConsumerConfig`
54
+
55
+ | Field | Required | Description |
56
+ |---|---|---|
57
+ | `info` | Yes | Your software identity (`name` must be non-empty). |
58
+ | `capabilities` | No | Capabilities you support. Defaults to `Capabilities()`. |
59
+
60
+ You never set `role` or `protocol_versions` — they are SDK facts, injected
61
+ automatically (`role` is implied by creating a *consumer*).
62
+
63
+ ---
64
+
65
+ ## Step 3: Discover tools
66
+
67
+ ```python
68
+ manifest = await consumer.list_tools() # ListToolsResult
69
+
70
+ for domain in manifest.domains:
71
+ for tool in domain.tools:
72
+ print(domain.role, tool.name, tool.description)
73
+ # tool.input_schema is the JSON Schema (a dict) of the arguments.
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Step 4: Invoke tools
79
+
80
+ ### Single call
81
+
82
+ ```python
83
+ result = await consumer.call_tool("search_items", {"query": "example", "limit": 5})
84
+
85
+ if result.is_error:
86
+ print("tool failed:", result.content[0].text)
87
+ else:
88
+ print(result.content[0].text) # inline text
89
+ if result.structured_content is not None:
90
+ print(result.structured_content) # typed data, when present
91
+ ```
92
+
93
+ ### Batch (multiple calls)
94
+
95
+ ```python
96
+ from scxp import Call
97
+
98
+ calls = [
99
+ Call(tool_call_id="c1", name="get_item", arguments={"id": "42"}),
100
+ Call(tool_call_id="c2", name="get_item", arguments={"id": "99"}),
101
+ ]
102
+ batch = await consumer.call_tools(calls)
103
+
104
+ for r in batch.results:
105
+ print(r.tool_call_id, r.is_error, r.content[0].text)
106
+ ```
107
+
108
+ Key rules:
109
+ - `tool_call_id` correlates each result to its call (order is NOT guaranteed).
110
+ - `tool_call_id` must be unique within the batch (your responsibility).
111
+ - One tool failing (`is_error = True`) does NOT break the others.
112
+
113
+ ---
114
+
115
+ ## Step 5: React to hot-swap
116
+
117
+ The provider can change its catalog at runtime. Subscribe with a callable:
118
+
119
+ ```python
120
+ def refresh():
121
+ print("catalog changed; re-fetching")
122
+ # schedule a task to call consumer.list_tools() again
123
+
124
+ consumer.on_tools_list_changed = refresh
125
+ ```
126
+
127
+ This fires only if the provider declared the `tools.listChanged` capability
128
+ (check `consumer.session.remote_capabilities.tools.list_changed`).
129
+
130
+ ---
131
+
132
+ ## Errors
133
+
134
+ | Exception | When | Notes |
135
+ |---|---|---|
136
+ | `HandshakeError` | During `create` | Peer rejected the handshake. `.code` ∈ {-32001 version, -32002 role}. |
137
+ | `ScxpProtocolError` | During `list_tools` / `call_tools` | Layer-1 failure (whole request). `.code` ∈ {-32601 unknown method, -32602 invalid params, -32603 internal}. |
138
+ | `ConnectionClosedError` | Channel closed with requests in flight | In-flight requests fault. |
139
+ | `asyncio.TimeoutError` | `send_request(..., timeout=...)` elapsed | Only when you pass a timeout (Connection level). |
140
+
141
+ Tool-level failures are NOT exceptions: they arrive as `is_error=True` in the
142
+ individual `ToolResult`, and the rest of the batch is fine.
143
+
144
+ ---
145
+
146
+ ## HandshakeMode: who greets?
147
+
148
+ By default the consumer greets (`HandshakeMode.INITIATE`). If the provider opened
149
+ the channel and greets first (multi-pod case), the consumer waits:
150
+
151
+ ```python
152
+ from scxp import HandshakeMode
153
+
154
+ consumer = await Consumer.create(channel, config, mode=HandshakeMode.AWAIT)
155
+ ```
156
+
157
+ Both modes produce the same Consumer with the same API.
158
+
159
+ ---
160
+
161
+ ## End-to-end minimal example
162
+
163
+ ```python
164
+ from scxp import Consumer, ConsumerConfig, Info
165
+
166
+ consumer = await Consumer.create(
167
+ channel, ConsumerConfig(info=Info(name="my-app", version="1.0.0"))
168
+ )
169
+ await consumer.list_tools()
170
+ result = await consumer.call_tool("add", {"a": 2, "b": 3})
171
+ print(result.content[0].text) # "5"
172
+ ```
173
+
174
+ For a runnable example with real WebSocket transport, see `examples/`.
@@ -0,0 +1,224 @@
1
+ # Provider Guide (Python)
2
+
3
+ > Audience: a developer who wants to **expose tools** to an SCXP consumer.
4
+
5
+ ---
6
+
7
+ ## What is a Provider?
8
+
9
+ A Provider registers and serves tools. It connects to a Consumer over a `Channel`,
10
+ runs the handshake automatically, and then answers `tools/list` (catalog) and
11
+ `tools/call` (invocation) from the registered catalog.
12
+
13
+ You define tools as decorated methods; the SDK generates the JSON Schema, batches,
14
+ isolates failures, and handles the protocol.
15
+
16
+ ---
17
+
18
+ ## Step 1: Define your tools
19
+
20
+ Decorate methods with `@tool`. Every tool belongs to a `domain`:
21
+
22
+ ```python
23
+ from scxp import tool
24
+
25
+ class InventoryTools:
26
+ @tool(
27
+ name="search_items",
28
+ domain="com.example.inventory",
29
+ description="Search items by name.",
30
+ when_to_use="When the caller needs to find items by keyword.",
31
+ )
32
+ def search_items(self, query: str, limit: int = 10) -> list[str]:
33
+ return ["item-1", "item-2"]
34
+
35
+ @tool(
36
+ name="create_order",
37
+ domain="com.example.orders",
38
+ description="Create an order.",
39
+ requires_approval=True,
40
+ destructive=True,
41
+ timeout_ms=10000,
42
+ )
43
+ def create_order(self, item_id: str, quantity: int) -> bool:
44
+ return True
45
+ ```
46
+
47
+ ### `@tool` parameters (closed list)
48
+
49
+ `name`, `domain`, `description`, `when_to_use`, `when_not_to_use`,
50
+ `requires_approval`, `idempotent`, `destructive`, `timeout_ms`, `meta`.
51
+
52
+ The decorator is keyword-only: an unknown keyword (e.g. a `requires_aproval`
53
+ typo) raises `TypeError` at decoration time.
54
+
55
+ ### Argument schema (Gen A)
56
+
57
+ The `input_schema` is generated from the method signature:
58
+
59
+ | Python annotation | JSON Schema |
60
+ |---|---|
61
+ | `str` / `int` / `float` / `bool` | scalar type |
62
+ | `list[str]` (leaf items) | `array` |
63
+ | `dict` | free `object` |
64
+ | `X | None` / `Optional[X]` | nullable scalar |
65
+ | `Literal["a", "b"]` | string `enum` |
66
+ | `Annotated[T, Field(description=...)]` | adds a per-parameter `description` |
67
+
68
+ Parameters with a default are optional; the rest are required. Nested objects,
69
+ arrays of objects/arrays, and non-string enums raise `ToolSchemaGenerationError`.
70
+
71
+ ### Return values
72
+
73
+ | Return | Consumer sees |
74
+ |---|---|
75
+ | `str` | `content: [text]` |
76
+ | `dict` or a pydantic model | `structured_content` + `content: [text]` |
77
+ | scalar / list | `content: [text]` |
78
+ | a `ToolResult` | passthrough (full control; no size guardrail) |
79
+ | `None` | `content: [text ""]` |
80
+ | a coroutine (`async def`) | awaited, then mapped as above |
81
+
82
+ ---
83
+
84
+ ## Step 2: Register the catalog
85
+
86
+ ```python
87
+ from scxp import ProviderCatalog, Info
88
+
89
+ catalog = ProviderCatalog.from_object(
90
+ InventoryTools(), Info(name="my-provider", version="1.0.0")
91
+ )
92
+ ```
93
+
94
+ Validations run at REGISTRATION (they raise, not at runtime):
95
+
96
+ | Rule | Raises |
97
+ |---|---|
98
+ | Two tools with the same name in the same domain | `ValueError` |
99
+ | A tool without a domain | `ValueError` |
100
+ | `_meta` key without a DNS-reverse prefix / reserved second label | `ToolSchemaGenerationError` |
101
+
102
+ `info.name` non-empty is enforced by the `Info` model itself.
103
+
104
+ Use `from_type(cls, info)` if you only need the manifest (static tools);
105
+ `from_object(instance, info)` when tools must be invoked.
106
+
107
+ ---
108
+
109
+ ## Step 3: Create the Provider
110
+
111
+ ```python
112
+ from scxp import Provider, Capabilities, ToolsCapability
113
+
114
+ capabilities = Capabilities(tools=ToolsCapability(list_changed=True))
115
+
116
+ # `channel` is your Channel implementation, already connected.
117
+ provider = await Provider.create(channel, catalog, capabilities=capabilities)
118
+ ```
119
+
120
+ `info` flows from `catalog.info` — you don't pass it again. After `create`
121
+ resolves, the provider is serving `tools/list` and `tools/call`.
122
+
123
+ ---
124
+
125
+ ## Step 4: Hot-swap (optional)
126
+
127
+ ```python
128
+ # Requires the tools.listChanged capability declared above.
129
+ await provider.notify_tools_list_changed()
130
+ ```
131
+
132
+ If you did NOT declare the capability, this raises `RuntimeError`.
133
+
134
+ ---
135
+
136
+ ## Large payloads: the inline-size policy
137
+
138
+ The SDK checks an auto-serialized result's size in three tiers (doc 05 §3):
139
+
140
+ - **≤ 325 KB** — travels inline, no flag.
141
+ - **325 KB – 1 MB** — travels inline, but the SDK emits a `warnings.warn` and attaches a
142
+ `_meta` advisory (`dev.scxp/inline-size-warning`) to the result.
143
+ - **> 1 MB** — the SDK returns an `is_error` result guiding you to use a `resource_link`.
144
+
145
+ For large data, return a `resource_link` instead of inline text:
146
+
147
+ ```python
148
+ from scxp import ToolResult, ResourceLinkContent, tool
149
+
150
+ class ReportTools:
151
+ @tool(name="export", domain="com.example.reports")
152
+ def export(self, report_id: str) -> ToolResult:
153
+ url = upload_to_storage(report_id) # your code
154
+ return ToolResult(
155
+ tool_call_id="", # forced by the SDK
156
+ content=[ResourceLinkContent(uri=url, mime="text/csv")],
157
+ )
158
+ ```
159
+
160
+ Returning a `ToolResult` manually skips the guardrail — you own the payload.
161
+
162
+ ---
163
+
164
+ ## `_meta`: tool metadata
165
+
166
+ ```python
167
+ @tool(
168
+ name="search_items",
169
+ domain="com.example.inventory",
170
+ meta={"com.example.analytics/category": "lookup"},
171
+ )
172
+ def search_items(self, query: str) -> list[str]: ...
173
+ ```
174
+
175
+ Keys must use a DNS-reverse prefix (e.g. `com.example.x/key`); a bare key or a
176
+ reserved second label (`mcp` / `modelcontextprotocol`) fails at registration.
177
+
178
+ ---
179
+
180
+ ## Errors the SDK handles for you
181
+
182
+ | Situation | Response to consumer | Your tools called? |
183
+ |---|---|---|
184
+ | Malformed `tools/call` | `INVALID_PARAMS` (-32602) | No |
185
+ | Unknown method | `UNKNOWN_METHOD` (-32601) | No |
186
+ | Duplicate in-flight request id | `DUPLICATE_ID` (-32004) | No |
187
+ | Handler raises an unexpected error | `INTERNAL_ERROR` (-32603) | — |
188
+ | Unknown / ambiguous tool name | `is_error` sub-result | Others: yes |
189
+ | Missing required argument | `is_error` sub-result | Others: yes |
190
+ | Tool raises | `is_error` sub-result | Others: yes |
191
+
192
+ One tool failing never breaks the batch (doc 05 §8.3).
193
+
194
+ ---
195
+
196
+ ## HandshakeMode: who greets?
197
+
198
+ By default the provider awaits (`HandshakeMode.AWAIT`). To open toward the
199
+ consumer and greet first:
200
+
201
+ ```python
202
+ from scxp import HandshakeMode
203
+
204
+ provider = await Provider.create(channel, catalog, mode=HandshakeMode.INITIATE)
205
+ ```
206
+
207
+ ---
208
+
209
+ ## End-to-end minimal example
210
+
211
+ ```python
212
+ from scxp import Provider, ProviderCatalog, tool, Info
213
+
214
+ class Tools:
215
+ @tool(name="add", domain="com.example.math", description="Add two integers.")
216
+ def add(self, a: int, b: int) -> int:
217
+ return a + b
218
+
219
+ catalog = ProviderCatalog.from_object(Tools(), Info(name="my-provider", version="1.0.0"))
220
+ provider = await Provider.create(channel, catalog)
221
+ # Serving tools/list and tools/call. Keep the connection alive.
222
+ ```
223
+
224
+ For a runnable example with real WebSocket transport, see `examples/`.
@@ -0,0 +1,52 @@
1
+ """A Channel implementation over a real WebSocket (the ``websockets`` library).
2
+
3
+ Shows the SDK is transport-agnostic (doc 04 §2, doc 06 §3): all the protocol
4
+ lives in the SDK; the transport only moves bytes. ``websockets`` is a dependency
5
+ of THIS example only — the SDK itself references nothing but ``Channel``.
6
+
7
+ Each WebSocket message = one Envelope (JSON bytes). Delivery is via a receive
8
+ loop (real async), unlike the reentrant in-memory channel used in tests.
9
+
10
+ Run the demo (needs ``pip install websockets``):
11
+
12
+ python examples/websocket_example.py
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Awaitable, Callable, Optional
18
+
19
+
20
+ class WebSocketChannel:
21
+ """Wraps a ``websockets`` connection (client or server side) as an SCXP Channel."""
22
+
23
+ def __init__(self, ws) -> None:
24
+ self._ws = ws
25
+ self._on_message: Optional[Callable[[bytes], Awaitable[None]]] = None
26
+ self._on_close: Optional[Callable[[Optional[Exception]], None]] = None
27
+
28
+ async def send(self, message: bytes) -> None:
29
+ await self._ws.send(message)
30
+
31
+ def on_message(self, handler: Callable[[bytes], Awaitable[None]]) -> None:
32
+ self._on_message = handler
33
+
34
+ def on_close(self, handler: Callable[[Optional[Exception]], None]) -> None:
35
+ self._on_close = handler
36
+
37
+ async def run_receive_loop(self) -> None:
38
+ """Pump each incoming WebSocket message (one Envelope) to the SDK handler.
39
+
40
+ Call once, concurrently, after registering handlers and before/while the
41
+ SDK uses the channel. Ends when the socket closes.
42
+ """
43
+ try:
44
+ async for message in self._ws:
45
+ data = message if isinstance(message, bytes) else message.encode("utf-8")
46
+ if self._on_message is not None:
47
+ await self._on_message(data)
48
+ if self._on_close is not None:
49
+ self._on_close(None)
50
+ except Exception as exc: # noqa: BLE001 — surface any transport error as a close cause.
51
+ if self._on_close is not None:
52
+ self._on_close(exc)
@@ -0,0 +1,86 @@
1
+ """End-to-end SCXP over a real WebSocket (the ``websockets`` library).
2
+
3
+ A single process: a server hosting a Provider and a client running a Consumer.
4
+ Exercises the SDK over a real async transport (not the reentrant in-memory
5
+ channel of the tests). Domain is 100% generic (com.example.*).
6
+
7
+ Run (needs ``pip install websockets`` and an editable install of scxp):
8
+
9
+ python examples/websocket_example.py
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+
16
+ import websockets
17
+ from websocket_channel import WebSocketChannel
18
+
19
+ from scxp import Consumer, ConsumerConfig, Info, Provider, ProviderCatalog, tool
20
+
21
+ HOST = "localhost"
22
+ PORT = 8765
23
+ URI = f"ws://{HOST}:{PORT}"
24
+
25
+
26
+ # --- Provider tools (agnostic) ----------------------------------------------
27
+
28
+ class Tools:
29
+ @tool(name="add", domain="com.example.math", description="Add two integers.")
30
+ def add(self, a: int, b: int) -> int:
31
+ return a + b
32
+
33
+ @tool(name="echo", domain="com.example.text", description="Return the message as-is.")
34
+ def echo(self, message: str) -> str:
35
+ return message
36
+
37
+
38
+ # --- Server side (Provider) --------------------------------------------------
39
+
40
+ async def serve_provider(ws) -> None:
41
+ channel = WebSocketChannel(ws)
42
+ receive = asyncio.create_task(channel.run_receive_loop())
43
+
44
+ catalog = ProviderCatalog.from_object(Tools(), Info(name="example-provider", version="1.0.0"))
45
+ provider = await Provider.create(channel, catalog)
46
+ print(f"[server] handshake ok, peer={provider.session.remote_info.name}")
47
+
48
+ await receive # serve until the socket closes
49
+
50
+
51
+ # --- Client side (Consumer) --------------------------------------------------
52
+
53
+ async def run_client() -> None:
54
+ async with websockets.connect(URI) as ws:
55
+ channel = WebSocketChannel(ws)
56
+ receive = asyncio.create_task(channel.run_receive_loop())
57
+
58
+ consumer = await Consumer.create(
59
+ channel, ConsumerConfig(info=Info(name="example-consumer", version="1.0.0"))
60
+ )
61
+ print(f"[client] handshake ok, peer={consumer.session.remote_info.name}")
62
+
63
+ manifest = await consumer.list_tools()
64
+ print(f"[client] tools/list -> {len(manifest.domains)} domain(s):")
65
+ for domain in manifest.domains:
66
+ for t in domain.tools:
67
+ print(f" - {domain.role}/{t.name}: {t.description}")
68
+
69
+ add = await consumer.call_tool("add", {"a": 3, "b": 5})
70
+ print(f"[client] add(3,5) -> {add.content[0].text}")
71
+
72
+ echo = await consumer.call_tool("echo", {"message": "hello SCXP"})
73
+ print(f"[client] echo -> {echo.content[0].text}")
74
+
75
+ receive.cancel()
76
+
77
+
78
+ async def main() -> None:
79
+ async with websockets.serve(serve_provider, HOST, PORT):
80
+ print(f"[server] listening on {URI}")
81
+ await run_client()
82
+ print("[done] example finished")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ asyncio.run(main())