codecortex-context-engine 0.1.0a1__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 (90) hide show
  1. codecortex/__init__.py +3 -0
  2. codecortex/architecture/__init__.py +23 -0
  3. codecortex/architecture/drift.py +182 -0
  4. codecortex/architecture/inference.py +160 -0
  5. codecortex/backends/__init__.py +35 -0
  6. codecortex/backends/base.py +38 -0
  7. codecortex/backends/context.py +118 -0
  8. codecortex/backends/contracts.py +65 -0
  9. codecortex/backends/factory.py +60 -0
  10. codecortex/backends/graph.py +107 -0
  11. codecortex/backends/manager.py +303 -0
  12. codecortex/backends/mcp_client.py +196 -0
  13. codecortex/backends/pool.py +128 -0
  14. codecortex/backends/spec.py +83 -0
  15. codecortex/backends/symbols.py +189 -0
  16. codecortex/benchmark.py +211 -0
  17. codecortex/cli.py +409 -0
  18. codecortex/config.py +27 -0
  19. codecortex/context/__init__.py +13 -0
  20. codecortex/context/budget.py +62 -0
  21. codecortex/context/integrated.py +60 -0
  22. codecortex/context/pipeline.py +223 -0
  23. codecortex/core/__init__.py +1 -0
  24. codecortex/core/contracts.py +45 -0
  25. codecortex/core/errors.py +17 -0
  26. codecortex/core/models.py +69 -0
  27. codecortex/dashboard.py +259 -0
  28. codecortex/editing.py +41 -0
  29. codecortex/engines/__init__.py +5 -0
  30. codecortex/engines/builtin/__init__.py +5 -0
  31. codecortex/engines/builtin/factory.py +26 -0
  32. codecortex/engines/builtin/memory.py +35 -0
  33. codecortex/engines/builtin/repository.py +73 -0
  34. codecortex/engines/builtin/symbols.py +89 -0
  35. codecortex/engines/builtin/validation.py +54 -0
  36. codecortex/engines/registry.py +26 -0
  37. codecortex/entrypoint.py +266 -0
  38. codecortex/evaluation/__init__.py +55 -0
  39. codecortex/evaluation/external.py +265 -0
  40. codecortex/evaluation/production.py +670 -0
  41. codecortex/evaluation/regression.py +188 -0
  42. codecortex/gateway.py +38 -0
  43. codecortex/git_intelligence.py +252 -0
  44. codecortex/indexing/__init__.py +6 -0
  45. codecortex/indexing/graph.py +78 -0
  46. codecortex/indexing/impact.py +127 -0
  47. codecortex/indexing/incremental.py +163 -0
  48. codecortex/indexing/incremental_graph.py +189 -0
  49. codecortex/indexing/indexer.py +172 -0
  50. codecortex/indexing/relationships.py +179 -0
  51. codecortex/indexing/resolution.py +88 -0
  52. codecortex/integrations/__init__.py +5 -0
  53. codecortex/integrations/agents.py +235 -0
  54. codecortex/interfaces/__init__.py +1 -0
  55. codecortex/interfaces/mcp_bridge.py +66 -0
  56. codecortex/languages/__init__.py +5 -0
  57. codecortex/languages/native.py +166 -0
  58. codecortex/languages/registry.py +232 -0
  59. codecortex/mcp/__init__.py +5 -0
  60. codecortex/mcp/extended.py +114 -0
  61. codecortex/mcp/server.py +473 -0
  62. codecortex/memory/__init__.py +11 -0
  63. codecortex/memory/json_store.py +52 -0
  64. codecortex/memory/knowledge.py +193 -0
  65. codecortex/memory/team_store.py +193 -0
  66. codecortex/orchestrator.py +153 -0
  67. codecortex/pr_intelligence.py +214 -0
  68. codecortex/retrieval/__init__.py +16 -0
  69. codecortex/retrieval/hybrid.py +67 -0
  70. codecortex/retrieval/index.py +135 -0
  71. codecortex/retrieval/providers.py +67 -0
  72. codecortex/retrieval/repository.py +94 -0
  73. codecortex/router/__init__.py +5 -0
  74. codecortex/router/router.py +79 -0
  75. codecortex/runtime.py +69 -0
  76. codecortex/setup.py +100 -0
  77. codecortex/symbols/__init__.py +5 -0
  78. codecortex/symbols/providers.py +192 -0
  79. codecortex/telemetry/__init__.py +5 -0
  80. codecortex/telemetry/collector.py +43 -0
  81. codecortex/tracing/__init__.py +9 -0
  82. codecortex/tracing/task_trace.py +235 -0
  83. codecortex/workspace/__init__.py +9 -0
  84. codecortex/workspace/federation.py +173 -0
  85. codecortex_context_engine-0.1.0a1.dist-info/METADATA +381 -0
  86. codecortex_context_engine-0.1.0a1.dist-info/RECORD +90 -0
  87. codecortex_context_engine-0.1.0a1.dist-info/WHEEL +4 -0
  88. codecortex_context_engine-0.1.0a1.dist-info/entry_points.txt +3 -0
  89. codecortex_context_engine-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  90. codecortex_context_engine-0.1.0a1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,107 @@
1
+ """Configurable repository graph backend adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from codecortex.backends.base import ManagedAdapterMixin
12
+ from codecortex.backends.manager import BackendManager
13
+ from codecortex.backends.spec import BACKENDS
14
+ from codecortex.core.contracts import Engine
15
+ from codecortex.core.models import AgentRequest, Capability, ContextChunk, EngineResult
16
+
17
+
18
+ class GraphBackendAdapter(ManagedAdapterMixin, Engine):
19
+ capability = Capability.REPOSITORY
20
+
21
+ def __init__(self, project_root: Path, manager: BackendManager | None = None) -> None:
22
+ self.project_root = project_root.resolve()
23
+ self.manager = manager or BackendManager()
24
+ self.spec = BACKENDS["graph"]
25
+
26
+ async def health(self) -> bool:
27
+ return await asyncio.to_thread(self.manager.probe, self.spec, False)
28
+
29
+ def _graph_path(self) -> Path:
30
+ configured = os.getenv(
31
+ "CODECORTEX_GRAPH_BACKEND_OUTPUT",
32
+ ".codecortex/backends/graph.json",
33
+ )
34
+ candidate = (self.project_root / configured).resolve()
35
+ try:
36
+ candidate.relative_to(self.project_root)
37
+ except ValueError:
38
+ raise ValueError("graph backend output must stay inside the project root") from None
39
+ return candidate
40
+
41
+ def build(self) -> dict[str, Any]:
42
+ self.manager.run(self.spec, (".",), cwd=self.project_root)
43
+ graph_path = self._graph_path()
44
+ if not graph_path.exists():
45
+ raise RuntimeError("graph backend completed without its configured graph payload")
46
+ payload = json.loads(graph_path.read_text(encoding="utf-8"))
47
+ if not isinstance(payload, dict):
48
+ raise RuntimeError("graph backend emitted an invalid graph payload")
49
+ return payload
50
+
51
+ def query(self, query: str) -> str:
52
+ return self.manager.run(
53
+ self.spec,
54
+ ("query", query),
55
+ cwd=self.project_root,
56
+ timeout_seconds=90,
57
+ ).stdout.strip()
58
+
59
+ def explain(self, node: str) -> str:
60
+ return self.manager.run(
61
+ self.spec,
62
+ ("explain", node),
63
+ cwd=self.project_root,
64
+ timeout_seconds=60,
65
+ ).stdout.strip()
66
+
67
+ def path(self, source: str, target: str) -> str:
68
+ return self.manager.run(
69
+ self.spec,
70
+ ("path", source, target),
71
+ cwd=self.project_root,
72
+ timeout_seconds=60,
73
+ ).stdout.strip()
74
+
75
+ async def execute(self, request: AgentRequest) -> EngineResult:
76
+ return await asyncio.to_thread(self._execute_sync, request)
77
+
78
+ def _execute_sync(self, request: AgentRequest) -> EngineResult:
79
+ mode = str(request.metadata.get("graph_mode", "query"))
80
+ if mode == "build":
81
+ content = json.dumps(self.build(), ensure_ascii=False)
82
+ elif mode == "explain":
83
+ content = self.explain(request.query)
84
+ elif mode == "path":
85
+ target = str(request.metadata.get("target", "")).strip()
86
+ if not target:
87
+ raise ValueError("graph path mode requires metadata.target")
88
+ content = self.path(request.query, target)
89
+ else:
90
+ content = self.query(request.query)
91
+ tokens = max(1, len(content) // 4) if content else 0
92
+ return EngineResult(
93
+ capability=self.capability,
94
+ content=content,
95
+ chunks=[
96
+ ContextChunk(
97
+ source="repository-graph",
98
+ content=content,
99
+ tokens=tokens,
100
+ relevance=0.95,
101
+ metadata={"backend": self.spec.key, "revision": self.spec.revision},
102
+ )
103
+ ]
104
+ if content
105
+ else [],
106
+ metadata={"backend": self.spec.key, "revision": self.spec.revision},
107
+ )
@@ -0,0 +1,303 @@
1
+ """Isolated lifecycle management for configurable backend engines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import venv
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import asdict, dataclass
14
+ from pathlib import Path
15
+
16
+ from codecortex.backends.spec import BackendSpec
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ProcessResult:
21
+ argv: tuple[str, ...]
22
+ returncode: int
23
+ stdout: str
24
+ stderr: str
25
+ duration_ms: float
26
+
27
+
28
+ class BackendProcessError(RuntimeError):
29
+ def __init__(self, result: ProcessResult) -> None:
30
+ self.result = result
31
+ message = result.stderr.strip() or result.stdout.strip() or "backend process failed"
32
+ super().__init__(f"{result.argv[0]} exited with {result.returncode}: {message[:500]}")
33
+
34
+
35
+ def _default_cache_root() -> Path:
36
+ configured = os.getenv("CODECORTEX_BACKEND_HOME")
37
+ if configured:
38
+ return Path(configured).expanduser().resolve()
39
+ if sys.platform == "darwin":
40
+ return Path.home() / "Library" / "Caches" / "codecortex" / "backends"
41
+ if os.name == "nt":
42
+ base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
43
+ return base / "CodeCortex" / "backends"
44
+ return Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "codecortex" / "backends"
45
+
46
+
47
+ def _discover_source_root() -> Path | None:
48
+ configured = os.getenv("CODECORTEX_SOURCE_ROOT")
49
+ if configured:
50
+ candidate = Path(configured).expanduser().resolve()
51
+ return candidate if candidate.is_dir() else None
52
+ for candidate in Path(__file__).resolve().parents:
53
+ if (candidate / "pyproject.toml").is_file():
54
+ return candidate
55
+ return None
56
+
57
+
58
+ class BackendManager:
59
+ """Provision explicitly configured engines in isolated environments."""
60
+
61
+ def __init__(
62
+ self,
63
+ cache_root: Path | None = None,
64
+ timeout_seconds: float = 300.0,
65
+ health_ttl_seconds: float = 30.0,
66
+ source_root: Path | None = None,
67
+ ) -> None:
68
+ self.cache_root = (cache_root or _default_cache_root()).expanduser().resolve()
69
+ self.timeout_seconds = timeout_seconds
70
+ self.health_ttl_seconds = health_ttl_seconds
71
+ self.source_root = source_root.expanduser().resolve() if source_root else _discover_source_root()
72
+ self._probe_cache: dict[tuple[str, str], tuple[float, bool]] = {}
73
+
74
+ def environment_dir(self, spec: BackendSpec) -> Path:
75
+ revision = spec.revision[:12] if spec.revision else "unconfigured"
76
+ return self.cache_root / spec.key / revision
77
+
78
+ def metadata_path(self, spec: BackendSpec) -> Path:
79
+ return self.environment_dir(spec) / ".codecortex-backend.json"
80
+
81
+ def python_path(self, spec: BackendSpec) -> Path:
82
+ env = self.environment_dir(spec)
83
+ return env / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
84
+
85
+ def command_path(self, spec: BackendSpec) -> Path:
86
+ env = self.environment_dir(spec)
87
+ suffix = ".exe" if os.name == "nt" else ""
88
+ command = spec.command or "__unconfigured__"
89
+ return env / ("Scripts" if os.name == "nt" else "bin") / f"{command}{suffix}"
90
+
91
+ def local_source_path(self, spec: BackendSpec) -> Path | None:
92
+ if not spec.configured or self.source_root is None or not spec.vendor_path:
93
+ return None
94
+ candidate = (self.source_root / spec.vendor_path).resolve()
95
+ try:
96
+ candidate.relative_to(self.source_root)
97
+ except ValueError:
98
+ raise RuntimeError(f"backend local path escapes source root: {spec.vendor_path}") from None
99
+ if not candidate.is_dir() or not (candidate / "pyproject.toml").is_file():
100
+ return None
101
+ revision = self._git_revision(candidate)
102
+ if revision is not None and revision != spec.revision:
103
+ raise RuntimeError(
104
+ f"backend revision mismatch for {spec.key}: expected {spec.revision}, found {revision}"
105
+ )
106
+ return candidate
107
+
108
+ def install_requirement(self, spec: BackendSpec) -> str:
109
+ if not spec.configured:
110
+ raise RuntimeError(f"backend {spec.key!r} is not configured")
111
+ local = self.local_source_path(spec)
112
+ if local is None:
113
+ return spec.source_requirement
114
+ if spec.extras:
115
+ return f"{spec.package}[{','.join(spec.extras)}] @ {local.as_uri()}"
116
+ return str(local)
117
+
118
+ def installation_metadata(self, spec: BackendSpec) -> dict[str, object] | None:
119
+ return self._load_metadata(spec)
120
+
121
+ def is_installed(self, spec: BackendSpec) -> bool:
122
+ if not spec.configured:
123
+ return False
124
+ metadata = self._load_metadata(spec)
125
+ if not metadata or metadata.get("revision") != spec.revision or not self.command_path(spec).exists():
126
+ return False
127
+ local = self.local_source_path(spec)
128
+ return local is None or metadata.get("source_kind") == "local"
129
+
130
+ def ensure(self, spec: BackendSpec) -> Path:
131
+ if not spec.configured:
132
+ raise RuntimeError(f"backend {spec.key!r} is not configured")
133
+ if self.is_installed(spec):
134
+ return self.command_path(spec)
135
+ env_dir = self.environment_dir(spec)
136
+ env_dir.parent.mkdir(parents=True, exist_ok=True)
137
+ lock = env_dir.with_suffix(".lock")
138
+ self._acquire_lock(lock)
139
+ try:
140
+ if self.is_installed(spec):
141
+ return self.command_path(spec)
142
+ if env_dir.exists():
143
+ shutil.rmtree(env_dir)
144
+ self._create_environment(env_dir, spec)
145
+ local = self.local_source_path(spec)
146
+ self._install(spec)
147
+ payload = asdict(spec)
148
+ payload["installed_at"] = time.time()
149
+ payload["source_kind"] = "local" if local is not None else "remote"
150
+ payload["source_path"] = str(local) if local is not None else None
151
+ self.metadata_path(spec).write_text(
152
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
153
+ encoding="utf-8",
154
+ )
155
+ command = self.command_path(spec)
156
+ if not command.exists():
157
+ raise RuntimeError(f"backend installed without expected command: {command}")
158
+ self._probe_cache.pop((spec.key, spec.revision), None)
159
+ return command
160
+ except Exception:
161
+ if env_dir.exists() and not self.is_installed(spec):
162
+ shutil.rmtree(env_dir, ignore_errors=True)
163
+ raise
164
+ finally:
165
+ self._release_lock(lock)
166
+
167
+ def run(
168
+ self,
169
+ spec: BackendSpec,
170
+ args: Sequence[str],
171
+ *,
172
+ cwd: Path | None = None,
173
+ env: Mapping[str, str] | None = None,
174
+ timeout_seconds: float | None = None,
175
+ check: bool = True,
176
+ provision: bool = True,
177
+ ) -> ProcessResult:
178
+ command = self.ensure(spec) if provision else self.command_path(spec)
179
+ if not command.exists():
180
+ raise FileNotFoundError(command)
181
+ argv = (str(command), *(str(item) for item in args))
182
+ started = time.perf_counter()
183
+ process = subprocess.run(
184
+ argv,
185
+ cwd=str(cwd) if cwd else None,
186
+ env={**os.environ, **dict(env or {})},
187
+ text=True,
188
+ capture_output=True,
189
+ timeout=timeout_seconds or self.timeout_seconds,
190
+ check=False,
191
+ )
192
+ result = ProcessResult(
193
+ argv,
194
+ process.returncode,
195
+ process.stdout,
196
+ process.stderr,
197
+ (time.perf_counter() - started) * 1000,
198
+ )
199
+ if check and result.returncode != 0:
200
+ raise BackendProcessError(result)
201
+ return result
202
+
203
+ def probe(self, spec: BackendSpec, provision: bool = False, *, force: bool = False) -> bool:
204
+ if not spec.configured:
205
+ return False
206
+ key = (spec.key, spec.revision)
207
+ now = time.monotonic()
208
+ cached = self._probe_cache.get(key)
209
+ if not force and cached is not None and now - cached[0] <= self.health_ttl_seconds:
210
+ return cached[1]
211
+ try:
212
+ if not provision and not self.is_installed(spec):
213
+ healthy = False
214
+ else:
215
+ result = self.run(
216
+ spec,
217
+ ("--help",),
218
+ timeout_seconds=30,
219
+ provision=provision,
220
+ check=False,
221
+ )
222
+ healthy = result.returncode == 0
223
+ except (OSError, RuntimeError, subprocess.SubprocessError):
224
+ healthy = False
225
+ self._probe_cache[key] = (now, healthy)
226
+ return healthy
227
+
228
+ def remove(self, spec: BackendSpec) -> None:
229
+ shutil.rmtree(self.environment_dir(spec), ignore_errors=True)
230
+ self._probe_cache.pop((spec.key, spec.revision), None)
231
+
232
+ def _create_environment(self, env_dir: Path, spec: BackendSpec) -> None:
233
+ uv = shutil.which("uv")
234
+ if uv:
235
+ result = subprocess.run(
236
+ [uv, "venv", "--python", spec.python, str(env_dir)],
237
+ text=True,
238
+ capture_output=True,
239
+ timeout=self.timeout_seconds,
240
+ check=False,
241
+ )
242
+ if result.returncode == 0:
243
+ return
244
+ venv.EnvBuilder(with_pip=True, clear=True).create(env_dir)
245
+
246
+ def _install(self, spec: BackendSpec) -> None:
247
+ python = self.python_path(spec)
248
+ requirement = self.install_requirement(spec)
249
+ uv = shutil.which("uv")
250
+ argv = (
251
+ [uv, "pip", "install", "--python", str(python), requirement]
252
+ if uv
253
+ else [str(python), "-m", "pip", "install", requirement]
254
+ )
255
+ process = subprocess.run(
256
+ argv,
257
+ text=True,
258
+ capture_output=True,
259
+ timeout=self.timeout_seconds,
260
+ check=False,
261
+ )
262
+ if process.returncode != 0:
263
+ raise RuntimeError(process.stderr.strip() or process.stdout.strip())
264
+
265
+ def _load_metadata(self, spec: BackendSpec) -> dict[str, object] | None:
266
+ try:
267
+ value = json.loads(self.metadata_path(spec).read_text(encoding="utf-8"))
268
+ except (OSError, json.JSONDecodeError):
269
+ return None
270
+ return value if isinstance(value, dict) else None
271
+
272
+ @staticmethod
273
+ def _git_revision(path: Path) -> str | None:
274
+ try:
275
+ process = subprocess.run(
276
+ ["git", "-C", str(path), "rev-parse", "HEAD"],
277
+ text=True,
278
+ capture_output=True,
279
+ timeout=10,
280
+ check=False,
281
+ )
282
+ except (OSError, subprocess.SubprocessError):
283
+ return None
284
+ revision = process.stdout.strip()
285
+ return revision if process.returncode == 0 and len(revision) == 40 else None
286
+
287
+ def _acquire_lock(self, lock: Path) -> None:
288
+ deadline = time.monotonic() + min(self.timeout_seconds, 120.0)
289
+ while True:
290
+ try:
291
+ lock.mkdir(parents=False)
292
+ return
293
+ except FileExistsError:
294
+ if time.monotonic() >= deadline:
295
+ raise TimeoutError(f"timed out waiting for backend lock: {lock}") from None
296
+ time.sleep(0.1)
297
+
298
+ @staticmethod
299
+ def _release_lock(lock: Path) -> None:
300
+ try:
301
+ lock.rmdir()
302
+ except OSError:
303
+ pass
@@ -0,0 +1,196 @@
1
+ """Small synchronous MCP stdio client used by isolated backend adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import queue
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ from collections.abc import Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from codecortex.backends.manager import BackendManager
16
+ from codecortex.backends.spec import BackendSpec
17
+
18
+
19
+ class MCPError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class MCPStdioClient:
24
+ """Persistent JSON-RPC client for an MCP server using stdio transport."""
25
+
26
+ def __init__(
27
+ self,
28
+ manager: BackendManager,
29
+ spec: BackendSpec,
30
+ server_args: Sequence[str],
31
+ *,
32
+ cwd: Path | None = None,
33
+ env: Mapping[str, str] | None = None,
34
+ timeout_seconds: float = 60.0,
35
+ ) -> None:
36
+ self.manager = manager
37
+ self.spec = spec
38
+ self.server_args = tuple(server_args)
39
+ self.cwd = cwd
40
+ self.env = dict(env or {})
41
+ self.timeout_seconds = timeout_seconds
42
+ self._process: subprocess.Popen[str] | None = None
43
+ self._messages: queue.Queue[dict[str, Any]] = queue.Queue()
44
+ self._stderr: queue.Queue[str] = queue.Queue()
45
+ self._request_id = 0
46
+ self._reader: threading.Thread | None = None
47
+ self._stderr_reader: threading.Thread | None = None
48
+
49
+ def __enter__(self) -> MCPStdioClient:
50
+ self.start()
51
+ return self
52
+
53
+ def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
54
+ self.close()
55
+
56
+ def start(self) -> None:
57
+ if self._process is not None:
58
+ return
59
+ command = self.manager.ensure(self.spec)
60
+ self._process = subprocess.Popen(
61
+ [str(command), *self.server_args],
62
+ cwd=str(self.cwd) if self.cwd else None,
63
+ env={**os.environ, **self.env},
64
+ stdin=subprocess.PIPE,
65
+ stdout=subprocess.PIPE,
66
+ stderr=subprocess.PIPE,
67
+ text=True,
68
+ encoding="utf-8",
69
+ bufsize=1,
70
+ )
71
+ self._reader = threading.Thread(target=self._read_stdout, daemon=True)
72
+ self._stderr_reader = threading.Thread(target=self._read_stderr, daemon=True)
73
+ self._reader.start()
74
+ self._stderr_reader.start()
75
+ self._initialize()
76
+
77
+ def close(self) -> None:
78
+ process = self._process
79
+ self._process = None
80
+ if process is None:
81
+ return
82
+ if process.poll() is None:
83
+ process.terminate()
84
+ try:
85
+ process.wait(timeout=3)
86
+ except subprocess.TimeoutExpired:
87
+ process.kill()
88
+ process.wait(timeout=3)
89
+
90
+ def tools(self) -> list[dict[str, Any]]:
91
+ result = self.request("tools/list", {})
92
+ tools = result.get("tools", []) if isinstance(result, dict) else []
93
+ return [item for item in tools if isinstance(item, dict)]
94
+
95
+ def call_tool(self, name: str, arguments: Mapping[str, Any] | None = None) -> dict[str, Any]:
96
+ result = self.request("tools/call", {"name": name, "arguments": dict(arguments or {})})
97
+ if not isinstance(result, dict):
98
+ raise MCPError(f"tool {name!r} returned a non-object result")
99
+ if result.get("isError"):
100
+ raise MCPError(self._content_text(result) or f"tool {name!r} failed")
101
+ return result
102
+
103
+ def request(self, method: str, params: Mapping[str, Any]) -> Any:
104
+ self._request_id += 1
105
+ request_id = self._request_id
106
+ self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params)})
107
+ deadline = time.monotonic() + self.timeout_seconds
108
+ deferred: list[dict[str, Any]] = []
109
+ try:
110
+ while time.monotonic() < deadline:
111
+ self._raise_if_exited()
112
+ try:
113
+ message = self._messages.get(timeout=min(0.25, max(0.01, deadline - time.monotonic())))
114
+ except queue.Empty:
115
+ continue
116
+ if message.get("id") != request_id:
117
+ deferred.append(message)
118
+ continue
119
+ if "error" in message:
120
+ raise MCPError(str(message["error"]))
121
+ return message.get("result")
122
+ raise TimeoutError(f"MCP request timed out: {method}")
123
+ finally:
124
+ for message in deferred:
125
+ self._messages.put(message)
126
+
127
+ @staticmethod
128
+ def content_text(result: Mapping[str, Any]) -> str:
129
+ return MCPStdioClient._content_text(result)
130
+
131
+ def _initialize(self) -> None:
132
+ protocol = os.getenv("CODECORTEX_MCP_PROTOCOL", "2025-06-18")
133
+ self.request(
134
+ "initialize",
135
+ {
136
+ "protocolVersion": protocol,
137
+ "capabilities": {},
138
+ "clientInfo": {"name": "CodeCortex", "version": "0.1.0"},
139
+ },
140
+ )
141
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
142
+
143
+ def _send(self, payload: Mapping[str, Any]) -> None:
144
+ process = self._process
145
+ if process is None or process.stdin is None:
146
+ raise MCPError("MCP process is not running")
147
+ process.stdin.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n")
148
+ process.stdin.flush()
149
+
150
+ def _read_stdout(self) -> None:
151
+ process = self._process
152
+ if process is None or process.stdout is None:
153
+ return
154
+ for raw in process.stdout:
155
+ line = raw.strip()
156
+ if not line:
157
+ continue
158
+ try:
159
+ message = json.loads(line)
160
+ except json.JSONDecodeError:
161
+ continue
162
+ if isinstance(message, dict):
163
+ self._messages.put(message)
164
+
165
+ def _read_stderr(self) -> None:
166
+ process = self._process
167
+ if process is None or process.stderr is None:
168
+ return
169
+ for raw in process.stderr:
170
+ if raw.strip():
171
+ self._stderr.put(raw.rstrip())
172
+
173
+ def _raise_if_exited(self) -> None:
174
+ process = self._process
175
+ if process is None:
176
+ raise MCPError("MCP process is not running")
177
+ code = process.poll()
178
+ if code is None:
179
+ return
180
+ lines: list[str] = []
181
+ while not self._stderr.empty() and len(lines) < 20:
182
+ lines.append(self._stderr.get_nowait())
183
+ detail = "\n".join(lines)
184
+ raise MCPError(f"MCP server exited with {code}: {detail}".strip())
185
+
186
+ @staticmethod
187
+ def _content_text(result: Mapping[str, Any]) -> str:
188
+ chunks = result.get("content", [])
189
+ texts: list[str] = []
190
+ if isinstance(chunks, list):
191
+ for chunk in chunks:
192
+ if isinstance(chunk, dict) and chunk.get("type") == "text":
193
+ text = chunk.get("text")
194
+ if isinstance(text, str):
195
+ texts.append(text)
196
+ return "\n".join(texts)