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,178 @@
|
|
|
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 — Graceful shutdown management
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: In-flight request draining and signal-handler integration for
|
|
20
|
+
# deterministic graceful shutdown.
|
|
21
|
+
# Related requirements: FR18.9
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Graceful shutdown support for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import signal
|
|
30
|
+
import threading
|
|
31
|
+
from typing import Any, Callable
|
|
32
|
+
|
|
33
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
34
|
+
from starlette.requests import Request
|
|
35
|
+
from starlette.responses import JSONResponse, Response
|
|
36
|
+
|
|
37
|
+
from cloud_dog_api_kit.envelopes.error import error_envelope
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GracefulShutdownManager:
|
|
41
|
+
"""Track in-flight requests and coordinate graceful shutdown.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
drain_timeout_seconds: Maximum wait time for in-flight requests.
|
|
45
|
+
|
|
46
|
+
Related tests: UT1.45_GracefulShutdown, ST1.13_StartupLifecycleIntegration
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, drain_timeout_seconds: float = 5.0) -> None:
|
|
50
|
+
if drain_timeout_seconds < 0:
|
|
51
|
+
raise ValueError("drain_timeout_seconds must be >= 0")
|
|
52
|
+
self._drain_timeout_seconds = drain_timeout_seconds
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._active_requests = 0
|
|
55
|
+
self._shutting_down = False
|
|
56
|
+
self._all_requests_drained = asyncio.Event()
|
|
57
|
+
self._all_requests_drained.set()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def active_requests(self) -> int:
|
|
61
|
+
"""Current number of active requests."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
return self._active_requests
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def shutting_down(self) -> bool:
|
|
67
|
+
"""Whether shutdown has started."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
return self._shutting_down
|
|
70
|
+
|
|
71
|
+
def mark_request_started(self) -> bool:
|
|
72
|
+
"""Mark a request start.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True when request is accepted, False when shutdown is active.
|
|
76
|
+
"""
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._shutting_down:
|
|
79
|
+
return False
|
|
80
|
+
self._active_requests += 1
|
|
81
|
+
self._all_requests_drained.clear()
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def mark_request_finished(self) -> None:
|
|
85
|
+
"""Mark request completion."""
|
|
86
|
+
with self._lock:
|
|
87
|
+
if self._active_requests > 0:
|
|
88
|
+
self._active_requests -= 1
|
|
89
|
+
if self._active_requests == 0:
|
|
90
|
+
self._all_requests_drained.set()
|
|
91
|
+
|
|
92
|
+
def set_shutting_down(self) -> None:
|
|
93
|
+
"""Set shutdown flag to reject new requests."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._shutting_down = True
|
|
96
|
+
if self._active_requests == 0:
|
|
97
|
+
self._all_requests_drained.set()
|
|
98
|
+
|
|
99
|
+
async def drain(self) -> bool:
|
|
100
|
+
"""Wait for in-flight requests to drain.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if drained before timeout, otherwise False.
|
|
104
|
+
"""
|
|
105
|
+
if self.active_requests == 0:
|
|
106
|
+
return True
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.wait_for(self._all_requests_drained.wait(), timeout=self._drain_timeout_seconds)
|
|
109
|
+
return True
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def initiate_shutdown(self) -> bool:
|
|
114
|
+
"""Start shutdown and drain requests."""
|
|
115
|
+
self.set_shutting_down()
|
|
116
|
+
return await self.drain()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ShutdownDrainMiddleware(BaseHTTPMiddleware):
|
|
120
|
+
"""Reject new requests during shutdown and track in-flight requests.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
app: The ASGI application.
|
|
124
|
+
manager: Shared graceful shutdown manager.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, app: Any, manager: GracefulShutdownManager) -> None:
|
|
128
|
+
super().__init__(app)
|
|
129
|
+
self._manager = manager
|
|
130
|
+
|
|
131
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
132
|
+
"""Track active request and reject when shutting down."""
|
|
133
|
+
if not self._manager.mark_request_started():
|
|
134
|
+
request_id = getattr(request.state, "request_id", "")
|
|
135
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
136
|
+
return JSONResponse(
|
|
137
|
+
status_code=503,
|
|
138
|
+
content=error_envelope(
|
|
139
|
+
code="INTERNAL_ERROR",
|
|
140
|
+
message="Service is shutting down",
|
|
141
|
+
details=None,
|
|
142
|
+
retryable=True,
|
|
143
|
+
request_id=request_id,
|
|
144
|
+
correlation_id=correlation_id,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
return await call_next(request)
|
|
149
|
+
finally:
|
|
150
|
+
self._manager.mark_request_finished()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def install_shutdown_signal_handlers(
|
|
154
|
+
manager: GracefulShutdownManager,
|
|
155
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
156
|
+
) -> dict[int, bool]:
|
|
157
|
+
"""Attempt to install SIGTERM/SIGINT handlers for graceful shutdown.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Mapping of signal number to whether installation succeeded.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
active_loop = loop or asyncio.get_running_loop()
|
|
164
|
+
except RuntimeError:
|
|
165
|
+
return {signal.SIGTERM: False, signal.SIGINT: False}
|
|
166
|
+
|
|
167
|
+
results: dict[int, bool] = {}
|
|
168
|
+
|
|
169
|
+
def _schedule_shutdown() -> None:
|
|
170
|
+
active_loop.create_task(manager.initiate_shutdown())
|
|
171
|
+
|
|
172
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
173
|
+
try:
|
|
174
|
+
active_loop.add_signal_handler(sig, _schedule_shutdown)
|
|
175
|
+
results[sig] = True
|
|
176
|
+
except (NotImplementedError, RuntimeError, ValueError):
|
|
177
|
+
results[sig] = False
|
|
178
|
+
return results
|
|
@@ -0,0 +1,122 @@
|
|
|
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 gateway
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: REST-to-MCP gateway helpers.
|
|
20
|
+
# Related requirements: FR14.1
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""MCP gateway helpers for cloud_dog_api_kit."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from cloud_dog_api_kit.mcp.client_transport import (
|
|
28
|
+
HTTPJSONRPCConfig,
|
|
29
|
+
HTTPJSONRPCTransport,
|
|
30
|
+
LegacySSETransport,
|
|
31
|
+
MCPProtocolError,
|
|
32
|
+
MCPSessionError,
|
|
33
|
+
MCPTransport,
|
|
34
|
+
MCPTransportError,
|
|
35
|
+
StdioConfig,
|
|
36
|
+
StdioTransport,
|
|
37
|
+
StreamableHTTPConfig,
|
|
38
|
+
StreamableHTTPTransport,
|
|
39
|
+
)
|
|
40
|
+
from cloud_dog_api_kit.mcp.async_jobs import AsyncJobStore, InMemoryAsyncJobStore
|
|
41
|
+
from cloud_dog_api_kit.mcp.gateway import MCPToolDefinition, create_mcp_tool_from_endpoint
|
|
42
|
+
from cloud_dog_api_kit.mcp.contract import MCPContractRegistration, register_mcp_contract
|
|
43
|
+
from cloud_dog_api_kit.mcp.error_mapper import map_legacy_mcp_payload
|
|
44
|
+
from cloud_dog_api_kit.mcp.legacy_sse import LegacySSEConfig
|
|
45
|
+
from cloud_dog_api_kit.mcp.session import SESSION_HEADER, McpSession, McpSessionManager
|
|
46
|
+
from cloud_dog_api_kit.mcp.sync_handler import (
|
|
47
|
+
BUDGET_EXCEEDED_CODE,
|
|
48
|
+
dispatch_with_sync_class,
|
|
49
|
+
format_a2a_task_response,
|
|
50
|
+
format_async_rest_response,
|
|
51
|
+
format_budget_exceeded_mcp_error,
|
|
52
|
+
validate_blocking_siblings,
|
|
53
|
+
)
|
|
54
|
+
from cloud_dog_api_kit.mcp.client_sdk import (
|
|
55
|
+
AsyncJobClient,
|
|
56
|
+
JobFailedError,
|
|
57
|
+
JobTimeoutError,
|
|
58
|
+
)
|
|
59
|
+
from cloud_dog_api_kit.mcp.tool_router import (
|
|
60
|
+
DEFAULT_SYNC_BUDGET_SECONDS,
|
|
61
|
+
MAX_SYNC_BUDGET_SECONDS,
|
|
62
|
+
SYNC_CLASS_ASYNC,
|
|
63
|
+
SYNC_CLASS_DEFAULT,
|
|
64
|
+
SYNC_CLASS_PROGRESS,
|
|
65
|
+
SYNC_CLASSES,
|
|
66
|
+
ToolContract,
|
|
67
|
+
register_tool_router,
|
|
68
|
+
)
|
|
69
|
+
from cloud_dog_api_kit.mcp.transport import (
|
|
70
|
+
AsyncJobResultShape,
|
|
71
|
+
MCPResource,
|
|
72
|
+
MCPResourceContent,
|
|
73
|
+
ResourcesHandler,
|
|
74
|
+
register_mcp_routes,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"AsyncJobStore",
|
|
79
|
+
"InMemoryAsyncJobStore",
|
|
80
|
+
"MCPContractRegistration",
|
|
81
|
+
"MCPToolDefinition",
|
|
82
|
+
"MCPProtocolError",
|
|
83
|
+
"MCPSessionError",
|
|
84
|
+
"MCPTransport",
|
|
85
|
+
"MCPTransportError",
|
|
86
|
+
"McpSession",
|
|
87
|
+
"McpSessionManager",
|
|
88
|
+
"SESSION_HEADER",
|
|
89
|
+
"HTTPJSONRPCConfig",
|
|
90
|
+
"HTTPJSONRPCTransport",
|
|
91
|
+
"LegacySSEConfig",
|
|
92
|
+
"LegacySSETransport",
|
|
93
|
+
"AsyncJobResultShape",
|
|
94
|
+
"MCPResource",
|
|
95
|
+
"MCPResourceContent",
|
|
96
|
+
"ResourcesHandler",
|
|
97
|
+
"StdioConfig",
|
|
98
|
+
"StdioTransport",
|
|
99
|
+
"StreamableHTTPConfig",
|
|
100
|
+
"StreamableHTTPTransport",
|
|
101
|
+
"BUDGET_EXCEEDED_CODE",
|
|
102
|
+
"DEFAULT_SYNC_BUDGET_SECONDS",
|
|
103
|
+
"MAX_SYNC_BUDGET_SECONDS",
|
|
104
|
+
"SYNC_CLASS_ASYNC",
|
|
105
|
+
"SYNC_CLASS_DEFAULT",
|
|
106
|
+
"SYNC_CLASS_PROGRESS",
|
|
107
|
+
"SYNC_CLASSES",
|
|
108
|
+
"ToolContract",
|
|
109
|
+
"dispatch_with_sync_class",
|
|
110
|
+
"format_a2a_task_response",
|
|
111
|
+
"format_async_rest_response",
|
|
112
|
+
"format_budget_exceeded_mcp_error",
|
|
113
|
+
"validate_blocking_siblings",
|
|
114
|
+
"create_mcp_tool_from_endpoint",
|
|
115
|
+
"map_legacy_mcp_payload",
|
|
116
|
+
"register_mcp_contract",
|
|
117
|
+
"register_mcp_routes",
|
|
118
|
+
"register_tool_router",
|
|
119
|
+
"AsyncJobClient",
|
|
120
|
+
"JobFailedError",
|
|
121
|
+
"JobTimeoutError",
|
|
122
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
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 async job helpers
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Shared async job-store protocol and in-memory reference
|
|
20
|
+
# implementation for wait=false MCP tool calls.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Async MCP tool-call job helpers."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import inspect
|
|
30
|
+
import threading
|
|
31
|
+
import uuid
|
|
32
|
+
from collections.abc import Awaitable, Callable
|
|
33
|
+
from typing import Any, Protocol
|
|
34
|
+
|
|
35
|
+
AsyncJobContext = dict[str, Any]
|
|
36
|
+
AsyncJobStatus = dict[str, Any]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncJobStore(Protocol):
|
|
40
|
+
"""Protocol for async MCP tool-call job stores."""
|
|
41
|
+
|
|
42
|
+
def submit(
|
|
43
|
+
self,
|
|
44
|
+
tool_name: str,
|
|
45
|
+
arguments: dict[str, Any],
|
|
46
|
+
context: AsyncJobContext,
|
|
47
|
+
) -> str | Awaitable[str]:
|
|
48
|
+
"""Submit a tool call for async execution and return a job id."""
|
|
49
|
+
|
|
50
|
+
def get_status(self, job_id: str) -> AsyncJobStatus | Awaitable[AsyncJobStatus]:
|
|
51
|
+
"""Return the current status payload for a submitted job."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InMemoryAsyncJobStore:
|
|
55
|
+
"""In-memory async job store for local tests and simple service deployments."""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._jobs: dict[str, AsyncJobStatus] = {}
|
|
60
|
+
self._tasks: dict[str, asyncio.Task[Any]] = {}
|
|
61
|
+
|
|
62
|
+
def submit(
|
|
63
|
+
self,
|
|
64
|
+
tool_name: str,
|
|
65
|
+
arguments: dict[str, Any],
|
|
66
|
+
context: AsyncJobContext,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Submit a tool call for background execution."""
|
|
69
|
+
runner = context.get("runner")
|
|
70
|
+
if not callable(runner):
|
|
71
|
+
raise TypeError("async job context requires a callable 'runner'")
|
|
72
|
+
|
|
73
|
+
result_formatter = context.get("result_formatter")
|
|
74
|
+
if result_formatter is not None and not callable(result_formatter):
|
|
75
|
+
raise TypeError("async job context 'result_formatter' must be callable when provided")
|
|
76
|
+
|
|
77
|
+
job_id = f"job-{uuid.uuid4().hex}"
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._jobs[job_id] = {"status": "pending", "tool_name": tool_name, "arguments": dict(arguments)}
|
|
80
|
+
|
|
81
|
+
task = asyncio.create_task(self._run_job(job_id, runner, result_formatter))
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._tasks[job_id] = task
|
|
84
|
+
return job_id
|
|
85
|
+
|
|
86
|
+
async def _run_job(
|
|
87
|
+
self,
|
|
88
|
+
job_id: str,
|
|
89
|
+
runner: Callable[[], Any],
|
|
90
|
+
result_formatter: Callable[[Any], Any] | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Execute a submitted async job and persist its lifecycle state."""
|
|
93
|
+
with self._lock:
|
|
94
|
+
job = dict(self._jobs.get(job_id) or {})
|
|
95
|
+
job["status"] = "running"
|
|
96
|
+
self._jobs[job_id] = job
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
result = runner()
|
|
100
|
+
if inspect.isawaitable(result):
|
|
101
|
+
result = await result
|
|
102
|
+
if result_formatter is not None:
|
|
103
|
+
result = result_formatter(result)
|
|
104
|
+
with self._lock:
|
|
105
|
+
job = dict(self._jobs.get(job_id) or {})
|
|
106
|
+
job["status"] = "completed"
|
|
107
|
+
job["result"] = result
|
|
108
|
+
job.pop("error", None)
|
|
109
|
+
self._jobs[job_id] = job
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
with self._lock:
|
|
112
|
+
job = dict(self._jobs.get(job_id) or {})
|
|
113
|
+
job["status"] = "failed"
|
|
114
|
+
job["error"] = str(exc)
|
|
115
|
+
self._jobs[job_id] = job
|
|
116
|
+
finally:
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._tasks.pop(job_id, None)
|
|
119
|
+
|
|
120
|
+
def get_status(self, job_id: str) -> AsyncJobStatus:
|
|
121
|
+
"""Return the current status payload for a job id."""
|
|
122
|
+
with self._lock:
|
|
123
|
+
status = self._jobs.get(job_id)
|
|
124
|
+
if status is None:
|
|
125
|
+
return {"status": "not_found", "error": "Job not found"}
|
|
126
|
+
return dict(status)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""PS-95 client SDK helpers for three-mode tool invocation (W28D-309).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- submit(): fire-and-forget async job submission (Mode 3)
|
|
7
|
+
- wait(): poll until job completes or timeout
|
|
8
|
+
- call_with_auto_wait(): smart caller that handles all three modes
|
|
9
|
+
- progress_token_handler(): handles Mode 2 progress notifications
|
|
10
|
+
- retry_idempotent(): idempotent retry with automatic dedup
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, Awaitable
|
|
18
|
+
|
|
19
|
+
# PS-95 constants (consistent with sync_handler.py)
|
|
20
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 1.0
|
|
21
|
+
DEFAULT_CLIENT_TIMEOUT_SECONDS = 300.0 # 5 minutes (PS-95 §9 worker_timeout)
|
|
22
|
+
BUDGET_EXCEEDED_CODE = -32000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncJobClient:
|
|
26
|
+
"""Client-side helper for PS-95 three-mode tool calls.
|
|
27
|
+
|
|
28
|
+
Wraps a transport (MCP/REST/A2A) and provides high-level helpers
|
|
29
|
+
that handle the three sync_class modes transparently.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
call_tool: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]],
|
|
36
|
+
poll_job: Callable[[str], Awaitable[dict[str, Any]]],
|
|
37
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
38
|
+
timeout: float = DEFAULT_CLIENT_TIMEOUT_SECONDS,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
call_tool: async callable(tool_name, arguments) -> server response dict
|
|
44
|
+
poll_job: async callable(job_id) -> job status dict
|
|
45
|
+
poll_interval: seconds between poll requests
|
|
46
|
+
timeout: max seconds to wait for job completion
|
|
47
|
+
"""
|
|
48
|
+
self._call_tool = call_tool
|
|
49
|
+
self._poll_job = poll_job
|
|
50
|
+
self._poll_interval = poll_interval
|
|
51
|
+
self._timeout = timeout
|
|
52
|
+
|
|
53
|
+
async def submit(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
54
|
+
"""Submit a tool call and return the job_id immediately (Mode 3 client).
|
|
55
|
+
|
|
56
|
+
If the server returns a sync result, wraps it as a completed job.
|
|
57
|
+
"""
|
|
58
|
+
response = await self._call_tool(tool_name, arguments)
|
|
59
|
+
job_id = _extract_job_id(response)
|
|
60
|
+
if job_id is None:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Server did not return a job_id for tool {tool_name!r}. "
|
|
63
|
+
"Use call_with_auto_wait() for tools that may return sync results."
|
|
64
|
+
)
|
|
65
|
+
return job_id
|
|
66
|
+
|
|
67
|
+
async def wait(self, job_id: str, *, timeout: float | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Poll a job until completion or timeout.
|
|
69
|
+
|
|
70
|
+
Returns the final job status dict with 'result' on success.
|
|
71
|
+
Raises TimeoutError with the job_id if timeout expires.
|
|
72
|
+
"""
|
|
73
|
+
deadline = time.monotonic() + (timeout or self._timeout)
|
|
74
|
+
while True:
|
|
75
|
+
status = await self._poll_job(job_id)
|
|
76
|
+
state = str(status.get("status", "")).lower()
|
|
77
|
+
if state in ("completed", "succeeded"):
|
|
78
|
+
return status
|
|
79
|
+
if state in ("failed", "cancelled", "timeout", "dead_lettered"):
|
|
80
|
+
raise JobFailedError(job_id=job_id, status=status)
|
|
81
|
+
if time.monotonic() >= deadline:
|
|
82
|
+
raise JobTimeoutError(job_id=job_id, last_status=status)
|
|
83
|
+
await asyncio.sleep(self._poll_interval)
|
|
84
|
+
|
|
85
|
+
async def call_with_auto_wait(
|
|
86
|
+
self,
|
|
87
|
+
tool_name: str,
|
|
88
|
+
arguments: dict[str, Any],
|
|
89
|
+
*,
|
|
90
|
+
timeout: float | None = None,
|
|
91
|
+
on_progress: Callable[[float, float | None, str | None], Awaitable[None]] | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Call a tool with automatic mode detection and waiting.
|
|
94
|
+
|
|
95
|
+
- If server returns sync result (Mode 1 completed): returns immediately.
|
|
96
|
+
- If server returns job_id (Mode 3 or budget-exceeded): polls until done.
|
|
97
|
+
- If server sends progress (Mode 2): calls on_progress, then returns result.
|
|
98
|
+
|
|
99
|
+
Returns dict with 'result' key on success.
|
|
100
|
+
Raises JobTimeoutError with job_id on timeout (client can resume polling).
|
|
101
|
+
"""
|
|
102
|
+
response = await self._call_tool(tool_name, arguments)
|
|
103
|
+
|
|
104
|
+
# Check for sync result (Mode 1 completed within budget)
|
|
105
|
+
if _is_sync_result(response):
|
|
106
|
+
return {"status": "completed", "result": response.get("result", response)}
|
|
107
|
+
|
|
108
|
+
# Check for budget-exceeded error (Mode 1 timeout)
|
|
109
|
+
if _is_budget_exceeded(response):
|
|
110
|
+
job_id = _extract_job_id_from_error(response)
|
|
111
|
+
if job_id:
|
|
112
|
+
return await self.wait(job_id, timeout=timeout)
|
|
113
|
+
raise ValueError("Budget exceeded but no job_id in error response")
|
|
114
|
+
|
|
115
|
+
# Async/job_id response (Mode 3)
|
|
116
|
+
job_id = _extract_job_id(response)
|
|
117
|
+
if job_id:
|
|
118
|
+
return await self.wait(job_id, timeout=timeout)
|
|
119
|
+
|
|
120
|
+
# Fallback: treat entire response as sync result
|
|
121
|
+
return {"status": "completed", "result": response}
|
|
122
|
+
|
|
123
|
+
async def retry_idempotent(
|
|
124
|
+
self,
|
|
125
|
+
tool_name: str,
|
|
126
|
+
arguments: dict[str, Any],
|
|
127
|
+
*,
|
|
128
|
+
idempotency_key: str | None = None,
|
|
129
|
+
max_retries: int = 3,
|
|
130
|
+
timeout: float | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Retry a tool call with idempotency guarantees.
|
|
133
|
+
|
|
134
|
+
If the call returns a job_id, subsequent retries poll the existing job
|
|
135
|
+
instead of resubmitting (deduplication).
|
|
136
|
+
"""
|
|
137
|
+
last_error: Exception | None = None
|
|
138
|
+
known_job_id: str | None = None
|
|
139
|
+
|
|
140
|
+
for attempt in range(max_retries):
|
|
141
|
+
try:
|
|
142
|
+
if known_job_id:
|
|
143
|
+
return await self.wait(known_job_id, timeout=timeout)
|
|
144
|
+
|
|
145
|
+
args = dict(arguments)
|
|
146
|
+
if idempotency_key:
|
|
147
|
+
args["_idempotency_key"] = idempotency_key
|
|
148
|
+
|
|
149
|
+
response = await self._call_tool(tool_name, args)
|
|
150
|
+
job_id = _extract_job_id(response)
|
|
151
|
+
if job_id:
|
|
152
|
+
known_job_id = job_id
|
|
153
|
+
return await self.wait(job_id, timeout=timeout)
|
|
154
|
+
|
|
155
|
+
if _is_sync_result(response):
|
|
156
|
+
return {"status": "completed", "result": response.get("result", response)}
|
|
157
|
+
|
|
158
|
+
return {"status": "completed", "result": response}
|
|
159
|
+
|
|
160
|
+
except JobTimeoutError as e:
|
|
161
|
+
known_job_id = e.job_id
|
|
162
|
+
last_error = e
|
|
163
|
+
except Exception as e:
|
|
164
|
+
last_error = e
|
|
165
|
+
|
|
166
|
+
if attempt < max_retries - 1:
|
|
167
|
+
await asyncio.sleep(self._poll_interval * (attempt + 1))
|
|
168
|
+
|
|
169
|
+
raise last_error or RuntimeError("Retry exhausted with no result or error")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Response parsing helpers ──
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_job_id(response: dict[str, Any]) -> str | None:
|
|
176
|
+
"""Extract job_id from various server response formats."""
|
|
177
|
+
if "job_id" in response:
|
|
178
|
+
return str(response["job_id"])
|
|
179
|
+
if "content" in response and isinstance(response["content"], list):
|
|
180
|
+
for item in response["content"]:
|
|
181
|
+
if isinstance(item, dict) and "job_id" in item:
|
|
182
|
+
return str(item["job_id"])
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _extract_job_id_from_error(response: dict[str, Any]) -> str | None:
|
|
187
|
+
"""Extract job_id from budget-exceeded error response."""
|
|
188
|
+
error = response.get("error", {})
|
|
189
|
+
if isinstance(error, dict):
|
|
190
|
+
data = error.get("data", {})
|
|
191
|
+
if isinstance(data, dict) and "job_id" in data:
|
|
192
|
+
return str(data["job_id"])
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_sync_result(response: dict[str, Any]) -> bool:
|
|
197
|
+
"""True if the response is a completed sync result (not a job envelope)."""
|
|
198
|
+
if "job_id" in response:
|
|
199
|
+
return False
|
|
200
|
+
if "error" in response:
|
|
201
|
+
return False
|
|
202
|
+
if response.get("mode") == "async":
|
|
203
|
+
return False
|
|
204
|
+
if response.get("status") == "submitted":
|
|
205
|
+
return False
|
|
206
|
+
return "result" in response or "content" in response
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_budget_exceeded(response: dict[str, Any]) -> bool:
|
|
210
|
+
"""True if the response is a PS-95 budget-exceeded error."""
|
|
211
|
+
error = response.get("error", {})
|
|
212
|
+
if isinstance(error, dict):
|
|
213
|
+
return error.get("code") == BUDGET_EXCEEDED_CODE
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Exceptions ──
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class JobTimeoutError(Exception):
|
|
221
|
+
"""Raised when polling exceeds timeout. Carries job_id for resumption."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, job_id: str, last_status: dict[str, Any] | None = None) -> None:
|
|
224
|
+
self.job_id = job_id
|
|
225
|
+
self.last_status = last_status
|
|
226
|
+
super().__init__(f"Job {job_id} timed out waiting for completion")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class JobFailedError(Exception):
|
|
230
|
+
"""Raised when a polled job reaches a terminal failure state."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, job_id: str, status: dict[str, Any]) -> None:
|
|
233
|
+
self.job_id = job_id
|
|
234
|
+
self.status = status
|
|
235
|
+
super().__init__(f"Job {job_id} failed: {status.get('error', 'unknown')}")
|