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,269 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """PS-95 three-mode sync_class handler for MCP tool dispatch.
4
+
5
+ Mode 1 (sync-default):
6
+ Submit to jobs queue, block until complete or sync_budget exhausted.
7
+ If budget exhausted, return error with job_id for recovery.
8
+
9
+ Mode 2 (sync-with-progress):
10
+ Submit to jobs queue, stream MCP progress notifications, return result.
11
+ Falls back to Mode 1 for clients without progressToken support.
12
+
13
+ Mode 3 (async-only):
14
+ Return job_id immediately. Client must poll via jobs/get.
15
+ Every external async-only tool MUST have a _blocking sibling.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+ from typing import Any, Awaitable, Callable
23
+
24
+ from cloud_dog_api_kit.mcp.tool_router import (
25
+ DEFAULT_SYNC_BUDGET_SECONDS,
26
+ MAX_SYNC_BUDGET_SECONDS,
27
+ SYNC_CLASS_ASYNC,
28
+ SYNC_CLASS_DEFAULT,
29
+ SYNC_CLASS_PROGRESS,
30
+ ToolContract,
31
+ )
32
+
33
+ # JSON-RPC error code for budget exceeded (PS-95 §5.4)
34
+ BUDGET_EXCEEDED_CODE = -32000
35
+
36
+
37
+ def validate_blocking_siblings(contracts: dict[str, ToolContract]) -> list[str]:
38
+ """Validate that every async-only tool has a mandatory _blocking sibling.
39
+
40
+ PS-95 §2 Mode 3: "Every external-facing async-only tool MUST have a
41
+ <tool>_blocking sibling (Mode 1 wrapper that submits and polls internally)."
42
+
43
+ Returns a list of error messages for missing siblings. Empty = valid.
44
+ """
45
+ errors: list[str] = []
46
+ for name, contract in contracts.items():
47
+ if contract.sync_class != SYNC_CLASS_ASYNC:
48
+ continue
49
+ # async-only tools ending in _async need a _async_blocking sibling
50
+ if name.endswith("_async"):
51
+ blocking_name = f"{name}_blocking"
52
+ else:
53
+ blocking_name = f"{name}_blocking"
54
+ if blocking_name not in contracts:
55
+ errors.append(
56
+ f"Tool {name!r} is sync_class=async-only but missing mandatory "
57
+ f"blocking sibling {blocking_name!r} (PS-95 §2 Mode 3)"
58
+ )
59
+ return errors
60
+
61
+
62
+ async def dispatch_with_sync_class(
63
+ contract: ToolContract,
64
+ runner: Callable[[], Any],
65
+ *,
66
+ async_job_store: Any | None = None,
67
+ tool_name: str = "",
68
+ arguments: dict[str, Any] | None = None,
69
+ context: dict[str, Any] | None = None,
70
+ client_supports_progress: bool = False,
71
+ progress_callback: Callable[[float, float | None, str | None], Awaitable[None]] | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Dispatch a tool call according to its sync_class.
74
+
75
+ Returns a dict with either:
76
+ {"mode": "sync", "result": <tool_result>}
77
+ {"mode": "async", "job_id": <id>, "status": "submitted"}
78
+ {"mode": "budget_exceeded", "job_id": <id>, "status": "running"}
79
+ """
80
+ sc = contract.sync_class
81
+ name = tool_name or contract.name
82
+ args = arguments or {}
83
+
84
+ if sc == SYNC_CLASS_ASYNC:
85
+ return await _dispatch_async(contract, runner, async_job_store, name, args, context)
86
+
87
+ if sc == SYNC_CLASS_PROGRESS and client_supports_progress and progress_callback:
88
+ return await _dispatch_with_progress(contract, runner, async_job_store, name, args, context, progress_callback)
89
+
90
+ # Mode 1: sync-default (also Mode 2 fallback for non-progress clients)
91
+ return await _dispatch_sync_default(contract, runner, async_job_store, name, args, context)
92
+
93
+
94
+ async def _dispatch_sync_default(
95
+ contract: ToolContract,
96
+ runner: Callable[[], Any],
97
+ async_job_store: Any | None,
98
+ tool_name: str,
99
+ arguments: dict[str, Any],
100
+ context: dict[str, Any] | None,
101
+ ) -> dict[str, Any]:
102
+ """Mode 1: submit and block within sync_budget."""
103
+ budget = min(contract.sync_budget_seconds, MAX_SYNC_BUDGET_SECONDS)
104
+
105
+ if async_job_store is None:
106
+ # No job store — run inline with timeout
107
+ try:
108
+ result = await asyncio.wait_for(_run(runner), timeout=budget)
109
+ return {"mode": "sync", "result": result}
110
+ except asyncio.TimeoutError:
111
+ return {
112
+ "mode": "budget_exceeded",
113
+ "error": {
114
+ "code": BUDGET_EXCEEDED_CODE,
115
+ "message": f"Tool exceeded sync budget ({budget}s). No job store configured.",
116
+ "data": {"tool_name": tool_name, "budget_seconds": budget},
117
+ },
118
+ }
119
+
120
+ # Submit to job store, then poll until budget
121
+ job_id = _submit_to_store(async_job_store, tool_name, arguments, context, runner)
122
+ start = time.monotonic()
123
+ while (time.monotonic() - start) < budget:
124
+ status = _get_store_status(async_job_store, job_id)
125
+ s = status.get("status", "")
126
+ if s == "completed":
127
+ return {"mode": "sync", "result": status.get("result")}
128
+ if s == "failed":
129
+ return {"mode": "sync", "result": status}
130
+ await asyncio.sleep(0.1)
131
+
132
+ # Budget exhausted — job still running
133
+ return {
134
+ "mode": "budget_exceeded",
135
+ "job_id": job_id,
136
+ "status": "running",
137
+ "error": {
138
+ "code": BUDGET_EXCEEDED_CODE,
139
+ "message": f"Tool exceeded sync budget ({budget}s). Job is still running.",
140
+ "data": {"job_id": job_id, "status": "running", "poll_url": f"/api/v1/jobs/{job_id}"},
141
+ },
142
+ }
143
+
144
+
145
+ async def _dispatch_with_progress(
146
+ contract: ToolContract,
147
+ runner: Callable[[], Any],
148
+ async_job_store: Any | None,
149
+ tool_name: str,
150
+ arguments: dict[str, Any],
151
+ context: dict[str, Any] | None,
152
+ progress_callback: Callable[[float, float | None, str | None], Awaitable[None]],
153
+ ) -> dict[str, Any]:
154
+ """Mode 2: run with progress notifications."""
155
+ if async_job_store is None:
156
+ # Run inline, emit progress at start/end
157
+ await progress_callback(0.0, 1.0, f"Starting {tool_name}")
158
+ result = await _run(runner)
159
+ await progress_callback(1.0, 1.0, f"Completed {tool_name}")
160
+ return {"mode": "sync", "result": result}
161
+
162
+ job_id = _submit_to_store(async_job_store, tool_name, arguments, context, runner)
163
+ await progress_callback(0.0, None, f"Submitted {tool_name} as {job_id}")
164
+
165
+ poll_count = 0
166
+ while True:
167
+ status = _get_store_status(async_job_store, job_id)
168
+ s = status.get("status", "")
169
+ if s == "completed":
170
+ await progress_callback(1.0, 1.0, "Completed")
171
+ return {"mode": "sync", "result": status.get("result")}
172
+ if s == "failed":
173
+ await progress_callback(1.0, 1.0, "Failed")
174
+ return {"mode": "sync", "result": status}
175
+
176
+ poll_count += 1
177
+ if poll_count % 10 == 0:
178
+ await progress_callback(float(poll_count), None, f"Running ({poll_count}s)")
179
+ await asyncio.sleep(1.0)
180
+
181
+
182
+ async def _dispatch_async(
183
+ contract: ToolContract,
184
+ runner: Callable[[], Any],
185
+ async_job_store: Any | None,
186
+ tool_name: str,
187
+ arguments: dict[str, Any],
188
+ context: dict[str, Any] | None,
189
+ ) -> dict[str, Any]:
190
+ """Mode 3: return job_id immediately."""
191
+ if async_job_store is None:
192
+ raise RuntimeError(
193
+ f"Tool {tool_name!r} is sync_class=async-only but no async_job_store is configured"
194
+ )
195
+ job_id = _submit_to_store(async_job_store, tool_name, arguments, context, runner)
196
+ return {"mode": "async", "job_id": job_id, "status": "submitted"}
197
+
198
+
199
+ def _submit_to_store(
200
+ store: Any,
201
+ tool_name: str,
202
+ arguments: dict[str, Any],
203
+ context: dict[str, Any] | None,
204
+ runner: Callable[[], Any],
205
+ ) -> str:
206
+ """Submit a job to the async job store."""
207
+ import inspect
208
+
209
+ ctx = dict(context or {})
210
+ ctx["runner"] = runner
211
+ result = store.submit(tool_name, arguments, ctx)
212
+ if inspect.isawaitable(result):
213
+ raise TypeError("Synchronous submit path received awaitable — use async store")
214
+ return str(result)
215
+
216
+
217
+ def _get_store_status(store: Any, job_id: str) -> dict[str, Any]:
218
+ """Get job status from store."""
219
+ import inspect
220
+
221
+ result = store.get_status(job_id)
222
+ if inspect.isawaitable(result):
223
+ raise TypeError("Synchronous status path received awaitable — use async store")
224
+ return dict(result) if isinstance(result, dict) else {"status": "unknown"}
225
+
226
+
227
+ async def _run(runner: Callable[[], Any]) -> Any:
228
+ """Execute runner, awaiting if needed."""
229
+ import inspect
230
+
231
+ result = runner()
232
+ if inspect.isawaitable(result):
233
+ return await result
234
+ return result
235
+
236
+
237
+ def format_async_rest_response(job_id: str) -> tuple[int, dict[str, str], dict[str, Any]]:
238
+ """Format REST 202 Accepted response for async jobs (PS-95 §5.2).
239
+
240
+ Returns (status_code, headers, body). The Location header points to the
241
+ polling endpoint per PS-95 §5.2.
242
+ """
243
+ return (
244
+ 202,
245
+ {"Location": f"/api/v1/jobs/{job_id}"},
246
+ {"job_id": job_id, "status": "submitted"},
247
+ )
248
+
249
+
250
+ def format_budget_exceeded_mcp_error(job_id: str, budget: float) -> dict[str, Any]:
251
+ """Format MCP JSON-RPC error for budget-exceeded (PS-95 §5.4)."""
252
+ return {
253
+ "code": BUDGET_EXCEEDED_CODE,
254
+ "message": f"Tool exceeded sync budget ({budget}s). Job is still running.",
255
+ "data": {"job_id": job_id, "status": "running", "poll_url": f"/api/v1/jobs/{job_id}"},
256
+ }
257
+
258
+
259
+ def format_a2a_task_response(job_id: str, status: str = "submitted") -> dict[str, Any]:
260
+ """Format A2A task response with 1:1 job/task ID mapping (PS-95 §5.2).
261
+
262
+ A2A task ID = job ID. This ensures callers can use standard A2A getTask
263
+ flow to poll job completion.
264
+ """
265
+ return {
266
+ "id": job_id,
267
+ "status": {"state": "working" if status in ("submitted", "running", "pending") else status},
268
+ "metadata": {"job_id": job_id},
269
+ }
@@ -0,0 +1,136 @@
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
+ """MCP tool audit middleware for PS-50 compliance.
16
+
17
+ License: Apache 2.0
18
+ Ownership: Cloud-Dog, Viewdeck Engineering Limited
19
+ Description: Wraps MCP tool handlers with structured audit logging.
20
+ Requirements: PS-50.AUD1
21
+ Tasks: W28A-737
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import time
28
+ import uuid
29
+ from datetime import datetime, timezone
30
+ from typing import Any, Callable, Optional
31
+
32
+ _DEFAULT_REDACT_FIELDS = frozenset({
33
+ "password", "secret", "token", "api_key", "credential", "auth",
34
+ "access_token", "refresh_token", "key_hash",
35
+ })
36
+
37
+
38
+ def _redact_params(
39
+ params: dict[str, Any],
40
+ redact_fields: frozenset[str] = _DEFAULT_REDACT_FIELDS,
41
+ ) -> dict[str, Any]:
42
+ """Redact sensitive parameter values.
43
+
44
+ Args:
45
+ params: The raw parameter dict from the tool call.
46
+ redact_fields: Field names whose values should be replaced.
47
+
48
+ Returns:
49
+ A copy of the dict with sensitive values replaced by ``[REDACTED]``.
50
+ """
51
+ cleaned: dict[str, Any] = {}
52
+ for key, value in params.items():
53
+ if key.lower() in redact_fields:
54
+ cleaned[key] = "[REDACTED]"
55
+ else:
56
+ cleaned[key] = value
57
+ return cleaned
58
+
59
+
60
+ def mcp_tool_audit_middleware(
61
+ tool_name: str,
62
+ handler: Callable,
63
+ *,
64
+ service: str,
65
+ logger: Optional[logging.Logger] = None,
66
+ redact_fields: Optional[frozenset[str]] = None,
67
+ ) -> Callable:
68
+ """Wrap an MCP tool handler to emit audit log entries for every tool call.
69
+
70
+ Args:
71
+ tool_name: The name of the MCP tool being wrapped.
72
+ handler: The original tool handler callable.
73
+ service: The emitting service name (e.g. ``"file-mcp-server"``).
74
+ logger: Optional logger instance. Falls back to stdlib logging.
75
+ redact_fields: Additional field names to redact from parameters.
76
+
77
+ Returns:
78
+ A wrapped handler that logs audit entries before and after execution.
79
+
80
+ Audit record fields:
81
+ - correlation_id (from request context or generated)
82
+ - service (the emitting service name)
83
+ - tool_name (which tool was called)
84
+ - actor (user/API key identity from auth context)
85
+ - parameters (redacted — no secrets/tokens/passwords)
86
+ - outcome ("success" | "error")
87
+ - duration_ms (wall clock time)
88
+ - timestamp (ISO 8601)
89
+ - error_detail (if outcome is "error")
90
+ """
91
+ effective_redact = redact_fields or _DEFAULT_REDACT_FIELDS
92
+ log = logger or logging.getLogger(f"cloud_dog_api_kit.mcp.audit.{service}")
93
+
94
+ def _wrapped(**kwargs: Any) -> Any:
95
+ correlation_id = str(uuid.uuid4().hex[:16])
96
+ ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
97
+ safe_params = _redact_params(kwargs, effective_redact)
98
+ t0 = time.monotonic()
99
+ try:
100
+ result = handler(**kwargs)
101
+ duration_ms = round((time.monotonic() - t0) * 1000, 2)
102
+ log.info(
103
+ "mcp_tool_call",
104
+ extra={
105
+ "event_type": "mcp_tool_call",
106
+ "correlation_id": correlation_id,
107
+ "service": service,
108
+ "tool_name": tool_name,
109
+ "parameters": safe_params,
110
+ "outcome": "success",
111
+ "duration_ms": duration_ms,
112
+ "timestamp": ts,
113
+ },
114
+ )
115
+ return result
116
+ except Exception as exc:
117
+ duration_ms = round((time.monotonic() - t0) * 1000, 2)
118
+ log.warning(
119
+ "mcp_tool_call",
120
+ extra={
121
+ "event_type": "mcp_tool_call",
122
+ "correlation_id": correlation_id,
123
+ "service": service,
124
+ "tool_name": tool_name,
125
+ "parameters": safe_params,
126
+ "outcome": "error",
127
+ "duration_ms": duration_ms,
128
+ "timestamp": ts,
129
+ "error_detail": str(exc),
130
+ },
131
+ )
132
+ raise
133
+
134
+ _wrapped.__name__ = handler.__name__ if hasattr(handler, "__name__") else tool_name
135
+ _wrapped.__doc__ = handler.__doc__
136
+ return _wrapped
@@ -0,0 +1,180 @@
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 tool router
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: Tool registry router for typed MCP tool calls and metadata.
20
+ # Related requirements: FR18.1
21
+ # Related architecture: SA1
22
+
23
+ """MCP tool router helpers."""
24
+
25
+ from __future__ import annotations
26
+
27
+ import inspect
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, Awaitable, Callable
30
+
31
+ from fastapi import FastAPI, Request
32
+ from starlette.responses import JSONResponse
33
+
34
+ from cloud_dog_api_kit.envelopes import error_envelope, success_envelope
35
+ from cloud_dog_api_kit.errors import APIError
36
+ from cloud_dog_api_kit.mcp.error_mapper import map_legacy_mcp_payload
37
+
38
+ ToolCallable = Callable[[dict[str, Any], Request], Awaitable[Any] | Any]
39
+ ToolRegistryType = dict[str, "ToolContract | ToolCallable | dict[str, Any]"]
40
+
41
+
42
+ SYNC_CLASS_DEFAULT = "sync-default"
43
+ SYNC_CLASS_PROGRESS = "sync-with-progress"
44
+ SYNC_CLASS_ASYNC = "async-only"
45
+ SYNC_CLASSES = frozenset({SYNC_CLASS_DEFAULT, SYNC_CLASS_PROGRESS, SYNC_CLASS_ASYNC})
46
+
47
+ DEFAULT_SYNC_BUDGET_SECONDS = 50
48
+ MAX_SYNC_BUDGET_SECONDS = 55
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class ToolContract:
53
+ """Contract for a tool exposed via MCP transport.
54
+
55
+ PS-95 sync_class values:
56
+ ``sync-default`` Mode 1 — block within sync_budget, return result or job_id on timeout.
57
+ ``sync-with-progress`` Mode 2 — stream progress notifications, return result on completion.
58
+ ``async-only`` Mode 3 — return job_id immediately; mandatory ``_blocking`` sibling.
59
+ """
60
+
61
+ name: str
62
+ handler: ToolCallable
63
+ description: str = ""
64
+ input_schema: dict[str, Any] = field(default_factory=dict)
65
+ output_schema: dict[str, Any] = field(default_factory=dict)
66
+ sync_class: str = SYNC_CLASS_DEFAULT
67
+ sync_budget_seconds: float = DEFAULT_SYNC_BUDGET_SECONDS
68
+
69
+
70
+ def normalise_tool_registry(tool_registry: ToolRegistryType | None) -> dict[str, ToolContract]:
71
+ """Normalise different registry value shapes into ToolContract values."""
72
+ contracts: dict[str, ToolContract] = {}
73
+ for name, value in (tool_registry or {}).items():
74
+ if isinstance(value, ToolContract):
75
+ contracts[name] = value
76
+ continue
77
+ if callable(value):
78
+ contracts[name] = ToolContract(name=name, handler=value)
79
+ continue
80
+ if isinstance(value, dict) and callable(value.get("handler")):
81
+ sc = str(value.get("sync_class", SYNC_CLASS_DEFAULT))
82
+ if sc not in SYNC_CLASSES:
83
+ raise ValueError(f"Invalid sync_class {sc!r} for tool {name!r}. Must be one of {SYNC_CLASSES}")
84
+ contracts[name] = ToolContract(
85
+ name=name,
86
+ handler=value["handler"],
87
+ description=str(value.get("description", "")),
88
+ input_schema=dict(value.get("input_schema") or {}),
89
+ output_schema=dict(value.get("output_schema") or {}),
90
+ sync_class=sc,
91
+ sync_budget_seconds=float(value.get("sync_budget_seconds", DEFAULT_SYNC_BUDGET_SECONDS)),
92
+ )
93
+ continue
94
+ raise TypeError(f"Unsupported tool registry entry for {name!r}")
95
+ return contracts
96
+
97
+
98
+ async def _invoke_tool(contract: ToolContract, payload: dict[str, Any], request: Request) -> Any:
99
+ """Invoke tool handler with best-effort signature compatibility."""
100
+ parameter_count = len(inspect.signature(contract.handler).parameters)
101
+ if parameter_count <= 1:
102
+ result = contract.handler(payload) # type: ignore[call-arg]
103
+ else:
104
+ result = contract.handler(payload, request)
105
+ if inspect.isawaitable(result):
106
+ return await result
107
+ return result
108
+
109
+
110
+ def register_tool_router(
111
+ app: FastAPI,
112
+ tool_registry: ToolRegistryType | None,
113
+ *,
114
+ base_path: str = "/mcp/tools",
115
+ ) -> dict[str, ToolContract]:
116
+ """Register MCP tool routes on a FastAPI app."""
117
+ contracts = normalise_tool_registry(tool_registry)
118
+
119
+ @app.get(base_path, tags=["mcp"])
120
+ async def list_tools() -> dict[str, Any]:
121
+ """List tools."""
122
+ return success_envelope(
123
+ data=[
124
+ {
125
+ "name": contract.name,
126
+ "description": contract.description,
127
+ "input_schema": contract.input_schema,
128
+ "output_schema": contract.output_schema,
129
+ }
130
+ for contract in contracts.values()
131
+ ],
132
+ )
133
+
134
+ @app.post(f"{base_path}/{{tool_name}}", tags=["mcp"])
135
+ async def call_tool(tool_name: str, request: Request) -> JSONResponse:
136
+ """Handle call tool."""
137
+ request_id = getattr(request.state, "request_id", "")
138
+ correlation_id = getattr(request.state, "correlation_id", None)
139
+ contract = contracts.get(tool_name)
140
+ if contract is None:
141
+ return JSONResponse(
142
+ status_code=404,
143
+ content=error_envelope(
144
+ code="NOT_FOUND",
145
+ message=f"Unknown MCP tool: {tool_name}",
146
+ request_id=request_id,
147
+ correlation_id=correlation_id,
148
+ ),
149
+ )
150
+ payload = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
151
+ try:
152
+ result = await _invoke_tool(contract, payload, request)
153
+ except APIError as exc:
154
+ return JSONResponse(
155
+ status_code=exc.status_code,
156
+ content=error_envelope(
157
+ code=exc.code,
158
+ message=exc.message,
159
+ details=exc.details,
160
+ retryable=exc.retryable,
161
+ request_id=request_id,
162
+ correlation_id=correlation_id,
163
+ ),
164
+ )
165
+ except Exception:
166
+ return JSONResponse(
167
+ status_code=500,
168
+ content=error_envelope(
169
+ code="INTERNAL_ERROR",
170
+ message="Tool execution failed",
171
+ request_id=request_id,
172
+ correlation_id=correlation_id,
173
+ ),
174
+ )
175
+
176
+ mapped = map_legacy_mcp_payload(result, request_id=request_id, correlation_id=correlation_id)
177
+ status_code = 200 if mapped.get("ok", True) else 400
178
+ return JSONResponse(status_code=status_code, content=mapped)
179
+
180
+ return contracts