cloud-dog-api-kit 0.13.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.
- cloud_dog_api_kit/__init__.py +170 -0
- cloud_dog_api_kit/a2a/__init__.py +53 -0
- cloud_dog_api_kit/a2a/card.py +138 -0
- cloud_dog_api_kit/a2a/events.py +1123 -0
- cloud_dog_api_kit/a2a/gateway.py +105 -0
- cloud_dog_api_kit/a2a/skill_audit.py +107 -0
- cloud_dog_api_kit/auth/__init__.py +35 -0
- cloud_dog_api_kit/auth/dependency.py +121 -0
- cloud_dog_api_kit/auth/rbac.py +107 -0
- cloud_dog_api_kit/auth/service_auth.py +54 -0
- cloud_dog_api_kit/clients/__init__.py +29 -0
- cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
- cloud_dog_api_kit/clients/http_client.py +127 -0
- cloud_dog_api_kit/clients/retry.py +83 -0
- cloud_dog_api_kit/compat/__init__.py +37 -0
- cloud_dog_api_kit/compat/envelope.py +120 -0
- cloud_dog_api_kit/compat/profile.py +102 -0
- cloud_dog_api_kit/compat/routes.py +90 -0
- cloud_dog_api_kit/config.py +54 -0
- cloud_dog_api_kit/correlation/__init__.py +50 -0
- cloud_dog_api_kit/correlation/context.py +118 -0
- cloud_dog_api_kit/correlation/middleware.py +133 -0
- cloud_dog_api_kit/envelopes/__init__.py +37 -0
- cloud_dog_api_kit/envelopes/error.py +87 -0
- cloud_dog_api_kit/envelopes/success.py +84 -0
- cloud_dog_api_kit/errors/__init__.py +51 -0
- cloud_dog_api_kit/errors/exceptions.py +184 -0
- cloud_dog_api_kit/errors/handler.py +102 -0
- cloud_dog_api_kit/errors/taxonomy.py +62 -0
- cloud_dog_api_kit/factory.py +157 -0
- cloud_dog_api_kit/idempotency/__init__.py +28 -0
- cloud_dog_api_kit/idempotency/middleware.py +118 -0
- cloud_dog_api_kit/idempotency/store.py +100 -0
- cloud_dog_api_kit/lifecycle/__init__.py +39 -0
- cloud_dog_api_kit/lifecycle/hooks.py +75 -0
- cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
- cloud_dog_api_kit/mcp/__init__.py +122 -0
- cloud_dog_api_kit/mcp/async_jobs.py +126 -0
- cloud_dog_api_kit/mcp/client_sdk.py +235 -0
- cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
- cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
- cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
- cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
- cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
- cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
- cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
- cloud_dog_api_kit/mcp/contract.py +113 -0
- cloud_dog_api_kit/mcp/error_mapper.py +84 -0
- cloud_dog_api_kit/mcp/gateway.py +117 -0
- cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
- cloud_dog_api_kit/mcp/session.py +96 -0
- cloud_dog_api_kit/mcp/sync_handler.py +269 -0
- cloud_dog_api_kit/mcp/tool_audit.py +136 -0
- cloud_dog_api_kit/mcp/tool_router.py +180 -0
- cloud_dog_api_kit/mcp/transport.py +1041 -0
- cloud_dog_api_kit/middleware/__init__.py +39 -0
- cloud_dog_api_kit/middleware/cors.py +74 -0
- cloud_dog_api_kit/middleware/logging.py +98 -0
- cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
- cloud_dog_api_kit/middleware/timeout.py +78 -0
- cloud_dog_api_kit/middleware/timing.py +52 -0
- cloud_dog_api_kit/openapi/__init__.py +30 -0
- cloud_dog_api_kit/openapi/customise.py +69 -0
- cloud_dog_api_kit/openapi/route.py +46 -0
- cloud_dog_api_kit/routers/__init__.py +41 -0
- cloud_dog_api_kit/routers/crud.py +173 -0
- cloud_dog_api_kit/routers/health.py +160 -0
- cloud_dog_api_kit/routers/jobs.py +69 -0
- cloud_dog_api_kit/routers/version.py +46 -0
- cloud_dog_api_kit/schemas/__init__.py +36 -0
- cloud_dog_api_kit/schemas/envelopes.py +37 -0
- cloud_dog_api_kit/schemas/filters.py +103 -0
- cloud_dog_api_kit/schemas/pagination.py +148 -0
- cloud_dog_api_kit/streaming/__init__.py +28 -0
- cloud_dog_api_kit/streaming/events.py +47 -0
- cloud_dog_api_kit/streaming/jsonl.py +68 -0
- cloud_dog_api_kit/streaming/sse.py +102 -0
- cloud_dog_api_kit/testing/__init__.py +46 -0
- cloud_dog_api_kit/testing/conformance.py +156 -0
- cloud_dog_api_kit/testing/fixtures.py +90 -0
- cloud_dog_api_kit/testing/flows/__init__.py +32 -0
- cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
- cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
- cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
- cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
- cloud_dog_api_kit/traceability_ids.py +84 -0
- cloud_dog_api_kit/versioning/__init__.py +30 -0
- cloud_dog_api_kit/versioning/header.py +52 -0
- cloud_dog_api_kit/web/__init__.py +7 -0
- cloud_dog_api_kit/web/proxy.py +222 -0
- cloud_dog_api_kit/webhook/__init__.py +29 -0
- cloud_dog_api_kit/webhook/signature.py +149 -0
- cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
- cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
- cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — MCP streamable HTTP client transport
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Streamable HTTP MCP client transport with session reuse, SSE
|
|
20
|
+
# multiplexing, and tool-router compatibility fallbacks.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Streamable HTTP MCP client transport."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Dict, Optional, cast
|
|
32
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from cloud_dog_api_kit.mcp.session import SESSION_HEADER
|
|
37
|
+
|
|
38
|
+
from .base import MCPTransport
|
|
39
|
+
from .exceptions import MCPProtocolError, MCPSessionError, MCPTransportError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class StreamableHTTPConfig:
|
|
44
|
+
"""Configuration for streamable HTTP MCP transport."""
|
|
45
|
+
|
|
46
|
+
base_url: str
|
|
47
|
+
mcp_path: str
|
|
48
|
+
api_key_header: Optional[str] = None
|
|
49
|
+
api_key: Optional[str] = None
|
|
50
|
+
accept_header: Optional[str] = None
|
|
51
|
+
sse_accept_header: Optional[str] = None
|
|
52
|
+
protocol_version: Optional[str] = None
|
|
53
|
+
auth_bearer_token: Optional[str] = None
|
|
54
|
+
enable_sse: bool = True
|
|
55
|
+
timeout_seconds: float = 30.0
|
|
56
|
+
read_timeout_seconds: Optional[float] = None
|
|
57
|
+
verify_tls: bool = True
|
|
58
|
+
extra_headers: Optional[Dict[str, str]] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StreamableHTTPTransport(MCPTransport):
|
|
62
|
+
"""MCP client transport for the streamable HTTP profile."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, cfg: StreamableHTTPConfig):
|
|
65
|
+
"""Initialise StreamableHTTPTransport state and dependencies."""
|
|
66
|
+
base_url, mcp_path = self._normalise_base_and_mcp_path(cfg.base_url, cfg.mcp_path)
|
|
67
|
+
cfg.base_url = base_url
|
|
68
|
+
cfg.mcp_path = mcp_path
|
|
69
|
+
self.cfg = cfg
|
|
70
|
+
self._client: httpx.AsyncClient | None = None
|
|
71
|
+
self._id = 0
|
|
72
|
+
self._session_id: str | None = None
|
|
73
|
+
self._sse_task: asyncio.Task[Any] | None = None
|
|
74
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _normalise_base_and_mcp_path(base_url: str, mcp_path: str) -> tuple[str, str]:
|
|
78
|
+
"""Normalise base URL and MCP path without doubling path segments."""
|
|
79
|
+
base = str(base_url or "").rstrip("/")
|
|
80
|
+
path = str(mcp_path or "").strip()
|
|
81
|
+
if not path:
|
|
82
|
+
path = "/mcp"
|
|
83
|
+
if not path.startswith("/"):
|
|
84
|
+
path = f"/{path}"
|
|
85
|
+
|
|
86
|
+
parsed = urlsplit(base)
|
|
87
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
88
|
+
if base_path and (path == base_path or path.startswith(f"{base_path}/")):
|
|
89
|
+
base = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
|
|
90
|
+
return base, path
|
|
91
|
+
|
|
92
|
+
async def connect(self) -> None:
|
|
93
|
+
"""Create the shared transport-owned HTTP client."""
|
|
94
|
+
if self._client is not None:
|
|
95
|
+
return
|
|
96
|
+
read_timeout = self.cfg.read_timeout_seconds
|
|
97
|
+
if read_timeout is not None and read_timeout <= 0:
|
|
98
|
+
read_timeout = None
|
|
99
|
+
elif read_timeout is None:
|
|
100
|
+
read_timeout = self.cfg.timeout_seconds
|
|
101
|
+
self._client = httpx.AsyncClient(
|
|
102
|
+
base_url=str(self.cfg.base_url).rstrip("/"),
|
|
103
|
+
timeout=httpx.Timeout(
|
|
104
|
+
self.cfg.timeout_seconds,
|
|
105
|
+
connect=self.cfg.timeout_seconds,
|
|
106
|
+
read=read_timeout,
|
|
107
|
+
),
|
|
108
|
+
verify=self.cfg.verify_tls,
|
|
109
|
+
trust_env=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def close(self) -> None:
|
|
113
|
+
"""Cancel the SSE loop, end the server session, and close the HTTP client."""
|
|
114
|
+
if self._sse_task is not None:
|
|
115
|
+
self._sse_task.cancel()
|
|
116
|
+
self._sse_task = None
|
|
117
|
+
|
|
118
|
+
if self._client is not None and self._session_id:
|
|
119
|
+
try:
|
|
120
|
+
await self._client.delete(self.cfg.mcp_path, headers=self._headers(include_session=True))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
if self._client is not None:
|
|
125
|
+
await self._client.aclose()
|
|
126
|
+
self._client = None
|
|
127
|
+
|
|
128
|
+
for future in list(self._pending.values()):
|
|
129
|
+
if not future.done():
|
|
130
|
+
future.set_exception(MCPTransportError("Transport closed"))
|
|
131
|
+
self._pending.clear()
|
|
132
|
+
|
|
133
|
+
async def terminate_session(self) -> None:
|
|
134
|
+
"""Explicitly terminate the current server-side MCP session."""
|
|
135
|
+
if self._client is None or not self._session_id:
|
|
136
|
+
raise MCPSessionError("Transport has no active session to terminate")
|
|
137
|
+
resp = await self._client.delete(self.cfg.mcp_path, headers=self._headers(include_session=True))
|
|
138
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
139
|
+
raise MCPSessionError(f"MCP session terminate failed: DELETE {self.cfg.mcp_path} -> {resp.status_code}")
|
|
140
|
+
|
|
141
|
+
async def open_sse_stream(self) -> None:
|
|
142
|
+
"""Open the server SSE stream and validate its content type."""
|
|
143
|
+
if self._client is None:
|
|
144
|
+
raise MCPTransportError("Transport not connected")
|
|
145
|
+
async with self._client.stream(
|
|
146
|
+
"GET",
|
|
147
|
+
self.cfg.mcp_path,
|
|
148
|
+
headers=self._sse_headers(),
|
|
149
|
+
timeout=None,
|
|
150
|
+
) as resp:
|
|
151
|
+
if resp.status_code != 200:
|
|
152
|
+
raise MCPTransportError(f"MCP SSE stream failed: GET {self.cfg.mcp_path} -> {resp.status_code}")
|
|
153
|
+
content_type = (resp.headers.get("content-type") or "").lower()
|
|
154
|
+
if "text/event-stream" not in content_type:
|
|
155
|
+
raise MCPProtocolError("MCP SSE stream missing text/event-stream content type")
|
|
156
|
+
|
|
157
|
+
async def ensure_sse_stream(self) -> None:
|
|
158
|
+
"""Ensure the background SSE loop is running when enabled."""
|
|
159
|
+
if not self.cfg.enable_sse:
|
|
160
|
+
return
|
|
161
|
+
await self._ensure_sse()
|
|
162
|
+
|
|
163
|
+
def _headers(self, *, include_session: bool) -> dict[str, str]:
|
|
164
|
+
"""Build standard POST headers for streamable HTTP calls."""
|
|
165
|
+
headers: dict[str, str] = {}
|
|
166
|
+
if isinstance(self.cfg.extra_headers, dict):
|
|
167
|
+
headers.update(
|
|
168
|
+
{
|
|
169
|
+
str(key): str(value)
|
|
170
|
+
for key, value in self.cfg.extra_headers.items()
|
|
171
|
+
if str(key).strip() and str(value).strip()
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
if self.cfg.api_key_header and self.cfg.api_key:
|
|
175
|
+
headers[self.cfg.api_key_header] = self.cfg.api_key
|
|
176
|
+
if self.cfg.auth_bearer_token:
|
|
177
|
+
headers["authorization"] = f"Bearer {self.cfg.auth_bearer_token}"
|
|
178
|
+
if self.cfg.accept_header:
|
|
179
|
+
headers["accept"] = self.cfg.accept_header
|
|
180
|
+
if self.cfg.protocol_version:
|
|
181
|
+
headers["mcp-protocol-version"] = self.cfg.protocol_version
|
|
182
|
+
if include_session and self._session_id:
|
|
183
|
+
headers[SESSION_HEADER] = self._session_id
|
|
184
|
+
return headers
|
|
185
|
+
|
|
186
|
+
def _sse_headers(self) -> dict[str, str]:
|
|
187
|
+
"""Build SSE request headers for streamable HTTP."""
|
|
188
|
+
headers: dict[str, str] = {}
|
|
189
|
+
if isinstance(self.cfg.extra_headers, dict):
|
|
190
|
+
headers.update(
|
|
191
|
+
{
|
|
192
|
+
str(key): str(value)
|
|
193
|
+
for key, value in self.cfg.extra_headers.items()
|
|
194
|
+
if str(key).strip() and str(value).strip()
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
if self.cfg.api_key_header and self.cfg.api_key:
|
|
198
|
+
headers[self.cfg.api_key_header] = self.cfg.api_key
|
|
199
|
+
if self.cfg.auth_bearer_token:
|
|
200
|
+
headers["authorization"] = f"Bearer {self.cfg.auth_bearer_token}"
|
|
201
|
+
accept_value = self.cfg.sse_accept_header or self.cfg.accept_header
|
|
202
|
+
if accept_value:
|
|
203
|
+
headers["accept"] = accept_value
|
|
204
|
+
if self.cfg.protocol_version:
|
|
205
|
+
headers["mcp-protocol-version"] = self.cfg.protocol_version
|
|
206
|
+
if self._session_id:
|
|
207
|
+
headers[SESSION_HEADER] = self._session_id
|
|
208
|
+
return headers
|
|
209
|
+
|
|
210
|
+
async def _ensure_sse(self) -> None:
|
|
211
|
+
"""Start the background SSE loop once a session exists."""
|
|
212
|
+
if self._sse_task is not None:
|
|
213
|
+
return
|
|
214
|
+
if self._client is None:
|
|
215
|
+
raise MCPTransportError("Transport not connected")
|
|
216
|
+
if not self._session_id:
|
|
217
|
+
raise MCPSessionError("Cannot open SSE stream without session id")
|
|
218
|
+
self._sse_task = asyncio.create_task(self._sse_loop())
|
|
219
|
+
|
|
220
|
+
async def _sse_loop(self) -> None:
|
|
221
|
+
"""Consume background SSE frames and resolve pending requests."""
|
|
222
|
+
assert self._client is not None
|
|
223
|
+
assert self._session_id
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
async with self._client.stream(
|
|
227
|
+
"GET",
|
|
228
|
+
self.cfg.mcp_path,
|
|
229
|
+
headers=self._sse_headers(),
|
|
230
|
+
timeout=None,
|
|
231
|
+
) as resp:
|
|
232
|
+
if resp.status_code != 200:
|
|
233
|
+
raise MCPTransportError(f"MCP SSE stream failed: GET {self.cfg.mcp_path} -> {resp.status_code}")
|
|
234
|
+
|
|
235
|
+
event_data: list[str] = []
|
|
236
|
+
async for line in resp.aiter_lines():
|
|
237
|
+
if line is None:
|
|
238
|
+
continue
|
|
239
|
+
if line == "":
|
|
240
|
+
if event_data:
|
|
241
|
+
raw = "\n".join(event_data)
|
|
242
|
+
event_data = []
|
|
243
|
+
try:
|
|
244
|
+
message = json.loads(raw)
|
|
245
|
+
except Exception:
|
|
246
|
+
continue
|
|
247
|
+
self._handle_incoming(message)
|
|
248
|
+
continue
|
|
249
|
+
if line.startswith(":"):
|
|
250
|
+
continue
|
|
251
|
+
if line.startswith("id:"):
|
|
252
|
+
continue
|
|
253
|
+
if line.startswith("data:"):
|
|
254
|
+
event_data.append(line[5:].lstrip())
|
|
255
|
+
except asyncio.CancelledError:
|
|
256
|
+
raise
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
for future in list(self._pending.values()):
|
|
259
|
+
if not future.done():
|
|
260
|
+
future.set_exception(MCPTransportError(f"SSE stream failed: {exc}"))
|
|
261
|
+
self._pending.clear()
|
|
262
|
+
|
|
263
|
+
def _handle_incoming(self, message: Any) -> None:
|
|
264
|
+
"""Handle background server-to-client JSON-RPC messages."""
|
|
265
|
+
if not isinstance(message, dict):
|
|
266
|
+
return
|
|
267
|
+
if message.get("jsonrpc") != "2.0":
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if "method" in message and message.get("id") is not None:
|
|
271
|
+
method = str(message.get("method") or "")
|
|
272
|
+
req_id = message.get("id")
|
|
273
|
+
if method in ("sampling/createMessage", "elicitation/create"):
|
|
274
|
+
error = {"code": -32601, "message": f"Client does not support {method}"}
|
|
275
|
+
else:
|
|
276
|
+
error = {
|
|
277
|
+
"code": -32601,
|
|
278
|
+
"message": f"Client does not support method {method}",
|
|
279
|
+
}
|
|
280
|
+
response = {"jsonrpc": "2.0", "id": req_id, "error": error}
|
|
281
|
+
asyncio.create_task(self._send_client_response(response))
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if "id" in message and message.get("id") is not None:
|
|
285
|
+
req_id = message.get("id")
|
|
286
|
+
if isinstance(req_id, int) and req_id in self._pending:
|
|
287
|
+
future = self._pending.pop(req_id)
|
|
288
|
+
if message.get("error") is not None:
|
|
289
|
+
future.set_exception(MCPTransportError(f"MCP error: {message['error']}"))
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
result = message.get("result")
|
|
293
|
+
if not isinstance(result, dict):
|
|
294
|
+
future.set_exception(MCPProtocolError("MCP result must be an object"))
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
future.set_result(result)
|
|
298
|
+
|
|
299
|
+
async def _send_client_response(self, response: dict[str, Any]) -> None:
|
|
300
|
+
"""Reply to server-initiated requests with method-not-found errors."""
|
|
301
|
+
if self._client is None:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
await self._client.post(
|
|
305
|
+
self.cfg.mcp_path,
|
|
306
|
+
json=response,
|
|
307
|
+
headers=self._headers(include_session=True),
|
|
308
|
+
)
|
|
309
|
+
except Exception:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
def _parse_inline_sse(self, text: Any) -> list[dict[str, Any]]:
|
|
313
|
+
"""Parse inline SSE payloads returned in a single HTTP response body."""
|
|
314
|
+
if isinstance(text, bytes):
|
|
315
|
+
text = text.decode(errors="replace")
|
|
316
|
+
if not isinstance(text, str):
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
messages: list[dict[str, Any]] = []
|
|
320
|
+
event_data: list[str] = []
|
|
321
|
+
for line in text.splitlines():
|
|
322
|
+
if line == "":
|
|
323
|
+
if event_data:
|
|
324
|
+
raw = "\n".join(event_data)
|
|
325
|
+
event_data = []
|
|
326
|
+
try:
|
|
327
|
+
message = json.loads(raw)
|
|
328
|
+
except Exception:
|
|
329
|
+
continue
|
|
330
|
+
if isinstance(message, dict):
|
|
331
|
+
messages.append(message)
|
|
332
|
+
continue
|
|
333
|
+
if line.startswith(":"):
|
|
334
|
+
continue
|
|
335
|
+
if line.startswith("data:"):
|
|
336
|
+
event_data.append(line[5:].lstrip())
|
|
337
|
+
if event_data:
|
|
338
|
+
raw = "\n".join(event_data)
|
|
339
|
+
try:
|
|
340
|
+
message = json.loads(raw)
|
|
341
|
+
except Exception:
|
|
342
|
+
return messages
|
|
343
|
+
if isinstance(message, dict):
|
|
344
|
+
messages.append(message)
|
|
345
|
+
return messages
|
|
346
|
+
|
|
347
|
+
def _tool_router_base_path(self) -> str:
|
|
348
|
+
"""Return the tool-router prefix paired with the MCP base path."""
|
|
349
|
+
base = self.cfg.mcp_path.rstrip("/")
|
|
350
|
+
if not base:
|
|
351
|
+
base = "/mcp"
|
|
352
|
+
return f"{base}/tools"
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _normalise_tools_list_payload(payload: Any) -> dict[str, Any]:
|
|
356
|
+
"""Normalise tool-router style tools/list payloads."""
|
|
357
|
+
if isinstance(payload, list):
|
|
358
|
+
return {"tools": payload}
|
|
359
|
+
if isinstance(payload, dict):
|
|
360
|
+
tools = payload.get("tools")
|
|
361
|
+
if isinstance(tools, list):
|
|
362
|
+
return {"tools": tools}
|
|
363
|
+
data = payload.get("data")
|
|
364
|
+
if isinstance(data, list):
|
|
365
|
+
return {"tools": data}
|
|
366
|
+
result = payload.get("result")
|
|
367
|
+
if isinstance(result, dict):
|
|
368
|
+
result_tools = result.get("tools")
|
|
369
|
+
if isinstance(result_tools, list):
|
|
370
|
+
return {"tools": result_tools}
|
|
371
|
+
result_items = result.get("items")
|
|
372
|
+
if isinstance(result_items, list):
|
|
373
|
+
return {"tools": result_items}
|
|
374
|
+
raise MCPTransportError("MCP tool-router tools/list response missing tools payload")
|
|
375
|
+
|
|
376
|
+
@staticmethod
|
|
377
|
+
def _normalise_tools_call_payload(payload: Any) -> dict[str, Any]:
|
|
378
|
+
"""Normalise tool-router style tools/call payloads."""
|
|
379
|
+
if isinstance(payload, dict) and isinstance(payload.get("content"), list):
|
|
380
|
+
return payload
|
|
381
|
+
|
|
382
|
+
is_error = False
|
|
383
|
+
data: Any = payload
|
|
384
|
+
if isinstance(payload, dict):
|
|
385
|
+
is_error = bool(payload.get("isError") is True) or bool(payload.get("ok") is False)
|
|
386
|
+
if payload.get("data") is not None:
|
|
387
|
+
data = payload.get("data")
|
|
388
|
+
elif payload.get("result") is not None:
|
|
389
|
+
data = payload.get("result")
|
|
390
|
+
|
|
391
|
+
text = json.dumps(data, ensure_ascii=True)
|
|
392
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _is_bare_result_method(method: str) -> bool:
|
|
396
|
+
"""Return True when a bare dict payload is a valid result object."""
|
|
397
|
+
return method in {
|
|
398
|
+
"prompts/list",
|
|
399
|
+
"prompts/get",
|
|
400
|
+
"resources/list",
|
|
401
|
+
"resources/read",
|
|
402
|
+
"resources/templates/list",
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async def _request_tool_router_fallback(self, *, method: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
|
406
|
+
"""Fallback to REST-style tool-router endpoints when needed."""
|
|
407
|
+
if self._client is None:
|
|
408
|
+
raise MCPTransportError("Transport not connected")
|
|
409
|
+
|
|
410
|
+
headers = self._headers(include_session=bool(self._session_id))
|
|
411
|
+
tools_path = self._tool_router_base_path()
|
|
412
|
+
|
|
413
|
+
async def _request_with_retry(
|
|
414
|
+
http_method: str, endpoint: str, *, json_body: dict[str, Any] | None = None
|
|
415
|
+
) -> httpx.Response:
|
|
416
|
+
last_error: Exception | None = None
|
|
417
|
+
for attempt in range(3):
|
|
418
|
+
try:
|
|
419
|
+
if http_method == "GET":
|
|
420
|
+
return await self._client.get(endpoint, headers=headers)
|
|
421
|
+
return await self._client.post(endpoint, json=json_body, headers=headers)
|
|
422
|
+
except (httpx.ConnectError, httpx.RemoteProtocolError) as exc:
|
|
423
|
+
last_error = exc
|
|
424
|
+
if attempt < 2:
|
|
425
|
+
await asyncio.sleep(0.25 * (attempt + 1))
|
|
426
|
+
continue
|
|
427
|
+
raise MCPTransportError(
|
|
428
|
+
f"MCP tool-router fallback transport failed for {http_method} {endpoint}: {exc}"
|
|
429
|
+
) from exc
|
|
430
|
+
raise MCPTransportError(
|
|
431
|
+
f"MCP tool-router fallback transport failed for {http_method} {endpoint}: {last_error}"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if method == "tools/list":
|
|
435
|
+
list_endpoints = [tools_path, "/api/v1/tools"]
|
|
436
|
+
for endpoint in list_endpoints:
|
|
437
|
+
resp = await _request_with_retry("GET", endpoint)
|
|
438
|
+
if resp.status_code in {404, 405}:
|
|
439
|
+
continue
|
|
440
|
+
if resp.status_code != 200:
|
|
441
|
+
raise MCPTransportError(f"MCP tool-router fallback failed: GET {endpoint} -> {resp.status_code}")
|
|
442
|
+
try:
|
|
443
|
+
payload = resp.json()
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
raise MCPProtocolError(
|
|
446
|
+
f"MCP tool-router tools/list returned non-JSON payload from {endpoint}"
|
|
447
|
+
) from exc
|
|
448
|
+
return self._normalise_tools_list_payload(payload)
|
|
449
|
+
|
|
450
|
+
raise MCPTransportError(f"MCP tool-router fallback has no tools/list endpoint under {tools_path}")
|
|
451
|
+
|
|
452
|
+
if method == "tools/call":
|
|
453
|
+
if not isinstance(params, dict):
|
|
454
|
+
raise MCPTransportError("MCP tool-router fallback requires object params for tools/call")
|
|
455
|
+
name = str(params.get("name") or "").strip()
|
|
456
|
+
if not name:
|
|
457
|
+
raise MCPTransportError("MCP tool-router fallback requires params.name for tools/call")
|
|
458
|
+
arguments = params.get("arguments")
|
|
459
|
+
if arguments is None:
|
|
460
|
+
arguments = {}
|
|
461
|
+
if not isinstance(arguments, dict):
|
|
462
|
+
raise MCPTransportError("MCP tool-router fallback requires params.arguments object for tools/call")
|
|
463
|
+
|
|
464
|
+
call_endpoints = [
|
|
465
|
+
(f"{tools_path}/{quote(name, safe='')}", arguments),
|
|
466
|
+
(f"/api/v1/tools/{quote(name, safe='')}", arguments),
|
|
467
|
+
]
|
|
468
|
+
last_error: str | None = None
|
|
469
|
+
for endpoint, body in call_endpoints:
|
|
470
|
+
resp = await _request_with_retry("POST", endpoint, json_body=body)
|
|
471
|
+
if resp.status_code in {404, 405, 500}:
|
|
472
|
+
last_error = f"MCP tool-router fallback failed: POST {endpoint} -> {resp.status_code}"
|
|
473
|
+
continue
|
|
474
|
+
if resp.status_code != 200:
|
|
475
|
+
raise MCPTransportError(f"MCP tool-router fallback failed: POST {endpoint} -> {resp.status_code}")
|
|
476
|
+
try:
|
|
477
|
+
payload = resp.json()
|
|
478
|
+
except Exception as exc:
|
|
479
|
+
raise MCPProtocolError(
|
|
480
|
+
f"MCP tool-router tools/call ({name}) returned non-JSON payload from {endpoint}"
|
|
481
|
+
) from exc
|
|
482
|
+
return self._normalise_tools_call_payload(payload)
|
|
483
|
+
|
|
484
|
+
if last_error:
|
|
485
|
+
raise MCPTransportError(last_error)
|
|
486
|
+
raise MCPTransportError(f"MCP tool-router fallback has no tools/call endpoint for '{name}'")
|
|
487
|
+
|
|
488
|
+
raise MCPTransportError(f"MCP tool-router fallback unsupported for method '{method}'")
|
|
489
|
+
|
|
490
|
+
async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
491
|
+
"""Send one MCP request over the streamable HTTP transport."""
|
|
492
|
+
if self._client is None:
|
|
493
|
+
raise MCPTransportError("Transport not connected")
|
|
494
|
+
|
|
495
|
+
self._id += 1
|
|
496
|
+
req_id = self._id
|
|
497
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
498
|
+
if params is not None:
|
|
499
|
+
payload["params"] = params
|
|
500
|
+
|
|
501
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
502
|
+
self._pending[req_id] = future
|
|
503
|
+
|
|
504
|
+
async def _send_once() -> str | dict[str, Any]:
|
|
505
|
+
headers = self._headers(include_session=bool(self._session_id))
|
|
506
|
+
async with self._client.stream("POST", self.cfg.mcp_path, json=payload, headers=headers) as resp:
|
|
507
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
508
|
+
self._pending.pop(req_id, None)
|
|
509
|
+
body_bytes = await resp.aread()
|
|
510
|
+
body = (body_bytes.decode(errors="replace") or "").strip()
|
|
511
|
+
if len(body) > 500:
|
|
512
|
+
body = body[:500] + "...<truncated>"
|
|
513
|
+
if method in {"tools/list", "tools/call"} and resp.status_code in {404, 405, 501}:
|
|
514
|
+
return await self._request_tool_router_fallback(method=method, params=params)
|
|
515
|
+
raise MCPTransportError(
|
|
516
|
+
f"MCP Streamable HTTP failed: POST {self.cfg.mcp_path} -> {resp.status_code}; body={body}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
session_id = resp.headers.get(SESSION_HEADER)
|
|
520
|
+
if session_id and not self._session_id:
|
|
521
|
+
self._session_id = session_id
|
|
522
|
+
if self.cfg.enable_sse:
|
|
523
|
+
await self._ensure_sse()
|
|
524
|
+
|
|
525
|
+
content_type = (resp.headers.get("content-type") or "").lower()
|
|
526
|
+
if "text/event-stream" in content_type:
|
|
527
|
+
event_data: list[str] = []
|
|
528
|
+
async for line in resp.aiter_lines():
|
|
529
|
+
if line == "":
|
|
530
|
+
if event_data:
|
|
531
|
+
raw = "\n".join(event_data)
|
|
532
|
+
event_data = []
|
|
533
|
+
try:
|
|
534
|
+
message = json.loads(raw)
|
|
535
|
+
except Exception:
|
|
536
|
+
continue
|
|
537
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
538
|
+
continue
|
|
539
|
+
if message.get("id") != req_id:
|
|
540
|
+
continue
|
|
541
|
+
if message.get("error") is not None:
|
|
542
|
+
self._pending.pop(req_id, None)
|
|
543
|
+
raise MCPTransportError(f"MCP error: {message['error']}")
|
|
544
|
+
result = message.get("result")
|
|
545
|
+
if not isinstance(result, dict):
|
|
546
|
+
self._pending.pop(req_id, None)
|
|
547
|
+
raise MCPProtocolError("MCP result must be an object")
|
|
548
|
+
self._pending.pop(req_id, None)
|
|
549
|
+
return result
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
if line.startswith(":"):
|
|
553
|
+
continue
|
|
554
|
+
if line.startswith("data:"):
|
|
555
|
+
event_data.append(line[5:].lstrip())
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
if event_data:
|
|
559
|
+
raw = "\n".join(event_data)
|
|
560
|
+
try:
|
|
561
|
+
message = json.loads(raw)
|
|
562
|
+
except Exception:
|
|
563
|
+
message = None
|
|
564
|
+
if (
|
|
565
|
+
isinstance(message, dict)
|
|
566
|
+
and message.get("jsonrpc") == "2.0"
|
|
567
|
+
and message.get("id") == req_id
|
|
568
|
+
):
|
|
569
|
+
if message.get("error") is not None:
|
|
570
|
+
self._pending.pop(req_id, None)
|
|
571
|
+
raise MCPTransportError(f"MCP error: {message['error']}")
|
|
572
|
+
result = message.get("result")
|
|
573
|
+
if not isinstance(result, dict):
|
|
574
|
+
self._pending.pop(req_id, None)
|
|
575
|
+
raise MCPProtocolError("MCP result must be an object")
|
|
576
|
+
self._pending.pop(req_id, None)
|
|
577
|
+
return result
|
|
578
|
+
raise MCPTransportError("MCP SSE response did not include a matching result")
|
|
579
|
+
|
|
580
|
+
body_bytes = await resp.aread()
|
|
581
|
+
return body_bytes.decode(errors="replace").strip()
|
|
582
|
+
|
|
583
|
+
body_or_result: str | dict[str, Any] = ""
|
|
584
|
+
for attempt in range(3):
|
|
585
|
+
try:
|
|
586
|
+
body_or_result = await _send_once()
|
|
587
|
+
break
|
|
588
|
+
except (httpx.ConnectError, httpx.RemoteProtocolError) as exc:
|
|
589
|
+
if attempt < 2:
|
|
590
|
+
await asyncio.sleep(0.25 * (attempt + 1))
|
|
591
|
+
continue
|
|
592
|
+
if method in {"tools/list", "tools/call"}:
|
|
593
|
+
try:
|
|
594
|
+
return await self._request_tool_router_fallback(method=method, params=params)
|
|
595
|
+
except MCPTransportError:
|
|
596
|
+
pass
|
|
597
|
+
self._pending.pop(req_id, None)
|
|
598
|
+
raise MCPTransportError(f"MCP Streamable HTTP transport failed: {exc}") from exc
|
|
599
|
+
|
|
600
|
+
data: Any = None
|
|
601
|
+
if isinstance(body_or_result, dict):
|
|
602
|
+
data = body_or_result
|
|
603
|
+
body_text = json.dumps(body_or_result, ensure_ascii=True)
|
|
604
|
+
else:
|
|
605
|
+
body_text = body_or_result
|
|
606
|
+
try:
|
|
607
|
+
data = json.loads(body_text) if body_text else None
|
|
608
|
+
except Exception:
|
|
609
|
+
data = None
|
|
610
|
+
|
|
611
|
+
if isinstance(data, dict) and data.get("jsonrpc") == "2.0" and data.get("id") == req_id:
|
|
612
|
+
if data.get("error") is not None:
|
|
613
|
+
error = data.get("error")
|
|
614
|
+
if (
|
|
615
|
+
method in {"tools/list", "tools/call"}
|
|
616
|
+
and isinstance(error, dict)
|
|
617
|
+
and int(error.get("code") or 0) == -32601
|
|
618
|
+
):
|
|
619
|
+
return await self._request_tool_router_fallback(method=method, params=params)
|
|
620
|
+
self._pending.pop(req_id, None)
|
|
621
|
+
raise MCPTransportError(f"MCP error: {error}")
|
|
622
|
+
|
|
623
|
+
result = data.get("result")
|
|
624
|
+
if not isinstance(result, dict):
|
|
625
|
+
self._pending.pop(req_id, None)
|
|
626
|
+
raise MCPProtocolError("MCP result must be an object")
|
|
627
|
+
|
|
628
|
+
self._pending.pop(req_id, None)
|
|
629
|
+
return result
|
|
630
|
+
|
|
631
|
+
if method == "initialize" and isinstance(data, dict):
|
|
632
|
+
if data.get("protocolVersion") is not None or data.get("serverInfo") is not None:
|
|
633
|
+
self._pending.pop(req_id, None)
|
|
634
|
+
return data
|
|
635
|
+
|
|
636
|
+
if self._is_bare_result_method(method) and isinstance(data, dict):
|
|
637
|
+
self._pending.pop(req_id, None)
|
|
638
|
+
return data
|
|
639
|
+
|
|
640
|
+
if method == "tools/list" and data is not None:
|
|
641
|
+
try:
|
|
642
|
+
result = self._normalise_tools_list_payload(data)
|
|
643
|
+
self._pending.pop(req_id, None)
|
|
644
|
+
return result
|
|
645
|
+
except MCPTransportError:
|
|
646
|
+
pass
|
|
647
|
+
|
|
648
|
+
if method == "tools/call" and data is not None:
|
|
649
|
+
try:
|
|
650
|
+
result = self._normalise_tools_call_payload(data)
|
|
651
|
+
self._pending.pop(req_id, None)
|
|
652
|
+
return result
|
|
653
|
+
except MCPTransportError:
|
|
654
|
+
pass
|
|
655
|
+
|
|
656
|
+
if body_text:
|
|
657
|
+
for message in self._parse_inline_sse(body_text):
|
|
658
|
+
if message.get("jsonrpc") == "2.0" and message.get("id") == req_id:
|
|
659
|
+
if message.get("error") is not None:
|
|
660
|
+
self._pending.pop(req_id, None)
|
|
661
|
+
raise MCPTransportError(f"MCP error: {message['error']}")
|
|
662
|
+
result = message.get("result")
|
|
663
|
+
if not isinstance(result, dict):
|
|
664
|
+
self._pending.pop(req_id, None)
|
|
665
|
+
raise MCPProtocolError("MCP result must be an object")
|
|
666
|
+
self._pending.pop(req_id, None)
|
|
667
|
+
return result
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
result = await asyncio.wait_for(future, timeout=self.cfg.timeout_seconds)
|
|
671
|
+
return cast(dict[str, Any], result)
|
|
672
|
+
except asyncio.TimeoutError as exc:
|
|
673
|
+
if method in {"tools/list", "tools/call"}:
|
|
674
|
+
try:
|
|
675
|
+
return await self._request_tool_router_fallback(method=method, params=params)
|
|
676
|
+
except MCPTransportError:
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
detail = {
|
|
680
|
+
"session_id": self._session_id,
|
|
681
|
+
"body": body_text[:500] + ("...<truncated>" if len(body_text) > 500 else ""),
|
|
682
|
+
}
|
|
683
|
+
raise MCPTransportError(f"MCP Streamable HTTP timeout waiting for response: {detail}") from exc
|
|
684
|
+
finally:
|
|
685
|
+
self._pending.pop(req_id, None)
|
|
686
|
+
|
|
687
|
+
async def initialize(
|
|
688
|
+
self,
|
|
689
|
+
*,
|
|
690
|
+
protocol_version: str,
|
|
691
|
+
client_name: str = "cloud-dog-chat-client",
|
|
692
|
+
client_version: str = "0.1.0",
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Perform initialisation with interop-tolerant fallbacks."""
|
|
695
|
+
if not protocol_version:
|
|
696
|
+
raise MCPTransportError("MCP initialize requires protocol_version")
|
|
697
|
+
try:
|
|
698
|
+
await self.request(
|
|
699
|
+
"initialize",
|
|
700
|
+
params={
|
|
701
|
+
"protocolVersion": protocol_version,
|
|
702
|
+
"clientInfo": {"name": client_name, "version": client_version},
|
|
703
|
+
"capabilities": {},
|
|
704
|
+
},
|
|
705
|
+
)
|
|
706
|
+
except MCPTransportError as exc:
|
|
707
|
+
message = str(exc)
|
|
708
|
+
if self._is_nonfatal_initialize_failure(message):
|
|
709
|
+
return
|
|
710
|
+
raise
|
|
711
|
+
try:
|
|
712
|
+
await self.notify("notifications/initialized")
|
|
713
|
+
except MCPTransportError as exc:
|
|
714
|
+
message = str(exc)
|
|
715
|
+
if "Streamable HTTP notifications require an established session" in message:
|
|
716
|
+
return
|
|
717
|
+
raise
|
|
718
|
+
|
|
719
|
+
@staticmethod
|
|
720
|
+
def _is_nonfatal_initialize_failure(message: str) -> bool:
|
|
721
|
+
"""Return True when initialise rejection is interop-tolerable."""
|
|
722
|
+
text = str(message or "").lower()
|
|
723
|
+
if (
|
|
724
|
+
"mcp streamable http failed" in text
|
|
725
|
+
and "post " in text
|
|
726
|
+
and ("-> 404" in text or "-> 405" in text or "-> 501" in text)
|
|
727
|
+
):
|
|
728
|
+
return True
|
|
729
|
+
if "mcp error" in text and ("-32601" in text or "method not found" in text):
|
|
730
|
+
return True
|
|
731
|
+
return False
|
|
732
|
+
|
|
733
|
+
async def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
734
|
+
"""Send a notification over the established streamable HTTP session."""
|
|
735
|
+
if self._client is None:
|
|
736
|
+
raise MCPTransportError("Transport not connected")
|
|
737
|
+
if not self._session_id:
|
|
738
|
+
raise MCPSessionError("Streamable HTTP notifications require an established session")
|
|
739
|
+
|
|
740
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
741
|
+
if params is not None:
|
|
742
|
+
payload["params"] = params
|
|
743
|
+
|
|
744
|
+
resp = await self._client.post(self.cfg.mcp_path, json=payload, headers=self._headers(include_session=True))
|
|
745
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
746
|
+
raise MCPTransportError(
|
|
747
|
+
f"MCP Streamable HTTP notify failed: POST {self.cfg.mcp_path} -> {resp.status_code}"
|
|
748
|
+
)
|