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