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.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {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()