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