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