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,47 @@
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 client transport exports
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: Public exports for MCP client transport implementations.
20
+ # Related requirements: FR18.1
21
+ # Related architecture: SA1
22
+
23
+ """Public exports for MCP client transports."""
24
+
25
+ from __future__ import annotations
26
+
27
+ from .base import MCPTransport, MCPTransportError
28
+ from .exceptions import MCPProtocolError, MCPSessionError
29
+ from .http_jsonrpc import HTTPJSONRPCConfig, HTTPJSONRPCTransport
30
+ from .legacy_sse import LegacySSEConfig, LegacySSETransport
31
+ from .stdio import StdioConfig, StdioTransport
32
+ from .streamable_http import StreamableHTTPConfig, StreamableHTTPTransport
33
+
34
+ __all__ = [
35
+ "HTTPJSONRPCConfig",
36
+ "HTTPJSONRPCTransport",
37
+ "LegacySSEConfig",
38
+ "LegacySSETransport",
39
+ "MCPProtocolError",
40
+ "MCPSessionError",
41
+ "MCPTransport",
42
+ "MCPTransportError",
43
+ "StdioConfig",
44
+ "StdioTransport",
45
+ "StreamableHTTPConfig",
46
+ "StreamableHTTPTransport",
47
+ ]
@@ -0,0 +1,98 @@
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 client transport base
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: Abstract MCP client transport contract shared by HTTP, SSE,
20
+ # and stdio implementations.
21
+ # Related requirements: FR18.1
22
+ # Related architecture: SA1
23
+
24
+ """Base MCP client transport contract."""
25
+
26
+ from __future__ import annotations
27
+
28
+ from abc import ABC, abstractmethod
29
+ from typing import Any, Dict, Optional
30
+
31
+ from .exceptions import MCPTransportError
32
+
33
+
34
+ class MCPTransport(ABC):
35
+ """Abstract base for MCP client transport implementations."""
36
+
37
+ @abstractmethod
38
+ async def connect(self) -> None:
39
+ """Establish the underlying transport connection."""
40
+
41
+ @abstractmethod
42
+ async def close(self) -> None:
43
+ """Close transport resources and pending sessions."""
44
+
45
+ @abstractmethod
46
+ async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
47
+ """Send an MCP request and return a JSON-RPC result object."""
48
+
49
+ @abstractmethod
50
+ async def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
51
+ """Send a fire-and-forget MCP notification."""
52
+
53
+ async def tools_list(self) -> Dict[str, Any]:
54
+ """Request the server's MCP tool list."""
55
+ return await self.request("tools/list")
56
+
57
+ async def prompts_list(self) -> Dict[str, Any]:
58
+ """Request the server's MCP prompt catalogue."""
59
+ return await self.request("prompts/list")
60
+
61
+ async def prompts_get(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
62
+ """Request a specific prompt from the server."""
63
+ params: Dict[str, Any] = {"name": name}
64
+ if arguments is not None:
65
+ params["arguments"] = arguments
66
+ return await self.request("prompts/get", params=params)
67
+
68
+ async def resources_list(self) -> Dict[str, Any]:
69
+ """Request the server's MCP resource catalogue."""
70
+ return await self.request("resources/list")
71
+
72
+ async def resources_read(self, uri: str) -> Dict[str, Any]:
73
+ """Request a specific MCP resource by URI."""
74
+ return await self.request("resources/read", params={"uri": uri})
75
+
76
+ async def tools_call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
77
+ """Invoke an MCP tool."""
78
+ return await self.request("tools/call", params={"name": name, "arguments": arguments})
79
+
80
+ async def initialize(
81
+ self,
82
+ *,
83
+ protocol_version: str,
84
+ client_name: str = "cloud-dog-chat-client",
85
+ client_version: str = "0.1.0",
86
+ ) -> None:
87
+ """Perform the standard MCP initialise + notifications/initialized flow."""
88
+ if not protocol_version:
89
+ raise MCPTransportError("MCP initialize requires protocol_version")
90
+ await self.request(
91
+ "initialize",
92
+ params={
93
+ "protocolVersion": protocol_version,
94
+ "clientInfo": {"name": client_name, "version": client_version},
95
+ "capabilities": {},
96
+ },
97
+ )
98
+ await self.notify("notifications/initialized")
@@ -0,0 +1,37 @@
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 client transport exceptions
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: Shared exception hierarchy for MCP client transport adapters.
20
+ # Related requirements: FR18.1
21
+ # Related architecture: SA1
22
+
23
+ """Exceptions raised by MCP client transports."""
24
+
25
+ from __future__ import annotations
26
+
27
+
28
+ class MCPTransportError(RuntimeError):
29
+ """Raised when an MCP transport operation fails."""
30
+
31
+
32
+ class MCPSessionError(MCPTransportError):
33
+ """Raised when an MCP transport session lifecycle operation fails."""
34
+
35
+
36
+ class MCPProtocolError(MCPTransportError):
37
+ """Raised when a peer violates the expected MCP protocol contract."""
@@ -0,0 +1,405 @@
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 HTTP JSON-RPC client transport
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: HTTP JSON-RPC MCP client transport with optional async job
20
+ # polling support.
21
+ # Related requirements: FR18.1
22
+ # Related architecture: SA1
23
+
24
+ """HTTP JSON-RPC 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
32
+ from urllib.parse import urlsplit, urlunsplit
33
+
34
+ import httpx
35
+
36
+ from .base import MCPTransport
37
+ from .exceptions import MCPProtocolError, MCPTransportError
38
+
39
+
40
+ @dataclass
41
+ class HTTPJSONRPCConfig:
42
+ """Configuration for HTTP JSON-RPC MCP transport."""
43
+
44
+ base_url: str
45
+ messages_path: str
46
+ health_path: str
47
+ api_key_header: Optional[str] = None
48
+ api_key: Optional[str] = None
49
+ accept_header: Optional[str] = None
50
+ timeout_seconds: float = 30.0
51
+ verify_tls: bool = True
52
+ async_jobs_enabled: bool = False
53
+ async_jobs_api_base_url: Optional[str] = None
54
+ async_jobs_status_path: str = "/jobs/{job_id}"
55
+ async_jobs_timeout_seconds: float = 120.0
56
+ async_jobs_poll_interval_seconds: float = 2.0
57
+ extra_headers: Optional[Dict[str, str]] = None
58
+
59
+
60
+ class HTTPJSONRPCTransport(MCPTransport):
61
+ """MCP client transport for `/messages` JSON-RPC endpoints."""
62
+
63
+ def __init__(self, cfg: HTTPJSONRPCConfig):
64
+ """Initialise HTTPJSONRPCTransport state and dependencies."""
65
+ original_base_url = str(cfg.base_url or "").strip()
66
+ base_url, messages_path = self._normalise_base_and_request_path(
67
+ original_base_url, cfg.messages_path, default_path="/messages"
68
+ )
69
+ _, health_path = self._normalise_base_and_request_path(base_url, cfg.health_path, default_path="/health")
70
+ cfg.base_url = base_url
71
+ cfg.messages_path = messages_path
72
+ cfg.health_path = health_path
73
+ self.cfg = cfg
74
+ self._client: httpx.AsyncClient | None = None
75
+ self._async_jobs_client: httpx.AsyncClient | None = None
76
+ self._id = 0
77
+ self._messages_paths = self._build_messages_paths(
78
+ original_base_url=original_base_url,
79
+ messages_path=messages_path,
80
+ )
81
+
82
+ @staticmethod
83
+ def _normalise_base_and_request_path(base_url: str, request_path: str, *, default_path: str) -> tuple[str, str]:
84
+ """Normalise base URL and request path without dropping base path segments."""
85
+ base = str(base_url or "").rstrip("/")
86
+ path = str(request_path or "").strip()
87
+ if not path:
88
+ path = default_path
89
+ if not path.startswith("/"):
90
+ path = f"/{path}"
91
+
92
+ parsed = urlsplit(base)
93
+ base_path = (parsed.path or "").rstrip("/")
94
+ if base_path:
95
+ if path == base_path or path.startswith(f"{base_path}/"):
96
+ base = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
97
+ else:
98
+ path = f"{base_path}{path}"
99
+ base = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
100
+ return base, path
101
+
102
+ @staticmethod
103
+ def _build_messages_paths(*, original_base_url: str, messages_path: str) -> list[str]:
104
+ """Return ordered JSON-RPC endpoint candidates for interop."""
105
+ paths: list[str] = [messages_path]
106
+ parsed = urlsplit(str(original_base_url or "").strip())
107
+ base_path = (parsed.path or "").rstrip("/")
108
+ if base_path and base_path not in paths and messages_path.endswith("/messages"):
109
+ paths.append(base_path)
110
+ return paths
111
+
112
+ async def connect(self) -> None:
113
+ """Create the shared transport-owned HTTP client."""
114
+ if self._client is not None:
115
+ return
116
+ self._client = httpx.AsyncClient(
117
+ base_url=str(self.cfg.base_url).rstrip("/"),
118
+ timeout=httpx.Timeout(self.cfg.timeout_seconds, connect=self.cfg.timeout_seconds),
119
+ verify=self.cfg.verify_tls,
120
+ trust_env=True,
121
+ )
122
+
123
+ async def close(self) -> None:
124
+ """Close all transport-owned HTTP clients."""
125
+ if self._async_jobs_client is not None:
126
+ await self._async_jobs_client.aclose()
127
+ self._async_jobs_client = None
128
+ if self._client is not None:
129
+ await self._client.aclose()
130
+ self._client = None
131
+
132
+ def _headers(self) -> dict[str, str]:
133
+ """Build request headers for JSON-RPC calls."""
134
+ headers: dict[str, str] = {}
135
+ if isinstance(self.cfg.extra_headers, dict):
136
+ headers.update(
137
+ {
138
+ str(key): str(value)
139
+ for key, value in self.cfg.extra_headers.items()
140
+ if str(key).strip() and str(value).strip()
141
+ }
142
+ )
143
+ if self.cfg.api_key_header and self.cfg.api_key:
144
+ headers[self.cfg.api_key_header] = self.cfg.api_key
145
+ if self.cfg.accept_header:
146
+ headers["accept"] = self.cfg.accept_header
147
+ return headers
148
+
149
+ async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
150
+ """Send a JSON-RPC request and return the result object."""
151
+ if self._client is None:
152
+ raise MCPTransportError("Transport not connected")
153
+
154
+ self._id += 1
155
+ req_id = self._id
156
+ payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
157
+ if params is not None:
158
+ payload["params"] = params
159
+
160
+ try:
161
+ resp, request_path = await self._post_jsonrpc(payload)
162
+ if resp.status_code != 200:
163
+ raise MCPTransportError(f"MCP HTTP JSON-RPC failed: POST {request_path} -> {resp.status_code}")
164
+ data = resp.json()
165
+ except httpx.RequestError as exc:
166
+ raise MCPTransportError(
167
+ f"MCP HTTP JSON-RPC request failed: POST {self.cfg.messages_path} -> {exc}"
168
+ ) from exc
169
+ except httpx.HTTPStatusError as exc:
170
+ status = exc.response.status_code if exc.response is not None else "unknown"
171
+ raise MCPTransportError(f"MCP HTTP JSON-RPC failed: POST {self.cfg.messages_path} -> {status}") from exc
172
+
173
+ if not isinstance(data, dict):
174
+ raise MCPProtocolError("MCP HTTP JSON-RPC returned non-object JSON")
175
+ if data.get("jsonrpc") != "2.0":
176
+ raise MCPProtocolError("MCP HTTP JSON-RPC invalid response: jsonrpc must be '2.0'")
177
+ if data.get("id") != req_id:
178
+ raise MCPProtocolError("MCP HTTP JSON-RPC response id mismatch")
179
+ if data.get("error") is not None:
180
+ raise MCPTransportError(f"MCP HTTP JSON-RPC error: {data['error']}")
181
+
182
+ result = data.get("result")
183
+ if not isinstance(result, dict):
184
+ raise MCPProtocolError("MCP HTTP JSON-RPC result must be an object")
185
+ result = await self._maybe_resolve_async_job(method=method, params=params, result=result)
186
+ return result
187
+
188
+ async def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
189
+ """Send a JSON-RPC notification."""
190
+ if self._client is None:
191
+ raise MCPTransportError("Transport not connected")
192
+
193
+ payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
194
+ if params is not None:
195
+ payload["params"] = params
196
+
197
+ resp, request_path = await self._post_jsonrpc(payload)
198
+ if resp.status_code < 200 or resp.status_code >= 300:
199
+ raise MCPTransportError(f"MCP HTTP JSON-RPC notify failed: POST {request_path} -> {resp.status_code}")
200
+
201
+ async def _maybe_resolve_async_job(
202
+ self, *, method: str, params: dict[str, Any] | None, result: dict[str, Any]
203
+ ) -> dict[str, Any]:
204
+ """Resolve async job handles when configured for wait=false flows."""
205
+ if not self.cfg.async_jobs_enabled:
206
+ return result
207
+ if method != "tools/call":
208
+ return result
209
+ if not isinstance(params, dict):
210
+ return result
211
+ arguments = params.get("arguments")
212
+ if not isinstance(arguments, dict):
213
+ return result
214
+ if bool(arguments.get("wait", True)):
215
+ return result
216
+
217
+ job_ref = self._extract_job_ref(result)
218
+ if not job_ref:
219
+ return result
220
+
221
+ job_id = str(job_ref.get("job_id") or "").strip()
222
+ guid = str(job_ref.get("guid") or "").strip()
223
+ if not job_id and not guid:
224
+ return result
225
+
226
+ base_url = self._async_jobs_base_url()
227
+ path_template = str(self.cfg.async_jobs_status_path or "/jobs/{job_id}")
228
+ deadline = asyncio.get_running_loop().time() + max(1.0, float(self.cfg.async_jobs_timeout_seconds))
229
+ poll_interval = max(0.1, float(self.cfg.async_jobs_poll_interval_seconds))
230
+ try:
231
+ return await self._poll_async_job(
232
+ base_url=base_url,
233
+ path_template=path_template,
234
+ job_id=job_id,
235
+ guid=guid,
236
+ deadline=deadline,
237
+ poll_interval=poll_interval,
238
+ )
239
+ except MCPTransportError as exc:
240
+ if not self._should_fallback_to_wait_true(exc):
241
+ raise
242
+ try:
243
+ return await self._retry_tools_call_wait_true(params=params)
244
+ except MCPTransportError as retry_exc:
245
+ raise MCPTransportError(f"{exc}; MCP wait=true fallback failed: {retry_exc}") from retry_exc
246
+
247
+ async def _ensure_async_jobs_client(self, base_url: str) -> httpx.AsyncClient:
248
+ """Create or reuse the shared async-jobs polling client."""
249
+ if self._async_jobs_client is None:
250
+ poll_timeout = min(30.0, max(1.0, float(self.cfg.timeout_seconds)))
251
+ self._async_jobs_client = httpx.AsyncClient(
252
+ base_url=base_url,
253
+ timeout=httpx.Timeout(poll_timeout, connect=poll_timeout),
254
+ verify=self.cfg.verify_tls,
255
+ trust_env=True,
256
+ )
257
+ return self._async_jobs_client
258
+
259
+ async def _poll_async_job(
260
+ self,
261
+ *,
262
+ base_url: str,
263
+ path_template: str,
264
+ job_id: str,
265
+ guid: str,
266
+ deadline: float,
267
+ poll_interval: float,
268
+ ) -> dict[str, Any]:
269
+ """Poll async job status endpoint until completion."""
270
+ client = await self._ensure_async_jobs_client(base_url)
271
+ while True:
272
+ path = self._job_status_path(path_template=path_template, job_id=job_id, guid=guid)
273
+ try:
274
+ resp = await client.get(path, headers=self._headers())
275
+ except httpx.RequestError as exc:
276
+ raise MCPTransportError(f"Async job polling request failed: GET {path} -> {exc}") from exc
277
+ if resp.status_code != 200:
278
+ raise MCPTransportError(f"Async job polling failed: GET {path} -> {resp.status_code}")
279
+ try:
280
+ payload = resp.json()
281
+ except Exception as exc:
282
+ raise MCPProtocolError("Async job polling returned invalid JSON") from exc
283
+ if not isinstance(payload, dict):
284
+ raise MCPProtocolError("Async job polling returned non-object JSON")
285
+
286
+ status = str(payload.get("status") or "").lower()
287
+ if status in {"completed", "failed", "error", "cancelled", "canceled"}:
288
+ return {
289
+ "content": [
290
+ {
291
+ "type": "text",
292
+ "text": json.dumps(payload, ensure_ascii=True),
293
+ }
294
+ ],
295
+ "isError": status != "completed",
296
+ }
297
+
298
+ if asyncio.get_running_loop().time() >= deadline:
299
+ raise MCPTransportError(
300
+ f"Async job polling timed out after {self.cfg.async_jobs_timeout_seconds}s "
301
+ f"(job_id={job_id or 'n/a'}, guid={guid or 'n/a'})"
302
+ )
303
+ await asyncio.sleep(poll_interval)
304
+
305
+ def _should_fallback_to_wait_true(self, error: MCPTransportError) -> bool:
306
+ """Return True when async status endpoint is not usable."""
307
+ msg = str(error).lower()
308
+ if "async job polling request failed" in msg:
309
+ return True
310
+ return "async job polling failed: get" in msg and "-> 404" in msg
311
+
312
+ async def _retry_tools_call_wait_true(self, *, params: dict[str, Any]) -> dict[str, Any]:
313
+ """Retry the same MCP tool call with wait=true using MCP transport only."""
314
+ if self._client is None:
315
+ raise MCPTransportError("Transport not connected")
316
+ retry_params = dict(params)
317
+ arguments = retry_params.get("arguments")
318
+ if not isinstance(arguments, dict):
319
+ raise MCPTransportError("MCP wait=true fallback requires arguments object")
320
+ retry_arguments = dict(arguments)
321
+ retry_arguments["wait"] = True
322
+ retry_params["arguments"] = retry_arguments
323
+
324
+ self._id += 1
325
+ req_id = self._id
326
+ payload: dict[str, Any] = {
327
+ "jsonrpc": "2.0",
328
+ "id": req_id,
329
+ "method": "tools/call",
330
+ "params": retry_params,
331
+ }
332
+ try:
333
+ resp, request_path = await self._post_jsonrpc(payload)
334
+ if resp.status_code != 200:
335
+ raise MCPTransportError(f"MCP wait=true fallback failed: POST {request_path} -> {resp.status_code}")
336
+ data = resp.json()
337
+ except httpx.RequestError as exc:
338
+ raise MCPTransportError(
339
+ f"MCP wait=true fallback request failed: POST {self.cfg.messages_path} -> {exc}"
340
+ ) from exc
341
+ if not isinstance(data, dict):
342
+ raise MCPProtocolError("MCP wait=true fallback returned non-object JSON")
343
+ if data.get("jsonrpc") != "2.0":
344
+ raise MCPProtocolError("MCP wait=true fallback invalid jsonrpc")
345
+ if data.get("id") != req_id:
346
+ raise MCPProtocolError("MCP wait=true fallback response id mismatch")
347
+ if data.get("error") is not None:
348
+ raise MCPTransportError(f"MCP wait=true fallback error: {data['error']}")
349
+ result = data.get("result")
350
+ if not isinstance(result, dict):
351
+ raise MCPProtocolError("MCP wait=true fallback result must be an object")
352
+ return result
353
+
354
+ async def _post_jsonrpc(self, payload: dict[str, Any]) -> tuple[httpx.Response, str]:
355
+ """POST JSON-RPC payload with compatibility fallback paths."""
356
+ if self._client is None:
357
+ raise MCPTransportError("Transport not connected")
358
+
359
+ response: httpx.Response | None = None
360
+ request_path = self.cfg.messages_path
361
+ for path in self._messages_paths:
362
+ request_path = path
363
+ response = await self._client.post(path, json=payload, headers=self._headers())
364
+ if response.status_code != 404:
365
+ return response, request_path
366
+ assert response is not None
367
+ return response, request_path
368
+
369
+ def _extract_job_ref(self, result: dict[str, Any]) -> dict[str, Any] | None:
370
+ """Extract async job metadata from a tool result payload."""
371
+ if "job_id" in result or "guid" in result:
372
+ return result
373
+ content = result.get("content")
374
+ if not isinstance(content, list):
375
+ return None
376
+ for item in content:
377
+ if not isinstance(item, dict) or item.get("type") != "text":
378
+ continue
379
+ text = item.get("text")
380
+ if not isinstance(text, str):
381
+ continue
382
+ try:
383
+ obj = json.loads(text)
384
+ except Exception:
385
+ continue
386
+ if isinstance(obj, dict) and ("job_id" in obj or "guid" in obj):
387
+ return obj
388
+ return None
389
+
390
+ def _async_jobs_base_url(self) -> str:
391
+ """Resolve the async jobs API base URL."""
392
+ explicit = str(self.cfg.async_jobs_api_base_url or "").strip()
393
+ if explicit:
394
+ return explicit.rstrip("/")
395
+ parsed = urlsplit(self.cfg.base_url)
396
+ if parsed.port == 8081:
397
+ netloc = f"{parsed.hostname}:8083" if parsed.hostname else parsed.netloc
398
+ return urlunsplit((parsed.scheme, netloc, "", "", "")).rstrip("/")
399
+ return self.cfg.base_url.rstrip("/")
400
+
401
+ def _job_status_path(self, *, path_template: str, job_id: str, guid: str) -> str:
402
+ """Build the job status endpoint path."""
403
+ if "{guid}" in path_template and guid:
404
+ return path_template.format(job_id=job_id or guid, guid=guid)
405
+ return path_template.format(job_id=job_id or guid, guid=guid)