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,320 @@
|
|
|
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 legacy SSE client transport
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Legacy SSE MCP client transport for endpoint-discovery servers.
|
|
20
|
+
# Related requirements: FR18.1
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""Legacy SSE MCP client transport."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
from cloud_dog_api_kit.mcp.session import SESSION_HEADER
|
|
36
|
+
|
|
37
|
+
from .base import MCPTransport
|
|
38
|
+
from .exceptions import MCPProtocolError, MCPTransportError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class LegacySSEConfig:
|
|
43
|
+
"""Configuration for legacy SSE MCP transport."""
|
|
44
|
+
|
|
45
|
+
base_url: str
|
|
46
|
+
sse_path: str
|
|
47
|
+
messages_path: str
|
|
48
|
+
api_key_header: Optional[str] = None
|
|
49
|
+
api_key: Optional[str] = None
|
|
50
|
+
accept_header: Optional[str] = None
|
|
51
|
+
auth_bearer_token: Optional[str] = None
|
|
52
|
+
protocol_version: Optional[str] = None
|
|
53
|
+
timeout_seconds: float = 30.0
|
|
54
|
+
verify_tls: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LegacySSETransport(MCPTransport):
|
|
58
|
+
"""MCP client transport for legacy SSE + message-post servers."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, cfg: LegacySSEConfig):
|
|
61
|
+
"""Initialise LegacySSETransport state and dependencies."""
|
|
62
|
+
self.cfg = cfg
|
|
63
|
+
self._client: httpx.AsyncClient | None = None
|
|
64
|
+
self._sse_task: asyncio.Task[Any] | None = None
|
|
65
|
+
self._pending: dict[int, asyncio.Future[Any]] = {}
|
|
66
|
+
self._id = 0
|
|
67
|
+
self._message_endpoint: str | None = None
|
|
68
|
+
self._session_id: str | None = None
|
|
69
|
+
self._endpoint_ready = asyncio.Event()
|
|
70
|
+
|
|
71
|
+
async def connect(self) -> None:
|
|
72
|
+
"""Create the shared HTTP client and start the SSE loop."""
|
|
73
|
+
if self._client is not None:
|
|
74
|
+
return
|
|
75
|
+
self._client = httpx.AsyncClient(
|
|
76
|
+
base_url=str(self.cfg.base_url).rstrip("/"),
|
|
77
|
+
timeout=httpx.Timeout(self.cfg.timeout_seconds, connect=self.cfg.timeout_seconds),
|
|
78
|
+
verify=self.cfg.verify_tls,
|
|
79
|
+
trust_env=True,
|
|
80
|
+
)
|
|
81
|
+
await self._ensure_sse()
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Close the SSE loop, HTTP client, and pending requests."""
|
|
85
|
+
if self._sse_task is not None:
|
|
86
|
+
self._sse_task.cancel()
|
|
87
|
+
self._sse_task = None
|
|
88
|
+
if self._client is not None:
|
|
89
|
+
await self._client.aclose()
|
|
90
|
+
self._client = None
|
|
91
|
+
for future in list(self._pending.values()):
|
|
92
|
+
if not future.done():
|
|
93
|
+
future.set_exception(MCPTransportError("Transport closed"))
|
|
94
|
+
self._pending.clear()
|
|
95
|
+
|
|
96
|
+
async def _ensure_sse(self) -> None:
|
|
97
|
+
"""Start the background SSE reader once."""
|
|
98
|
+
if self._sse_task is not None:
|
|
99
|
+
return
|
|
100
|
+
if self._client is None:
|
|
101
|
+
raise MCPTransportError("Transport not connected")
|
|
102
|
+
self._sse_task = asyncio.create_task(self._sse_loop())
|
|
103
|
+
|
|
104
|
+
def _headers(self) -> dict[str, str]:
|
|
105
|
+
"""Build standard JSON request headers."""
|
|
106
|
+
headers: dict[str, str] = {"accept": "application/json"}
|
|
107
|
+
if self.cfg.accept_header:
|
|
108
|
+
headers["accept"] = self.cfg.accept_header
|
|
109
|
+
if self.cfg.api_key_header and self.cfg.api_key:
|
|
110
|
+
headers[self.cfg.api_key_header] = self.cfg.api_key
|
|
111
|
+
if self.cfg.auth_bearer_token:
|
|
112
|
+
headers["authorization"] = f"Bearer {self.cfg.auth_bearer_token}"
|
|
113
|
+
if self.cfg.protocol_version:
|
|
114
|
+
headers["mcp-protocol-version"] = self.cfg.protocol_version
|
|
115
|
+
return headers
|
|
116
|
+
|
|
117
|
+
def _sse_headers(self) -> dict[str, str]:
|
|
118
|
+
"""Build SSE request headers."""
|
|
119
|
+
headers: dict[str, str] = {"accept": "text/event-stream"}
|
|
120
|
+
if self.cfg.api_key_header and self.cfg.api_key:
|
|
121
|
+
headers[self.cfg.api_key_header] = self.cfg.api_key
|
|
122
|
+
if self.cfg.auth_bearer_token:
|
|
123
|
+
headers["authorization"] = f"Bearer {self.cfg.auth_bearer_token}"
|
|
124
|
+
if self.cfg.protocol_version:
|
|
125
|
+
headers["mcp-protocol-version"] = self.cfg.protocol_version
|
|
126
|
+
return headers
|
|
127
|
+
|
|
128
|
+
def _set_endpoint(self, endpoint: str) -> None:
|
|
129
|
+
"""Capture the messages endpoint announced via SSE."""
|
|
130
|
+
endpoint = endpoint.strip()
|
|
131
|
+
parsed = urlparse(endpoint)
|
|
132
|
+
if parsed.scheme and parsed.netloc:
|
|
133
|
+
self._message_endpoint = endpoint
|
|
134
|
+
query = parse_qs(parsed.query)
|
|
135
|
+
else:
|
|
136
|
+
if not endpoint.startswith("/"):
|
|
137
|
+
endpoint = f"/{endpoint}"
|
|
138
|
+
self._message_endpoint = endpoint
|
|
139
|
+
query = parse_qs(urlparse(endpoint).query)
|
|
140
|
+
session_id = query.get("sessionId", []) or query.get("session_id", [])
|
|
141
|
+
if session_id:
|
|
142
|
+
self._session_id = session_id[0]
|
|
143
|
+
self._endpoint_ready.set()
|
|
144
|
+
|
|
145
|
+
def _endpoint_with_session(self, endpoint: str) -> str:
|
|
146
|
+
"""Append session_id query param when required for compatibility."""
|
|
147
|
+
if not self._session_id:
|
|
148
|
+
return endpoint
|
|
149
|
+
parsed = urlparse(endpoint)
|
|
150
|
+
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
151
|
+
if "session_id" in query or "sessionId" in query:
|
|
152
|
+
return endpoint
|
|
153
|
+
query["session_id"] = self._session_id
|
|
154
|
+
rebuilt = urlunparse(parsed._replace(query=urlencode(query, doseq=True)))
|
|
155
|
+
if parsed.scheme and parsed.netloc:
|
|
156
|
+
return rebuilt
|
|
157
|
+
if not rebuilt.startswith("/"):
|
|
158
|
+
return f"/{rebuilt}"
|
|
159
|
+
return rebuilt
|
|
160
|
+
|
|
161
|
+
def _message_headers(self, endpoint: str) -> dict[str, str]:
|
|
162
|
+
"""Build POST headers for legacy message endpoint calls."""
|
|
163
|
+
headers = self._headers()
|
|
164
|
+
if self._session_id:
|
|
165
|
+
has_session_header = any(str(key).lower() == SESSION_HEADER.lower() for key in headers)
|
|
166
|
+
if not has_session_header:
|
|
167
|
+
headers[SESSION_HEADER] = self._session_id
|
|
168
|
+
return headers
|
|
169
|
+
|
|
170
|
+
async def _wait_for_endpoint(self) -> None:
|
|
171
|
+
"""Wait for the server to announce the message endpoint."""
|
|
172
|
+
if self._message_endpoint:
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait_for(self._endpoint_ready.wait(), timeout=self.cfg.timeout_seconds)
|
|
176
|
+
except asyncio.TimeoutError as exc:
|
|
177
|
+
raise MCPTransportError("Timed out waiting for legacy SSE endpoint") from exc
|
|
178
|
+
if not self._message_endpoint:
|
|
179
|
+
raise MCPTransportError("Legacy SSE endpoint not provided by server")
|
|
180
|
+
|
|
181
|
+
async def _sse_loop(self) -> None:
|
|
182
|
+
"""Consume legacy SSE events and dispatch JSON-RPC replies."""
|
|
183
|
+
assert self._client is not None
|
|
184
|
+
event_name: str | None = None
|
|
185
|
+
data_lines: list[str] = []
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
async with self._client.stream("GET", self.cfg.sse_path, headers=self._sse_headers(), timeout=None) as resp:
|
|
189
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
190
|
+
raise MCPTransportError(f"Legacy SSE GET failed: {resp.status_code}")
|
|
191
|
+
content_type = (resp.headers.get("content-type") or "").lower()
|
|
192
|
+
if "text/event-stream" not in content_type:
|
|
193
|
+
raise MCPProtocolError("Legacy SSE stream missing text/event-stream content type")
|
|
194
|
+
|
|
195
|
+
async for line in resp.aiter_lines():
|
|
196
|
+
if line == "":
|
|
197
|
+
if not data_lines:
|
|
198
|
+
continue
|
|
199
|
+
data_text = "\n".join(data_lines)
|
|
200
|
+
self._handle_event(event_name, data_text)
|
|
201
|
+
event_name = None
|
|
202
|
+
data_lines = []
|
|
203
|
+
continue
|
|
204
|
+
if line.startswith(":"):
|
|
205
|
+
continue
|
|
206
|
+
if line.startswith("event:"):
|
|
207
|
+
event_name = line[len("event:") :].strip()
|
|
208
|
+
continue
|
|
209
|
+
if line.startswith("data:"):
|
|
210
|
+
data_lines.append(line[len("data:") :].lstrip())
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
def _handle_event(self, event_name: str | None, data_text: str) -> None:
|
|
215
|
+
"""Handle legacy SSE endpoint and message events."""
|
|
216
|
+
if event_name == "endpoint":
|
|
217
|
+
try:
|
|
218
|
+
payload = json.loads(data_text)
|
|
219
|
+
if isinstance(payload, dict):
|
|
220
|
+
session_id = payload.get("session_id") or payload.get("sessionId")
|
|
221
|
+
if session_id:
|
|
222
|
+
self._session_id = str(session_id)
|
|
223
|
+
endpoint = payload.get("endpoint") or payload.get("path") or payload.get("messages_path") or ""
|
|
224
|
+
if endpoint:
|
|
225
|
+
self._set_endpoint(str(endpoint))
|
|
226
|
+
return
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
self._set_endpoint(data_text)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
payload = json.loads(data_text)
|
|
234
|
+
except Exception:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if isinstance(payload, dict) and payload.get("jsonrpc") == "2.0":
|
|
238
|
+
msg_id = payload.get("id")
|
|
239
|
+
if msg_id is not None and msg_id in self._pending:
|
|
240
|
+
future = self._pending.pop(msg_id)
|
|
241
|
+
if not future.done():
|
|
242
|
+
future.set_result(payload)
|
|
243
|
+
|
|
244
|
+
def _extract_result(self, payload: Any, *, req_id: int) -> dict[str, Any]:
|
|
245
|
+
"""Validate a JSON-RPC response payload and return the result object."""
|
|
246
|
+
if not isinstance(payload, dict):
|
|
247
|
+
raise MCPProtocolError("Legacy SSE response returned non-object JSON")
|
|
248
|
+
if payload.get("jsonrpc") != "2.0":
|
|
249
|
+
raise MCPProtocolError("Legacy SSE invalid response: jsonrpc must be '2.0'")
|
|
250
|
+
if payload.get("id") != req_id:
|
|
251
|
+
raise MCPProtocolError("Legacy SSE response id mismatch")
|
|
252
|
+
if payload.get("error") is not None:
|
|
253
|
+
raise MCPTransportError(f"Legacy SSE JSON-RPC error: {payload['error']}")
|
|
254
|
+
result = payload.get("result")
|
|
255
|
+
if not isinstance(result, dict):
|
|
256
|
+
raise MCPProtocolError("Legacy SSE result must be an object")
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
260
|
+
"""Send a JSON-RPC request via legacy message POST + SSE response."""
|
|
261
|
+
await self.connect()
|
|
262
|
+
await self._wait_for_endpoint()
|
|
263
|
+
assert self._client is not None
|
|
264
|
+
self._id += 1
|
|
265
|
+
req_id = self._id
|
|
266
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
267
|
+
if params is not None:
|
|
268
|
+
payload["params"] = params
|
|
269
|
+
future = asyncio.get_running_loop().create_future()
|
|
270
|
+
self._pending[req_id] = future
|
|
271
|
+
|
|
272
|
+
endpoint = self._endpoint_with_session(self._message_endpoint or self.cfg.messages_path)
|
|
273
|
+
resp = await self._client.post(endpoint, json=payload, headers=self._message_headers(endpoint))
|
|
274
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
275
|
+
self._pending.pop(req_id, None)
|
|
276
|
+
raise MCPTransportError(f"Legacy SSE message POST failed: {resp.status_code}")
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
direct_result = resp.json()
|
|
280
|
+
except Exception:
|
|
281
|
+
direct_result = None
|
|
282
|
+
if isinstance(direct_result, dict) and direct_result.get("jsonrpc") == "2.0":
|
|
283
|
+
self._pending.pop(req_id, None)
|
|
284
|
+
if not future.done():
|
|
285
|
+
future.cancel()
|
|
286
|
+
result = self._extract_result(direct_result, req_id=req_id)
|
|
287
|
+
return {
|
|
288
|
+
**result,
|
|
289
|
+
"jsonrpc": "2.0",
|
|
290
|
+
"id": req_id,
|
|
291
|
+
"result": result,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = await asyncio.wait_for(future, timeout=self.cfg.timeout_seconds)
|
|
296
|
+
except asyncio.TimeoutError as exc:
|
|
297
|
+
self._pending.pop(req_id, None)
|
|
298
|
+
if not future.done():
|
|
299
|
+
future.cancel()
|
|
300
|
+
raise MCPTransportError("Timed out waiting for legacy SSE response event") from exc
|
|
301
|
+
extracted = self._extract_result(result, req_id=req_id)
|
|
302
|
+
return {
|
|
303
|
+
**extracted,
|
|
304
|
+
"jsonrpc": "2.0",
|
|
305
|
+
"id": req_id,
|
|
306
|
+
"result": extracted,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
310
|
+
"""Send a JSON-RPC notification via the legacy message endpoint."""
|
|
311
|
+
await self.connect()
|
|
312
|
+
await self._wait_for_endpoint()
|
|
313
|
+
assert self._client is not None
|
|
314
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
315
|
+
if params is not None:
|
|
316
|
+
payload["params"] = params
|
|
317
|
+
endpoint = self._endpoint_with_session(self._message_endpoint or self.cfg.messages_path)
|
|
318
|
+
resp = await self._client.post(endpoint, json=payload, headers=self._message_headers(endpoint))
|
|
319
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
320
|
+
raise MCPTransportError(f"Legacy SSE notify failed: {resp.status_code}")
|
|
@@ -0,0 +1,322 @@
|
|
|
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 stdio client transport
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Stdio-based MCP client transport with content-length and
|
|
20
|
+
# newline framing support.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Stdio 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
|
+
|
|
33
|
+
from .base import MCPTransport
|
|
34
|
+
from .exceptions import MCPProtocolError, MCPTransportError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class StdioConfig:
|
|
39
|
+
"""Configuration for stdio MCP transport."""
|
|
40
|
+
|
|
41
|
+
command: str
|
|
42
|
+
args: list[str]
|
|
43
|
+
env: Optional[Dict[str, str]] = None
|
|
44
|
+
framing: str = "content_length"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StdioTransport(MCPTransport):
|
|
48
|
+
"""MCP client transport that talks to subprocess stdio."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, cfg: StdioConfig):
|
|
51
|
+
"""Initialise StdioTransport state and dependencies."""
|
|
52
|
+
self.cfg = cfg
|
|
53
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
54
|
+
self._reader_task: asyncio.Task[Any] | None = None
|
|
55
|
+
self._stderr_task: asyncio.Task[Any] | None = None
|
|
56
|
+
self._wait_task: asyncio.Task[Any] | None = None
|
|
57
|
+
self._stderr_buf = bytearray()
|
|
58
|
+
self._line_buf = bytearray()
|
|
59
|
+
self._pending: dict[str, asyncio.Future[Any]] = {}
|
|
60
|
+
self._id = 0
|
|
61
|
+
|
|
62
|
+
async def connect(self) -> None:
|
|
63
|
+
"""Start the subprocess and background readers."""
|
|
64
|
+
if self._proc is not None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
env = None
|
|
68
|
+
if self.cfg.env is not None:
|
|
69
|
+
env = {str(key): str(value) for key, value in self.cfg.env.items()}
|
|
70
|
+
|
|
71
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
72
|
+
self.cfg.command,
|
|
73
|
+
*self.cfg.args,
|
|
74
|
+
stdin=asyncio.subprocess.PIPE,
|
|
75
|
+
stdout=asyncio.subprocess.PIPE,
|
|
76
|
+
stderr=asyncio.subprocess.PIPE,
|
|
77
|
+
env=env,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if self._proc.stdin is None or self._proc.stdout is None:
|
|
81
|
+
raise MCPTransportError("Failed to open stdio pipes")
|
|
82
|
+
|
|
83
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
84
|
+
if self._proc.stderr is not None:
|
|
85
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
86
|
+
self._wait_task = asyncio.create_task(self._wait_for_exit())
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
"""Shut down the subprocess and fail pending requests."""
|
|
90
|
+
if self._reader_task is not None:
|
|
91
|
+
self._reader_task.cancel()
|
|
92
|
+
self._reader_task = None
|
|
93
|
+
|
|
94
|
+
if self._stderr_task is not None:
|
|
95
|
+
self._stderr_task.cancel()
|
|
96
|
+
self._stderr_task = None
|
|
97
|
+
|
|
98
|
+
if self._wait_task is not None:
|
|
99
|
+
self._wait_task.cancel()
|
|
100
|
+
self._wait_task = None
|
|
101
|
+
|
|
102
|
+
if self._proc is not None:
|
|
103
|
+
try:
|
|
104
|
+
if self._proc.stdin:
|
|
105
|
+
self._proc.stdin.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
self._proc.terminate()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await asyncio.wait_for(self._proc.wait(), timeout=2.0)
|
|
116
|
+
except Exception:
|
|
117
|
+
try:
|
|
118
|
+
self._proc.kill()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
self._proc = None
|
|
122
|
+
|
|
123
|
+
for future in list(self._pending.values()):
|
|
124
|
+
if not future.done():
|
|
125
|
+
future.set_exception(MCPTransportError("Transport closed"))
|
|
126
|
+
self._pending.clear()
|
|
127
|
+
|
|
128
|
+
def _stderr_tail(self, *, limit: int = 4096) -> str:
|
|
129
|
+
"""Return a safe tail of captured stderr."""
|
|
130
|
+
if not self._stderr_buf:
|
|
131
|
+
return ""
|
|
132
|
+
tail = bytes(self._stderr_buf[-limit:])
|
|
133
|
+
return tail.decode("utf-8", errors="replace")
|
|
134
|
+
|
|
135
|
+
async def _drain_stderr(self) -> None:
|
|
136
|
+
"""Continuously capture stderr for diagnostics."""
|
|
137
|
+
assert self._proc is not None
|
|
138
|
+
assert self._proc.stderr is not None
|
|
139
|
+
|
|
140
|
+
while True:
|
|
141
|
+
chunk = await self._proc.stderr.read(4096)
|
|
142
|
+
if not chunk:
|
|
143
|
+
return
|
|
144
|
+
self._stderr_buf.extend(chunk)
|
|
145
|
+
if len(self._stderr_buf) > 200_000:
|
|
146
|
+
del self._stderr_buf[:100_000]
|
|
147
|
+
|
|
148
|
+
async def _wait_for_exit(self) -> None:
|
|
149
|
+
"""Fail pending requests if the subprocess exits unexpectedly."""
|
|
150
|
+
assert self._proc is not None
|
|
151
|
+
return_code = await self._proc.wait()
|
|
152
|
+
message = f"STDIO process exited with code {return_code}"
|
|
153
|
+
tail = self._stderr_tail()
|
|
154
|
+
if tail:
|
|
155
|
+
message = message + f"; stderr tail: {tail.strip()}"
|
|
156
|
+
error = MCPTransportError(message)
|
|
157
|
+
for future in list(self._pending.values()):
|
|
158
|
+
if not future.done():
|
|
159
|
+
future.set_exception(error)
|
|
160
|
+
self._pending.clear()
|
|
161
|
+
|
|
162
|
+
async def _read_exactly(self, size: int) -> bytes:
|
|
163
|
+
"""Read exactly the requested number of bytes from stdout."""
|
|
164
|
+
assert self._proc is not None
|
|
165
|
+
assert self._proc.stdout is not None
|
|
166
|
+
try:
|
|
167
|
+
return await self._proc.stdout.readexactly(size)
|
|
168
|
+
except asyncio.IncompleteReadError as exc:
|
|
169
|
+
raise MCPTransportError("STDIO server closed stdout") from exc
|
|
170
|
+
|
|
171
|
+
async def _read_message(self) -> dict[str, Any]:
|
|
172
|
+
"""Read one JSON-RPC message using configured framing."""
|
|
173
|
+
assert self._proc is not None
|
|
174
|
+
assert self._proc.stdout is not None
|
|
175
|
+
|
|
176
|
+
if str(self.cfg.framing).lower().strip() in ("newline", "ndjson", "jsonl"):
|
|
177
|
+
while True:
|
|
178
|
+
if b"\n" not in self._line_buf:
|
|
179
|
+
chunk = await self._proc.stdout.read(4096)
|
|
180
|
+
if not chunk:
|
|
181
|
+
raise MCPTransportError("STDIO server closed stdout")
|
|
182
|
+
self._line_buf.extend(chunk)
|
|
183
|
+
continue
|
|
184
|
+
line_bytes, _, remainder = self._line_buf.partition(b"\n")
|
|
185
|
+
self._line_buf = bytearray(remainder)
|
|
186
|
+
line = line_bytes.decode("utf-8", errors="replace").strip()
|
|
187
|
+
if not line:
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
message = json.loads(line)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
raise MCPProtocolError("Failed to parse newline-delimited STDIO JSON message") from exc
|
|
193
|
+
if not isinstance(message, dict):
|
|
194
|
+
raise MCPProtocolError("STDIO message must be a JSON object")
|
|
195
|
+
return message
|
|
196
|
+
|
|
197
|
+
content_length: int | None = None
|
|
198
|
+
while True:
|
|
199
|
+
header_line = await self._proc.stdout.readline()
|
|
200
|
+
if not header_line:
|
|
201
|
+
raise MCPTransportError("STDIO server closed stdout")
|
|
202
|
+
|
|
203
|
+
line = header_line.decode("utf-8", errors="replace")
|
|
204
|
+
stripped = line.strip("\r\n")
|
|
205
|
+
|
|
206
|
+
if content_length is None:
|
|
207
|
+
if not stripped:
|
|
208
|
+
continue
|
|
209
|
+
if stripped.lower().startswith("content-length:"):
|
|
210
|
+
try:
|
|
211
|
+
content_length = int(stripped.split(":", 1)[1].strip())
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise MCPProtocolError(f"Invalid Content-Length header: {stripped}") from exc
|
|
214
|
+
else:
|
|
215
|
+
continue
|
|
216
|
+
else:
|
|
217
|
+
if stripped == "":
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
body = await self._read_exactly(content_length)
|
|
221
|
+
try:
|
|
222
|
+
message = json.loads(body.decode("utf-8", errors="replace"))
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
raise MCPProtocolError("Failed to parse STDIO JSON message") from exc
|
|
225
|
+
|
|
226
|
+
if not isinstance(message, dict):
|
|
227
|
+
raise MCPProtocolError("STDIO message must be a JSON object")
|
|
228
|
+
return message
|
|
229
|
+
|
|
230
|
+
async def _read_loop(self) -> None:
|
|
231
|
+
"""Dispatch subprocess responses to waiting futures."""
|
|
232
|
+
try:
|
|
233
|
+
while True:
|
|
234
|
+
message = await self._read_message()
|
|
235
|
+
|
|
236
|
+
if "id" in message and message.get("id") is not None:
|
|
237
|
+
raw_id = message.get("id")
|
|
238
|
+
key: str | None = None
|
|
239
|
+
if isinstance(raw_id, str):
|
|
240
|
+
key = raw_id
|
|
241
|
+
elif isinstance(raw_id, int):
|
|
242
|
+
key = str(raw_id)
|
|
243
|
+
elif isinstance(raw_id, float) and raw_id.is_integer():
|
|
244
|
+
key = str(int(raw_id))
|
|
245
|
+
|
|
246
|
+
if key is not None and key in self._pending:
|
|
247
|
+
future = self._pending.pop(key)
|
|
248
|
+
if message.get("error") is not None:
|
|
249
|
+
future.set_exception(MCPTransportError(f"MCP error: {message['error']}"))
|
|
250
|
+
else:
|
|
251
|
+
result = message.get("result")
|
|
252
|
+
if not isinstance(result, dict):
|
|
253
|
+
future.set_exception(MCPProtocolError("MCP result must be an object"))
|
|
254
|
+
else:
|
|
255
|
+
future.set_result(result)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
tail = self._stderr_tail()
|
|
258
|
+
error: Exception
|
|
259
|
+
if tail:
|
|
260
|
+
error = MCPTransportError(f"STDIO read loop error: {exc}; stderr tail: {tail.strip()}")
|
|
261
|
+
else:
|
|
262
|
+
error = exc
|
|
263
|
+
for future in list(self._pending.values()):
|
|
264
|
+
if not future.done():
|
|
265
|
+
future.set_exception(error)
|
|
266
|
+
self._pending.clear()
|
|
267
|
+
raise
|
|
268
|
+
|
|
269
|
+
async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
270
|
+
"""Send a JSON-RPC request over stdio and await the result."""
|
|
271
|
+
if self._proc is None or self._proc.stdin is None:
|
|
272
|
+
raise MCPTransportError("Transport not connected")
|
|
273
|
+
|
|
274
|
+
self._id += 1
|
|
275
|
+
req_id = self._id
|
|
276
|
+
key = str(req_id)
|
|
277
|
+
|
|
278
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
279
|
+
if params is not None:
|
|
280
|
+
payload["params"] = params
|
|
281
|
+
|
|
282
|
+
future = asyncio.get_running_loop().create_future()
|
|
283
|
+
self._pending[key] = future
|
|
284
|
+
|
|
285
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
286
|
+
framing = str(self.cfg.framing).lower().strip()
|
|
287
|
+
if framing in ("newline", "ndjson", "jsonl"):
|
|
288
|
+
self._proc.stdin.write(body + b"\n")
|
|
289
|
+
else:
|
|
290
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
|
|
291
|
+
self._proc.stdin.write(header + body)
|
|
292
|
+
await self._proc.stdin.drain()
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = await asyncio.wait_for(future, timeout=30.0)
|
|
296
|
+
return result
|
|
297
|
+
except asyncio.TimeoutError as exc:
|
|
298
|
+
tail = self._stderr_tail()
|
|
299
|
+
message = f"STDIO request timed out after 30.0s: method={method}"
|
|
300
|
+
if tail:
|
|
301
|
+
message = message + f"; stderr tail: {tail.strip()}"
|
|
302
|
+
raise MCPTransportError(message) from exc
|
|
303
|
+
finally:
|
|
304
|
+
self._pending.pop(key, None)
|
|
305
|
+
|
|
306
|
+
async def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
307
|
+
"""Send a JSON-RPC notification over stdio."""
|
|
308
|
+
if self._proc is None or self._proc.stdin is None:
|
|
309
|
+
raise MCPTransportError("Transport not connected")
|
|
310
|
+
|
|
311
|
+
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
312
|
+
if params is not None:
|
|
313
|
+
payload["params"] = params
|
|
314
|
+
|
|
315
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
316
|
+
framing = str(self.cfg.framing).lower().strip()
|
|
317
|
+
if framing in ("newline", "ndjson", "jsonl"):
|
|
318
|
+
self._proc.stdin.write(body + b"\n")
|
|
319
|
+
else:
|
|
320
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
|
|
321
|
+
self._proc.stdin.write(header + body)
|
|
322
|
+
await self._proc.stdin.drain()
|