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,1041 @@
|
|
|
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 transport helpers
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Route registration helpers for MCP transport compatibility
|
|
20
|
+
# modes and tool dispatch.
|
|
21
|
+
# Related requirements: FR18.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""MCP transport route registration helpers."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from typing import Any, Awaitable, Callable, Literal, TypedDict
|
|
32
|
+
|
|
33
|
+
if sys.version_info >= (3, 11):
|
|
34
|
+
from typing import NotRequired
|
|
35
|
+
else:
|
|
36
|
+
from typing_extensions import NotRequired
|
|
37
|
+
from uuid import uuid4
|
|
38
|
+
|
|
39
|
+
from fastapi import FastAPI, Request
|
|
40
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
41
|
+
|
|
42
|
+
from cloud_dog_api_kit.envelopes import error_envelope, success_envelope
|
|
43
|
+
from cloud_dog_api_kit.mcp.async_jobs import AsyncJobStore
|
|
44
|
+
from cloud_dog_api_kit.mcp.error_mapper import map_legacy_mcp_payload
|
|
45
|
+
from cloud_dog_api_kit.mcp.legacy_sse import LegacySSEBroker, LegacySSEConfig
|
|
46
|
+
from cloud_dog_api_kit.mcp.session import SESSION_HEADER, McpSessionManager
|
|
47
|
+
from cloud_dog_api_kit.mcp.tool_router import ToolContract, normalise_tool_registry
|
|
48
|
+
|
|
49
|
+
TransportModes = set[str]
|
|
50
|
+
ToolCallable = Callable[[dict[str, Any], Request], Awaitable[Any] | Any]
|
|
51
|
+
RequestContextHook = Callable[[Request], dict[str, Any] | None]
|
|
52
|
+
SessionTerminationMode = Literal["204_idempotent", "200_json"]
|
|
53
|
+
ErrorResponseMode = Literal["http_404_legacy", "jsonrpc_200"]
|
|
54
|
+
ResourcesHandler = Callable[[dict[str, Any], Request], Awaitable[dict[str, Any]] | dict[str, Any]]
|
|
55
|
+
AsyncJobResultShape = Literal["modern", "legacy_content_text"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MCPResource(TypedDict):
|
|
59
|
+
"""MCP Resource descriptor returned by ``resources/list``."""
|
|
60
|
+
|
|
61
|
+
uri: str
|
|
62
|
+
name: str
|
|
63
|
+
description: NotRequired[str]
|
|
64
|
+
mimeType: NotRequired[str]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MCPResourceContent(TypedDict):
|
|
68
|
+
"""MCP Resource content item returned by ``resources/read``."""
|
|
69
|
+
|
|
70
|
+
uri: str
|
|
71
|
+
text: NotRequired[str]
|
|
72
|
+
blob: NotRequired[str]
|
|
73
|
+
mimeType: NotRequired[str]
|
|
74
|
+
|
|
75
|
+
SUPPORTED_TRANSPORT_MODES = frozenset({"streamable_http", "http_jsonrpc", "legacy_sse", "stdio"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalise_transport_modes(transport_modes: list[str] | set[str] | tuple[str, ...] | None) -> TransportModes:
|
|
79
|
+
modes = set(transport_modes or SUPPORTED_TRANSPORT_MODES)
|
|
80
|
+
unknown = modes - SUPPORTED_TRANSPORT_MODES
|
|
81
|
+
if unknown:
|
|
82
|
+
unknown_str = ", ".join(sorted(unknown))
|
|
83
|
+
raise ValueError(f"Unsupported transport mode(s): {unknown_str}")
|
|
84
|
+
return modes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _jsonrpc_response(
|
|
88
|
+
request_id: Any,
|
|
89
|
+
*,
|
|
90
|
+
result: dict[str, Any] | None = None,
|
|
91
|
+
error: dict[str, Any] | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
response: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
|
|
94
|
+
if error is not None:
|
|
95
|
+
response["error"] = error
|
|
96
|
+
else:
|
|
97
|
+
response["result"] = dict(result or {})
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _jsonrpc_error(
|
|
102
|
+
code: int,
|
|
103
|
+
message: str,
|
|
104
|
+
request_id: Any = None,
|
|
105
|
+
data: dict[str, Any] | None = None,
|
|
106
|
+
) -> JSONResponse:
|
|
107
|
+
error: dict[str, Any] = {
|
|
108
|
+
"code": int(code),
|
|
109
|
+
"message": str(message),
|
|
110
|
+
}
|
|
111
|
+
if data:
|
|
112
|
+
error["data"] = dict(data)
|
|
113
|
+
return JSONResponse(status_code=200, content=_jsonrpc_response(request_id, error=error))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_DEFAULT_MCP_CAPABILITIES: dict[str, Any] = {"tools": {}, "resources": {}, "progressNotifications": True}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _mcp_initialize_payload(
|
|
120
|
+
*,
|
|
121
|
+
protocol_version: str,
|
|
122
|
+
server_name: str,
|
|
123
|
+
server_version: str,
|
|
124
|
+
capabilities_override: dict[str, Any] | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
negotiated = str(protocol_version or "").strip() or "2025-11-25"
|
|
127
|
+
capabilities = (
|
|
128
|
+
dict(capabilities_override)
|
|
129
|
+
if capabilities_override is not None
|
|
130
|
+
else dict(_DEFAULT_MCP_CAPABILITIES)
|
|
131
|
+
)
|
|
132
|
+
return {
|
|
133
|
+
"protocolVersion": negotiated,
|
|
134
|
+
"capabilities": capabilities,
|
|
135
|
+
"serverInfo": {
|
|
136
|
+
"name": server_name,
|
|
137
|
+
"version": server_version,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _mcp_tools_list_payload(tools: dict[str, ToolContract]) -> dict[str, Any]:
|
|
143
|
+
"""Render the MCP `tools/list` payload.
|
|
144
|
+
|
|
145
|
+
MCP spec 2024-11-05: if a tool declares `outputSchema`, tool-call responses
|
|
146
|
+
MUST populate `structuredContent` matching that schema. The transport does
|
|
147
|
+
not synthesise structured content on behalf of handlers, so we only emit
|
|
148
|
+
`outputSchema` when the tool explicitly declares a non-empty schema. Tools
|
|
149
|
+
with no declared output schema therefore advertise no `outputSchema` key,
|
|
150
|
+
which matches the spec's "optional" semantics and prevents strict MCP
|
|
151
|
+
clients from rejecting otherwise-valid tool-call responses.
|
|
152
|
+
|
|
153
|
+
RF-02-F1 reference: `working/W28A-RF-02-REPORT.md`.
|
|
154
|
+
"""
|
|
155
|
+
tool_list: list[dict[str, Any]] = []
|
|
156
|
+
for tool in tools.values():
|
|
157
|
+
entry: dict[str, Any] = {
|
|
158
|
+
"name": tool.name,
|
|
159
|
+
"description": tool.description,
|
|
160
|
+
"inputSchema": tool.input_schema,
|
|
161
|
+
}
|
|
162
|
+
if tool.output_schema:
|
|
163
|
+
entry["outputSchema"] = tool.output_schema
|
|
164
|
+
tool_list.append(entry)
|
|
165
|
+
return {"tools": tool_list}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _mcp_tool_call_payload(result: Any) -> dict[str, Any]:
|
|
169
|
+
if isinstance(result, dict) and isinstance(result.get("content"), list):
|
|
170
|
+
return result
|
|
171
|
+
if isinstance(result, list) and all(isinstance(item, dict) and "type" in item for item in result):
|
|
172
|
+
return {"content": result}
|
|
173
|
+
|
|
174
|
+
is_error = False
|
|
175
|
+
data: Any = result
|
|
176
|
+
if isinstance(result, dict):
|
|
177
|
+
if result.get("ok") is False:
|
|
178
|
+
is_error = True
|
|
179
|
+
if result.get("data") is not None:
|
|
180
|
+
data = result.get("data")
|
|
181
|
+
elif result.get("result") is not None:
|
|
182
|
+
data = result.get("result")
|
|
183
|
+
elif result.get("error") is not None:
|
|
184
|
+
is_error = True
|
|
185
|
+
data = result.get("error")
|
|
186
|
+
|
|
187
|
+
if isinstance(data, str):
|
|
188
|
+
text = data
|
|
189
|
+
else:
|
|
190
|
+
text = json.dumps(data, default=str, ensure_ascii=True)
|
|
191
|
+
payload: dict[str, Any] = {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
192
|
+
# MCP 2024-11-05: tools declaring outputSchema MUST return structuredContent.
|
|
193
|
+
# Include it when the result data is structured (dict/list), allowing strict
|
|
194
|
+
# MCP clients (ragflow) to consume it without "has output schema but did not
|
|
195
|
+
# return structured content" errors.
|
|
196
|
+
if isinstance(data, (dict, list)):
|
|
197
|
+
payload["structuredContent"] = data
|
|
198
|
+
return payload
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _maybe_await(value: Any) -> Any:
|
|
202
|
+
if inspect.isawaitable(value):
|
|
203
|
+
return await value
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _messages_compat_payload(
|
|
208
|
+
*,
|
|
209
|
+
jsonrpc_payload: dict[str, Any],
|
|
210
|
+
legacy_result: Any | None,
|
|
211
|
+
request_id: Any,
|
|
212
|
+
correlation_id: Any,
|
|
213
|
+
error: dict[str, Any] | None = None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Attach legacy envelope fields to `/messages` JSON-RPC responses."""
|
|
216
|
+
payload = dict(jsonrpc_payload)
|
|
217
|
+
if error is not None:
|
|
218
|
+
payload["ok"] = False
|
|
219
|
+
payload["error"] = {"code": error.get("code"), "message": error.get("message")}
|
|
220
|
+
return payload
|
|
221
|
+
|
|
222
|
+
mapped = map_legacy_mcp_payload(legacy_result, request_id=request_id, correlation_id=correlation_id)
|
|
223
|
+
payload["ok"] = mapped.get("ok", True)
|
|
224
|
+
if "data" in mapped:
|
|
225
|
+
payload["data"] = mapped["data"]
|
|
226
|
+
if "error" in mapped:
|
|
227
|
+
payload["error"] = mapped["error"]
|
|
228
|
+
return payload
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _call_tool(
|
|
232
|
+
tool: ToolContract,
|
|
233
|
+
payload: dict[str, Any],
|
|
234
|
+
request: Request,
|
|
235
|
+
tool_context: dict[str, Any],
|
|
236
|
+
) -> Any:
|
|
237
|
+
parameter_count = len(inspect.signature(tool.handler).parameters)
|
|
238
|
+
if parameter_count <= 1:
|
|
239
|
+
result = tool.handler(payload) # type: ignore[call-arg]
|
|
240
|
+
elif parameter_count == 2:
|
|
241
|
+
result = tool.handler(payload, request)
|
|
242
|
+
else:
|
|
243
|
+
result = tool.handler(payload, request, tool_context) # type: ignore[call-arg]
|
|
244
|
+
if inspect.isawaitable(result):
|
|
245
|
+
return await result
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _normalise_alternate_endpoints(
|
|
250
|
+
alternate_endpoints: list[dict[str, Any]] | None,
|
|
251
|
+
) -> list[dict[str, str]]:
|
|
252
|
+
normalised: list[dict[str, str]] = []
|
|
253
|
+
seen_paths: set[str] = set()
|
|
254
|
+
for index, entry in enumerate(alternate_endpoints or []):
|
|
255
|
+
if not isinstance(entry, dict):
|
|
256
|
+
raise TypeError("alternate_endpoints entries must be dictionaries")
|
|
257
|
+
raw_path = str(entry.get("path") or "").strip()
|
|
258
|
+
if not raw_path:
|
|
259
|
+
raise ValueError("alternate_endpoints entries require a non-empty path")
|
|
260
|
+
path = raw_path if raw_path.startswith("/") else f"/{raw_path}"
|
|
261
|
+
if path in seen_paths:
|
|
262
|
+
continue
|
|
263
|
+
seen_paths.add(path)
|
|
264
|
+
auth_mode = str(entry.get("auth") or "custom").strip() or "custom"
|
|
265
|
+
name = str(entry.get("name") or f"alternate-{index + 1}").strip() or f"alternate-{index + 1}"
|
|
266
|
+
normalised.append({"path": path, "auth": auth_mode, "name": name})
|
|
267
|
+
return normalised
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _resolve_tool_context(
|
|
271
|
+
request: Request,
|
|
272
|
+
*,
|
|
273
|
+
request_context_hook: RequestContextHook | None,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
base_context = getattr(request.state, "mcp_context", None)
|
|
276
|
+
has_context = isinstance(base_context, dict)
|
|
277
|
+
context = dict(base_context) if has_context else {}
|
|
278
|
+
if request_context_hook is not None:
|
|
279
|
+
hook_result = request_context_hook(request)
|
|
280
|
+
if inspect.isawaitable(hook_result):
|
|
281
|
+
hook_result = await hook_result
|
|
282
|
+
if hook_result is not None:
|
|
283
|
+
if not isinstance(hook_result, dict):
|
|
284
|
+
raise TypeError("request_context_hook must return a dict or None")
|
|
285
|
+
context.update(hook_result)
|
|
286
|
+
has_context = True
|
|
287
|
+
if has_context:
|
|
288
|
+
request.state.mcp_context = context
|
|
289
|
+
return context
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def _dispatch_payload(
|
|
293
|
+
tools: dict[str, ToolContract],
|
|
294
|
+
payload: dict[str, Any],
|
|
295
|
+
request: Request,
|
|
296
|
+
*,
|
|
297
|
+
is_messages_path: bool,
|
|
298
|
+
session_manager: McpSessionManager,
|
|
299
|
+
request_context_hook: RequestContextHook | None,
|
|
300
|
+
async_job_store: AsyncJobStore | None,
|
|
301
|
+
modern_jsonrpc: bool,
|
|
302
|
+
error_response_mode: ErrorResponseMode,
|
|
303
|
+
server_name: str,
|
|
304
|
+
server_version: str,
|
|
305
|
+
capabilities_override: dict[str, Any] | None,
|
|
306
|
+
resources_handler: ResourcesHandler | None,
|
|
307
|
+
async_job_result_shape: AsyncJobResultShape,
|
|
308
|
+
invalid_session_error_code: int,
|
|
309
|
+
invalid_session_ids: set[str],
|
|
310
|
+
session_id_override: str | None = None,
|
|
311
|
+
) -> Response:
|
|
312
|
+
request_id = getattr(request.state, "request_id", "")
|
|
313
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
314
|
+
session_id = (
|
|
315
|
+
session_id_override
|
|
316
|
+
or request.headers.get(SESSION_HEADER)
|
|
317
|
+
or request.query_params.get("session_id")
|
|
318
|
+
or request.query_params.get("sessionId")
|
|
319
|
+
)
|
|
320
|
+
if session_id and session_id in invalid_session_ids and not session_manager.exists(session_id):
|
|
321
|
+
if payload.get("jsonrpc") == "2.0" and modern_jsonrpc:
|
|
322
|
+
error_payload = {"code": int(invalid_session_error_code), "message": "Invalid or expired MCP session"}
|
|
323
|
+
response_payload = _jsonrpc_response(
|
|
324
|
+
payload.get("id"),
|
|
325
|
+
error=error_payload,
|
|
326
|
+
)
|
|
327
|
+
if is_messages_path:
|
|
328
|
+
response_payload = _messages_compat_payload(
|
|
329
|
+
jsonrpc_payload=response_payload,
|
|
330
|
+
legacy_result=None,
|
|
331
|
+
request_id=request_id,
|
|
332
|
+
correlation_id=correlation_id,
|
|
333
|
+
error=error_payload,
|
|
334
|
+
)
|
|
335
|
+
response = JSONResponse(
|
|
336
|
+
status_code=200,
|
|
337
|
+
content=response_payload,
|
|
338
|
+
)
|
|
339
|
+
response.headers[SESSION_HEADER] = session_id
|
|
340
|
+
return response
|
|
341
|
+
if error_response_mode == "jsonrpc_200":
|
|
342
|
+
response = _jsonrpc_error(
|
|
343
|
+
int(invalid_session_error_code),
|
|
344
|
+
"Invalid or expired MCP session",
|
|
345
|
+
request_id=payload.get("id"),
|
|
346
|
+
data={"session_id": session_id} if session_id else None,
|
|
347
|
+
)
|
|
348
|
+
if session_id:
|
|
349
|
+
response.headers[SESSION_HEADER] = session_id
|
|
350
|
+
return response
|
|
351
|
+
response = JSONResponse(
|
|
352
|
+
status_code=404,
|
|
353
|
+
content=error_envelope(
|
|
354
|
+
code="INVALID_SESSION",
|
|
355
|
+
message="Invalid or expired MCP session",
|
|
356
|
+
request_id=request_id,
|
|
357
|
+
correlation_id=correlation_id,
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
response.headers[SESSION_HEADER] = session_id
|
|
361
|
+
return response
|
|
362
|
+
|
|
363
|
+
session, created = session_manager.ensure(session_id)
|
|
364
|
+
invalid_session_ids.discard(session.session_id)
|
|
365
|
+
|
|
366
|
+
# JSON-RPC shape
|
|
367
|
+
if payload.get("jsonrpc") == "2.0":
|
|
368
|
+
method = str(payload.get("method", ""))
|
|
369
|
+
params = dict(payload.get("params") or {})
|
|
370
|
+
rpc_request_id = payload.get("id")
|
|
371
|
+
if modern_jsonrpc:
|
|
372
|
+
if method == "initialize":
|
|
373
|
+
protocol_version = str(params.get("protocolVersion") or "2025-11-25").strip()
|
|
374
|
+
session.metadata["protocol_version"] = protocol_version
|
|
375
|
+
init_payload = _mcp_initialize_payload(
|
|
376
|
+
protocol_version=protocol_version,
|
|
377
|
+
server_name=server_name,
|
|
378
|
+
server_version=server_version,
|
|
379
|
+
capabilities_override=capabilities_override,
|
|
380
|
+
)
|
|
381
|
+
response_payload = _jsonrpc_response(
|
|
382
|
+
rpc_request_id,
|
|
383
|
+
result=init_payload,
|
|
384
|
+
)
|
|
385
|
+
if is_messages_path:
|
|
386
|
+
response_payload = _messages_compat_payload(
|
|
387
|
+
jsonrpc_payload=response_payload,
|
|
388
|
+
legacy_result=init_payload,
|
|
389
|
+
request_id=request_id,
|
|
390
|
+
correlation_id=correlation_id,
|
|
391
|
+
)
|
|
392
|
+
response = JSONResponse(
|
|
393
|
+
status_code=200,
|
|
394
|
+
content=response_payload,
|
|
395
|
+
)
|
|
396
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
397
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
398
|
+
return response
|
|
399
|
+
if method == "notifications/initialized":
|
|
400
|
+
session.metadata["initialized"] = True
|
|
401
|
+
response = Response(status_code=204, media_type="application/json")
|
|
402
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
403
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
404
|
+
return response
|
|
405
|
+
if method == "tools/list":
|
|
406
|
+
tools_payload = _mcp_tools_list_payload(tools)
|
|
407
|
+
response_payload = _jsonrpc_response(
|
|
408
|
+
rpc_request_id,
|
|
409
|
+
result=tools_payload,
|
|
410
|
+
)
|
|
411
|
+
if is_messages_path:
|
|
412
|
+
response_payload = _messages_compat_payload(
|
|
413
|
+
jsonrpc_payload=response_payload,
|
|
414
|
+
legacy_result=tools_payload,
|
|
415
|
+
request_id=request_id,
|
|
416
|
+
correlation_id=correlation_id,
|
|
417
|
+
)
|
|
418
|
+
response = JSONResponse(
|
|
419
|
+
status_code=200,
|
|
420
|
+
content=response_payload,
|
|
421
|
+
)
|
|
422
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
423
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
424
|
+
return response
|
|
425
|
+
if method in {"resources/list", "resources/read"} and resources_handler is not None:
|
|
426
|
+
try:
|
|
427
|
+
resources_payload = await _maybe_await(
|
|
428
|
+
resources_handler({"method": method, "params": params}, request)
|
|
429
|
+
)
|
|
430
|
+
except (ValueError, KeyError, LookupError) as exc:
|
|
431
|
+
# Handler signalled an error (e.g. unsupported URI, missing resource).
|
|
432
|
+
# -32602 (Invalid params) for resources/read with bad URI;
|
|
433
|
+
# -32601 (Method not found) for unsupported resources method.
|
|
434
|
+
error_code = -32602 if method == "resources/read" else -32601
|
|
435
|
+
error_message = str(exc) or "Resource error"
|
|
436
|
+
if error_response_mode == "jsonrpc_200":
|
|
437
|
+
response = _jsonrpc_error(
|
|
438
|
+
error_code,
|
|
439
|
+
error_message,
|
|
440
|
+
request_id=rpc_request_id,
|
|
441
|
+
data={"method": method},
|
|
442
|
+
)
|
|
443
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
444
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
445
|
+
return response
|
|
446
|
+
error_payload = {"code": error_code, "message": error_message}
|
|
447
|
+
response_payload = _jsonrpc_response(
|
|
448
|
+
rpc_request_id,
|
|
449
|
+
error=error_payload,
|
|
450
|
+
)
|
|
451
|
+
if is_messages_path:
|
|
452
|
+
response_payload = _messages_compat_payload(
|
|
453
|
+
jsonrpc_payload=response_payload,
|
|
454
|
+
legacy_result=None,
|
|
455
|
+
request_id=request_id,
|
|
456
|
+
correlation_id=correlation_id,
|
|
457
|
+
error=error_payload,
|
|
458
|
+
)
|
|
459
|
+
response = JSONResponse(
|
|
460
|
+
status_code=200,
|
|
461
|
+
content=response_payload,
|
|
462
|
+
)
|
|
463
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
464
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
465
|
+
return response
|
|
466
|
+
if not isinstance(resources_payload, dict):
|
|
467
|
+
raise TypeError("resources_handler must return a dict payload")
|
|
468
|
+
response_payload = _jsonrpc_response(
|
|
469
|
+
rpc_request_id,
|
|
470
|
+
result=resources_payload,
|
|
471
|
+
)
|
|
472
|
+
if is_messages_path:
|
|
473
|
+
response_payload = _messages_compat_payload(
|
|
474
|
+
jsonrpc_payload=response_payload,
|
|
475
|
+
legacy_result=resources_payload,
|
|
476
|
+
request_id=request_id,
|
|
477
|
+
correlation_id=correlation_id,
|
|
478
|
+
)
|
|
479
|
+
response = JSONResponse(
|
|
480
|
+
status_code=200,
|
|
481
|
+
content=response_payload,
|
|
482
|
+
)
|
|
483
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
484
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
485
|
+
return response
|
|
486
|
+
if method == "ping":
|
|
487
|
+
ping_payload = {"ok": True}
|
|
488
|
+
response_payload = _jsonrpc_response(
|
|
489
|
+
rpc_request_id,
|
|
490
|
+
result=ping_payload,
|
|
491
|
+
)
|
|
492
|
+
if is_messages_path:
|
|
493
|
+
response_payload = _messages_compat_payload(
|
|
494
|
+
jsonrpc_payload=response_payload,
|
|
495
|
+
legacy_result=ping_payload,
|
|
496
|
+
request_id=request_id,
|
|
497
|
+
correlation_id=correlation_id,
|
|
498
|
+
)
|
|
499
|
+
response = JSONResponse(
|
|
500
|
+
status_code=200,
|
|
501
|
+
content=response_payload,
|
|
502
|
+
)
|
|
503
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
504
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
505
|
+
return response
|
|
506
|
+
if method != "tools/call":
|
|
507
|
+
error_payload = {"code": -32601, "message": f"Unsupported JSON-RPC method: {method}"}
|
|
508
|
+
response_payload = _jsonrpc_response(
|
|
509
|
+
rpc_request_id,
|
|
510
|
+
error=error_payload,
|
|
511
|
+
)
|
|
512
|
+
if is_messages_path:
|
|
513
|
+
response_payload = _messages_compat_payload(
|
|
514
|
+
jsonrpc_payload=response_payload,
|
|
515
|
+
legacy_result=None,
|
|
516
|
+
request_id=request_id,
|
|
517
|
+
correlation_id=correlation_id,
|
|
518
|
+
error=error_payload,
|
|
519
|
+
)
|
|
520
|
+
response = JSONResponse(
|
|
521
|
+
status_code=200,
|
|
522
|
+
content=response_payload,
|
|
523
|
+
)
|
|
524
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
525
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
526
|
+
return response
|
|
527
|
+
if method == "tools/list":
|
|
528
|
+
response = JSONResponse(
|
|
529
|
+
status_code=200,
|
|
530
|
+
content=success_envelope(
|
|
531
|
+
data=[
|
|
532
|
+
{
|
|
533
|
+
"name": tool.name,
|
|
534
|
+
"description": tool.description,
|
|
535
|
+
"input_schema": tool.input_schema,
|
|
536
|
+
"output_schema": tool.output_schema,
|
|
537
|
+
}
|
|
538
|
+
for tool in tools.values()
|
|
539
|
+
],
|
|
540
|
+
request_id=request_id,
|
|
541
|
+
correlation_id=correlation_id,
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
545
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
546
|
+
return response
|
|
547
|
+
if method != "tools/call":
|
|
548
|
+
response = JSONResponse(
|
|
549
|
+
status_code=400,
|
|
550
|
+
content=error_envelope(
|
|
551
|
+
code="INVALID_REQUEST",
|
|
552
|
+
message=f"Unsupported JSON-RPC method: {method}",
|
|
553
|
+
request_id=request_id,
|
|
554
|
+
correlation_id=correlation_id,
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
558
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
559
|
+
return response
|
|
560
|
+
tool_name = str(params.get("name", ""))
|
|
561
|
+
arguments = dict(params.get("arguments") or {})
|
|
562
|
+
wait_value = params.get("wait")
|
|
563
|
+
if "wait" in arguments:
|
|
564
|
+
wait_value = arguments.pop("wait")
|
|
565
|
+
else:
|
|
566
|
+
tool_name = str(payload.get("tool", payload.get("name", "")))
|
|
567
|
+
arguments = dict(payload.get("arguments") or payload.get("input") or {})
|
|
568
|
+
wait_value = payload.get("wait")
|
|
569
|
+
if "wait" in arguments:
|
|
570
|
+
wait_value = arguments.pop("wait")
|
|
571
|
+
|
|
572
|
+
tool = tools.get(tool_name)
|
|
573
|
+
if tool is None:
|
|
574
|
+
if error_response_mode == "jsonrpc_200":
|
|
575
|
+
# JSON-RPC 2.0: -32602 (Invalid params) for known method (tools/call) with unknown tool param.
|
|
576
|
+
# -32601 (Method not found) is reserved for unknown top-level methods (e.g. unknown `tools/foo`).
|
|
577
|
+
# Changed from -32601 per F-29 refinement (W28A-1034b) — expert-agent ST1.2 compliance.
|
|
578
|
+
response = _jsonrpc_error(
|
|
579
|
+
-32602,
|
|
580
|
+
f"Unknown tool: {tool_name}",
|
|
581
|
+
request_id=payload.get("id"),
|
|
582
|
+
data={"tool": tool_name},
|
|
583
|
+
)
|
|
584
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
585
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
586
|
+
return response
|
|
587
|
+
response = JSONResponse(
|
|
588
|
+
status_code=404,
|
|
589
|
+
content=error_envelope(
|
|
590
|
+
code="NOT_FOUND",
|
|
591
|
+
message=f"Unknown MCP tool: {tool_name}",
|
|
592
|
+
request_id=request_id,
|
|
593
|
+
correlation_id=correlation_id,
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
597
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
598
|
+
return response
|
|
599
|
+
|
|
600
|
+
tool_context = await _resolve_tool_context(request, request_context_hook=request_context_hook)
|
|
601
|
+
if payload.get("jsonrpc") == "2.0" and modern_jsonrpc and async_job_store is not None and wait_value is False:
|
|
602
|
+
job_guid = uuid4().hex
|
|
603
|
+
|
|
604
|
+
async def _runner() -> Any:
|
|
605
|
+
return await _call_tool(tool, arguments, request, tool_context)
|
|
606
|
+
|
|
607
|
+
job_submission = await _maybe_await(
|
|
608
|
+
async_job_store.submit(
|
|
609
|
+
tool_name,
|
|
610
|
+
arguments,
|
|
611
|
+
{
|
|
612
|
+
"request": request,
|
|
613
|
+
"session_id": session.session_id,
|
|
614
|
+
"guid": job_guid,
|
|
615
|
+
"tool_context": tool_context,
|
|
616
|
+
"runner": _runner,
|
|
617
|
+
"result_formatter": _mcp_tool_call_payload,
|
|
618
|
+
},
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
extra_job_fields: dict[str, Any] = {}
|
|
622
|
+
if isinstance(job_submission, dict):
|
|
623
|
+
raw_job_id = job_submission.get("job_id", job_submission.get("id"))
|
|
624
|
+
if raw_job_id is None or str(raw_job_id).strip() == "":
|
|
625
|
+
raise TypeError("async job store submit() dict result requires a non-empty job_id")
|
|
626
|
+
job_id = str(raw_job_id)
|
|
627
|
+
submit_guid = job_submission.get("guid")
|
|
628
|
+
if submit_guid is not None and str(submit_guid).strip():
|
|
629
|
+
job_guid = str(submit_guid)
|
|
630
|
+
extra_job_fields = {
|
|
631
|
+
key: value
|
|
632
|
+
for key, value in job_submission.items()
|
|
633
|
+
if key not in {"job_id", "id", "guid"}
|
|
634
|
+
}
|
|
635
|
+
else:
|
|
636
|
+
if job_submission is None or str(job_submission).strip() == "":
|
|
637
|
+
raise TypeError("async job store submit() must return a non-empty job id")
|
|
638
|
+
job_id = str(job_submission)
|
|
639
|
+
if async_job_result_shape == "legacy_content_text":
|
|
640
|
+
legacy_job_payload = {"job_id": job_id, "guid": job_guid}
|
|
641
|
+
legacy_job_payload.update(extra_job_fields)
|
|
642
|
+
job_payload = {
|
|
643
|
+
"content": [
|
|
644
|
+
{
|
|
645
|
+
"type": "text",
|
|
646
|
+
"text": json.dumps(legacy_job_payload, default=str, ensure_ascii=True),
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
}
|
|
650
|
+
else:
|
|
651
|
+
job_payload = {"job_id": job_id, "status": "pending"}
|
|
652
|
+
response_payload = _jsonrpc_response(payload.get("id"), result=job_payload)
|
|
653
|
+
if is_messages_path:
|
|
654
|
+
response_payload = _messages_compat_payload(
|
|
655
|
+
jsonrpc_payload=response_payload,
|
|
656
|
+
legacy_result=job_payload,
|
|
657
|
+
request_id=request_id,
|
|
658
|
+
correlation_id=correlation_id,
|
|
659
|
+
)
|
|
660
|
+
response = JSONResponse(
|
|
661
|
+
status_code=200,
|
|
662
|
+
content=response_payload,
|
|
663
|
+
)
|
|
664
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
665
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
666
|
+
return response
|
|
667
|
+
|
|
668
|
+
result = await _call_tool(tool, arguments, request, tool_context)
|
|
669
|
+
if payload.get("jsonrpc") == "2.0" and modern_jsonrpc:
|
|
670
|
+
tool_payload = _mcp_tool_call_payload(result)
|
|
671
|
+
response_payload = _jsonrpc_response(payload.get("id"), result=tool_payload)
|
|
672
|
+
if is_messages_path:
|
|
673
|
+
response_payload = _messages_compat_payload(
|
|
674
|
+
jsonrpc_payload=response_payload,
|
|
675
|
+
legacy_result=result,
|
|
676
|
+
request_id=request_id,
|
|
677
|
+
correlation_id=correlation_id,
|
|
678
|
+
)
|
|
679
|
+
response = JSONResponse(
|
|
680
|
+
status_code=200,
|
|
681
|
+
content=response_payload,
|
|
682
|
+
)
|
|
683
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
684
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
685
|
+
return response
|
|
686
|
+
mapped = map_legacy_mcp_payload(result, request_id=request_id, correlation_id=correlation_id)
|
|
687
|
+
status = 200 if mapped.get("ok", True) else 400
|
|
688
|
+
response = JSONResponse(status_code=status, content=mapped)
|
|
689
|
+
response.headers[SESSION_HEADER] = session.session_id
|
|
690
|
+
response.headers["X-Mcp-Session-Created"] = "true" if created else "false"
|
|
691
|
+
return response
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _normalise_transport_path(raw_path: str | None, *, default: str, arg_name: str) -> str:
|
|
695
|
+
"""Normalise a transport route path to an absolute, non-empty path."""
|
|
696
|
+
value = str(raw_path or "").strip() or default
|
|
697
|
+
if not value.startswith("/"):
|
|
698
|
+
raise ValueError(f"{arg_name} must start with '/'")
|
|
699
|
+
if value != "/":
|
|
700
|
+
value = value.rstrip("/")
|
|
701
|
+
return value or "/"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _default_transport_messages_path(transport_base_path: str | None) -> str:
|
|
705
|
+
"""Derive the messages route from the configured transport base path."""
|
|
706
|
+
if transport_base_path is None:
|
|
707
|
+
return "/messages"
|
|
708
|
+
base_path = _normalise_transport_path(
|
|
709
|
+
transport_base_path,
|
|
710
|
+
default="/mcp",
|
|
711
|
+
arg_name="transport_base_path",
|
|
712
|
+
)
|
|
713
|
+
if base_path == "/":
|
|
714
|
+
return "/messages"
|
|
715
|
+
return f"{base_path}/messages"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def register_mcp_routes(
|
|
719
|
+
app: FastAPI,
|
|
720
|
+
tools: dict[str, ToolContract | ToolCallable | dict[str, Any]] | None,
|
|
721
|
+
transport_modes: list[str] | set[str] | tuple[str, ...] | None = None,
|
|
722
|
+
*,
|
|
723
|
+
session_manager: McpSessionManager | None = None,
|
|
724
|
+
request_context_hook: RequestContextHook | None = None,
|
|
725
|
+
alternate_endpoints: list[dict[str, Any]] | None = None,
|
|
726
|
+
async_job_store: AsyncJobStore | None = None,
|
|
727
|
+
async_job_status_path: str | None = "/jobs/{job_id}",
|
|
728
|
+
legacy_sse: LegacySSEConfig | None = None,
|
|
729
|
+
session_termination_mode: SessionTerminationMode = "204_idempotent",
|
|
730
|
+
error_response_mode: ErrorResponseMode = "http_404_legacy",
|
|
731
|
+
transport_base_path: str | None = None,
|
|
732
|
+
transport_messages_path: str | None = None,
|
|
733
|
+
capabilities_override: dict[str, Any] | None = None,
|
|
734
|
+
resources_handler: ResourcesHandler | None = None,
|
|
735
|
+
async_job_result_shape: AsyncJobResultShape = "modern",
|
|
736
|
+
invalid_session_error_code: int = -32001,
|
|
737
|
+
tools_catalogue_route: str | None = None,
|
|
738
|
+
) -> McpSessionManager:
|
|
739
|
+
"""Register MCP compatibility routes.
|
|
740
|
+
|
|
741
|
+
Registers:
|
|
742
|
+
- `POST /mcp` for streamable HTTP and JSON-RPC payloads
|
|
743
|
+
- `POST /messages` legacy alias for MCP calls
|
|
744
|
+
- `GET /mcp` SSE compatibility stream (legacy mode)
|
|
745
|
+
- `DELETE /mcp` session termination for streamable HTTP clients
|
|
746
|
+
|
|
747
|
+
Keyword-only options:
|
|
748
|
+
- `request_context_hook`: optional per-request hook that returns a context
|
|
749
|
+
dict merged into `request.state.mcp_context` and passed to 3-argument tool
|
|
750
|
+
handlers as `(payload, request, context)`.
|
|
751
|
+
- `alternate_endpoints`: optional additional MCP route families, e.g.
|
|
752
|
+
`[{"path": "/webmcp", "auth": "cookie", "name": "web"}]`.
|
|
753
|
+
- `async_job_store`: optional async wait=false job store for JSON-RPC
|
|
754
|
+
`tools/call` requests.
|
|
755
|
+
- `async_job_status_path`: optional GET route for async job status polling.
|
|
756
|
+
- `legacy_sse`: optional `/sse` + `/message` compatibility transport config.
|
|
757
|
+
- `session_termination_mode`: choose the default `204` idempotent delete
|
|
758
|
+
behaviour or the expert-agent-compatible `200` JSON variant.
|
|
759
|
+
- `error_response_mode`: preserve the legacy HTTP 404 envelope behaviour,
|
|
760
|
+
or return HTTP 200 JSON-RPC error payloads for transport-scoped MCP
|
|
761
|
+
error paths.
|
|
762
|
+
- `transport_base_path`: optional custom MCP transport path. When omitted,
|
|
763
|
+
the helper preserves the legacy `/mcp` route family.
|
|
764
|
+
- `transport_messages_path`: optional custom POST messages path. When omitted,
|
|
765
|
+
the helper preserves `/messages` for the default transport path and derives
|
|
766
|
+
`{transport_base_path}/messages` when a custom base path is configured.
|
|
767
|
+
- `resources_handler`: optional modern JSON-RPC handler for `resources/list`
|
|
768
|
+
and `resources/read`. When omitted, those methods keep the existing
|
|
769
|
+
unsupported-method behaviour. Handler errors (``ValueError``,
|
|
770
|
+
``KeyError``, ``LookupError``) are caught and mapped to JSON-RPC error
|
|
771
|
+
responses: ``-32602`` for ``resources/read`` errors (invalid params /
|
|
772
|
+
resource not found), ``-32601`` for other resource method errors.
|
|
773
|
+
``error_response_mode`` controls whether the error is rendered as HTTP 200
|
|
774
|
+
+ JSON-RPC error payload or as an inline JSON-RPC error in the modern
|
|
775
|
+
path.
|
|
776
|
+
- `async_job_result_shape`: choose the default modern async `wait=false`
|
|
777
|
+
payload or the legacy sql-agent-compatible `content[0].text` JSON wrapper.
|
|
778
|
+
This only takes effect when `async_job_store` is configured.
|
|
779
|
+
- `invalid_session_error_code`: JSON-RPC code used when a caller reuses an
|
|
780
|
+
invalid or expired MCP session id. A non-default value also enables
|
|
781
|
+
delete-then-reuse rejection semantics for `204_idempotent` termination.
|
|
782
|
+
- `tools_catalogue_route`: optional GET route family that returns the MCP
|
|
783
|
+
`tools/list` payload. This is independent of `transport_base_path`.
|
|
784
|
+
|
|
785
|
+
Legacy SSE note:
|
|
786
|
+
- `legacy_sse=LegacySSEConfig(...)` remains independent. Its `/sse` and
|
|
787
|
+
`/message` routes still use `LegacySSEConfig.sse_path` and
|
|
788
|
+
`LegacySSEConfig.message_path` even when `transport_base_path` is set.
|
|
789
|
+
"""
|
|
790
|
+
enabled_modes = _normalise_transport_modes(transport_modes)
|
|
791
|
+
tool_registry = normalise_tool_registry(tools)
|
|
792
|
+
manager = session_manager or McpSessionManager()
|
|
793
|
+
alternate_routes = _normalise_alternate_endpoints(alternate_endpoints)
|
|
794
|
+
server_name = str(getattr(app, "title", "") or "mcp-server").strip() or "mcp-server"
|
|
795
|
+
server_version = str(getattr(app, "version", "") or "0.0.0").strip() or "0.0.0"
|
|
796
|
+
invalid_session_ids: set[str] = set()
|
|
797
|
+
legacy_sse_config = legacy_sse
|
|
798
|
+
legacy_sse_broker = LegacySSEBroker(legacy_sse_config) if legacy_sse_config is not None else None
|
|
799
|
+
effective_transport_path = _normalise_transport_path(
|
|
800
|
+
transport_base_path,
|
|
801
|
+
default="/mcp",
|
|
802
|
+
arg_name="transport_base_path",
|
|
803
|
+
)
|
|
804
|
+
effective_messages_path = _normalise_transport_path(
|
|
805
|
+
transport_messages_path,
|
|
806
|
+
default=_default_transport_messages_path(transport_base_path),
|
|
807
|
+
arg_name="transport_messages_path",
|
|
808
|
+
)
|
|
809
|
+
if effective_transport_path == effective_messages_path:
|
|
810
|
+
raise ValueError("transport_messages_path must differ from transport_base_path")
|
|
811
|
+
|
|
812
|
+
app.state.mcp_transport_modes = sorted(enabled_modes)
|
|
813
|
+
app.state.mcp_session_manager = manager
|
|
814
|
+
|
|
815
|
+
async def _handle_mcp(
|
|
816
|
+
request: Request,
|
|
817
|
+
*,
|
|
818
|
+
modern_jsonrpc: bool,
|
|
819
|
+
is_messages_path: bool,
|
|
820
|
+
session_id_override: str | None = None,
|
|
821
|
+
) -> Response:
|
|
822
|
+
try:
|
|
823
|
+
payload = await request.json()
|
|
824
|
+
if not isinstance(payload, dict):
|
|
825
|
+
payload = {}
|
|
826
|
+
except Exception:
|
|
827
|
+
payload = {}
|
|
828
|
+
|
|
829
|
+
return await _dispatch_payload(
|
|
830
|
+
tool_registry,
|
|
831
|
+
payload,
|
|
832
|
+
request,
|
|
833
|
+
is_messages_path=is_messages_path,
|
|
834
|
+
session_manager=manager,
|
|
835
|
+
request_context_hook=request_context_hook,
|
|
836
|
+
async_job_store=async_job_store,
|
|
837
|
+
modern_jsonrpc=modern_jsonrpc,
|
|
838
|
+
error_response_mode=error_response_mode,
|
|
839
|
+
server_name=server_name,
|
|
840
|
+
server_version=server_version,
|
|
841
|
+
capabilities_override=capabilities_override,
|
|
842
|
+
resources_handler=resources_handler,
|
|
843
|
+
async_job_result_shape=async_job_result_shape,
|
|
844
|
+
invalid_session_error_code=invalid_session_error_code,
|
|
845
|
+
invalid_session_ids=invalid_session_ids,
|
|
846
|
+
session_id_override=session_id_override,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
async def _handle_delete(request: Request) -> Response:
|
|
850
|
+
session_id = request.headers.get(SESSION_HEADER)
|
|
851
|
+
if session_termination_mode == "204_idempotent":
|
|
852
|
+
if session_id:
|
|
853
|
+
manager.delete(session_id)
|
|
854
|
+
if invalid_session_error_code != -32001:
|
|
855
|
+
invalid_session_ids.add(session_id)
|
|
856
|
+
else:
|
|
857
|
+
invalid_session_ids.discard(session_id)
|
|
858
|
+
return Response(status_code=204)
|
|
859
|
+
|
|
860
|
+
if not session_id or not manager.delete(session_id):
|
|
861
|
+
if error_response_mode == "jsonrpc_200":
|
|
862
|
+
response = _jsonrpc_error(
|
|
863
|
+
-32002,
|
|
864
|
+
"Invalid or expired MCP session",
|
|
865
|
+
data={"session_id": session_id} if session_id else None,
|
|
866
|
+
)
|
|
867
|
+
if session_id:
|
|
868
|
+
response.headers[SESSION_HEADER] = session_id
|
|
869
|
+
return response
|
|
870
|
+
return JSONResponse(status_code=404, content={"error": "invalid session"})
|
|
871
|
+
invalid_session_ids.add(session_id)
|
|
872
|
+
return JSONResponse(status_code=200, content={"status": "terminated", "session_id": session_id})
|
|
873
|
+
|
|
874
|
+
@app.post(effective_transport_path, tags=["mcp"])
|
|
875
|
+
async def mcp_transport(request: Request) -> Response:
|
|
876
|
+
"""Handle mcp transport."""
|
|
877
|
+
return await _handle_mcp(request, modern_jsonrpc=True, is_messages_path=False)
|
|
878
|
+
|
|
879
|
+
@app.post(effective_messages_path, tags=["mcp"])
|
|
880
|
+
async def mcp_messages(request: Request) -> Response:
|
|
881
|
+
"""Handle mcp messages."""
|
|
882
|
+
return await _handle_mcp(request, modern_jsonrpc=True, is_messages_path=True)
|
|
883
|
+
|
|
884
|
+
@app.delete(effective_transport_path, tags=["mcp"])
|
|
885
|
+
async def mcp_transport_delete(request: Request) -> Response:
|
|
886
|
+
"""Terminate an MCP session if present."""
|
|
887
|
+
return await _handle_delete(request)
|
|
888
|
+
|
|
889
|
+
@app.get(effective_transport_path, tags=["mcp"], response_model=None)
|
|
890
|
+
async def mcp_legacy_sse() -> Response:
|
|
891
|
+
"""Handle mcp legacy sse."""
|
|
892
|
+
if "legacy_sse" not in enabled_modes:
|
|
893
|
+
if error_response_mode == "jsonrpc_200":
|
|
894
|
+
return _jsonrpc_error(
|
|
895
|
+
-32601,
|
|
896
|
+
"Method not found",
|
|
897
|
+
data={"route": effective_transport_path, "mode": "legacy_sse"},
|
|
898
|
+
)
|
|
899
|
+
return JSONResponse(status_code=404, content=error_envelope(code="NOT_FOUND", message="Route not enabled"))
|
|
900
|
+
|
|
901
|
+
async def _event_stream() -> Any:
|
|
902
|
+
payload = {
|
|
903
|
+
"type": "ready",
|
|
904
|
+
"modes": sorted(enabled_modes),
|
|
905
|
+
"tools": sorted(tool_registry.keys()),
|
|
906
|
+
}
|
|
907
|
+
yield f"event: ready\ndata: {json.dumps(payload)}\n\n"
|
|
908
|
+
|
|
909
|
+
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
|
910
|
+
|
|
911
|
+
if tools_catalogue_route is not None:
|
|
912
|
+
effective_tools_catalogue_route = _normalise_transport_path(
|
|
913
|
+
tools_catalogue_route,
|
|
914
|
+
default="/mcp/tools",
|
|
915
|
+
arg_name="tools_catalogue_route",
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
@app.get(effective_tools_catalogue_route, tags=["mcp"])
|
|
919
|
+
@app.get(f"{effective_tools_catalogue_route}/", tags=["mcp"])
|
|
920
|
+
async def mcp_transport_tools_catalogue() -> JSONResponse:
|
|
921
|
+
"""Return a transport-level MCP tools catalogue."""
|
|
922
|
+
return JSONResponse(status_code=200, content=_mcp_tools_list_payload(tool_registry))
|
|
923
|
+
|
|
924
|
+
if async_job_store is not None and async_job_status_path:
|
|
925
|
+
async def mcp_async_job_status(job_id: str) -> Response:
|
|
926
|
+
"""Return the current async MCP tool-call job status."""
|
|
927
|
+
status = await _maybe_await(async_job_store.get_status(job_id))
|
|
928
|
+
if not isinstance(status, dict):
|
|
929
|
+
return JSONResponse(status_code=500, content={"error": "invalid async job status payload"})
|
|
930
|
+
if status.get("status") == "not_found":
|
|
931
|
+
if error_response_mode == "jsonrpc_200":
|
|
932
|
+
return _jsonrpc_error(
|
|
933
|
+
-32000,
|
|
934
|
+
"Job not found",
|
|
935
|
+
data={"job_id": job_id},
|
|
936
|
+
)
|
|
937
|
+
return JSONResponse(status_code=404, content={"error": status.get("error", "Job not found")})
|
|
938
|
+
return JSONResponse(status_code=200, content=status)
|
|
939
|
+
|
|
940
|
+
app.add_api_route(
|
|
941
|
+
async_job_status_path,
|
|
942
|
+
mcp_async_job_status,
|
|
943
|
+
methods=["GET"],
|
|
944
|
+
tags=["mcp"],
|
|
945
|
+
name="mcp_async_job_status",
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
if legacy_sse_config is not None and legacy_sse_broker is not None:
|
|
949
|
+
@app.get(legacy_sse_config.sse_path, tags=["mcp"], response_model=None)
|
|
950
|
+
async def legacy_sse_stream(request: Request) -> Response:
|
|
951
|
+
"""Handle legacy SSE compatibility bootstrap and message delivery."""
|
|
952
|
+
session_id = request.headers.get(legacy_sse_config.session_header)
|
|
953
|
+
session, _created = manager.ensure(session_id)
|
|
954
|
+
invalid_session_ids.discard(session.session_id)
|
|
955
|
+
return StreamingResponse(
|
|
956
|
+
legacy_sse_broker.event_stream(request, session.session_id),
|
|
957
|
+
media_type="text/event-stream",
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
@app.post(legacy_sse_config.message_path, tags=["mcp"])
|
|
961
|
+
async def legacy_sse_message(request: Request) -> Response:
|
|
962
|
+
"""Accept a legacy SSE-correlated JSON-RPC POST and push the reply to the stream."""
|
|
963
|
+
resolved_session = (
|
|
964
|
+
request.query_params.get("session_id")
|
|
965
|
+
or request.query_params.get("sessionId")
|
|
966
|
+
or request.headers.get(legacy_sse_config.session_header)
|
|
967
|
+
)
|
|
968
|
+
if not resolved_session:
|
|
969
|
+
return JSONResponse(status_code=400, content={"error": "missing session_id"})
|
|
970
|
+
|
|
971
|
+
response = await _handle_mcp(
|
|
972
|
+
request,
|
|
973
|
+
modern_jsonrpc=True,
|
|
974
|
+
is_messages_path=False,
|
|
975
|
+
session_id_override=resolved_session,
|
|
976
|
+
)
|
|
977
|
+
if response.status_code != 204 and getattr(response, "body", b""):
|
|
978
|
+
try:
|
|
979
|
+
payload = json.loads(response.body.decode("utf-8"))
|
|
980
|
+
except Exception:
|
|
981
|
+
payload = {"error": "invalid response payload"}
|
|
982
|
+
await legacy_sse_broker.push(resolved_session, payload)
|
|
983
|
+
return JSONResponse(status_code=200, content={"accepted": True, "session_id": resolved_session})
|
|
984
|
+
|
|
985
|
+
for route in alternate_routes:
|
|
986
|
+
route_path = route["path"]
|
|
987
|
+
tags = ["mcp", f"mcp:{route['auth']}"]
|
|
988
|
+
|
|
989
|
+
async def _alternate_post(request: Request, *, _modern: bool = True) -> Response:
|
|
990
|
+
return await _handle_mcp(request, modern_jsonrpc=_modern, is_messages_path=False)
|
|
991
|
+
|
|
992
|
+
async def _alternate_delete(request: Request) -> Response:
|
|
993
|
+
return await _handle_delete(request)
|
|
994
|
+
|
|
995
|
+
async def _alternate_get() -> Response:
|
|
996
|
+
if "legacy_sse" not in enabled_modes:
|
|
997
|
+
if error_response_mode == "jsonrpc_200":
|
|
998
|
+
return _jsonrpc_error(
|
|
999
|
+
-32601,
|
|
1000
|
+
"Method not found",
|
|
1001
|
+
data={"route": route_path, "mode": "legacy_sse"},
|
|
1002
|
+
)
|
|
1003
|
+
return JSONResponse(
|
|
1004
|
+
status_code=404,
|
|
1005
|
+
content=error_envelope(code="NOT_FOUND", message="Route not enabled"),
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
async def _event_stream() -> Any:
|
|
1009
|
+
payload = {
|
|
1010
|
+
"type": "ready",
|
|
1011
|
+
"modes": sorted(enabled_modes),
|
|
1012
|
+
"tools": sorted(tool_registry.keys()),
|
|
1013
|
+
}
|
|
1014
|
+
yield f"event: ready\ndata: {json.dumps(payload)}\n\n"
|
|
1015
|
+
|
|
1016
|
+
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
|
1017
|
+
|
|
1018
|
+
app.add_api_route(
|
|
1019
|
+
route_path,
|
|
1020
|
+
_alternate_post,
|
|
1021
|
+
methods=["POST"],
|
|
1022
|
+
tags=tags,
|
|
1023
|
+
name=f"mcp_transport_{route['name']}",
|
|
1024
|
+
)
|
|
1025
|
+
app.add_api_route(
|
|
1026
|
+
route_path,
|
|
1027
|
+
_alternate_get,
|
|
1028
|
+
methods=["GET"],
|
|
1029
|
+
tags=tags,
|
|
1030
|
+
response_model=None,
|
|
1031
|
+
name=f"mcp_transport_sse_{route['name']}",
|
|
1032
|
+
)
|
|
1033
|
+
app.add_api_route(
|
|
1034
|
+
route_path,
|
|
1035
|
+
_alternate_delete,
|
|
1036
|
+
methods=["DELETE"],
|
|
1037
|
+
tags=tags,
|
|
1038
|
+
name=f"mcp_transport_delete_{route['name']}",
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
return manager
|