krons 0.1.1__py3-none-any.whl → 0.2.1__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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +115 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +242 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
- krons-0.2.1.dist-info/RECORD +151 -0
- krons/enforcement/__init__.py +0 -57
- krons/enforcement/policy.py +0 -80
- krons/enforcement/service.py +0 -370
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons/specs/phrase.py +0 -405
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import warnings
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import orjson
|
|
13
|
+
|
|
14
|
+
from krons.errors import KronsError
|
|
15
|
+
from krons.utils.concurrency import Lock
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
"MCP_ENV_ALLOWLIST",
|
|
19
|
+
"CommandNotAllowedError",
|
|
20
|
+
"MCPConnectionPool",
|
|
21
|
+
"MCPConnectionPoolInstance",
|
|
22
|
+
"MCPSecurityConfig",
|
|
23
|
+
"create_mcp_pool",
|
|
24
|
+
"filter_mcp_environment",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Default environment variable allowlist for MCP subprocesses
|
|
29
|
+
# Only these variables (or patterns) are inherited from the parent environment
|
|
30
|
+
# This prevents accidental leakage of sensitive environment variables (API keys, tokens, etc.)
|
|
31
|
+
MCP_ENV_ALLOWLIST: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
# System essentials
|
|
34
|
+
"PATH",
|
|
35
|
+
"HOME",
|
|
36
|
+
"USER",
|
|
37
|
+
"SHELL",
|
|
38
|
+
"TERM",
|
|
39
|
+
"TMPDIR",
|
|
40
|
+
"TMP",
|
|
41
|
+
"TEMP",
|
|
42
|
+
# Locale settings (LC_* handled via pattern)
|
|
43
|
+
"LANG",
|
|
44
|
+
"LANGUAGE",
|
|
45
|
+
# Python environment
|
|
46
|
+
"PYTHONPATH",
|
|
47
|
+
"PYTHONHOME",
|
|
48
|
+
"VIRTUAL_ENV",
|
|
49
|
+
"CONDA_PREFIX",
|
|
50
|
+
"CONDA_DEFAULT_ENV",
|
|
51
|
+
# Node.js environment
|
|
52
|
+
"NODE_PATH",
|
|
53
|
+
"NODE_ENV",
|
|
54
|
+
"NPM_CONFIG_PREFIX",
|
|
55
|
+
# MCP-specific variables (MCP_*, FASTMCP_* handled via pattern)
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Patterns for environment variables that should be allowed
|
|
60
|
+
# These are checked via regex if the exact name is not in MCP_ENV_ALLOWLIST
|
|
61
|
+
_MCP_ENV_PATTERNS: tuple[re.Pattern, ...] = (
|
|
62
|
+
re.compile(r"^LC_"), # Locale settings: LC_ALL, LC_CTYPE, etc.
|
|
63
|
+
re.compile(r"^MCP_"), # MCP-specific: MCP_DEBUG, MCP_QUIET, etc.
|
|
64
|
+
re.compile(r"^FASTMCP_"), # FastMCP-specific: FASTMCP_QUIET, etc.
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def filter_mcp_environment(
|
|
69
|
+
env: dict[str, str] | None = None,
|
|
70
|
+
allowlist: frozenset[str] | set[str] | None = None,
|
|
71
|
+
patterns: tuple[re.Pattern, ...] | None = None,
|
|
72
|
+
debug: bool = False,
|
|
73
|
+
) -> dict[str, str]:
|
|
74
|
+
"""Filter environment variables to only include allowed ones for MCP subprocesses.
|
|
75
|
+
|
|
76
|
+
This function filters environment variables to prevent accidental leakage of
|
|
77
|
+
sensitive data (API keys, tokens, credentials) to MCP subprocess environments.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
env: Source environment dict. If None, uses os.environ.
|
|
81
|
+
allowlist: Set of exact variable names to allow. Defaults to MCP_ENV_ALLOWLIST.
|
|
82
|
+
patterns: Tuple of compiled regex patterns to match. Defaults to _MCP_ENV_PATTERNS
|
|
83
|
+
(LC_*, MCP_*, FASTMCP_*).
|
|
84
|
+
debug: If True, logs variables that were filtered out.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Filtered environment dictionary containing only allowed variables.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> # Get filtered environment with defaults
|
|
91
|
+
>>> env = filter_mcp_environment()
|
|
92
|
+
>>> "PATH" in env # Allowed
|
|
93
|
+
True
|
|
94
|
+
>>> "OPENAI_API_KEY" in env # Filtered out (not in allowlist)
|
|
95
|
+
False
|
|
96
|
+
>>>
|
|
97
|
+
>>> # Custom allowlist
|
|
98
|
+
>>> env = filter_mcp_environment(allowlist={"PATH", "HOME", "MY_SAFE_VAR"})
|
|
99
|
+
"""
|
|
100
|
+
if env is None:
|
|
101
|
+
env = dict(os.environ)
|
|
102
|
+
if allowlist is None:
|
|
103
|
+
allowlist = MCP_ENV_ALLOWLIST
|
|
104
|
+
if patterns is None:
|
|
105
|
+
patterns = _MCP_ENV_PATTERNS
|
|
106
|
+
|
|
107
|
+
filtered = {}
|
|
108
|
+
excluded = []
|
|
109
|
+
|
|
110
|
+
for key, value in env.items():
|
|
111
|
+
# Check exact match first
|
|
112
|
+
if key in allowlist:
|
|
113
|
+
filtered[key] = value
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Check pattern match
|
|
117
|
+
if any(pattern.match(key) for pattern in patterns):
|
|
118
|
+
filtered[key] = value
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Not allowed
|
|
122
|
+
excluded.append(key)
|
|
123
|
+
|
|
124
|
+
if debug and excluded:
|
|
125
|
+
logging.debug(
|
|
126
|
+
"MCP subprocess environment filtered. Excluded %d variables: %s",
|
|
127
|
+
len(excluded),
|
|
128
|
+
", ".join(sorted(excluded)[:10]) + ("..." if len(excluded) > 10 else ""),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return filtered
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CommandNotAllowedError(KronsError):
|
|
135
|
+
"""Raised when a command is not in the allowlist.
|
|
136
|
+
|
|
137
|
+
This exception is raised when strict_mode is enabled (default) and
|
|
138
|
+
a command is attempted that is not in the configured allowlist.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
default_message = "Command not allowed"
|
|
142
|
+
default_retryable = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Default safe commands for MCP servers
|
|
146
|
+
# These are commonly used interpreters/runners that MCP servers typically use
|
|
147
|
+
DEFAULT_ALLOWED_COMMANDS: frozenset[str] = frozenset(
|
|
148
|
+
{
|
|
149
|
+
# Python
|
|
150
|
+
"python",
|
|
151
|
+
"python3",
|
|
152
|
+
"python3.10",
|
|
153
|
+
"python3.11",
|
|
154
|
+
"python3.12",
|
|
155
|
+
"python3.13",
|
|
156
|
+
# Node.js
|
|
157
|
+
"node",
|
|
158
|
+
"npx",
|
|
159
|
+
"npm",
|
|
160
|
+
# Package managers / runners
|
|
161
|
+
"uv",
|
|
162
|
+
"uvx",
|
|
163
|
+
"pipx",
|
|
164
|
+
"pdm",
|
|
165
|
+
"poetry",
|
|
166
|
+
"rye",
|
|
167
|
+
# Other common MCP server runners
|
|
168
|
+
"deno",
|
|
169
|
+
"bun",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Suppress MCP server logging by default
|
|
174
|
+
logging.getLogger("mcp").setLevel(logging.WARNING)
|
|
175
|
+
logging.getLogger("fastmcp").setLevel(logging.WARNING)
|
|
176
|
+
logging.getLogger("mcp.server").setLevel(logging.WARNING)
|
|
177
|
+
logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING)
|
|
178
|
+
logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.WARNING)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class MCPSecurityConfig:
|
|
183
|
+
"""Immutable security configuration for MCP connection pools.
|
|
184
|
+
|
|
185
|
+
This configuration is frozen at creation time and cannot be modified afterward,
|
|
186
|
+
preventing runtime security weakening.
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
allowed_commands: Set of command names allowed to execute.
|
|
190
|
+
strict_mode: If True, only allowlisted commands can execute.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
allowed_commands: frozenset[str] = field(
|
|
194
|
+
default_factory=lambda: DEFAULT_ALLOWED_COMMANDS
|
|
195
|
+
)
|
|
196
|
+
strict_mode: bool = True
|
|
197
|
+
|
|
198
|
+
def __post_init__(self):
|
|
199
|
+
"""Ensure allowed_commands is a frozenset."""
|
|
200
|
+
if not isinstance(self.allowed_commands, frozenset):
|
|
201
|
+
object.__setattr__(
|
|
202
|
+
self, "allowed_commands", frozenset(self.allowed_commands)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def with_commands(self, additional_commands: set[str]) -> "MCPSecurityConfig":
|
|
206
|
+
"""Create a new config with additional allowed commands.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
additional_commands: Commands to add to the allowlist.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
New MCPSecurityConfig with extended allowlist.
|
|
213
|
+
"""
|
|
214
|
+
return MCPSecurityConfig(
|
|
215
|
+
allowed_commands=self.allowed_commands | frozenset(additional_commands),
|
|
216
|
+
strict_mode=self.strict_mode,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MCPConnectionPoolInstance:
|
|
221
|
+
"""Session-scoped connection pool for MCP clients.
|
|
222
|
+
|
|
223
|
+
Unlike the global MCPConnectionPool, this class maintains instance-level state,
|
|
224
|
+
making it safe for concurrent use across multiple sessions without state leakage.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
security_config: Immutable security configuration. Defaults to strict mode
|
|
228
|
+
with standard allowed commands.
|
|
229
|
+
configs: Pre-loaded server configurations (from .mcp.json).
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
>>> # Create session-scoped pool
|
|
233
|
+
>>> security = MCPSecurityConfig(
|
|
234
|
+
... allowed_commands=frozenset({"python", "node", "my-runner"}),
|
|
235
|
+
... strict_mode=True,
|
|
236
|
+
... )
|
|
237
|
+
>>> pool = MCPConnectionPoolInstance(security_config=security)
|
|
238
|
+
>>>
|
|
239
|
+
>>> # Load config and get client
|
|
240
|
+
>>> pool.load_config(".mcp.json")
|
|
241
|
+
>>> client = await pool.get_client({"server": "search"})
|
|
242
|
+
>>>
|
|
243
|
+
>>> # Cleanup when done
|
|
244
|
+
>>> await pool.cleanup()
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
security_config: MCPSecurityConfig | None = None,
|
|
250
|
+
configs: dict[str, dict] | None = None,
|
|
251
|
+
):
|
|
252
|
+
"""Initialize session-scoped connection pool.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
security_config: Immutable security config. If None, uses default strict mode.
|
|
256
|
+
configs: Pre-loaded server configurations.
|
|
257
|
+
"""
|
|
258
|
+
self._security = security_config or MCPSecurityConfig()
|
|
259
|
+
self._clients: dict[str, Any] = {}
|
|
260
|
+
self._configs: dict[str, dict] = configs.copy() if configs else {}
|
|
261
|
+
self._lock = Lock()
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def security_config(self) -> MCPSecurityConfig:
|
|
265
|
+
"""Get the immutable security configuration."""
|
|
266
|
+
return self._security
|
|
267
|
+
|
|
268
|
+
async def __aenter__(self):
|
|
269
|
+
"""Context manager entry."""
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
async def __aexit__(self, *_):
|
|
273
|
+
"""Context manager exit - cleanup connections."""
|
|
274
|
+
await self.cleanup()
|
|
275
|
+
|
|
276
|
+
def _validate_command(self, command: str) -> None:
|
|
277
|
+
"""Validate a command against the security config.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
command: The command to validate.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
CommandNotAllowedError: If strict_mode and command not allowed.
|
|
284
|
+
"""
|
|
285
|
+
if not self._security.strict_mode:
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if "/" in command or "\\" in command:
|
|
289
|
+
raise CommandNotAllowedError(
|
|
290
|
+
f"Command '{command}' contains path separators which are not allowed "
|
|
291
|
+
f"in strict mode. Use bare command names (e.g., 'python' not './python')."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if command not in self._security.allowed_commands:
|
|
295
|
+
allowed_list = ", ".join(sorted(self._security.allowed_commands))
|
|
296
|
+
raise CommandNotAllowedError(
|
|
297
|
+
f"Command '{command}' is not in the allowlist. Allowed commands: [{allowed_list}]."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def load_config(self, path: str = ".mcp.json") -> None:
|
|
301
|
+
"""Load MCP server configurations from file.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
path: Path to .mcp.json configuration file.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
FileNotFoundError: If config file doesn't exist.
|
|
308
|
+
ValueError: If config file has invalid JSON or structure.
|
|
309
|
+
"""
|
|
310
|
+
config_path = Path(path)
|
|
311
|
+
if not config_path.exists():
|
|
312
|
+
raise FileNotFoundError(f"MCP config file not found: {path}")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
content = config_path.read_text(encoding="utf-8")
|
|
316
|
+
data = orjson.loads(content)
|
|
317
|
+
except (ValueError, TypeError) as e:
|
|
318
|
+
raise ValueError(f"Invalid JSON in MCP config file: {e}") from e
|
|
319
|
+
|
|
320
|
+
if not isinstance(data, dict):
|
|
321
|
+
raise ValueError("MCP config must be a JSON object")
|
|
322
|
+
|
|
323
|
+
servers = data.get("mcpServers", {})
|
|
324
|
+
if not isinstance(servers, dict):
|
|
325
|
+
raise ValueError("mcpServers must be a dictionary")
|
|
326
|
+
|
|
327
|
+
self._configs.update(servers)
|
|
328
|
+
|
|
329
|
+
async def get_client(self, server_config: dict[str, Any]) -> Any:
|
|
330
|
+
"""Get or create a pooled MCP client.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
server_config: Either {"server": "name"} or full config with command/args.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
FastMCP Client instance (connected).
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: If server reference not found or config invalid.
|
|
340
|
+
"""
|
|
341
|
+
if server_config.get("server") is not None:
|
|
342
|
+
server_name = server_config["server"]
|
|
343
|
+
if server_name not in self._configs:
|
|
344
|
+
self.load_config()
|
|
345
|
+
if server_name not in self._configs:
|
|
346
|
+
raise ValueError(f"Unknown MCP server: {server_name}")
|
|
347
|
+
|
|
348
|
+
config = self._configs[server_name]
|
|
349
|
+
cache_key = f"server:{server_name}"
|
|
350
|
+
else:
|
|
351
|
+
config = server_config
|
|
352
|
+
cache_key = f"inline:{config.get('command')}:{id(config)}"
|
|
353
|
+
|
|
354
|
+
async with self._lock:
|
|
355
|
+
if cache_key in self._clients:
|
|
356
|
+
client = self._clients[cache_key]
|
|
357
|
+
if hasattr(client, "is_connected") and client.is_connected():
|
|
358
|
+
return client
|
|
359
|
+
else:
|
|
360
|
+
del self._clients[cache_key]
|
|
361
|
+
|
|
362
|
+
client = await self._create_client(config)
|
|
363
|
+
self._clients[cache_key] = client
|
|
364
|
+
return client
|
|
365
|
+
|
|
366
|
+
async def _create_client(self, config: dict[str, Any]) -> Any:
|
|
367
|
+
"""Create a new MCP client from config."""
|
|
368
|
+
if not isinstance(config, dict):
|
|
369
|
+
raise ValueError("Config must be a dictionary")
|
|
370
|
+
|
|
371
|
+
if not any(config.get(k) is not None for k in ["url", "command"]):
|
|
372
|
+
raise ValueError(
|
|
373
|
+
"Config must have either 'url' or 'command' with non-None value"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
from fastmcp import Client as FastMCPClient
|
|
378
|
+
except ImportError as e:
|
|
379
|
+
raise ImportError("FastMCP not installed. Run: pip install fastmcp") from e
|
|
380
|
+
|
|
381
|
+
if config.get("url") is not None:
|
|
382
|
+
client = FastMCPClient(config["url"])
|
|
383
|
+
elif config.get("command") is not None:
|
|
384
|
+
command = config["command"]
|
|
385
|
+
self._validate_command(command)
|
|
386
|
+
|
|
387
|
+
args = config.get("args", [])
|
|
388
|
+
if not isinstance(args, list):
|
|
389
|
+
raise ValueError("Config 'args' must be a list")
|
|
390
|
+
|
|
391
|
+
debug_mode = (
|
|
392
|
+
config.get("debug", False)
|
|
393
|
+
or os.environ.get("MCP_DEBUG", "").lower() == "true"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
env = filter_mcp_environment(debug=debug_mode)
|
|
397
|
+
env.update(config.get("env", {}))
|
|
398
|
+
|
|
399
|
+
if not debug_mode:
|
|
400
|
+
env.setdefault("LOG_LEVEL", "ERROR")
|
|
401
|
+
env.setdefault("PYTHONWARNINGS", "ignore")
|
|
402
|
+
env.setdefault("FASTMCP_QUIET", "true")
|
|
403
|
+
env.setdefault("MCP_QUIET", "true")
|
|
404
|
+
|
|
405
|
+
from fastmcp.client.transports import StdioTransport
|
|
406
|
+
|
|
407
|
+
transport = StdioTransport(command=command, args=args, env=env)
|
|
408
|
+
client = FastMCPClient(transport)
|
|
409
|
+
else:
|
|
410
|
+
raise ValueError("Config must have 'url' or 'command' with non-None value")
|
|
411
|
+
|
|
412
|
+
await client.__aenter__()
|
|
413
|
+
return client
|
|
414
|
+
|
|
415
|
+
async def cleanup(self):
|
|
416
|
+
"""Clean up all pooled connections."""
|
|
417
|
+
async with self._lock:
|
|
418
|
+
for cache_key, client in self._clients.items():
|
|
419
|
+
try:
|
|
420
|
+
await client.__aexit__(None, None, None)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logging.debug(f"Error cleaning up MCP client {cache_key}: {e}")
|
|
423
|
+
self._clients.clear()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def create_mcp_pool(
|
|
427
|
+
allowed_commands: set[str] | None = None,
|
|
428
|
+
strict_mode: bool = True,
|
|
429
|
+
extend_defaults: bool = True,
|
|
430
|
+
configs: dict[str, dict] | None = None,
|
|
431
|
+
) -> MCPConnectionPoolInstance:
|
|
432
|
+
"""Factory function to create a session-scoped MCP connection pool.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
allowed_commands: Additional commands to allow. If None, uses defaults only.
|
|
436
|
+
strict_mode: If True, only allowlisted commands can execute.
|
|
437
|
+
extend_defaults: If True, allowed_commands extends defaults. If False, replaces.
|
|
438
|
+
configs: Pre-loaded server configurations.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
New MCPConnectionPoolInstance with the specified security settings.
|
|
442
|
+
|
|
443
|
+
Example:
|
|
444
|
+
>>> # Create pool with custom commands
|
|
445
|
+
>>> pool = create_mcp_pool(allowed_commands={"my-runner"})
|
|
446
|
+
>>>
|
|
447
|
+
>>> # Create pool with only specific commands (no defaults)
|
|
448
|
+
>>> pool = create_mcp_pool(
|
|
449
|
+
... allowed_commands={"python", "node"},
|
|
450
|
+
... extend_defaults=False,
|
|
451
|
+
... )
|
|
452
|
+
"""
|
|
453
|
+
if allowed_commands is None:
|
|
454
|
+
base_commands = DEFAULT_ALLOWED_COMMANDS
|
|
455
|
+
elif extend_defaults:
|
|
456
|
+
base_commands = DEFAULT_ALLOWED_COMMANDS | frozenset(allowed_commands)
|
|
457
|
+
else:
|
|
458
|
+
base_commands = frozenset(allowed_commands)
|
|
459
|
+
|
|
460
|
+
security = MCPSecurityConfig(
|
|
461
|
+
allowed_commands=base_commands, strict_mode=strict_mode
|
|
462
|
+
)
|
|
463
|
+
return MCPConnectionPoolInstance(security_config=security, configs=configs)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class MCPConnectionPool:
|
|
467
|
+
"""Global connection pool for MCP clients.
|
|
468
|
+
|
|
469
|
+
.. deprecated::
|
|
470
|
+
This class uses global state shared across all sessions. Use
|
|
471
|
+
:class:`MCPConnectionPoolInstance` or :func:`create_mcp_pool` for
|
|
472
|
+
session-scoped isolation.
|
|
473
|
+
|
|
474
|
+
Manages FastMCP client instances with connection pooling and lifecycle management.
|
|
475
|
+
Clients are cached by config and reused across calls for efficiency.
|
|
476
|
+
|
|
477
|
+
Warning:
|
|
478
|
+
This class uses class-level state that is shared globally. For session
|
|
479
|
+
isolation, use MCPConnectionPoolInstance instead:
|
|
480
|
+
|
|
481
|
+
>>> pool = create_mcp_pool(allowed_commands={"my-runner"})
|
|
482
|
+
>>> client = await pool.get_client({"server": "search"})
|
|
483
|
+
>>> await pool.cleanup()
|
|
484
|
+
|
|
485
|
+
Security:
|
|
486
|
+
By default, only commands in the allowlist can be executed (strict_mode=True).
|
|
487
|
+
Use configure_security() to customize the allowlist or disable strict mode.
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
>>> # Load config
|
|
491
|
+
>>> MCPConnectionPool.load_config(".mcp.json")
|
|
492
|
+
>>>
|
|
493
|
+
>>> # Get client (auto-connects)
|
|
494
|
+
>>> client = await MCPConnectionPool.get_client({"server": "search"})
|
|
495
|
+
>>> result = await client.call_tool("exa_search", {"query": "AI"})
|
|
496
|
+
>>>
|
|
497
|
+
>>> # Cleanup on shutdown
|
|
498
|
+
>>> await MCPConnectionPool.cleanup()
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
_clients: dict[str, Any] = {}
|
|
502
|
+
_configs: dict[str, dict] = {}
|
|
503
|
+
_lock = Lock()
|
|
504
|
+
|
|
505
|
+
# Security: Command allowlist
|
|
506
|
+
_allowed_commands: set[str] = set(DEFAULT_ALLOWED_COMMANDS)
|
|
507
|
+
_strict_mode: bool = True
|
|
508
|
+
|
|
509
|
+
async def __aenter__(self):
|
|
510
|
+
"""Context manager entry."""
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
async def __aexit__(self, *_):
|
|
514
|
+
"""Context manager exit - cleanup connections."""
|
|
515
|
+
await self.cleanup()
|
|
516
|
+
|
|
517
|
+
@classmethod
|
|
518
|
+
def configure_security(
|
|
519
|
+
cls,
|
|
520
|
+
allowed_commands: set[str] | None = None,
|
|
521
|
+
strict_mode: bool | None = None,
|
|
522
|
+
extend_defaults: bool = True,
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Configure command execution security settings.
|
|
525
|
+
|
|
526
|
+
.. deprecated::
|
|
527
|
+
This method modifies global state affecting all sessions. Use
|
|
528
|
+
:func:`create_mcp_pool` with security options for session isolation.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
allowed_commands: Set of allowed command names. If extend_defaults=True,
|
|
532
|
+
these are added to the default allowlist. If extend_defaults=False,
|
|
533
|
+
these replace the allowlist entirely.
|
|
534
|
+
strict_mode: If True (default), only allowlisted commands can execute.
|
|
535
|
+
If False, all commands are allowed (use with caution).
|
|
536
|
+
extend_defaults: If True (default), allowed_commands extends the default
|
|
537
|
+
allowlist. If False, allowed_commands replaces it entirely.
|
|
538
|
+
|
|
539
|
+
Example:
|
|
540
|
+
>>> # Preferred: use create_mcp_pool for session isolation
|
|
541
|
+
>>> pool = create_mcp_pool(allowed_commands={"my-runner"})
|
|
542
|
+
>>>
|
|
543
|
+
>>> # Legacy: global configuration (deprecated)
|
|
544
|
+
>>> MCPConnectionPool.configure_security(allowed_commands={"my-custom-runner"})
|
|
545
|
+
"""
|
|
546
|
+
warnings.warn(
|
|
547
|
+
"MCPConnectionPool.configure_security() modifies global state. "
|
|
548
|
+
"Use create_mcp_pool() for session-scoped isolation.",
|
|
549
|
+
DeprecationWarning,
|
|
550
|
+
stacklevel=2,
|
|
551
|
+
)
|
|
552
|
+
if strict_mode is not None:
|
|
553
|
+
cls._strict_mode = strict_mode
|
|
554
|
+
|
|
555
|
+
if allowed_commands is not None:
|
|
556
|
+
if extend_defaults:
|
|
557
|
+
cls._allowed_commands = set(DEFAULT_ALLOWED_COMMANDS) | allowed_commands
|
|
558
|
+
else:
|
|
559
|
+
cls._allowed_commands = set(allowed_commands)
|
|
560
|
+
|
|
561
|
+
@classmethod
|
|
562
|
+
def reset_security(cls) -> None:
|
|
563
|
+
"""Reset security settings to defaults.
|
|
564
|
+
|
|
565
|
+
Restores:
|
|
566
|
+
- strict_mode to True
|
|
567
|
+
- allowed_commands to DEFAULT_ALLOWED_COMMANDS
|
|
568
|
+
"""
|
|
569
|
+
cls._strict_mode = True
|
|
570
|
+
cls._allowed_commands = set(DEFAULT_ALLOWED_COMMANDS)
|
|
571
|
+
|
|
572
|
+
@classmethod
|
|
573
|
+
def _validate_command(cls, command: str) -> None:
|
|
574
|
+
"""Validate a command against the allowlist.
|
|
575
|
+
|
|
576
|
+
In strict mode, commands with path separators are rejected to prevent
|
|
577
|
+
attackers from bypassing the allowlist with paths like ./python or
|
|
578
|
+
/tmp/python. Only bare command names that will be resolved via PATH
|
|
579
|
+
are allowed.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
command: The command to validate (must be bare name in strict mode)
|
|
583
|
+
|
|
584
|
+
Raises:
|
|
585
|
+
CommandNotAllowedError: If strict_mode is True and:
|
|
586
|
+
- command contains path separators (/, \\)
|
|
587
|
+
- command not in allowlist
|
|
588
|
+
"""
|
|
589
|
+
if not cls._strict_mode:
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
# In strict mode, reject any command with path separators
|
|
593
|
+
# This prevents bypass via ./python, /tmp/python, etc.
|
|
594
|
+
if "/" in command or "\\" in command:
|
|
595
|
+
raise CommandNotAllowedError(
|
|
596
|
+
f"Command '{command}' contains path separators which are not allowed "
|
|
597
|
+
f"in strict mode. Use bare command names (e.g., 'python' not './python'). "
|
|
598
|
+
f"This prevents allowlist bypass via malicious binaries in writable paths."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if command not in cls._allowed_commands:
|
|
602
|
+
allowed_list = ", ".join(sorted(cls._allowed_commands))
|
|
603
|
+
raise CommandNotAllowedError(
|
|
604
|
+
f"Command '{command}' is not in the allowlist. "
|
|
605
|
+
f"Allowed commands: [{allowed_list}]. "
|
|
606
|
+
f"Use MCPConnectionPool.configure_security() to add custom commands "
|
|
607
|
+
f"or set strict_mode=False (not recommended)."
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
@classmethod
|
|
611
|
+
def load_config(cls, path: str = ".mcp.json") -> None:
|
|
612
|
+
"""Load MCP server configurations from file.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
path: Path to .mcp.json configuration file
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
FileNotFoundError: If config file doesn't exist
|
|
619
|
+
ValueError: If config file has invalid JSON or structure is invalid
|
|
620
|
+
|
|
621
|
+
Example:
|
|
622
|
+
>>> MCPConnectionPool.load_config(".mcp.json")
|
|
623
|
+
>>> # Now can reference servers: {"server": "name"}
|
|
624
|
+
"""
|
|
625
|
+
config_path = Path(path)
|
|
626
|
+
if not config_path.exists():
|
|
627
|
+
raise FileNotFoundError(f"MCP config file not found: {path}")
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
content = config_path.read_text(encoding="utf-8")
|
|
631
|
+
data = orjson.loads(content)
|
|
632
|
+
except (ValueError, TypeError) as e:
|
|
633
|
+
raise ValueError(f"Invalid JSON in MCP config file: {e}") from e
|
|
634
|
+
|
|
635
|
+
if not isinstance(data, dict):
|
|
636
|
+
raise ValueError("MCP config must be a JSON object")
|
|
637
|
+
|
|
638
|
+
servers = data.get("mcpServers", {})
|
|
639
|
+
if not isinstance(servers, dict):
|
|
640
|
+
raise ValueError("mcpServers must be a dictionary")
|
|
641
|
+
|
|
642
|
+
cls._configs.update(servers)
|
|
643
|
+
|
|
644
|
+
@classmethod
|
|
645
|
+
async def get_client(cls, server_config: dict[str, Any]) -> Any:
|
|
646
|
+
"""Get or create a pooled MCP client.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
server_config: Either {"server": "name"} or full config with command/args
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
FastMCP Client instance (connected)
|
|
653
|
+
|
|
654
|
+
Raises:
|
|
655
|
+
ValueError: If server reference not found or config invalid
|
|
656
|
+
|
|
657
|
+
Example:
|
|
658
|
+
>>> # Via server reference
|
|
659
|
+
>>> client = await MCPConnectionPool.get_client({"server": "search"})
|
|
660
|
+
>>>
|
|
661
|
+
>>> # Via inline config
|
|
662
|
+
>>> client = await MCPConnectionPool.get_client(
|
|
663
|
+
... {
|
|
664
|
+
... "command": "python",
|
|
665
|
+
... "args": ["-m", "server"],
|
|
666
|
+
... }
|
|
667
|
+
... )
|
|
668
|
+
"""
|
|
669
|
+
# Generate unique key for this config
|
|
670
|
+
if server_config.get("server") is not None:
|
|
671
|
+
# Server reference from .mcp.json
|
|
672
|
+
server_name = server_config["server"]
|
|
673
|
+
if server_name not in cls._configs:
|
|
674
|
+
# Try loading config
|
|
675
|
+
cls.load_config()
|
|
676
|
+
if server_name not in cls._configs:
|
|
677
|
+
raise ValueError(f"Unknown MCP server: {server_name}")
|
|
678
|
+
|
|
679
|
+
config = cls._configs[server_name]
|
|
680
|
+
cache_key = f"server:{server_name}"
|
|
681
|
+
else:
|
|
682
|
+
# Inline config - use command as key
|
|
683
|
+
config = server_config
|
|
684
|
+
cache_key = f"inline:{config.get('command')}:{id(config)}"
|
|
685
|
+
|
|
686
|
+
# Check if client exists and is connected
|
|
687
|
+
async with cls._lock:
|
|
688
|
+
if cache_key in cls._clients:
|
|
689
|
+
client = cls._clients[cache_key]
|
|
690
|
+
# Simple connectivity check
|
|
691
|
+
if hasattr(client, "is_connected") and client.is_connected():
|
|
692
|
+
return client
|
|
693
|
+
else:
|
|
694
|
+
# Remove stale client
|
|
695
|
+
del cls._clients[cache_key]
|
|
696
|
+
|
|
697
|
+
# Create new client
|
|
698
|
+
client = await cls._create_client(config)
|
|
699
|
+
cls._clients[cache_key] = client
|
|
700
|
+
return client
|
|
701
|
+
|
|
702
|
+
@classmethod
|
|
703
|
+
async def _create_client(cls, config: dict[str, Any]) -> Any:
|
|
704
|
+
"""Create a new MCP client from config.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
config: Server configuration with 'url' or 'command' + optional 'args' and 'env'
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
ValueError: If config format is invalid
|
|
711
|
+
ImportError: If fastmcp not installed
|
|
712
|
+
"""
|
|
713
|
+
# Validate config structure
|
|
714
|
+
if not isinstance(config, dict):
|
|
715
|
+
raise ValueError("Config must be a dictionary")
|
|
716
|
+
|
|
717
|
+
# Check that at least one of url or command has a non-None value
|
|
718
|
+
if not any(config.get(k) is not None for k in ["url", "command"]):
|
|
719
|
+
raise ValueError(
|
|
720
|
+
"Config must have either 'url' or 'command' with non-None value"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
from fastmcp import Client as FastMCPClient
|
|
725
|
+
except ImportError as e:
|
|
726
|
+
raise ImportError("FastMCP not installed. Run: pip install fastmcp") from e
|
|
727
|
+
|
|
728
|
+
# Handle different config formats
|
|
729
|
+
if config.get("url") is not None:
|
|
730
|
+
# Direct URL connection
|
|
731
|
+
client = FastMCPClient(config["url"])
|
|
732
|
+
elif config.get("command") is not None:
|
|
733
|
+
# Command-based connection
|
|
734
|
+
command = config["command"]
|
|
735
|
+
|
|
736
|
+
# SECURITY: Validate command against allowlist
|
|
737
|
+
cls._validate_command(command)
|
|
738
|
+
|
|
739
|
+
# Validate args if provided
|
|
740
|
+
args = config.get("args", [])
|
|
741
|
+
if not isinstance(args, list):
|
|
742
|
+
raise ValueError("Config 'args' must be a list")
|
|
743
|
+
|
|
744
|
+
# Check debug mode
|
|
745
|
+
debug_mode = (
|
|
746
|
+
config.get("debug", False)
|
|
747
|
+
or os.environ.get("MCP_DEBUG", "").lower() == "true"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# SECURITY: Filter environment variables to prevent leaking secrets
|
|
751
|
+
# Only allowlisted variables are passed to the subprocess
|
|
752
|
+
env = filter_mcp_environment(debug=debug_mode)
|
|
753
|
+
|
|
754
|
+
# Merge user-specified environment variables (these take precedence)
|
|
755
|
+
env.update(config.get("env", {}))
|
|
756
|
+
|
|
757
|
+
# Suppress server logging unless debug mode is enabled
|
|
758
|
+
if not debug_mode:
|
|
759
|
+
# Common environment variables to suppress logging
|
|
760
|
+
env.setdefault("LOG_LEVEL", "ERROR")
|
|
761
|
+
env.setdefault("PYTHONWARNINGS", "ignore")
|
|
762
|
+
# Suppress FastMCP server logs
|
|
763
|
+
env.setdefault("FASTMCP_QUIET", "true")
|
|
764
|
+
env.setdefault("MCP_QUIET", "true")
|
|
765
|
+
|
|
766
|
+
# Create client with command
|
|
767
|
+
from fastmcp.client.transports import StdioTransport
|
|
768
|
+
|
|
769
|
+
transport = StdioTransport(
|
|
770
|
+
command=command,
|
|
771
|
+
args=args,
|
|
772
|
+
env=env,
|
|
773
|
+
)
|
|
774
|
+
client = FastMCPClient(transport)
|
|
775
|
+
else:
|
|
776
|
+
# Defense-in-depth: should never reach here due to validation at line 160
|
|
777
|
+
raise ValueError("Config must have 'url' or 'command' with non-None value")
|
|
778
|
+
|
|
779
|
+
# Initialize connection
|
|
780
|
+
await client.__aenter__()
|
|
781
|
+
return client
|
|
782
|
+
|
|
783
|
+
@classmethod
|
|
784
|
+
async def cleanup(cls):
|
|
785
|
+
"""Clean up all pooled connections.
|
|
786
|
+
|
|
787
|
+
Safe to call multiple times. Errors are logged but don't raise.
|
|
788
|
+
|
|
789
|
+
Example:
|
|
790
|
+
>>> await MCPConnectionPool.cleanup()
|
|
791
|
+
"""
|
|
792
|
+
async with cls._lock:
|
|
793
|
+
for cache_key, client in cls._clients.items():
|
|
794
|
+
try:
|
|
795
|
+
await client.__aexit__(None, None, None)
|
|
796
|
+
except Exception as e:
|
|
797
|
+
# Log cleanup errors for debugging while continuing cleanup
|
|
798
|
+
logging.debug(f"Error cleaning up MCP client {cache_key}: {e}")
|
|
799
|
+
cls._clients.clear()
|