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.
Files changed (98) hide show
  1. cloud_dog_api_kit/__init__.py +170 -0
  2. cloud_dog_api_kit/a2a/__init__.py +53 -0
  3. cloud_dog_api_kit/a2a/card.py +138 -0
  4. cloud_dog_api_kit/a2a/events.py +1123 -0
  5. cloud_dog_api_kit/a2a/gateway.py +105 -0
  6. cloud_dog_api_kit/a2a/skill_audit.py +107 -0
  7. cloud_dog_api_kit/auth/__init__.py +35 -0
  8. cloud_dog_api_kit/auth/dependency.py +121 -0
  9. cloud_dog_api_kit/auth/rbac.py +107 -0
  10. cloud_dog_api_kit/auth/service_auth.py +54 -0
  11. cloud_dog_api_kit/clients/__init__.py +29 -0
  12. cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
  13. cloud_dog_api_kit/clients/http_client.py +127 -0
  14. cloud_dog_api_kit/clients/retry.py +83 -0
  15. cloud_dog_api_kit/compat/__init__.py +37 -0
  16. cloud_dog_api_kit/compat/envelope.py +120 -0
  17. cloud_dog_api_kit/compat/profile.py +102 -0
  18. cloud_dog_api_kit/compat/routes.py +90 -0
  19. cloud_dog_api_kit/config.py +54 -0
  20. cloud_dog_api_kit/correlation/__init__.py +50 -0
  21. cloud_dog_api_kit/correlation/context.py +118 -0
  22. cloud_dog_api_kit/correlation/middleware.py +133 -0
  23. cloud_dog_api_kit/envelopes/__init__.py +37 -0
  24. cloud_dog_api_kit/envelopes/error.py +87 -0
  25. cloud_dog_api_kit/envelopes/success.py +84 -0
  26. cloud_dog_api_kit/errors/__init__.py +51 -0
  27. cloud_dog_api_kit/errors/exceptions.py +184 -0
  28. cloud_dog_api_kit/errors/handler.py +102 -0
  29. cloud_dog_api_kit/errors/taxonomy.py +62 -0
  30. cloud_dog_api_kit/factory.py +157 -0
  31. cloud_dog_api_kit/idempotency/__init__.py +28 -0
  32. cloud_dog_api_kit/idempotency/middleware.py +118 -0
  33. cloud_dog_api_kit/idempotency/store.py +100 -0
  34. cloud_dog_api_kit/lifecycle/__init__.py +39 -0
  35. cloud_dog_api_kit/lifecycle/hooks.py +75 -0
  36. cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
  37. cloud_dog_api_kit/mcp/__init__.py +122 -0
  38. cloud_dog_api_kit/mcp/async_jobs.py +126 -0
  39. cloud_dog_api_kit/mcp/client_sdk.py +235 -0
  40. cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
  41. cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
  42. cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
  43. cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
  44. cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
  45. cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
  46. cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
  47. cloud_dog_api_kit/mcp/contract.py +113 -0
  48. cloud_dog_api_kit/mcp/error_mapper.py +84 -0
  49. cloud_dog_api_kit/mcp/gateway.py +117 -0
  50. cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
  51. cloud_dog_api_kit/mcp/session.py +96 -0
  52. cloud_dog_api_kit/mcp/sync_handler.py +269 -0
  53. cloud_dog_api_kit/mcp/tool_audit.py +136 -0
  54. cloud_dog_api_kit/mcp/tool_router.py +180 -0
  55. cloud_dog_api_kit/mcp/transport.py +1041 -0
  56. cloud_dog_api_kit/middleware/__init__.py +39 -0
  57. cloud_dog_api_kit/middleware/cors.py +74 -0
  58. cloud_dog_api_kit/middleware/logging.py +98 -0
  59. cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
  60. cloud_dog_api_kit/middleware/timeout.py +78 -0
  61. cloud_dog_api_kit/middleware/timing.py +52 -0
  62. cloud_dog_api_kit/openapi/__init__.py +30 -0
  63. cloud_dog_api_kit/openapi/customise.py +69 -0
  64. cloud_dog_api_kit/openapi/route.py +46 -0
  65. cloud_dog_api_kit/routers/__init__.py +41 -0
  66. cloud_dog_api_kit/routers/crud.py +173 -0
  67. cloud_dog_api_kit/routers/health.py +160 -0
  68. cloud_dog_api_kit/routers/jobs.py +69 -0
  69. cloud_dog_api_kit/routers/version.py +46 -0
  70. cloud_dog_api_kit/schemas/__init__.py +36 -0
  71. cloud_dog_api_kit/schemas/envelopes.py +37 -0
  72. cloud_dog_api_kit/schemas/filters.py +103 -0
  73. cloud_dog_api_kit/schemas/pagination.py +148 -0
  74. cloud_dog_api_kit/streaming/__init__.py +28 -0
  75. cloud_dog_api_kit/streaming/events.py +47 -0
  76. cloud_dog_api_kit/streaming/jsonl.py +68 -0
  77. cloud_dog_api_kit/streaming/sse.py +102 -0
  78. cloud_dog_api_kit/testing/__init__.py +46 -0
  79. cloud_dog_api_kit/testing/conformance.py +156 -0
  80. cloud_dog_api_kit/testing/fixtures.py +90 -0
  81. cloud_dog_api_kit/testing/flows/__init__.py +32 -0
  82. cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
  83. cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
  84. cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
  85. cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
  86. cloud_dog_api_kit/traceability_ids.py +84 -0
  87. cloud_dog_api_kit/versioning/__init__.py +30 -0
  88. cloud_dog_api_kit/versioning/header.py +52 -0
  89. cloud_dog_api_kit/web/__init__.py +7 -0
  90. cloud_dog_api_kit/web/proxy.py +222 -0
  91. cloud_dog_api_kit/webhook/__init__.py +29 -0
  92. cloud_dog_api_kit/webhook/signature.py +149 -0
  93. cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
  94. cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
  95. cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
  96. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
  97. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
  98. 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
+ )