scxp 0.1.0__py3-none-any.whl
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/__init__.py +92 -0
- scxp/channel.py +41 -0
- scxp/codec.py +54 -0
- scxp/connection.py +176 -0
- scxp/consumer.py +138 -0
- scxp/contract_builder.py +82 -0
- scxp/errors.py +105 -0
- scxp/handshake.py +208 -0
- scxp/protocol_version.py +19 -0
- scxp/provider.py +74 -0
- scxp/provider_catalog.py +156 -0
- scxp/tool.py +74 -0
- scxp/tool_invoker.py +167 -0
- scxp/tool_schema.py +154 -0
- scxp/wire/__init__.py +67 -0
- scxp/wire/call_tool.py +84 -0
- scxp/wire/envelope.py +71 -0
- scxp/wire/initialize.py +73 -0
- scxp/wire/manifest.py +65 -0
- scxp-0.1.0.dist-info/METADATA +91 -0
- scxp-0.1.0.dist-info/RECORD +22 -0
- scxp-0.1.0.dist-info/WHEEL +4 -0
scxp/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""SCXP SDK for Python — Symmetric Context Exchange Protocol.
|
|
2
|
+
|
|
3
|
+
Public surface for both sides of the protocol:
|
|
4
|
+
|
|
5
|
+
from scxp import Consumer, ConsumerConfig, Provider, ProviderCatalog, tool, Info
|
|
6
|
+
|
|
7
|
+
The transport is yours: implement ``Channel`` (WebSocket, TCP, stdio, ...) and
|
|
8
|
+
pass it to ``Consumer.create`` / ``Provider.create``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
from .channel import Channel
|
|
16
|
+
from .connection import Connection
|
|
17
|
+
from .consumer import Consumer, ConsumerConfig
|
|
18
|
+
from .contract_builder import build_tool_contract
|
|
19
|
+
from .errors import (
|
|
20
|
+
ConnectionClosedError,
|
|
21
|
+
HandshakeError,
|
|
22
|
+
ProtocolErrors,
|
|
23
|
+
ScxpError,
|
|
24
|
+
ScxpProtocolError,
|
|
25
|
+
)
|
|
26
|
+
from .handshake import HandshakeMode, HandshakeResult
|
|
27
|
+
from .protocol_version import CURRENT, SUPPORTED
|
|
28
|
+
from .provider import Provider
|
|
29
|
+
from .provider_catalog import ProviderCatalog
|
|
30
|
+
from .tool import ToolMetadata, tool
|
|
31
|
+
from .tool_schema import ToolSchemaGenerationError, generate_input_schema
|
|
32
|
+
from .wire import (
|
|
33
|
+
Call,
|
|
34
|
+
CallToolParams,
|
|
35
|
+
CallToolResult,
|
|
36
|
+
Capabilities,
|
|
37
|
+
ContentBlock,
|
|
38
|
+
Domain,
|
|
39
|
+
Execution,
|
|
40
|
+
Info,
|
|
41
|
+
ListToolsResult,
|
|
42
|
+
ResourceLinkContent,
|
|
43
|
+
Role,
|
|
44
|
+
TextContent,
|
|
45
|
+
Tool,
|
|
46
|
+
ToolResult,
|
|
47
|
+
ToolsCapability,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"__version__",
|
|
52
|
+
# surface
|
|
53
|
+
"Consumer",
|
|
54
|
+
"ConsumerConfig",
|
|
55
|
+
"Provider",
|
|
56
|
+
"ProviderCatalog",
|
|
57
|
+
"Channel",
|
|
58
|
+
"Connection",
|
|
59
|
+
"HandshakeMode",
|
|
60
|
+
"HandshakeResult",
|
|
61
|
+
# tools
|
|
62
|
+
"tool",
|
|
63
|
+
"ToolMetadata",
|
|
64
|
+
"build_tool_contract",
|
|
65
|
+
"generate_input_schema",
|
|
66
|
+
"ToolSchemaGenerationError",
|
|
67
|
+
# errors
|
|
68
|
+
"ScxpError",
|
|
69
|
+
"ScxpProtocolError",
|
|
70
|
+
"HandshakeError",
|
|
71
|
+
"ConnectionClosedError",
|
|
72
|
+
"ProtocolErrors",
|
|
73
|
+
# protocol version
|
|
74
|
+
"CURRENT",
|
|
75
|
+
"SUPPORTED",
|
|
76
|
+
# wire types
|
|
77
|
+
"Info",
|
|
78
|
+
"Capabilities",
|
|
79
|
+
"ToolsCapability",
|
|
80
|
+
"Role",
|
|
81
|
+
"Call",
|
|
82
|
+
"CallToolParams",
|
|
83
|
+
"CallToolResult",
|
|
84
|
+
"ToolResult",
|
|
85
|
+
"ListToolsResult",
|
|
86
|
+
"Domain",
|
|
87
|
+
"Tool",
|
|
88
|
+
"Execution",
|
|
89
|
+
"TextContent",
|
|
90
|
+
"ResourceLinkContent",
|
|
91
|
+
"ContentBlock",
|
|
92
|
+
]
|
scxp/channel.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The transport seam between the user's transport and the protocol (doc 04 §2).
|
|
2
|
+
|
|
3
|
+
The SDK does NOT open connections: it receives an already-open ``Channel``. A
|
|
4
|
+
channel exposes exactly three capabilities: write bytes, subscribe to incoming
|
|
5
|
+
bytes, and learn when the channel dies.
|
|
6
|
+
|
|
7
|
+
The SDK depends on nothing but ``Channel``: it does not reference WebSocket, HTTP
|
|
8
|
+
or stdio (transport-agnostic). Wrapping a WebSocket, stdin/stdout, a raw TCP
|
|
9
|
+
socket, or a mock in an implementation of this Protocol runs the same SDK over
|
|
10
|
+
any transport.
|
|
11
|
+
|
|
12
|
+
Messages are the bytes of a serialized Envelope (JSON UTF-8). The SDK encodes and
|
|
13
|
+
decodes internally; the interface only carries opaque bytes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Awaitable, Callable, Optional, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Channel(Protocol):
|
|
23
|
+
"""Transport seam: send bytes, receive bytes, observe close."""
|
|
24
|
+
|
|
25
|
+
async def send(self, message: bytes) -> None:
|
|
26
|
+
"""Write one message (the bytes of an Envelope). Called by the SDK."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def on_message(self, handler: Callable[[bytes], Awaitable[None]]) -> None:
|
|
30
|
+
"""Register the handler the SDK uses to receive incoming messages.
|
|
31
|
+
|
|
32
|
+
Each complete message (one Envelope) invokes the handler once.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def on_close(self, handler: Callable[[Optional[Exception]], None]) -> None:
|
|
37
|
+
"""Register the handler the SDK uses to learn the channel closed.
|
|
38
|
+
|
|
39
|
+
The argument is the cause (or None if the close was clean).
|
|
40
|
+
"""
|
|
41
|
+
...
|
scxp/codec.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Envelope (de)serialization — the single gate between bytes and typed models.
|
|
2
|
+
|
|
3
|
+
The Connection sends/receives opaque bytes (one Envelope each); this module
|
|
4
|
+
encodes a typed Envelope to JSON UTF-8 bytes and decodes incoming bytes back to
|
|
5
|
+
the correct typed shape (discriminated by ``kind``).
|
|
6
|
+
|
|
7
|
+
None policy on encode:
|
|
8
|
+
- Request / Notification: ``params`` is omitted when None (optional).
|
|
9
|
+
- Error: ``data`` is omitted when None (optional).
|
|
10
|
+
- Response: ``result`` is a required key that MAY be null, so it is ALWAYS
|
|
11
|
+
emitted (never dropped).
|
|
12
|
+
|
|
13
|
+
Malformed input (bad JSON, unknown/missing ``kind``, schema-invalid) raises
|
|
14
|
+
ValueError / json.JSONDecodeError / pydantic.ValidationError; the protocol layer
|
|
15
|
+
maps those to INVALID_PARAMS.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Type
|
|
22
|
+
|
|
23
|
+
from .wire import Envelope, Error, Notification, Request, Response
|
|
24
|
+
|
|
25
|
+
_DECODERS: dict[str, Type] = {
|
|
26
|
+
"request": Request,
|
|
27
|
+
"response": Response,
|
|
28
|
+
"error": Error,
|
|
29
|
+
"notification": Notification,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode(envelope: Envelope) -> bytes:
|
|
34
|
+
"""Serialize a typed Envelope to JSON UTF-8 bytes."""
|
|
35
|
+
if isinstance(envelope, Response):
|
|
36
|
+
# ``result`` is required and may be null → never drop it.
|
|
37
|
+
data = envelope.model_dump(mode="json", by_alias=True)
|
|
38
|
+
else:
|
|
39
|
+
data = envelope.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
40
|
+
return json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode(data: bytes) -> Envelope:
|
|
44
|
+
"""Deserialize JSON UTF-8 bytes to the correct Envelope shape (by ``kind``)."""
|
|
45
|
+
obj = json.loads(data)
|
|
46
|
+
if not isinstance(obj, dict):
|
|
47
|
+
raise ValueError("Envelope must be a JSON object.")
|
|
48
|
+
|
|
49
|
+
kind = obj.get("kind")
|
|
50
|
+
model = _DECODERS.get(kind)
|
|
51
|
+
if model is None:
|
|
52
|
+
raise ValueError(f"Unknown or missing envelope kind: {kind!r}")
|
|
53
|
+
|
|
54
|
+
return model.model_validate(obj)
|
scxp/connection.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Connection — request/response correlation, dispatch, robustness (doc 04 §3).
|
|
2
|
+
|
|
3
|
+
Sits on top of a Channel. Outbound: ``send_request`` correlates a Response/Error
|
|
4
|
+
to its Request by ``id`` (via a Future), with optional timeout and clean pending
|
|
5
|
+
removal on cancellation/timeout. ``send_notification`` is fire-and-forget.
|
|
6
|
+
|
|
7
|
+
Inbound robustness:
|
|
8
|
+
- a handler raising ScxpProtocolError → an Error with that error,
|
|
9
|
+
- a handler raising anything else → INTERNAL_ERROR (-32603); the loop survives,
|
|
10
|
+
- a notification handler raising → swallowed,
|
|
11
|
+
- a malformed inbound message (can't correlate an id) → dropped,
|
|
12
|
+
- a Request with an id already in flight → DUPLICATE_ID (-32004).
|
|
13
|
+
|
|
14
|
+
Shutdown: ``aclose`` / ``async with`` faults pending requests (idempotent). The
|
|
15
|
+
channel itself is owned by the user and is not closed here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
23
|
+
|
|
24
|
+
from .channel import Channel
|
|
25
|
+
from .codec import decode, encode
|
|
26
|
+
from .errors import ConnectionClosedError, ProtocolErrors, ScxpProtocolError
|
|
27
|
+
from .wire import Error, Notification, Request, Response
|
|
28
|
+
|
|
29
|
+
RequestHandler = Callable[[str, Any], Awaitable[Any]]
|
|
30
|
+
NotificationHandler = Callable[[str, Any], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Connection:
|
|
34
|
+
"""Correlates requests to replies and dispatches inbound messages."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, channel: Channel) -> None:
|
|
37
|
+
self._channel = channel
|
|
38
|
+
self._pending: dict[str, asyncio.Future[Any]] = {}
|
|
39
|
+
self._in_flight: set[str] = set()
|
|
40
|
+
self._closed = False
|
|
41
|
+
self.request_handler: Optional[RequestHandler] = None
|
|
42
|
+
self.notification_handler: Optional[NotificationHandler] = None
|
|
43
|
+
|
|
44
|
+
channel.on_message(self._handle_incoming)
|
|
45
|
+
channel.on_close(self._handle_close)
|
|
46
|
+
|
|
47
|
+
# --- Outbound ------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def send_request(
|
|
50
|
+
self, method: str, params: Any = None, timeout: Optional[float] = None
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Send a request and await the correlated result (raises on Error).
|
|
53
|
+
|
|
54
|
+
If ``timeout`` elapses the wait is cancelled with ``asyncio.TimeoutError``;
|
|
55
|
+
cancellation or timeout removes the pending entry so it never leaks.
|
|
56
|
+
"""
|
|
57
|
+
if self._closed:
|
|
58
|
+
raise ConnectionClosedError("Connection is closed.")
|
|
59
|
+
|
|
60
|
+
request_id = str(uuid.uuid4())
|
|
61
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
62
|
+
self._pending[request_id] = future
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
await self._channel.send(encode(Request(id=request_id, method=method, params=params)))
|
|
66
|
+
except BaseException:
|
|
67
|
+
self._pending.pop(request_id, None)
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if timeout is not None:
|
|
72
|
+
return await asyncio.wait_for(future, timeout)
|
|
73
|
+
return await future
|
|
74
|
+
except BaseException:
|
|
75
|
+
# Cancellation / timeout / any error: don't leak the pending entry.
|
|
76
|
+
self._pending.pop(request_id, None)
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
async def send_notification(self, method: str, params: Any = None) -> None:
|
|
80
|
+
"""Send a one-way notification (no reply)."""
|
|
81
|
+
if self._closed:
|
|
82
|
+
raise ConnectionClosedError("Connection is closed.")
|
|
83
|
+
await self._channel.send(encode(Notification(method=method, params=params)))
|
|
84
|
+
|
|
85
|
+
# --- Inbound -------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
async def _handle_incoming(self, data: bytes) -> None:
|
|
88
|
+
try:
|
|
89
|
+
envelope = decode(data)
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
# Malformed inbound message: can't correlate an id → drop (keep the loop alive).
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if isinstance(envelope, Response):
|
|
95
|
+
future = self._pending.pop(envelope.id, None)
|
|
96
|
+
if future is not None and not future.done():
|
|
97
|
+
future.set_result(envelope.result)
|
|
98
|
+
|
|
99
|
+
elif isinstance(envelope, Error):
|
|
100
|
+
future = self._pending.pop(envelope.id, None)
|
|
101
|
+
if future is not None and not future.done():
|
|
102
|
+
future.set_exception(ScxpProtocolError(envelope.error))
|
|
103
|
+
|
|
104
|
+
elif isinstance(envelope, Request):
|
|
105
|
+
await self._dispatch_request(envelope)
|
|
106
|
+
|
|
107
|
+
elif isinstance(envelope, Notification):
|
|
108
|
+
handler = self.notification_handler
|
|
109
|
+
if handler is not None:
|
|
110
|
+
try:
|
|
111
|
+
await handler(envelope.method, envelope.params)
|
|
112
|
+
except Exception: # noqa: BLE001
|
|
113
|
+
# A notification handler failure must not break the receive loop.
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
async def _dispatch_request(self, request: Request) -> None:
|
|
117
|
+
# Inbound DUPLICATE_ID (-32004): a request whose id is already being processed.
|
|
118
|
+
if request.id in self._in_flight:
|
|
119
|
+
await self._send(Error(id=request.id, error=ProtocolErrors.duplicate_id()))
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._in_flight.add(request.id)
|
|
123
|
+
try:
|
|
124
|
+
handler = self.request_handler
|
|
125
|
+
if handler is None:
|
|
126
|
+
await self._send(Error(id=request.id, error=ProtocolErrors.unknown_method()))
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
result = await handler(request.method, request.params)
|
|
131
|
+
except ScxpProtocolError as exc:
|
|
132
|
+
await self._send(Error(id=request.id, error=exc.error))
|
|
133
|
+
return
|
|
134
|
+
except Exception as exc: # noqa: BLE001
|
|
135
|
+
# The handler threw an unexpected error → INTERNAL_ERROR; the loop survives.
|
|
136
|
+
await self._send(Error(id=request.id, error=ProtocolErrors.internal_error(str(exc))))
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
await self._send(Response(id=request.id, result=result))
|
|
140
|
+
finally:
|
|
141
|
+
self._in_flight.discard(request.id)
|
|
142
|
+
|
|
143
|
+
# --- Close / shutdown ----------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _handle_close(self, cause: Optional[Exception]) -> None:
|
|
146
|
+
self._closed = True
|
|
147
|
+
pending = list(self._pending.values())
|
|
148
|
+
self._pending.clear()
|
|
149
|
+
|
|
150
|
+
closed = ConnectionClosedError("The channel was closed.")
|
|
151
|
+
if cause is not None:
|
|
152
|
+
closed.__cause__ = cause
|
|
153
|
+
|
|
154
|
+
for future in pending:
|
|
155
|
+
if not future.done():
|
|
156
|
+
future.set_exception(closed)
|
|
157
|
+
|
|
158
|
+
async def aclose(self) -> None:
|
|
159
|
+
"""Shut down: mark closed and fault pending requests (idempotent).
|
|
160
|
+
|
|
161
|
+
Does not close the channel — the transport is owned by the user.
|
|
162
|
+
"""
|
|
163
|
+
if self._closed:
|
|
164
|
+
return
|
|
165
|
+
self._handle_close(None)
|
|
166
|
+
|
|
167
|
+
async def __aenter__(self) -> "Connection":
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
171
|
+
await self.aclose()
|
|
172
|
+
|
|
173
|
+
# --- Helpers -------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def _send(self, envelope: Any) -> None:
|
|
176
|
+
await self._channel.send(encode(envelope))
|
scxp/consumer.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Consumer surface — ``create_consumer`` (doc 04 §3/§4).
|
|
2
|
+
|
|
3
|
+
The user doesn't build envelopes or run ``initialize`` by hand: ``Consumer.create``
|
|
4
|
+
runs the handshake and, once the session is negotiated, exposes the two consumer
|
|
5
|
+
operations — ``list_tools`` (tools/list) and ``call_tools`` (tools/call).
|
|
6
|
+
|
|
7
|
+
Role is implied by the method (creating a consumer ⇒ Role.consumer). The user
|
|
8
|
+
never writes ``role`` or ``protocolVersions`` — the SDK injects them. By default
|
|
9
|
+
the consumer INITIATEs the handshake, but it can AWAIT (a provider opened the
|
|
10
|
+
channel and greets — the multi-pod case).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Callable, Optional, Type, TypeVar
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ValidationError
|
|
20
|
+
|
|
21
|
+
from .channel import Channel
|
|
22
|
+
from .connection import Connection
|
|
23
|
+
from .errors import ProtocolErrors, ScxpProtocolError
|
|
24
|
+
from .handshake import HandshakeMode, HandshakeResponder, HandshakeResult, initiate
|
|
25
|
+
from .wire import (
|
|
26
|
+
Call,
|
|
27
|
+
CallToolParams,
|
|
28
|
+
CallToolResult,
|
|
29
|
+
Capabilities,
|
|
30
|
+
Info,
|
|
31
|
+
ListToolsResult,
|
|
32
|
+
Role,
|
|
33
|
+
ToolResult,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
TOOLS_LIST_METHOD = "tools/list"
|
|
37
|
+
TOOLS_CALL_METHOD = "tools/call"
|
|
38
|
+
TOOLS_LIST_CHANGED_METHOD = "notifications/tools/list_changed"
|
|
39
|
+
|
|
40
|
+
_T = TypeVar("_T", bound=BaseModel)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ConsumerConfig:
|
|
45
|
+
"""Minimal consumer config (doc 04 §3.1): only genuine user decisions.
|
|
46
|
+
|
|
47
|
+
``role`` / ``protocolVersions`` are not here on purpose — they are SDK facts,
|
|
48
|
+
injected, so they cannot be written wrong (input defense by construction).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
info: Info
|
|
52
|
+
capabilities: Optional[Capabilities] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Consumer:
|
|
56
|
+
"""The consumer side: discovers and invokes tools after the handshake."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, connection: Connection, session: HandshakeResult) -> None:
|
|
59
|
+
self._connection = connection
|
|
60
|
+
self.session = session
|
|
61
|
+
# No events in Python: a settable callable, invoked on tools/list_changed.
|
|
62
|
+
self.on_tools_list_changed: Optional[Callable[[], None]] = None
|
|
63
|
+
self._connection.notification_handler = self._handle_notification
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
async def create(
|
|
67
|
+
cls,
|
|
68
|
+
channel: Channel,
|
|
69
|
+
config: ConsumerConfig,
|
|
70
|
+
mode: HandshakeMode = HandshakeMode.INITIATE,
|
|
71
|
+
) -> "Consumer":
|
|
72
|
+
"""Create a consumer over an already-open channel and run the handshake."""
|
|
73
|
+
connection = Connection(channel)
|
|
74
|
+
caps = config.capabilities or Capabilities()
|
|
75
|
+
|
|
76
|
+
if mode is HandshakeMode.INITIATE:
|
|
77
|
+
session = await initiate(connection, Role.consumer, caps, config.info)
|
|
78
|
+
else:
|
|
79
|
+
responder = HandshakeResponder(Role.consumer, caps, config.info)
|
|
80
|
+
connection.request_handler = responder.handle_request
|
|
81
|
+
session = await responder.completed
|
|
82
|
+
|
|
83
|
+
return cls(connection, session)
|
|
84
|
+
|
|
85
|
+
async def list_tools(self) -> ListToolsResult:
|
|
86
|
+
"""tools/list — fetch the provider's manifest, typed."""
|
|
87
|
+
raw = await self._connection.send_request(TOOLS_LIST_METHOD)
|
|
88
|
+
return self._parse(ListToolsResult, raw, TOOLS_LIST_METHOD)
|
|
89
|
+
|
|
90
|
+
async def call_tools(self, calls: list[Call]) -> CallToolResult:
|
|
91
|
+
"""tools/call — invoke a batch of tools; sub-results correlate by tool_call_id.
|
|
92
|
+
|
|
93
|
+
A ScxpProtocolError here is a layer-1 failure (the whole batch). A single
|
|
94
|
+
tool failing does NOT raise: it travels in its ToolResult with isError.
|
|
95
|
+
"""
|
|
96
|
+
params = CallToolParams(calls=calls).model_dump(
|
|
97
|
+
mode="json", by_alias=True, exclude_none=True
|
|
98
|
+
)
|
|
99
|
+
raw = await self._connection.send_request(TOOLS_CALL_METHOD, params)
|
|
100
|
+
return self._parse(CallToolResult, raw, TOOLS_CALL_METHOD)
|
|
101
|
+
|
|
102
|
+
async def call_tool(
|
|
103
|
+
self,
|
|
104
|
+
name: str,
|
|
105
|
+
arguments: dict[str, Any],
|
|
106
|
+
tool_call_id: Optional[str] = None,
|
|
107
|
+
context: Optional[dict[str, Any]] = None,
|
|
108
|
+
) -> ToolResult:
|
|
109
|
+
"""Convenience: invoke a single tool (a batch of one, doc 05 §8.1)."""
|
|
110
|
+
call = Call(
|
|
111
|
+
tool_call_id=tool_call_id or uuid.uuid4().hex,
|
|
112
|
+
name=name,
|
|
113
|
+
arguments=arguments,
|
|
114
|
+
context=context,
|
|
115
|
+
)
|
|
116
|
+
result = await self.call_tools([call])
|
|
117
|
+
if not result.results:
|
|
118
|
+
raise ScxpProtocolError(
|
|
119
|
+
ProtocolErrors.internal_error(
|
|
120
|
+
"The provider returned no sub-result for the sent call (empty result)."
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return result.results[0]
|
|
124
|
+
|
|
125
|
+
async def _handle_notification(self, method: str, params: Any) -> None:
|
|
126
|
+
if method == TOOLS_LIST_CHANGED_METHOD and self.on_tools_list_changed is not None:
|
|
127
|
+
self.on_tools_list_changed()
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _parse(model: Type[_T], raw: Any, method: str) -> _T:
|
|
131
|
+
try:
|
|
132
|
+
return model.model_validate(raw)
|
|
133
|
+
except ValidationError as exc:
|
|
134
|
+
raise ScxpProtocolError(
|
|
135
|
+
ProtocolErrors.internal_error(
|
|
136
|
+
f"The result of '{method}' is not a valid {model.__name__}."
|
|
137
|
+
)
|
|
138
|
+
) from exc
|
scxp/contract_builder.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""ContractBuilder — assembles the full Tool Contract from a decorated tool (doc 02).
|
|
2
|
+
|
|
3
|
+
Routes each declared param to its wire channel:
|
|
4
|
+
- LLM channel: description (or docstring fallback), when_to_use, when_not_to_use.
|
|
5
|
+
- Schema: input_schema (Gen A).
|
|
6
|
+
- Orchestrator channel: execution (only the flags that were set).
|
|
7
|
+
- Domain metadata: _meta (validated key by key).
|
|
8
|
+
|
|
9
|
+
``domain`` is NOT part of the Contract (it is structural — the catalog uses it).
|
|
10
|
+
Invalid _meta keys raise ``ToolSchemaGenerationError`` (same error as Gen A).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from .tool import ToolMetadata, get_tool_metadata
|
|
19
|
+
from .tool_schema import ToolSchemaGenerationError, generate_input_schema
|
|
20
|
+
|
|
21
|
+
# _meta key: DNS-reverse prefix ending in '/', then a non-space/quote/backslash tail.
|
|
22
|
+
_META_KEY = re.compile("^([a-z0-9-]+\\.)+[a-z0-9-]+/[^\\s\"'\\\\]+$")
|
|
23
|
+
|
|
24
|
+
# Second labels reserved for the protocol (cases.json reserved_meta_second_labels).
|
|
25
|
+
_RESERVED_SECOND_LABELS = {"mcp", "modelcontextprotocol"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_meta_key(key: str) -> None:
|
|
29
|
+
if not _META_KEY.match(key):
|
|
30
|
+
raise ToolSchemaGenerationError(
|
|
31
|
+
f"Invalid _meta key {key!r}: must be a DNS-reverse prefix ending in '/…'."
|
|
32
|
+
)
|
|
33
|
+
prefix = key.split("/", 1)[0]
|
|
34
|
+
labels = prefix.split(".")
|
|
35
|
+
if len(labels) >= 2 and labels[1] in _RESERVED_SECOND_LABELS:
|
|
36
|
+
raise ToolSchemaGenerationError(
|
|
37
|
+
f"_meta key {key!r} uses a reserved second label ({labels[1]!r})."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _execution(metadata: ToolMetadata) -> dict[str, Any]:
|
|
42
|
+
execution: dict[str, Any] = {}
|
|
43
|
+
if metadata.requires_approval is not None:
|
|
44
|
+
execution["requires_approval"] = metadata.requires_approval
|
|
45
|
+
if metadata.idempotent is not None:
|
|
46
|
+
execution["idempotent"] = metadata.idempotent
|
|
47
|
+
if metadata.destructive is not None:
|
|
48
|
+
execution["destructive"] = metadata.destructive
|
|
49
|
+
if metadata.timeout_ms is not None:
|
|
50
|
+
execution["timeout_ms"] = metadata.timeout_ms
|
|
51
|
+
return execution
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_tool_contract(func: Callable[..., Any]) -> dict[str, Any]:
|
|
55
|
+
"""Build the full Tool Contract dict for a decorated tool."""
|
|
56
|
+
metadata = get_tool_metadata(func) or ToolMetadata()
|
|
57
|
+
|
|
58
|
+
name = metadata.name or func.__name__
|
|
59
|
+
description = metadata.description
|
|
60
|
+
if description is None and func.__doc__:
|
|
61
|
+
description = func.__doc__.strip()
|
|
62
|
+
|
|
63
|
+
contract: dict[str, Any] = {"name": name}
|
|
64
|
+
if description:
|
|
65
|
+
contract["description"] = description
|
|
66
|
+
if metadata.when_to_use:
|
|
67
|
+
contract["when_to_use"] = metadata.when_to_use
|
|
68
|
+
if metadata.when_not_to_use:
|
|
69
|
+
contract["when_not_to_use"] = metadata.when_not_to_use
|
|
70
|
+
|
|
71
|
+
contract["input_schema"] = generate_input_schema(func)
|
|
72
|
+
|
|
73
|
+
execution = _execution(metadata)
|
|
74
|
+
if execution:
|
|
75
|
+
contract["execution"] = execution
|
|
76
|
+
|
|
77
|
+
if metadata.meta:
|
|
78
|
+
for key in metadata.meta:
|
|
79
|
+
_validate_meta_key(key)
|
|
80
|
+
contract["_meta"] = dict(metadata.meta)
|
|
81
|
+
|
|
82
|
+
return contract
|
scxp/errors.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Protocol error codes, error factories, and SDK exceptions (doc 03 §6).
|
|
2
|
+
|
|
3
|
+
Codes mirror protocol.yaml. ``ProtocolErrors`` builds wire ``ProtocolError``
|
|
4
|
+
payloads; the exception types are what the SDK raises to the caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .wire import ProtocolError
|
|
12
|
+
|
|
13
|
+
# --- Error codes (doc 03 §6 / protocol.yaml) ---------------------------------
|
|
14
|
+
|
|
15
|
+
VERSION_MISMATCH = -32001
|
|
16
|
+
ROLE_CONFLICT = -32002
|
|
17
|
+
PREMATURE_METHOD = -32003
|
|
18
|
+
DUPLICATE_ID = -32004
|
|
19
|
+
UNKNOWN_METHOD = -32601
|
|
20
|
+
INVALID_PARAMS = -32602
|
|
21
|
+
INTERNAL_ERROR = -32603
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- Exceptions --------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
class ScxpError(Exception):
|
|
27
|
+
"""Base class for every error raised by the SDK."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ScxpProtocolError(ScxpError):
|
|
31
|
+
"""Carries a wire ``ProtocolError`` (an incoming Error envelope or a raised one)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, error: ProtocolError) -> None:
|
|
34
|
+
self.error = error
|
|
35
|
+
super().__init__(f"[{error.code}] {error.message}")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def code(self) -> int:
|
|
39
|
+
return self.error.code
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def message(self) -> str:
|
|
43
|
+
return self.error.message
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def data(self) -> Optional[Any]:
|
|
47
|
+
return self.error.data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HandshakeError(ScxpProtocolError):
|
|
51
|
+
"""Raised when the initialize handshake fails (version/role/layer-1 error)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConnectionClosedError(ScxpError):
|
|
55
|
+
"""Raised on in-flight requests when the underlying channel closes."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Factories ---------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class ProtocolErrors:
|
|
61
|
+
"""Builders for the standard wire ``ProtocolError`` payloads."""
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def version_mismatch(
|
|
65
|
+
message: str = "No common protocol version.", data: Optional[Any] = None
|
|
66
|
+
) -> ProtocolError:
|
|
67
|
+
return ProtocolError(code=VERSION_MISMATCH, message=message, data=data)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def role_conflict(
|
|
71
|
+
message: str = "Both sides declared the same role.", data: Optional[Any] = None
|
|
72
|
+
) -> ProtocolError:
|
|
73
|
+
return ProtocolError(code=ROLE_CONFLICT, message=message, data=data)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def premature_method(
|
|
77
|
+
message: str = "A method was sent before the handshake completed.",
|
|
78
|
+
data: Optional[Any] = None,
|
|
79
|
+
) -> ProtocolError:
|
|
80
|
+
return ProtocolError(code=PREMATURE_METHOD, message=message, data=data)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def duplicate_id(
|
|
84
|
+
message: str = "A request with this id is already in flight.",
|
|
85
|
+
data: Optional[Any] = None,
|
|
86
|
+
) -> ProtocolError:
|
|
87
|
+
return ProtocolError(code=DUPLICATE_ID, message=message, data=data)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def unknown_method(
|
|
91
|
+
message: str = "Unknown method.", data: Optional[Any] = None
|
|
92
|
+
) -> ProtocolError:
|
|
93
|
+
return ProtocolError(code=UNKNOWN_METHOD, message=message, data=data)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def invalid_params(
|
|
97
|
+
message: str = "Invalid params.", data: Optional[Any] = None
|
|
98
|
+
) -> ProtocolError:
|
|
99
|
+
return ProtocolError(code=INVALID_PARAMS, message=message, data=data)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def internal_error(
|
|
103
|
+
message: str = "Internal error.", data: Optional[Any] = None
|
|
104
|
+
) -> ProtocolError:
|
|
105
|
+
return ProtocolError(code=INTERNAL_ERROR, message=message, data=data)
|