dataify-sdk 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.
@@ -0,0 +1,37 @@
1
+ """Dataify MCP SDK — Python client for the Dataify MCP API.
2
+
3
+ Provides access to web unlocker, search engines (Google, Bing, Yandex, DuckDuckGo),
4
+ and platform scrapers (Amazon, YouTube, TikTok, Facebook, Instagram, etc.) through
5
+ the Model Context Protocol.
6
+ """
7
+
8
+ from dataify_mcp._version import __version__
9
+ from dataify_mcp.client._base import DataifyClient
10
+ from dataify_mcp.client._http import HTTPTransport
11
+ from dataify_mcp.client._sse import SSETransport
12
+ from dataify_mcp.types._errors import (
13
+ AuthenticationError,
14
+ ConnectionError,
15
+ DataifyError,
16
+ ProtocolError,
17
+ ServerError,
18
+ TimeoutError,
19
+ ToolAccessError,
20
+ ToolError,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "DataifyClient",
26
+ "HTTPTransport",
27
+ "SSETransport",
28
+ # Errors
29
+ "DataifyError",
30
+ "AuthenticationError",
31
+ "ToolAccessError",
32
+ "ConnectionError",
33
+ "ProtocolError",
34
+ "ToolError",
35
+ "TimeoutError",
36
+ "ServerError",
37
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Client transports and the main DataifyClient."""
2
+
3
+ from dataify_mcp.client._base import DataifyClient
4
+ from dataify_mcp.client._http import HTTPTransport
5
+ from dataify_mcp.client._sse import SSETransport
6
+
7
+ __all__ = ["DataifyClient", "HTTPTransport", "SSETransport"]
@@ -0,0 +1,223 @@
1
+ """DataifyClient — the main entry point for the Dataify MCP SDK.
2
+
3
+ Usage::
4
+
5
+ import asyncio
6
+ from dataify_mcp import DataifyClient
7
+
8
+ async def main():
9
+ async with DataifyClient(
10
+ base_url="http://localhost:7780",
11
+ token="your-api-token",
12
+ ) as client:
13
+ tools = await client.list_tools()
14
+ print(f"Available tools: {len(tools)}")
15
+
16
+ result = await client.call_tool("google_search", {"q": "weather"})
17
+ print(result)
18
+
19
+ asyncio.run(main())
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from types import TracebackType
26
+ from typing import Any
27
+
28
+ from dataify_mcp._version import __version__
29
+ from dataify_mcp.client._http import HTTPTransport
30
+ from dataify_mcp.client._protocol import MCPProtocol
31
+ from dataify_mcp.client._sse import SSETransport
32
+ from dataify_mcp.types._errors import ConnectionError
33
+ from dataify_mcp.types._mcp import ServerInfo, ToolDefinition
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ CLIENT_NAME = "dataify-mcp-sdk"
38
+
39
+
40
+ class DataifyClient:
41
+ """Async client for the Dataify MCP API.
42
+
43
+ Wraps an MCP transport and protocol to provide a simple, Pythonic
44
+ interface for listing and calling MCP tools.
45
+
46
+ Parameters
47
+ ----------
48
+ base_url:
49
+ Base URL of the MCP server, e.g. ``http://localhost:7780``.
50
+ token:
51
+ Dataify API token. Obtain from https://dashboard.dataify.com.
52
+ tool_codes:
53
+ Optional comma-separated list of ``class_code,tool_code`` pairs
54
+ to filter which tools are available. Without this parameter only
55
+ free tools are visible.
56
+ timeout:
57
+ HTTP request timeout in seconds (default 30).
58
+ transport:
59
+ Transport mode: ``"http"`` (default) or ``"sse"``.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ base_url: str,
65
+ token: str,
66
+ *,
67
+ tool_codes: str | None = None,
68
+ timeout: float = 30.0,
69
+ transport: str = "http",
70
+ ) -> None:
71
+ self._base_url = base_url.rstrip("/")
72
+ self._token = token
73
+ self._tool_codes = tool_codes
74
+ self._timeout = timeout
75
+
76
+ self._protocol = MCPProtocol()
77
+
78
+ if transport == "sse":
79
+ self._transport = SSETransport(
80
+ base_url=base_url,
81
+ token=token,
82
+ tool_codes=tool_codes,
83
+ timeout=timeout,
84
+ )
85
+ else:
86
+ self._transport = HTTPTransport(
87
+ base_url=base_url,
88
+ token=token,
89
+ tool_codes=tool_codes,
90
+ timeout=timeout,
91
+ )
92
+
93
+ self._transport_type = transport
94
+
95
+ # ------------------------------------------------------------------
96
+ # Context manager
97
+ # ------------------------------------------------------------------
98
+
99
+ async def __aenter__(self) -> "DataifyClient":
100
+ await self.initialize()
101
+ return self
102
+
103
+ async def __aexit__(
104
+ self,
105
+ exc_type: type[BaseException] | None,
106
+ exc_val: BaseException | None,
107
+ exc_tb: TracebackType | None,
108
+ ) -> None:
109
+ await self.close()
110
+
111
+ # ------------------------------------------------------------------
112
+ # Lifecycle
113
+ # ------------------------------------------------------------------
114
+
115
+ async def initialize(self) -> ServerInfo:
116
+ """Open the transport, perform the MCP handshake, and return server info.
117
+
118
+ This is called automatically when using the async context manager.
119
+ If you create a client manually you must call this before using it.
120
+ """
121
+ await self._transport.open()
122
+
123
+ # Step 1: initialize
124
+ init_req = self._protocol.build_initialize_request(CLIENT_NAME, __version__)
125
+ init_resp = await self._transport.send_request(init_req)
126
+ info = self._protocol.process_initialize_response(init_resp)
127
+
128
+ logger.info(
129
+ "Connected to %s v%s (protocol %s)",
130
+ info.name,
131
+ info.version,
132
+ info.protocol_version,
133
+ )
134
+
135
+ # Step 2: send initialized notification
136
+ notif = self._protocol.build_initialized_notification()
137
+ await self._transport.send_notification(notif)
138
+
139
+ return info
140
+
141
+ async def close(self) -> None:
142
+ """Close the underlying transport."""
143
+ await self._transport.close()
144
+
145
+ @property
146
+ def server_info(self) -> ServerInfo | None:
147
+ """Information returned by the server during initialization."""
148
+ return self._protocol.server_info
149
+
150
+ @property
151
+ def is_initialized(self) -> bool:
152
+ """Whether the MCP handshake has completed."""
153
+ return self._protocol.initialized
154
+
155
+ # ------------------------------------------------------------------
156
+ # MCP operations
157
+ # ------------------------------------------------------------------
158
+
159
+ async def list_tools(self) -> list[ToolDefinition]:
160
+ """Fetch the list of available tools from the server.
161
+
162
+ The returned list respects the ``tool_codes`` filter if one was
163
+ provided at construction time.
164
+ """
165
+ if not self.is_initialized:
166
+ raise ConnectionError("Client not initialized. Call initialize() first.")
167
+
168
+ req = self._protocol.build_list_tools_request()
169
+ resp = await self._transport.send_request(req)
170
+ return self._protocol.process_list_tools_response(resp)
171
+
172
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
173
+ """Call an MCP tool by name with the given keyword arguments.
174
+
175
+ Parameters
176
+ ----------
177
+ name:
178
+ Tool name (e.g. ``"google_search"``, ``"request_web_unlocker"``).
179
+ arguments:
180
+ Tool parameters as a dictionary. All values are passed as
181
+ strings matching the server's expected format.
182
+
183
+ Returns
184
+ -------
185
+ Any
186
+ The tool result content (usually a dict).
187
+
188
+ Raises
189
+ ------
190
+ ToolError
191
+ When the tool execution fails on the server side.
192
+ ConnectionError
193
+ When the transport is not initialized or has been closed.
194
+ """
195
+ if not self.is_initialized:
196
+ raise ConnectionError("Client not initialized. Call initialize() first.")
197
+
198
+ req = self._protocol.build_call_tool_request(name, arguments)
199
+ resp = await self._transport.send_request(req)
200
+ return self._protocol.process_call_tool_response(resp)
201
+
202
+ async def ping(self) -> None:
203
+ """Send a ping to verify the server is reachable."""
204
+ req = self._protocol.build_ping_request()
205
+ resp = await self._transport.send_request(req)
206
+ self._protocol.process_ping_response(resp)
207
+
208
+ # ------------------------------------------------------------------
209
+ # -- High-level convenience methods (auto-attached) --
210
+ #
211
+ # Typed tool methods (google_search, request_web_unlocker, etc.) are
212
+ # attached to this class at import time by ``dataify_mcp.tools.attach_all``.
213
+ # See ``src/dataify_mcp/tools/__init__.py``.
214
+ #
215
+ # When the server adds new tools, regenerate the tool modules by running:
216
+ # python scripts/codegen.py --server http://localhost:7780
217
+ # ------------------------------------------------------------------
218
+
219
+
220
+ # Attach all typed tool methods to the DataifyClient class at import time.
221
+ from dataify_mcp.tools import attach_all # noqa: E402
222
+
223
+ attach_all(DataifyClient)
@@ -0,0 +1,205 @@
1
+ """Streamable HTTP transport for MCP.
2
+
3
+ Implements the Streamable HTTP transport defined by the MCP spec:
4
+ POST ``/mcp`` with JSON-RPC bodies; the server may return the response
5
+ directly or stream it via SSE.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from dataify_mcp.types._errors import (
18
+ ConnectionError,
19
+ ProtocolError,
20
+ ServerError,
21
+ TimeoutError,
22
+ )
23
+ from dataify_mcp.types._mcp import JSONRPCError, JSONRPCRequest, JSONRPCResponse
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Headers used by the MCP Streamable HTTP transport.
28
+ HEADER_SESSION_ID = "Mcp-Session-Id"
29
+ HEADER_LAST_EVENT_ID = "Last-Event-ID"
30
+ HEADER_CONTENT_TYPE = "Content-Type"
31
+ MIME_JSON = "application/json"
32
+
33
+
34
+ @dataclass
35
+ class HTTPTransport:
36
+ """Streamable HTTP transport that talks to the ``/mcp`` endpoint.
37
+
38
+ Parameters
39
+ ----------
40
+ base_url:
41
+ Base URL of the MCP server, e.g. ``http://localhost:7780``.
42
+ Must not include a trailing slash.
43
+ token:
44
+ Dataify API token passed as the ``?token=`` query parameter.
45
+ tool_codes:
46
+ Optional comma-separated tool codes passed as ``?tools=``.
47
+ timeout:
48
+ HTTP request timeout in seconds (default 30).
49
+ """
50
+
51
+ base_url: str
52
+ token: str
53
+ tool_codes: str | None = None
54
+ timeout: float = 30.0
55
+
56
+ # --- internal state -------------------------------------------------
57
+
58
+ _client: httpx.AsyncClient | None = None
59
+ _session_id: str | None = None
60
+
61
+ # --- URL construction -----------------------------------------------
62
+
63
+ @property
64
+ def endpoint(self) -> str:
65
+ """Full URL of the MCP endpoint including auth query params."""
66
+ url = f"{self.base_url.rstrip('/')}/mcp?token={self.token}"
67
+ if self.tool_codes:
68
+ url += f"&tools={self.tool_codes}"
69
+ return url
70
+
71
+ # --- lifecycle ------------------------------------------------------
72
+
73
+ async def open(self) -> None:
74
+ """Create the underlying HTTP client if not already open."""
75
+ if self._client is not None:
76
+ return
77
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self.timeout))
78
+
79
+ async def close(self) -> None:
80
+ """Close the underlying HTTP client."""
81
+ if self._client is not None:
82
+ await self._client.aclose()
83
+ self._client = None
84
+ self._session_id = None
85
+
86
+ @property
87
+ def is_open(self) -> bool:
88
+ """Check whether the transport is currently open."""
89
+ return self._client is not None and not self._client.is_closed
90
+
91
+ # --- request/response -----------------------------------------------
92
+
93
+ async def send_request(self, request: JSONRPCRequest) -> JSONRPCResponse:
94
+ """Send a JSON-RPC request and return the parsed response.
95
+
96
+ Raises
97
+ ------
98
+ ConnectionError
99
+ On network-level failures.
100
+ ProtocolError
101
+ When the server response is not valid JSON or lacks the expected fields.
102
+ ServerError
103
+ When the server returns HTTP 5xx.
104
+ TimeoutError
105
+ When the request exceeds the configured timeout.
106
+ """
107
+ if self._client is None:
108
+ raise ConnectionError("Transport is not open. Call open() first.")
109
+
110
+ headers: dict[str, str] = {
111
+ HEADER_CONTENT_TYPE: MIME_JSON,
112
+ "Accept": MIME_JSON,
113
+ }
114
+ if self._session_id:
115
+ headers[HEADER_SESSION_ID] = self._session_id
116
+
117
+ body = {
118
+ "jsonrpc": request.jsonrpc,
119
+ "id": request.id,
120
+ "method": request.method,
121
+ "params": request.params or {},
122
+ }
123
+
124
+ logger.debug("MCP request id=%d method=%s", request.id, request.method)
125
+
126
+ try:
127
+ http_resp = await self._client.post(
128
+ self.endpoint,
129
+ json=body,
130
+ headers=headers,
131
+ )
132
+ except httpx.TimeoutException as exc:
133
+ raise TimeoutError(
134
+ f"Request id={request.id} method={request.method} timed out "
135
+ f"after {self.timeout}s"
136
+ ) from exc
137
+ except httpx.NetworkError as exc:
138
+ raise ConnectionError(
139
+ f"Network error sending request id={request.id}: {exc}"
140
+ ) from exc
141
+
142
+ # Capture session id from server response
143
+ session_id = http_resp.headers.get(HEADER_SESSION_ID)
144
+ if session_id:
145
+ self._session_id = session_id
146
+
147
+ # Handle HTTP errors
148
+ if http_resp.status_code >= 500:
149
+ raise ServerError(
150
+ f"Server returned HTTP {http_resp.status_code}: {http_resp.text[:500]}"
151
+ )
152
+ if http_resp.status_code == 401 or http_resp.status_code == 403:
153
+ from dataify_mcp.types._errors import AuthenticationError
154
+
155
+ raise AuthenticationError(
156
+ f"Authentication failed (HTTP {http_resp.status_code}). "
157
+ "Check your API token at https://dashboard.dataify.com"
158
+ )
159
+
160
+ # Try to parse the response body as JSON
161
+ try:
162
+ data = http_resp.json()
163
+ except json.JSONDecodeError as exc:
164
+ raise ProtocolError(
165
+ f"Invalid JSON response for id={request.id}: {http_resp.text[:500]}"
166
+ ) from exc
167
+
168
+ # Build JSONRPCResponse
169
+ return JSONRPCResponse(
170
+ jsonrpc=data.get("jsonrpc", "2.0"),
171
+ id=data.get("id", request.id),
172
+ result=data.get("result"),
173
+ error=JSONRPCError(**data["error"]) if "error" in data and data["error"] else None,
174
+ )
175
+
176
+ async def send_notification(self, request: JSONRPCRequest) -> None:
177
+ """Send a JSON-RPC notification (no response expected)."""
178
+ if self._client is None:
179
+ raise ConnectionError("Transport is not open. Call open() first.")
180
+
181
+ headers: dict[str, str] = {
182
+ HEADER_CONTENT_TYPE: MIME_JSON,
183
+ "Accept": MIME_JSON,
184
+ }
185
+ if self._session_id:
186
+ headers[HEADER_SESSION_ID] = self._session_id
187
+
188
+ body = {
189
+ "jsonrpc": request.jsonrpc,
190
+ "method": request.method,
191
+ "params": request.params or {},
192
+ }
193
+
194
+ logger.debug("MCP notification method=%s", request.method)
195
+
196
+ try:
197
+ await self._client.post(
198
+ self.endpoint,
199
+ json=body,
200
+ headers=headers,
201
+ )
202
+ except httpx.TimeoutException:
203
+ logger.warning("Notification %s timed out (ignoring)", request.method)
204
+ except httpx.NetworkError as exc:
205
+ logger.warning("Notification %s failed: %s", request.method, exc)
@@ -0,0 +1,204 @@
1
+ """MCP protocol lifecycle management.
2
+
3
+ Handles JSON-RPC 2.0 message IDs, the ``initialize`` → ``initialized``
4
+ handshake, ``tools/list``, ``tools/call``, and ``ping``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from dataify_mcp.types._errors import ERROR_CODE_MAP, ProtocolError, ToolError
13
+ from dataify_mcp.types._mcp import (
14
+ JSONRPCError,
15
+ JSONRPCRequest,
16
+ JSONRPCResponse,
17
+ ServerInfo,
18
+ ToolDefinition,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # MCP protocol version requested by this SDK.
24
+ MCP_PROTOCOL_VERSION = "2024-11-05"
25
+
26
+ # Standard MCP method names.
27
+ METHOD_INITIALIZE = "initialize"
28
+ METHOD_INITIALIZED = "notifications/initialized"
29
+ METHOD_TOOLS_LIST = "tools/list"
30
+ METHOD_TOOLS_CALL = "tools/call"
31
+ METHOD_PING = "ping"
32
+
33
+
34
+ class MCPProtocol:
35
+ """Manages the MCP JSON-RPC conversation with a single transport.
36
+
37
+ The protocol is responsible for:
38
+
39
+ * Tracking monotonically increasing message IDs.
40
+ * Performing the MCP handshake (``initialize`` + ``initialized``).
41
+ * Exposing high-level methods that match MCP capabilities
42
+ (``list_tools``, ``call_tool``, ``ping``).
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._next_id = 0
47
+ self._server_info: ServerInfo | None = None
48
+ self._initialized = False
49
+
50
+ # ------------------------------------------------------------------
51
+ # ID management
52
+ # ------------------------------------------------------------------
53
+
54
+ def _allocate_id(self) -> int:
55
+ """Return the next JSON-RPC message id."""
56
+ self._next_id += 1
57
+ return self._next_id
58
+
59
+ @property
60
+ def server_info(self) -> ServerInfo | None:
61
+ """Information returned by the server during initialization."""
62
+ return self._server_info
63
+
64
+ @property
65
+ def initialized(self) -> bool:
66
+ """Whether the MCP handshake has completed."""
67
+ return self._initialized
68
+
69
+ # ------------------------------------------------------------------
70
+ # Request builders
71
+ # ------------------------------------------------------------------
72
+
73
+ def build_initialize_request(self, client_name: str, client_version: str) -> JSONRPCRequest:
74
+ """Build the ``initialize`` request."""
75
+ return JSONRPCRequest(
76
+ method=METHOD_INITIALIZE,
77
+ id=self._allocate_id(),
78
+ params={
79
+ "protocolVersion": MCP_PROTOCOL_VERSION,
80
+ "capabilities": {},
81
+ "clientInfo": {
82
+ "name": client_name,
83
+ "version": client_version,
84
+ },
85
+ },
86
+ )
87
+
88
+ def build_initialized_notification(self) -> JSONRPCRequest:
89
+ """Build the ``notifications/initialized`` notification."""
90
+ return JSONRPCRequest(
91
+ method=METHOD_INITIALIZED,
92
+ id=self._allocate_id(),
93
+ params={},
94
+ )
95
+
96
+ def build_list_tools_request(self) -> JSONRPCRequest:
97
+ """Build the ``tools/list`` request."""
98
+ return JSONRPCRequest(
99
+ method=METHOD_TOOLS_LIST,
100
+ id=self._allocate_id(),
101
+ params={},
102
+ )
103
+
104
+ def build_call_tool_request(self, name: str, arguments: dict[str, Any]) -> JSONRPCRequest:
105
+ """Build the ``tools/call`` request."""
106
+ return JSONRPCRequest(
107
+ method=METHOD_TOOLS_CALL,
108
+ id=self._allocate_id(),
109
+ params={"name": name, "arguments": arguments},
110
+ )
111
+
112
+ def build_ping_request(self) -> JSONRPCRequest:
113
+ """Build the ``ping`` request."""
114
+ return JSONRPCRequest(
115
+ method=METHOD_PING,
116
+ id=self._allocate_id(),
117
+ params={},
118
+ )
119
+
120
+ # ------------------------------------------------------------------
121
+ # Response processors
122
+ # ------------------------------------------------------------------
123
+
124
+ def process_initialize_response(self, response: JSONRPCResponse) -> ServerInfo:
125
+ """Validate and extract server info from an ``initialize`` response."""
126
+ self._check_response(response)
127
+ result = response.result
128
+
129
+ capabilities_raw = result.get("capabilities", {})
130
+ from dataify_mcp.types._mcp import MCPServerCapabilities
131
+
132
+ info = ServerInfo(
133
+ name=result.get("serverInfo", {}).get("name", "unknown"),
134
+ version=result.get("serverInfo", {}).get("version", "0.0.0"),
135
+ protocol_version=result.get("protocolVersion", ""),
136
+ capabilities=MCPServerCapabilities(
137
+ tools=capabilities_raw.get("tools"),
138
+ resources=capabilities_raw.get("resources"),
139
+ prompts=capabilities_raw.get("prompts"),
140
+ logging=capabilities_raw.get("logging"),
141
+ experimental=capabilities_raw.get("experimental"),
142
+ ),
143
+ instructions=result.get("instructions"),
144
+ )
145
+ self._server_info = info
146
+ self._initialized = True
147
+ return info
148
+
149
+ def process_list_tools_response(self, response: JSONRPCResponse) -> list[ToolDefinition]:
150
+ """Extract tool definitions from a ``tools/list`` response."""
151
+ self._check_response(response)
152
+ tools_data: list[dict[str, Any]] = response.result.get("tools", [])
153
+ return [
154
+ ToolDefinition(
155
+ name=t["name"],
156
+ description=t.get("description", ""),
157
+ inputSchema=t.get("inputSchema", {}),
158
+ )
159
+ for t in tools_data
160
+ ]
161
+
162
+ def process_call_tool_response(self, response: JSONRPCResponse) -> Any:
163
+ """Extract the tool result from a ``tools/call`` response.
164
+
165
+ Returns the ``content`` field of the result. Raises ``ToolError``
166
+ when ``isError`` is true.
167
+ """
168
+ self._check_response(response)
169
+
170
+ # MCP tools/call result may be nested inside content array
171
+ result = response.result
172
+ is_error = result.get("isError", False)
173
+
174
+ content = result.get("content", result)
175
+ if isinstance(content, list) and len(content) == 1:
176
+ item = content[0]
177
+ if isinstance(item, dict) and "text" in item:
178
+ content = item["text"]
179
+ elif isinstance(item, dict) and "data" in item:
180
+ content = item["data"]
181
+ else:
182
+ content = item
183
+
184
+ if is_error:
185
+ error_text = content if isinstance(content, str) else result.get("content", [{}])[0].get("text", str(content)) if isinstance(result.get("content"), list) else str(content)
186
+ raise ToolError(error_text)
187
+
188
+ return content
189
+
190
+ def process_ping_response(self, response: JSONRPCResponse) -> None:
191
+ """Validate a ``ping`` response (no-op on success)."""
192
+ self._check_response(response)
193
+
194
+ # ------------------------------------------------------------------
195
+ # Helpers
196
+ # ------------------------------------------------------------------
197
+
198
+ @staticmethod
199
+ def _check_response(response: JSONRPCResponse) -> None:
200
+ """Raise the appropriate SDK exception if the response is an error."""
201
+ if response.error is not None:
202
+ err = response.error
203
+ exc_cls = ERROR_CODE_MAP.get(err.code, ProtocolError)
204
+ raise exc_cls(f"[{err.code}] {err.message}" + (f": {err.data}" if err.data else ""))