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