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.
- scxp-0.1.0/.gitignore +1 -0
- scxp-0.1.0/PKG-INFO +91 -0
- scxp-0.1.0/README.md +67 -0
- scxp-0.1.0/docs/consumer-guide.md +174 -0
- scxp-0.1.0/docs/provider-guide.md +224 -0
- scxp-0.1.0/examples/websocket_channel.py +52 -0
- scxp-0.1.0/examples/websocket_example.py +86 -0
- scxp-0.1.0/pyproject.toml +49 -0
- scxp-0.1.0/src/scxp/__init__.py +92 -0
- scxp-0.1.0/src/scxp/channel.py +41 -0
- scxp-0.1.0/src/scxp/codec.py +54 -0
- scxp-0.1.0/src/scxp/connection.py +176 -0
- scxp-0.1.0/src/scxp/consumer.py +138 -0
- scxp-0.1.0/src/scxp/contract_builder.py +82 -0
- scxp-0.1.0/src/scxp/errors.py +105 -0
- scxp-0.1.0/src/scxp/handshake.py +208 -0
- scxp-0.1.0/src/scxp/protocol_version.py +19 -0
- scxp-0.1.0/src/scxp/provider.py +74 -0
- scxp-0.1.0/src/scxp/provider_catalog.py +156 -0
- scxp-0.1.0/src/scxp/tool.py +74 -0
- scxp-0.1.0/src/scxp/tool_invoker.py +167 -0
- scxp-0.1.0/src/scxp/tool_schema.py +154 -0
- scxp-0.1.0/src/scxp/wire/__init__.py +67 -0
- scxp-0.1.0/src/scxp/wire/call_tool.py +84 -0
- scxp-0.1.0/src/scxp/wire/envelope.py +71 -0
- scxp-0.1.0/src/scxp/wire/initialize.py +73 -0
- scxp-0.1.0/src/scxp/wire/manifest.py +65 -0
- scxp-0.1.0/tests/__init__.py +0 -0
- scxp-0.1.0/tests/conformance_support.py +159 -0
- scxp-0.1.0/tests/conftest.py +1 -0
- scxp-0.1.0/tests/in_memory_channel.py +51 -0
- scxp-0.1.0/tests/test_codec.py +80 -0
- scxp-0.1.0/tests/test_conformance_contract.py +31 -0
- scxp-0.1.0/tests/test_conformance_schema.py +39 -0
- scxp-0.1.0/tests/test_connection.py +86 -0
- scxp-0.1.0/tests/test_connection_robustness.py +137 -0
- scxp-0.1.0/tests/test_handshake.py +142 -0
- scxp-0.1.0/tests/test_in_memory_channel.py +69 -0
- scxp-0.1.0/tests/test_protocol_version.py +42 -0
- scxp-0.1.0/tests/test_provider.py +83 -0
- scxp-0.1.0/tests/test_provider_catalog.py +130 -0
- scxp-0.1.0/tests/test_surface_e2e.py +88 -0
- scxp-0.1.0/tests/test_tool_invoker.py +151 -0
- 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())
|