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,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()