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,113 @@
|
|
|
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 contract registration helper
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: One-call registration helper enforcing MCP catalogue contract.
|
|
20
|
+
# Related requirements: FR18.1
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""MCP contract registration helper.
|
|
24
|
+
|
|
25
|
+
Provides a single entry point that registers:
|
|
26
|
+
- MCP transport routes (`/mcp`, `/messages`)
|
|
27
|
+
- MCP tool catalogue + execution routes (`/mcp/tools`, `/mcp/tools/{tool_name}`)
|
|
28
|
+
- Optional legacy read-only catalogue alias (`/tools`)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from fastapi import FastAPI
|
|
37
|
+
|
|
38
|
+
from cloud_dog_api_kit.mcp.session import McpSessionManager
|
|
39
|
+
from cloud_dog_api_kit.mcp.tool_router import ToolContract, ToolRegistryType, register_tool_router
|
|
40
|
+
from cloud_dog_api_kit.mcp.transport import register_mcp_routes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class MCPContractRegistration:
|
|
45
|
+
"""Captured outputs from MCP contract registration."""
|
|
46
|
+
|
|
47
|
+
contracts: dict[str, ToolContract]
|
|
48
|
+
session_manager: McpSessionManager
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _catalogue_payload(contracts: dict[str, ToolContract]) -> dict[str, list[dict[str, Any]]]:
|
|
52
|
+
return {
|
|
53
|
+
"tools": [
|
|
54
|
+
{
|
|
55
|
+
"name": contract.name,
|
|
56
|
+
"description": contract.description,
|
|
57
|
+
"input_schema": contract.input_schema,
|
|
58
|
+
"output_schema": contract.output_schema,
|
|
59
|
+
}
|
|
60
|
+
for contract in contracts.values()
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def register_mcp_contract(
|
|
66
|
+
app: FastAPI,
|
|
67
|
+
tool_registry: ToolRegistryType | None,
|
|
68
|
+
transport_modes: list[str] | set[str] | tuple[str, ...] | None = None,
|
|
69
|
+
*,
|
|
70
|
+
include_legacy_tools_alias: bool = True,
|
|
71
|
+
legacy_tools_path: str = "/tools",
|
|
72
|
+
mcp_tools_path: str = "/mcp/tools",
|
|
73
|
+
session_manager: McpSessionManager | None = None,
|
|
74
|
+
transport_base_path: str | None = None,
|
|
75
|
+
transport_messages_path: str | None = None,
|
|
76
|
+
) -> MCPContractRegistration:
|
|
77
|
+
"""Register a complete MCP surface with canonical tool catalogue contract.
|
|
78
|
+
|
|
79
|
+
Canonical contract:
|
|
80
|
+
- `GET /mcp/tools` returns tool catalogue (required)
|
|
81
|
+
- `POST /mcp/tools/{tool_name}` executes a tool
|
|
82
|
+
- `POST /mcp` and `POST /messages` provide MCP transport compatibility
|
|
83
|
+
|
|
84
|
+
Optional compatibility:
|
|
85
|
+
- `GET /tools` read-only alias for catalogue payload
|
|
86
|
+
|
|
87
|
+
Route threading:
|
|
88
|
+
- `mcp_tools_path` controls the REST catalogue/tool surface
|
|
89
|
+
- `transport_base_path` and `transport_messages_path` control the MCP
|
|
90
|
+
transport registration forwarded into `register_mcp_routes(...)`
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
contracts = register_tool_router(app, tool_registry, base_path=mcp_tools_path)
|
|
94
|
+
manager = register_mcp_routes(
|
|
95
|
+
app,
|
|
96
|
+
contracts,
|
|
97
|
+
transport_modes=transport_modes,
|
|
98
|
+
session_manager=session_manager,
|
|
99
|
+
transport_base_path=transport_base_path,
|
|
100
|
+
transport_messages_path=transport_messages_path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if include_legacy_tools_alias:
|
|
104
|
+
if not legacy_tools_path.startswith("/"):
|
|
105
|
+
raise ValueError("legacy_tools_path must start with '/'")
|
|
106
|
+
if legacy_tools_path == mcp_tools_path:
|
|
107
|
+
raise ValueError("legacy_tools_path must differ from mcp_tools_path")
|
|
108
|
+
|
|
109
|
+
@app.get(legacy_tools_path, tags=["mcp"])
|
|
110
|
+
async def _legacy_tools_alias() -> dict[str, list[dict[str, Any]]]:
|
|
111
|
+
return _catalogue_payload(contracts)
|
|
112
|
+
|
|
113
|
+
return MCPContractRegistration(contracts=contracts, session_manager=manager)
|
|
@@ -0,0 +1,84 @@
|
|
|
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 — Legacy MCP error mapper
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Compatibility mapper for legacy MCP payloads that do not use
|
|
20
|
+
# standard PS-20 success/error envelopes.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Legacy MCP payload mapping utilities."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from cloud_dog_api_kit.envelopes import error_envelope, success_envelope
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def map_legacy_mcp_payload(
|
|
34
|
+
payload: Any,
|
|
35
|
+
*,
|
|
36
|
+
request_id: str = "",
|
|
37
|
+
correlation_id: str | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Map legacy MCP payload shapes to standard envelopes."""
|
|
40
|
+
if isinstance(payload, dict) and "ok" in payload and ("data" in payload or "error" in payload):
|
|
41
|
+
mapped = dict(payload)
|
|
42
|
+
meta = dict(mapped.get("meta") or {})
|
|
43
|
+
meta.setdefault("request_id", request_id)
|
|
44
|
+
if correlation_id is not None:
|
|
45
|
+
meta.setdefault("correlation_id", correlation_id)
|
|
46
|
+
mapped["meta"] = meta
|
|
47
|
+
return mapped
|
|
48
|
+
|
|
49
|
+
if isinstance(payload, dict):
|
|
50
|
+
if payload.get("success") is False:
|
|
51
|
+
return error_envelope(
|
|
52
|
+
code=str(payload.get("code", "INTERNAL_ERROR")),
|
|
53
|
+
message=str(payload.get("message", "Request failed")),
|
|
54
|
+
details=payload.get("details"),
|
|
55
|
+
retryable=bool(payload.get("retryable", False)),
|
|
56
|
+
request_id=request_id,
|
|
57
|
+
correlation_id=correlation_id,
|
|
58
|
+
)
|
|
59
|
+
if "error" in payload:
|
|
60
|
+
error_value = payload.get("error")
|
|
61
|
+
if isinstance(error_value, dict):
|
|
62
|
+
code = str(error_value.get("code", payload.get("code", "INTERNAL_ERROR")))
|
|
63
|
+
message = str(error_value.get("message", payload.get("message", "Request failed")))
|
|
64
|
+
details = error_value.get("details", payload.get("details"))
|
|
65
|
+
retryable = bool(error_value.get("retryable", payload.get("retryable", False)))
|
|
66
|
+
else:
|
|
67
|
+
code = str(payload.get("code", "INTERNAL_ERROR"))
|
|
68
|
+
message = str(error_value)
|
|
69
|
+
details = payload.get("details")
|
|
70
|
+
retryable = bool(payload.get("retryable", False))
|
|
71
|
+
return error_envelope(
|
|
72
|
+
code=code,
|
|
73
|
+
message=message,
|
|
74
|
+
details=details,
|
|
75
|
+
retryable=retryable,
|
|
76
|
+
request_id=request_id,
|
|
77
|
+
correlation_id=correlation_id,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return success_envelope(
|
|
81
|
+
data=payload,
|
|
82
|
+
request_id=request_id,
|
|
83
|
+
correlation_id=correlation_id,
|
|
84
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
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 helpers
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Helpers for mapping REST endpoints to MCP tool definitions.
|
|
20
|
+
# MCP tools MUST map directly to REST endpoints and share schemas.
|
|
21
|
+
# Related requirements: FR14.1
|
|
22
|
+
# Related architecture: CC1.18
|
|
23
|
+
|
|
24
|
+
"""MCP gateway helpers for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MCPToolDefinition:
|
|
34
|
+
"""MCP tool definition mapped from a REST endpoint.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
name: The tool name (derived from endpoint path).
|
|
38
|
+
description: Human-readable description.
|
|
39
|
+
endpoint_path: The REST endpoint path this tool maps to.
|
|
40
|
+
method: HTTP method. Defaults to ``POST``.
|
|
41
|
+
input_schema: JSON Schema for the tool's input parameters.
|
|
42
|
+
output_schema: JSON Schema for the tool's output.
|
|
43
|
+
|
|
44
|
+
Related tests: UT1.32_MCPGateway
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
description: str
|
|
49
|
+
endpoint_path: str
|
|
50
|
+
method: str = "POST"
|
|
51
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
output_schema: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Convert to a dictionary suitable for MCP tool registration.
|
|
56
|
+
|
|
57
|
+
MCP spec 2024-11-05: `outputSchema` is optional on tool definitions.
|
|
58
|
+
Emitting an empty `{}` forces strict MCP clients to expect
|
|
59
|
+
`structuredContent` in every tool-call response, which breaks
|
|
60
|
+
interoperability with tools that return plain text content only.
|
|
61
|
+
We therefore only emit `outputSchema` when a non-empty schema is
|
|
62
|
+
declared. See RF-02-F1 / W28A-RF-F1-APPLY.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A dictionary with the tool definition.
|
|
66
|
+
"""
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"name": self.name,
|
|
69
|
+
"description": self.description,
|
|
70
|
+
"inputSchema": self.input_schema,
|
|
71
|
+
}
|
|
72
|
+
if self.output_schema:
|
|
73
|
+
payload["outputSchema"] = self.output_schema
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_mcp_tool_from_endpoint(
|
|
78
|
+
endpoint_path: str,
|
|
79
|
+
method: str = "POST",
|
|
80
|
+
description: str = "",
|
|
81
|
+
name: str | None = None,
|
|
82
|
+
input_schema: dict[str, Any] | None = None,
|
|
83
|
+
output_schema: dict[str, Any] | None = None,
|
|
84
|
+
) -> MCPToolDefinition:
|
|
85
|
+
"""Create an MCP tool definition from a REST endpoint.
|
|
86
|
+
|
|
87
|
+
The tool name is derived from the endpoint path if not provided
|
|
88
|
+
(e.g., ``/api/v1/users`` becomes ``users``).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
endpoint_path: The REST endpoint path.
|
|
92
|
+
method: HTTP method. Defaults to ``POST``.
|
|
93
|
+
description: Tool description.
|
|
94
|
+
name: Override tool name. Derived from path if None.
|
|
95
|
+
input_schema: JSON Schema for inputs.
|
|
96
|
+
output_schema: JSON Schema for outputs.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
An MCPToolDefinition instance.
|
|
100
|
+
|
|
101
|
+
Related tests: UT1.32_MCPGateway
|
|
102
|
+
"""
|
|
103
|
+
if name is None:
|
|
104
|
+
# Derive name from path: /api/v1/users/{id}:run -> users_run
|
|
105
|
+
parts = endpoint_path.strip("/").split("/")
|
|
106
|
+
# Skip version prefix
|
|
107
|
+
filtered = [p for p in parts if not p.startswith("{") and p not in ("api", "v1", "v2")]
|
|
108
|
+
name = "_".join(filtered).replace(":", "_")
|
|
109
|
+
|
|
110
|
+
return MCPToolDefinition(
|
|
111
|
+
name=name,
|
|
112
|
+
description=description,
|
|
113
|
+
endpoint_path=endpoint_path,
|
|
114
|
+
method=method,
|
|
115
|
+
input_schema=input_schema or {},
|
|
116
|
+
output_schema=output_schema or {},
|
|
117
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
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 server helpers
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Legacy SSE server-side transport helpers for `/sse` + `/message`
|
|
20
|
+
# compatibility routes.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Legacy SSE server helpers for MCP compatibility routes."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from cloud_dog_api_kit.mcp.session import SESSION_HEADER
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class LegacySSEConfig:
|
|
38
|
+
"""Configuration for server-side legacy SSE MCP compatibility routes.
|
|
39
|
+
|
|
40
|
+
The dataclass also keeps the common client-side fields so
|
|
41
|
+
`from cloud_dog_api_kit.mcp import LegacySSEConfig` remains useful for code
|
|
42
|
+
that pairs the root import with `LegacySSETransport`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
base_url: str = ""
|
|
46
|
+
sse_path: str = "/sse"
|
|
47
|
+
message_path: str = "/message"
|
|
48
|
+
messages_path: str = "/message"
|
|
49
|
+
session_header: str = SESSION_HEADER
|
|
50
|
+
api_key_header: str | None = None
|
|
51
|
+
api_key: str | None = None
|
|
52
|
+
accept_header: str | None = None
|
|
53
|
+
auth_bearer_token: str | None = None
|
|
54
|
+
protocol_version: str | None = None
|
|
55
|
+
timeout_seconds: float = 30.0
|
|
56
|
+
verify_tls: bool = True
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
self.sse_path = _normalise_path(self.sse_path, "/sse")
|
|
60
|
+
self.message_path = _normalise_path(self.message_path or self.messages_path, "/message")
|
|
61
|
+
self.messages_path = _normalise_path(self.messages_path or self.message_path, "/message")
|
|
62
|
+
self.session_header = str(self.session_header or SESSION_HEADER).strip() or SESSION_HEADER
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalise_path(value: str | None, default: str) -> str:
|
|
66
|
+
raw = str(value or "").strip() or default
|
|
67
|
+
if not raw.startswith("/"):
|
|
68
|
+
return f"/{raw}"
|
|
69
|
+
return raw
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LegacySSEBroker:
|
|
73
|
+
"""Queue-backed broker for legacy SSE bootstrap and message delivery."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config: LegacySSEConfig) -> None:
|
|
76
|
+
self._config = config
|
|
77
|
+
self._queues: dict[str, asyncio.Queue[dict[str, Any]]] = {}
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
|
|
80
|
+
async def open_session(self, session_id: str) -> asyncio.Queue[dict[str, Any]]:
|
|
81
|
+
"""Create or reuse the queue for a legacy SSE session."""
|
|
82
|
+
async with self._lock:
|
|
83
|
+
queue = self._queues.get(session_id)
|
|
84
|
+
if queue is None:
|
|
85
|
+
queue = asyncio.Queue()
|
|
86
|
+
self._queues[session_id] = queue
|
|
87
|
+
return queue
|
|
88
|
+
|
|
89
|
+
async def push(self, session_id: str, payload: dict[str, Any]) -> bool:
|
|
90
|
+
"""Push a JSON payload to an active legacy SSE stream."""
|
|
91
|
+
async with self._lock:
|
|
92
|
+
queue = self._queues.get(session_id)
|
|
93
|
+
if queue is None:
|
|
94
|
+
return False
|
|
95
|
+
await queue.put(dict(payload))
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
async def close_session(self, session_id: str) -> None:
|
|
99
|
+
"""Remove a legacy SSE session queue."""
|
|
100
|
+
async with self._lock:
|
|
101
|
+
self._queues.pop(session_id, None)
|
|
102
|
+
|
|
103
|
+
def bootstrap_payload(self, session_id: str) -> dict[str, Any]:
|
|
104
|
+
"""Build the bootstrap payload sent on first SSE connect."""
|
|
105
|
+
endpoint = f"{self._config.message_path}?session_id={session_id}&sessionId={session_id}"
|
|
106
|
+
return {
|
|
107
|
+
"endpoint": endpoint,
|
|
108
|
+
"path": endpoint,
|
|
109
|
+
"messages_path": self._config.message_path,
|
|
110
|
+
"session_id": session_id,
|
|
111
|
+
"sessionId": session_id,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async def event_stream(self, request: Any, session_id: str) -> Any:
|
|
115
|
+
"""Yield the bootstrap event and subsequent message events."""
|
|
116
|
+
queue = await self.open_session(session_id)
|
|
117
|
+
bootstrap = self.bootstrap_payload(session_id)
|
|
118
|
+
yield f"event: endpoint\ndata: {json.dumps(bootstrap, ensure_ascii=True)}\n\n"
|
|
119
|
+
try:
|
|
120
|
+
while True:
|
|
121
|
+
if hasattr(request, "is_disconnected") and await request.is_disconnected():
|
|
122
|
+
break
|
|
123
|
+
try:
|
|
124
|
+
payload = await asyncio.wait_for(queue.get(), timeout=0.25)
|
|
125
|
+
except asyncio.TimeoutError:
|
|
126
|
+
continue
|
|
127
|
+
yield f"event: message\ndata: {json.dumps(payload, ensure_ascii=True)}\n\n"
|
|
128
|
+
finally:
|
|
129
|
+
await self.close_session(session_id)
|
|
@@ -0,0 +1,96 @@
|
|
|
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 session lifecycle
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Lightweight in-memory MCP session manager with create/resume/
|
|
20
|
+
# delete semantics for Mcp-Session-Id handling.
|
|
21
|
+
# Related requirements: FR18.5
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""MCP session lifecycle support."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class McpSession:
|
|
39
|
+
"""In-memory MCP session state."""
|
|
40
|
+
|
|
41
|
+
session_id: str
|
|
42
|
+
created_at: float
|
|
43
|
+
last_seen_at: float
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class McpSessionManager:
|
|
48
|
+
"""Manage in-memory MCP sessions.
|
|
49
|
+
|
|
50
|
+
Related tests: UT1.41_MCPSessionLifecycle
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, clock: Callable[[], float] | None = None) -> None:
|
|
54
|
+
self._clock = clock or time.time
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._sessions: dict[str, McpSession] = {}
|
|
57
|
+
|
|
58
|
+
def create(self) -> McpSession:
|
|
59
|
+
"""Create a new session."""
|
|
60
|
+
now = self._clock()
|
|
61
|
+
session = McpSession(session_id=uuid.uuid4().hex, created_at=now, last_seen_at=now)
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._sessions[session.session_id] = session
|
|
64
|
+
return session
|
|
65
|
+
|
|
66
|
+
def resume(self, session_id: str) -> McpSession | None:
|
|
67
|
+
"""Resume an existing session by ID."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
session = self._sessions.get(session_id)
|
|
70
|
+
if session is None:
|
|
71
|
+
return None
|
|
72
|
+
session.last_seen_at = self._clock()
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
def ensure(self, session_id: str | None) -> tuple[McpSession, bool]:
|
|
76
|
+
"""Get an existing session or create a new one.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Tuple of (session, created_new).
|
|
80
|
+
"""
|
|
81
|
+
if session_id:
|
|
82
|
+
resumed = self.resume(session_id)
|
|
83
|
+
if resumed is not None:
|
|
84
|
+
return resumed, False
|
|
85
|
+
created = self.create()
|
|
86
|
+
return created, True
|
|
87
|
+
|
|
88
|
+
def delete(self, session_id: str) -> bool:
|
|
89
|
+
"""Delete a session by ID."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
return self._sessions.pop(session_id, None) is not None
|
|
92
|
+
|
|
93
|
+
def exists(self, session_id: str) -> bool:
|
|
94
|
+
"""Check if a session exists."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
return session_id in self._sessions
|