codrspot-processor-mcp 0.1.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.
- codepreproc_client/__init__.py +14 -0
- codepreproc_client/__main__.py +9 -0
- codepreproc_client/layer1_business/__init__.py +0 -0
- codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
- codepreproc_client/layer1_business/api_client/__init__.py +0 -0
- codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
- codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
- codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
- codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
- codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
- codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
- codepreproc_client/layer1_business/cli/__init__.py +0 -0
- codepreproc_client/layer1_business/cli/init.py +578 -0
- codepreproc_client/layer1_business/cli/main.py +78 -0
- codepreproc_client/layer1_business/config.py +104 -0
- codepreproc_client/layer1_business/git_utils.py +67 -0
- codepreproc_client/layer1_business/license/__init__.py +0 -0
- codepreproc_client/layer1_business/license/fingerprint.py +61 -0
- codepreproc_client/layer1_business/license/jwt_client.py +60 -0
- codepreproc_client/layer1_business/license/project_lock.py +44 -0
- codepreproc_client/layer1_business/local_registry.py +40 -0
- codepreproc_client/layer1_business/manifest/__init__.py +0 -0
- codepreproc_client/layer1_business/manifest/credentials.py +35 -0
- codepreproc_client/layer1_business/mcp/__init__.py +0 -0
- codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
- codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
- codepreproc_client/layer1_business/mcp/server.py +656 -0
- codepreproc_client/layer1_business/mcp/tools.py +1665 -0
- codepreproc_client/layer1_business/project_registry.py +99 -0
- codepreproc_client/layer2_tooling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
- codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
- codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
- codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
- codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
- codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
- codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
- codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
- codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
- codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
- codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
- codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
- codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
- codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
- codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
- codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
- codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
- codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
- codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
- codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
- codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
- codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
- codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
- codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
- codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
- codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
- codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
- codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
- codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
- codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
- codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
- codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
- codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
- codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
- codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
- codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
- codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
- codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
- codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
- codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
- codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
- codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
- codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
- codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
- contracts/__init__.py +0 -0
- contracts/dtos.py +1239 -0
- contracts/ir.py +102 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""MCP connection lifecycle logging and instrumented server/session wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import signal
|
|
8
|
+
import threading
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
import atexit
|
|
11
|
+
import time
|
|
12
|
+
from itertools import count
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
import mcp.types as types
|
|
17
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
18
|
+
from mcp.server.experimental.request_context import Experimental
|
|
19
|
+
from mcp.server.lowlevel.server import Server
|
|
20
|
+
from mcp.server.lowlevel.server import logger as sdk_server_logger
|
|
21
|
+
from mcp.server.lowlevel.server import request_ctx
|
|
22
|
+
from mcp.server.models import InitializationOptions
|
|
23
|
+
from mcp.server.session import InitializationState, ServerSession
|
|
24
|
+
from mcp.shared.context import RequestContext
|
|
25
|
+
from mcp.shared.exceptions import McpError
|
|
26
|
+
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
|
27
|
+
from mcp.shared.session import RequestResponder
|
|
28
|
+
from contracts.dtos import MCPConnectionVerbosity, WorldModelLease
|
|
29
|
+
|
|
30
|
+
_world_model_lease_logger = logging.getLogger("codepreproc_client.mcp.world_model_lease")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WorldModelLeaseGuard:
|
|
34
|
+
"""Owns the temporary world-model copy for a session and guarantees deletion.
|
|
35
|
+
|
|
36
|
+
Architecture doc S5/S12: the leased world model is kept only in
|
|
37
|
+
session memory/scratch and MUST be discarded on the client's last MCP
|
|
38
|
+
call - not persisted to disk, not reused offline, deleted even on
|
|
39
|
+
abrupt termination. This registers teardown three ways so no exit
|
|
40
|
+
path skips it: explicit `discard()`/context-manager exit, `atexit`,
|
|
41
|
+
and the process signal handlers (SIGINT/SIGTERM; SIGBREAK on Windows).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_instances_lock = threading.Lock()
|
|
45
|
+
_instances: "set[WorldModelLeaseGuard]" = set()
|
|
46
|
+
_signal_handlers_installed = False
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._lease: WorldModelLease | None = None
|
|
50
|
+
with WorldModelLeaseGuard._instances_lock:
|
|
51
|
+
WorldModelLeaseGuard._instances.add(self)
|
|
52
|
+
WorldModelLeaseGuard._ensure_global_teardown_hooks()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def lease(self) -> WorldModelLease | None:
|
|
56
|
+
return self._lease
|
|
57
|
+
|
|
58
|
+
def set_lease(self, lease: WorldModelLease) -> None:
|
|
59
|
+
self._lease = lease
|
|
60
|
+
|
|
61
|
+
def discard(self) -> None:
|
|
62
|
+
"""Drop the in-memory temporary world-model copy. Idempotent."""
|
|
63
|
+
|
|
64
|
+
if self._lease is not None:
|
|
65
|
+
_world_model_lease_logger.debug("world_model_lease_discarded lease_id=%s", self._lease.lease_id)
|
|
66
|
+
self._lease = None
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> "WorldModelLeaseGuard":
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
72
|
+
self.discard()
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def _ensure_global_teardown_hooks(cls) -> None:
|
|
76
|
+
if cls._signal_handlers_installed:
|
|
77
|
+
return
|
|
78
|
+
cls._signal_handlers_installed = True
|
|
79
|
+
atexit.register(cls._discard_all)
|
|
80
|
+
for sig_name in ("SIGINT", "SIGTERM", "SIGBREAK"):
|
|
81
|
+
sig = getattr(signal, sig_name, None)
|
|
82
|
+
if sig is None:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
previous_handler = signal.getsignal(sig)
|
|
86
|
+
except (ValueError, OSError):
|
|
87
|
+
# Not in the main thread, or unsupported on this platform - atexit still covers this.
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
def _handler(signum: int, frame: Any, _previous: Any = previous_handler) -> None:
|
|
91
|
+
cls._discard_all()
|
|
92
|
+
if callable(_previous):
|
|
93
|
+
_previous(signum, frame)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
signal.signal(sig, _handler)
|
|
97
|
+
except (ValueError, OSError):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def _discard_all(cls) -> None:
|
|
102
|
+
with cls._instances_lock:
|
|
103
|
+
for instance in cls._instances:
|
|
104
|
+
instance.discard()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MCPConnectionLifecycleLogger:
|
|
108
|
+
"""Log MCP transport/session lifecycle events to file and client notifications."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, logger: logging.Logger, verbosity: MCPConnectionVerbosity = "basic") -> None:
|
|
111
|
+
self.logger = logger
|
|
112
|
+
self.verbosity = verbosity
|
|
113
|
+
self._session_counter = count(1)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def enabled(self) -> bool:
|
|
117
|
+
return self.verbosity != "off"
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def verbose(self) -> bool:
|
|
121
|
+
return self.verbosity == "verbose"
|
|
122
|
+
|
|
123
|
+
def next_session_id(self) -> str:
|
|
124
|
+
return f"session-{next(self._session_counter)}"
|
|
125
|
+
|
|
126
|
+
async def emit(
|
|
127
|
+
self,
|
|
128
|
+
event: str,
|
|
129
|
+
*,
|
|
130
|
+
session: InstrumentedServerSession | Any | None = None,
|
|
131
|
+
request_id: str | int | None = None,
|
|
132
|
+
reason: str | None = None,
|
|
133
|
+
exception: BaseException | None = None,
|
|
134
|
+
error: str | None = None,
|
|
135
|
+
fields: dict[str, Any] | None = None,
|
|
136
|
+
verbose_fields: dict[str, Any] | None = None,
|
|
137
|
+
mirror_to_client: bool = True,
|
|
138
|
+
) -> None:
|
|
139
|
+
if not self.enabled:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
payload: dict[str, Any] = {"event": event}
|
|
143
|
+
if session is not None:
|
|
144
|
+
payload.update(_session_log_fields(session))
|
|
145
|
+
if request_id is not None:
|
|
146
|
+
payload["request_id"] = request_id
|
|
147
|
+
if reason:
|
|
148
|
+
payload["reason"] = reason
|
|
149
|
+
if exception is not None:
|
|
150
|
+
payload["exception_type"] = type(exception).__name__
|
|
151
|
+
if error is None and exception is not None:
|
|
152
|
+
error = str(exception)
|
|
153
|
+
if error:
|
|
154
|
+
payload["error"] = _clean_text(error, limit=200)
|
|
155
|
+
payload.update(fields or {})
|
|
156
|
+
if self.verbose and verbose_fields:
|
|
157
|
+
payload.update(verbose_fields)
|
|
158
|
+
|
|
159
|
+
message = _format_log_line(payload)
|
|
160
|
+
self.logger.info(message)
|
|
161
|
+
|
|
162
|
+
if not mirror_to_client or session is None or not getattr(session, "can_mirror_lifecycle_logs", False):
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
await session.send_log_message(
|
|
166
|
+
level="info",
|
|
167
|
+
data=message,
|
|
168
|
+
logger="codepreproc.mcp.lifecycle",
|
|
169
|
+
related_request_id=request_id,
|
|
170
|
+
)
|
|
171
|
+
except Exception:
|
|
172
|
+
self.logger.debug("failed_to_send_lifecycle_log event=%s", event, exc_info=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class InstrumentedServerSession(ServerSession):
|
|
176
|
+
"""ServerSession with explicit lifecycle logging hooks."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
181
|
+
write_stream: MemoryObjectSendStream[SessionMessage],
|
|
182
|
+
init_options: InitializationOptions,
|
|
183
|
+
*,
|
|
184
|
+
lifecycle_logger: MCPConnectionLifecycleLogger,
|
|
185
|
+
session_id: str,
|
|
186
|
+
stateless: bool = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
super().__init__(read_stream, write_stream, init_options, stateless=stateless)
|
|
189
|
+
self.lifecycle_logger = lifecycle_logger
|
|
190
|
+
self.session_id = session_id
|
|
191
|
+
self.started_at = time.monotonic()
|
|
192
|
+
self.initialize_started_at: float | None = None
|
|
193
|
+
self.initialize_completed_at: float | None = None
|
|
194
|
+
self.transport_close_reason: str | None = None
|
|
195
|
+
self.transport_close_error: str | None = None
|
|
196
|
+
self.transport_close_exception_type: str | None = None
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def uptime_ms(self) -> int:
|
|
200
|
+
return int((time.monotonic() - self.started_at) * 1000)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def client_name(self) -> str | None:
|
|
204
|
+
return self._client_params.clientInfo.name if self._client_params else None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def client_version(self) -> str | None:
|
|
208
|
+
return self._client_params.clientInfo.version if self._client_params else None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def protocol_version(self) -> str | int | None:
|
|
212
|
+
return self._client_params.protocolVersion if self._client_params else None
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def can_mirror_lifecycle_logs(self) -> bool:
|
|
216
|
+
return self._initialization_state == InitializationState.Initialized
|
|
217
|
+
|
|
218
|
+
async def _received_request(self, responder: RequestResponder[types.ClientRequest, types.ServerResult]):
|
|
219
|
+
match responder.request.root:
|
|
220
|
+
case types.InitializeRequest(params=params):
|
|
221
|
+
self.initialize_started_at = time.monotonic()
|
|
222
|
+
await self.lifecycle_logger.emit(
|
|
223
|
+
"initialize_received",
|
|
224
|
+
session=self,
|
|
225
|
+
request_id=responder.request_id,
|
|
226
|
+
fields={
|
|
227
|
+
"client_name": params.clientInfo.name,
|
|
228
|
+
"client_version": params.clientInfo.version,
|
|
229
|
+
"protocol_version": params.protocolVersion,
|
|
230
|
+
},
|
|
231
|
+
verbose_fields={
|
|
232
|
+
"client_capabilities": _capabilities_summary(params.capabilities),
|
|
233
|
+
"roots_supported": bool(params.capabilities and params.capabilities.roots),
|
|
234
|
+
},
|
|
235
|
+
mirror_to_client=False,
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
await super()._received_request(responder)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
await self.lifecycle_logger.emit(
|
|
241
|
+
"initialize_failed",
|
|
242
|
+
session=self,
|
|
243
|
+
request_id=responder.request_id,
|
|
244
|
+
reason="initialize_request_failed",
|
|
245
|
+
exception=exc,
|
|
246
|
+
fields={
|
|
247
|
+
"client_name": params.clientInfo.name,
|
|
248
|
+
"client_version": params.clientInfo.version,
|
|
249
|
+
"protocol_version": params.protocolVersion,
|
|
250
|
+
},
|
|
251
|
+
verbose_fields={
|
|
252
|
+
"client_capabilities": _capabilities_summary(params.capabilities),
|
|
253
|
+
},
|
|
254
|
+
mirror_to_client=False,
|
|
255
|
+
)
|
|
256
|
+
raise
|
|
257
|
+
self.initialize_completed_at = time.monotonic()
|
|
258
|
+
await self.lifecycle_logger.emit(
|
|
259
|
+
"initialize_completed",
|
|
260
|
+
session=self,
|
|
261
|
+
request_id=responder.request_id,
|
|
262
|
+
fields={
|
|
263
|
+
"client_name": params.clientInfo.name,
|
|
264
|
+
"client_version": params.clientInfo.version,
|
|
265
|
+
"protocol_version": params.protocolVersion,
|
|
266
|
+
"initialization_ms": int((self.initialize_completed_at - self.initialize_started_at) * 1000),
|
|
267
|
+
},
|
|
268
|
+
verbose_fields={
|
|
269
|
+
"client_capabilities": _capabilities_summary(params.capabilities),
|
|
270
|
+
"roots_supported": bool(params.capabilities and params.capabilities.roots),
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
case types.PingRequest():
|
|
274
|
+
await super()._received_request(responder)
|
|
275
|
+
case _:
|
|
276
|
+
if self._initialization_state != InitializationState.Initialized:
|
|
277
|
+
message = "Received request before initialization was complete"
|
|
278
|
+
await self.lifecycle_logger.emit(
|
|
279
|
+
"request_before_initialize",
|
|
280
|
+
session=self,
|
|
281
|
+
request_id=responder.request_id,
|
|
282
|
+
reason="request_before_initialize",
|
|
283
|
+
error=message,
|
|
284
|
+
fields={"request_type": type(responder.request.root).__name__},
|
|
285
|
+
mirror_to_client=False,
|
|
286
|
+
)
|
|
287
|
+
raise RuntimeError(message)
|
|
288
|
+
await super()._received_request(responder)
|
|
289
|
+
|
|
290
|
+
async def _received_notification(self, notification: types.ClientNotification) -> None:
|
|
291
|
+
await super()._received_notification(notification)
|
|
292
|
+
if isinstance(notification.root, types.InitializedNotification):
|
|
293
|
+
await self.lifecycle_logger.emit(
|
|
294
|
+
"initialized_notification_received",
|
|
295
|
+
session=self,
|
|
296
|
+
verbose_fields={
|
|
297
|
+
"client_capabilities": _capabilities_summary(self._client_params.capabilities)
|
|
298
|
+
if self._client_params
|
|
299
|
+
else "unknown",
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def _receive_loop(self) -> None:
|
|
304
|
+
transport_reason = "client_eof"
|
|
305
|
+
transport_exception: BaseException | None = None
|
|
306
|
+
async with (self._read_stream, self._write_stream):
|
|
307
|
+
try:
|
|
308
|
+
async for message in self._read_stream:
|
|
309
|
+
if isinstance(message, Exception): # pragma: no cover
|
|
310
|
+
await self._handle_incoming(message)
|
|
311
|
+
elif isinstance(message.message.root, types.JSONRPCRequest):
|
|
312
|
+
try:
|
|
313
|
+
validated_request = self._receive_request_type.model_validate(
|
|
314
|
+
message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
315
|
+
)
|
|
316
|
+
responder = RequestResponder(
|
|
317
|
+
request_id=message.message.root.id,
|
|
318
|
+
request_meta=validated_request.root.params.meta if validated_request.root.params else None,
|
|
319
|
+
request=validated_request,
|
|
320
|
+
session=self,
|
|
321
|
+
on_complete=lambda r: self._in_flight.pop(r.request_id, None),
|
|
322
|
+
message_metadata=message.metadata,
|
|
323
|
+
)
|
|
324
|
+
self._in_flight[responder.request_id] = responder
|
|
325
|
+
await self._received_request(responder)
|
|
326
|
+
|
|
327
|
+
if not responder._completed: # type: ignore[reportPrivateUsage]
|
|
328
|
+
await self._handle_incoming(responder)
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
sdk_server_logger.warning("Failed to validate request: %s", exc)
|
|
331
|
+
sdk_server_logger.debug("Message that failed validation: %s", message.message.root)
|
|
332
|
+
error_response = types.JSONRPCError(
|
|
333
|
+
jsonrpc="2.0",
|
|
334
|
+
id=message.message.root.id,
|
|
335
|
+
error=types.ErrorData(
|
|
336
|
+
code=types.INVALID_PARAMS,
|
|
337
|
+
message="Invalid request parameters",
|
|
338
|
+
data="",
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
session_message = SessionMessage(message=types.JSONRPCMessage(error_response))
|
|
342
|
+
await self._write_stream.send(session_message)
|
|
343
|
+
|
|
344
|
+
elif isinstance(message.message.root, types.JSONRPCNotification):
|
|
345
|
+
try:
|
|
346
|
+
notification = self._receive_notification_type.model_validate(
|
|
347
|
+
message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
348
|
+
)
|
|
349
|
+
if isinstance(notification.root, types.CancelledNotification):
|
|
350
|
+
cancelled_id = notification.root.params.requestId
|
|
351
|
+
if cancelled_id in self._in_flight:
|
|
352
|
+
await self._in_flight[cancelled_id].cancel()
|
|
353
|
+
else:
|
|
354
|
+
if isinstance(notification.root, types.ProgressNotification): # pragma: no cover
|
|
355
|
+
progress_token = notification.root.params.progressToken
|
|
356
|
+
if progress_token in self._progress_callbacks:
|
|
357
|
+
callback = self._progress_callbacks[progress_token]
|
|
358
|
+
try:
|
|
359
|
+
await callback(
|
|
360
|
+
notification.root.params.progress,
|
|
361
|
+
notification.root.params.total,
|
|
362
|
+
notification.root.params.message,
|
|
363
|
+
)
|
|
364
|
+
except Exception as exc: # pragma: no cover
|
|
365
|
+
sdk_server_logger.error("Progress callback raised an exception: %s", exc)
|
|
366
|
+
await self._received_notification(notification)
|
|
367
|
+
await self._handle_incoming(notification)
|
|
368
|
+
except Exception as exc: # pragma: no cover
|
|
369
|
+
sdk_server_logger.warning(
|
|
370
|
+
"Failed to validate notification: %s. Message was: %s",
|
|
371
|
+
exc,
|
|
372
|
+
message.message.root,
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
await self._handle_response(message)
|
|
376
|
+
|
|
377
|
+
except anyio.ClosedResourceError as exc:
|
|
378
|
+
transport_reason = "read_stream_closed"
|
|
379
|
+
transport_exception = exc
|
|
380
|
+
except Exception as exc: # pragma: no cover
|
|
381
|
+
transport_reason = "receive_loop_exception"
|
|
382
|
+
transport_exception = exc
|
|
383
|
+
await self.lifecycle_logger.emit(
|
|
384
|
+
"unhandled_receive_exception",
|
|
385
|
+
session=self,
|
|
386
|
+
reason=transport_reason,
|
|
387
|
+
exception=exc,
|
|
388
|
+
mirror_to_client=False,
|
|
389
|
+
)
|
|
390
|
+
finally:
|
|
391
|
+
self.transport_close_reason = transport_reason
|
|
392
|
+
if transport_exception is not None:
|
|
393
|
+
self.transport_close_exception_type = type(transport_exception).__name__
|
|
394
|
+
self.transport_close_error = _clean_text(str(transport_exception), limit=200)
|
|
395
|
+
pending_response_count = len(self._response_streams)
|
|
396
|
+
in_flight_request_count = len(self._in_flight)
|
|
397
|
+
|
|
398
|
+
for response_id, stream in list(self._response_streams.items()):
|
|
399
|
+
error = types.ErrorData(code=types.CONNECTION_CLOSED, message="Connection closed")
|
|
400
|
+
try:
|
|
401
|
+
await stream.send(types.JSONRPCError(jsonrpc="2.0", id=response_id, error=error))
|
|
402
|
+
await stream.aclose()
|
|
403
|
+
except Exception: # pragma: no cover
|
|
404
|
+
pass
|
|
405
|
+
self._response_streams.clear()
|
|
406
|
+
|
|
407
|
+
await self.lifecycle_logger.emit(
|
|
408
|
+
"transport_closed",
|
|
409
|
+
session=self,
|
|
410
|
+
reason=transport_reason,
|
|
411
|
+
exception=transport_exception,
|
|
412
|
+
verbose_fields={
|
|
413
|
+
"pending_response_count": pending_response_count,
|
|
414
|
+
"in_flight_request_count": in_flight_request_count,
|
|
415
|
+
"transport_reason_detail": transport_reason,
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class InstrumentedServer(Server):
|
|
421
|
+
"""Server wrapper that instantiates InstrumentedServerSession and logs lifecycle events."""
|
|
422
|
+
|
|
423
|
+
def __init__(self, *args: Any, lifecycle_logger: MCPConnectionLifecycleLogger, **kwargs: Any) -> None:
|
|
424
|
+
super().__init__(*args, **kwargs)
|
|
425
|
+
self.lifecycle_logger = lifecycle_logger
|
|
426
|
+
|
|
427
|
+
async def run(
|
|
428
|
+
self,
|
|
429
|
+
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
430
|
+
write_stream: MemoryObjectSendStream[SessionMessage],
|
|
431
|
+
initialization_options: InitializationOptions,
|
|
432
|
+
raise_exceptions: bool = False,
|
|
433
|
+
stateless: bool = False,
|
|
434
|
+
) -> None:
|
|
435
|
+
async with AsyncExitStack() as stack:
|
|
436
|
+
lifespan_context = await stack.enter_async_context(self.lifespan(self))
|
|
437
|
+
session = await stack.enter_async_context(
|
|
438
|
+
InstrumentedServerSession(
|
|
439
|
+
read_stream,
|
|
440
|
+
write_stream,
|
|
441
|
+
initialization_options,
|
|
442
|
+
stateless=stateless,
|
|
443
|
+
lifecycle_logger=self.lifecycle_logger,
|
|
444
|
+
session_id=self.lifecycle_logger.next_session_id(),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
await self.lifecycle_logger.emit(
|
|
448
|
+
"transport_open",
|
|
449
|
+
session=session,
|
|
450
|
+
verbose_fields={"stateless": stateless},
|
|
451
|
+
mirror_to_client=False,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
|
|
455
|
+
if task_support is not None:
|
|
456
|
+
task_support.configure_session(session)
|
|
457
|
+
await stack.enter_async_context(task_support.run())
|
|
458
|
+
|
|
459
|
+
async with anyio.create_task_group() as tg:
|
|
460
|
+
try:
|
|
461
|
+
async for message in session.incoming_messages:
|
|
462
|
+
sdk_server_logger.debug("Received message: %s", message)
|
|
463
|
+
tg.start_soon(
|
|
464
|
+
self._handle_message,
|
|
465
|
+
message,
|
|
466
|
+
session,
|
|
467
|
+
lifespan_context,
|
|
468
|
+
raise_exceptions,
|
|
469
|
+
)
|
|
470
|
+
finally:
|
|
471
|
+
tg.cancel_scope.cancel()
|
|
472
|
+
await self.lifecycle_logger.emit(
|
|
473
|
+
"session_shutdown",
|
|
474
|
+
session=session,
|
|
475
|
+
reason=session.transport_close_reason or "server_run_complete",
|
|
476
|
+
verbose_fields={
|
|
477
|
+
"in_flight_request_count": len(session._in_flight),
|
|
478
|
+
"pending_response_count": len(session._response_streams),
|
|
479
|
+
},
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def _handle_request(
|
|
483
|
+
self,
|
|
484
|
+
message: RequestResponder[types.ClientRequest, types.ServerResult],
|
|
485
|
+
req: types.ClientRequestType,
|
|
486
|
+
session: ServerSession,
|
|
487
|
+
lifespan_context: Any,
|
|
488
|
+
raise_exceptions: bool,
|
|
489
|
+
):
|
|
490
|
+
sdk_server_logger.info("Processing request of type %s", type(req).__name__)
|
|
491
|
+
|
|
492
|
+
if handler := self.request_handlers.get(type(req)):
|
|
493
|
+
sdk_server_logger.debug("Dispatching request of type %s", type(req).__name__)
|
|
494
|
+
|
|
495
|
+
token = None
|
|
496
|
+
try:
|
|
497
|
+
request_data = None
|
|
498
|
+
close_sse_stream_cb = None
|
|
499
|
+
close_standalone_sse_stream_cb = None
|
|
500
|
+
if message.message_metadata is not None and isinstance(message.message_metadata, ServerMessageMetadata):
|
|
501
|
+
request_data = message.message_metadata.request_context
|
|
502
|
+
close_sse_stream_cb = message.message_metadata.close_sse_stream
|
|
503
|
+
close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream
|
|
504
|
+
|
|
505
|
+
client_capabilities = session.client_params.capabilities if session.client_params else None
|
|
506
|
+
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
|
|
507
|
+
task_metadata = None
|
|
508
|
+
if hasattr(req, "params") and req.params is not None:
|
|
509
|
+
task_metadata = getattr(req.params, "task", None)
|
|
510
|
+
token = request_ctx.set(
|
|
511
|
+
RequestContext(
|
|
512
|
+
message.request_id,
|
|
513
|
+
message.request_meta,
|
|
514
|
+
session,
|
|
515
|
+
lifespan_context,
|
|
516
|
+
Experimental(
|
|
517
|
+
task_metadata=task_metadata,
|
|
518
|
+
_client_capabilities=client_capabilities,
|
|
519
|
+
_session=session,
|
|
520
|
+
_task_support=task_support,
|
|
521
|
+
),
|
|
522
|
+
request=request_data,
|
|
523
|
+
close_sse_stream=close_sse_stream_cb,
|
|
524
|
+
close_standalone_sse_stream=close_standalone_sse_stream_cb,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
response = await handler(req)
|
|
528
|
+
except McpError as err: # pragma: no cover
|
|
529
|
+
response = err.error
|
|
530
|
+
except anyio.get_cancelled_exc_class():
|
|
531
|
+
if message.cancelled:
|
|
532
|
+
sdk_server_logger.info("Request %s cancelled - duplicate response suppressed", message.request_id)
|
|
533
|
+
return
|
|
534
|
+
raise
|
|
535
|
+
except Exception as err: # pragma: no cover
|
|
536
|
+
if raise_exceptions:
|
|
537
|
+
raise err
|
|
538
|
+
response = types.ErrorData(code=0, message=str(err), data=None)
|
|
539
|
+
finally:
|
|
540
|
+
if token is not None:
|
|
541
|
+
request_ctx.reset(token)
|
|
542
|
+
else: # pragma: no cover
|
|
543
|
+
response = types.ErrorData(
|
|
544
|
+
code=types.METHOD_NOT_FOUND,
|
|
545
|
+
message="Method not found",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
await message.respond(response)
|
|
550
|
+
except (anyio.BrokenResourceError, anyio.ClosedResourceError) as exc:
|
|
551
|
+
await self.lifecycle_logger.emit(
|
|
552
|
+
"response_dropped_transport_closed",
|
|
553
|
+
session=session,
|
|
554
|
+
request_id=message.request_id,
|
|
555
|
+
reason="transport_closed_before_response",
|
|
556
|
+
exception=exc,
|
|
557
|
+
)
|
|
558
|
+
sdk_server_logger.debug("Response for %s dropped - transport closed", message.request_id)
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
sdk_server_logger.debug("Response sent")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
async def resolve_session_roots(
|
|
565
|
+
*,
|
|
566
|
+
session: ServerSession,
|
|
567
|
+
lifecycle_logger: MCPConnectionLifecycleLogger,
|
|
568
|
+
request_id: str | int | None,
|
|
569
|
+
) -> list[str]:
|
|
570
|
+
"""Resolve client roots and log failures without breaking tool calls."""
|
|
571
|
+
|
|
572
|
+
if not (session.client_params and session.client_params.capabilities and session.client_params.capabilities.roots):
|
|
573
|
+
return []
|
|
574
|
+
try:
|
|
575
|
+
roots_result = await session.list_roots()
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
await lifecycle_logger.emit(
|
|
578
|
+
"roots_resolution_failed",
|
|
579
|
+
session=session,
|
|
580
|
+
request_id=request_id,
|
|
581
|
+
reason="list_roots_failed",
|
|
582
|
+
exception=exc,
|
|
583
|
+
verbose_fields={"roots_supported": True},
|
|
584
|
+
)
|
|
585
|
+
return []
|
|
586
|
+
return [str(root.uri) for root in roots_result.roots if str(root.uri).startswith("file://")]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _session_log_fields(session: InstrumentedServerSession | Any) -> dict[str, Any]:
|
|
590
|
+
payload: dict[str, Any] = {}
|
|
591
|
+
session_id = getattr(session, "session_id", None)
|
|
592
|
+
if session_id is not None:
|
|
593
|
+
payload["session_id"] = session_id
|
|
594
|
+
client_name = getattr(session, "client_name", None)
|
|
595
|
+
if client_name:
|
|
596
|
+
payload["client_name"] = client_name
|
|
597
|
+
client_version = getattr(session, "client_version", None)
|
|
598
|
+
if client_version:
|
|
599
|
+
payload["client_version"] = client_version
|
|
600
|
+
protocol_version = getattr(session, "protocol_version", None)
|
|
601
|
+
if protocol_version is not None:
|
|
602
|
+
payload["protocol_version"] = protocol_version
|
|
603
|
+
uptime_ms = getattr(session, "uptime_ms", None)
|
|
604
|
+
if uptime_ms is not None:
|
|
605
|
+
payload["uptime_ms"] = uptime_ms
|
|
606
|
+
return payload
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _capabilities_summary(capabilities: types.ClientCapabilities | None) -> str:
|
|
610
|
+
if capabilities is None:
|
|
611
|
+
return "none"
|
|
612
|
+
names: list[str] = []
|
|
613
|
+
if capabilities.roots is not None:
|
|
614
|
+
names.append("roots")
|
|
615
|
+
if capabilities.sampling is not None:
|
|
616
|
+
names.append("sampling")
|
|
617
|
+
if capabilities.elicitation is not None:
|
|
618
|
+
names.append("elicitation")
|
|
619
|
+
if capabilities.experimental:
|
|
620
|
+
names.append("experimental")
|
|
621
|
+
if capabilities.tasks is not None:
|
|
622
|
+
names.append("tasks")
|
|
623
|
+
return ",".join(names) or "none"
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _clean_text(value: str, *, limit: int) -> str:
|
|
627
|
+
return " ".join(value.split())[:limit]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _format_log_line(payload: dict[str, Any]) -> str:
|
|
631
|
+
parts = ["mcp_lifecycle"]
|
|
632
|
+
for key, value in payload.items():
|
|
633
|
+
if value is None:
|
|
634
|
+
continue
|
|
635
|
+
parts.append(f"{key}={_format_value(value)}")
|
|
636
|
+
return " ".join(parts)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _format_value(value: Any) -> str:
|
|
640
|
+
if isinstance(value, bool):
|
|
641
|
+
return "true" if value else "false"
|
|
642
|
+
if isinstance(value, (int, float)):
|
|
643
|
+
return str(value)
|
|
644
|
+
if isinstance(value, (dict, list)):
|
|
645
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
646
|
+
text = str(value)
|
|
647
|
+
if not text or any(char.isspace() for char in text) or any(char in {'"', "="} for char in text):
|
|
648
|
+
return json.dumps(text, ensure_ascii=False)
|
|
649
|
+
return text
|