llmcode-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,360 @@
1
+ """Plugin registry system — search adapters for official, npm, GitHub and custom sources."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Data models
14
+ # ---------------------------------------------------------------------------
15
+
16
+ @dataclass(frozen=True)
17
+ class PluginSummary:
18
+ """Lightweight summary returned from registry search results."""
19
+
20
+ name: str
21
+ description: str
22
+ registry: str
23
+ version: str = ""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PluginDetails:
28
+ """Detailed information about a single plugin."""
29
+
30
+ name: str
31
+ description: str
32
+ version: str = ""
33
+ repository: str = ""
34
+ install_command: str = ""
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Abstract base
39
+ # ---------------------------------------------------------------------------
40
+
41
+ class PluginRegistry(ABC):
42
+ """Abstract base class for all plugin registries."""
43
+
44
+ @abstractmethod
45
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
46
+ """Search the registry and return matching summaries."""
47
+ ...
48
+
49
+ @abstractmethod
50
+ async def get_details(self, name: str) -> PluginDetails | None:
51
+ """Fetch detailed information about a named plugin."""
52
+ ...
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Official MCP registry
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class OfficialRegistry(PluginRegistry):
60
+ """Adapter for registry.modelcontextprotocol.io."""
61
+
62
+ BASE_URL = "https://registry.modelcontextprotocol.io"
63
+
64
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
65
+ try:
66
+ async with httpx.AsyncClient() as client:
67
+ resp = await client.get(
68
+ f"{self.BASE_URL}/v0/servers",
69
+ params={"q": query},
70
+ timeout=10.0,
71
+ )
72
+ resp.raise_for_status()
73
+ data: dict[str, Any] = resp.json()
74
+ except Exception:
75
+ return []
76
+
77
+ results: list[PluginSummary] = []
78
+ for server in data.get("servers", []):
79
+ name: str = server.get("name", "")
80
+ description: str = server.get("description", "")
81
+ # Filter by query match when the API doesn't do server-side filtering
82
+ if query.lower() in name.lower() or query.lower() in description.lower():
83
+ results.append(
84
+ PluginSummary(
85
+ name=name,
86
+ description=description,
87
+ registry="official",
88
+ version=str(server.get("version", "")),
89
+ )
90
+ )
91
+ if len(results) >= limit:
92
+ break
93
+ return results
94
+
95
+ async def get_details(self, name: str) -> PluginDetails | None:
96
+ try:
97
+ async with httpx.AsyncClient() as client:
98
+ resp = await client.get(
99
+ f"{self.BASE_URL}/v0/servers/{name}",
100
+ timeout=10.0,
101
+ )
102
+ resp.raise_for_status()
103
+ data = resp.json()
104
+ except Exception:
105
+ return None
106
+
107
+ return PluginDetails(
108
+ name=data.get("name", name),
109
+ description=data.get("description", ""),
110
+ version=str(data.get("version", "")),
111
+ repository=str(data.get("repository", "")),
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Smithery registry
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class SmitheryRegistry(PluginRegistry):
120
+ """Adapter for api.smithery.ai."""
121
+
122
+ BASE_URL = "https://api.smithery.ai"
123
+
124
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
125
+ try:
126
+ async with httpx.AsyncClient() as client:
127
+ resp = await client.get(
128
+ f"{self.BASE_URL}/v1/servers",
129
+ params={"q": query},
130
+ timeout=10.0,
131
+ )
132
+ resp.raise_for_status()
133
+ data = resp.json()
134
+ except Exception:
135
+ return []
136
+
137
+ results: list[PluginSummary] = []
138
+ for server in data.get("servers", []):
139
+ results.append(
140
+ PluginSummary(
141
+ name=server.get("name", ""),
142
+ description=server.get("description", ""),
143
+ registry="smithery",
144
+ version=str(server.get("version", "")),
145
+ )
146
+ )
147
+ if len(results) >= limit:
148
+ break
149
+ return results
150
+
151
+ async def get_details(self, name: str) -> PluginDetails | None:
152
+ return None
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # npm registry
157
+ # ---------------------------------------------------------------------------
158
+
159
+ class NpmRegistry(PluginRegistry):
160
+ """Adapter for the npm registry search API."""
161
+
162
+ BASE_URL = "https://registry.npmjs.org"
163
+
164
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
165
+ try:
166
+ async with httpx.AsyncClient() as client:
167
+ resp = await client.get(
168
+ f"{self.BASE_URL}/-/v1/search",
169
+ params={"text": f"mcp {query}", "size": limit},
170
+ timeout=10.0,
171
+ )
172
+ resp.raise_for_status()
173
+ data = resp.json()
174
+ except Exception:
175
+ return []
176
+
177
+ results: list[PluginSummary] = []
178
+ for obj in data.get("objects", []):
179
+ pkg = obj.get("package", {})
180
+ results.append(
181
+ PluginSummary(
182
+ name=pkg.get("name", ""),
183
+ description=pkg.get("description", ""),
184
+ registry="npm",
185
+ version=str(pkg.get("version", "")),
186
+ )
187
+ )
188
+ return results
189
+
190
+ async def get_details(self, name: str) -> PluginDetails | None:
191
+ try:
192
+ async with httpx.AsyncClient() as client:
193
+ resp = await client.get(
194
+ f"{self.BASE_URL}/{name}",
195
+ timeout=10.0,
196
+ )
197
+ resp.raise_for_status()
198
+ data = resp.json()
199
+ except Exception:
200
+ return None
201
+
202
+ latest = data.get("dist-tags", {}).get("latest", "")
203
+ version_data = data.get("versions", {}).get(latest, {})
204
+ return PluginDetails(
205
+ name=data.get("name", name),
206
+ description=data.get("description", ""),
207
+ version=latest,
208
+ repository=str(version_data.get("repository", {}).get("url", "")),
209
+ install_command=f"npm install {name}",
210
+ )
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # GitHub marketplace registry
215
+ # ---------------------------------------------------------------------------
216
+
217
+ class GitHubRegistry(PluginRegistry):
218
+ """Adapter for a GitHub-hosted marketplace.json file."""
219
+
220
+ def __init__(self, repo: str) -> None:
221
+ """Initialize with a 'owner/repo' string."""
222
+ self._repo = repo
223
+
224
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
225
+ url = f"https://raw.githubusercontent.com/{self._repo}/main/marketplace.json"
226
+ try:
227
+ async with httpx.AsyncClient() as client:
228
+ resp = await client.get(url, timeout=10.0)
229
+ resp.raise_for_status()
230
+ data = resp.json()
231
+ except Exception:
232
+ return []
233
+
234
+ results: list[PluginSummary] = []
235
+ for plugin in data.get("plugins", []):
236
+ name: str = plugin.get("name", "")
237
+ description: str = plugin.get("description", "")
238
+ if query.lower() in name.lower() or query.lower() in description.lower():
239
+ results.append(
240
+ PluginSummary(
241
+ name=name,
242
+ description=description,
243
+ registry="github",
244
+ version=str(plugin.get("version", "")),
245
+ )
246
+ )
247
+ if len(results) >= limit:
248
+ break
249
+ return results
250
+
251
+ async def get_details(self, name: str) -> PluginDetails | None:
252
+ url = f"https://raw.githubusercontent.com/{self._repo}/main/marketplace.json"
253
+ try:
254
+ async with httpx.AsyncClient() as client:
255
+ resp = await client.get(url, timeout=10.0)
256
+ resp.raise_for_status()
257
+ data = resp.json()
258
+ except Exception:
259
+ return None
260
+
261
+ for plugin in data.get("plugins", []):
262
+ if plugin.get("name") == name:
263
+ return PluginDetails(
264
+ name=plugin.get("name", ""),
265
+ description=plugin.get("description", ""),
266
+ version=str(plugin.get("version", "")),
267
+ repository=str(plugin.get("repository", "")),
268
+ )
269
+ return None
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Custom URL registry
274
+ # ---------------------------------------------------------------------------
275
+
276
+ class CustomRegistry(PluginRegistry):
277
+ """Adapter for an arbitrary URL serving a plugins[] JSON payload."""
278
+
279
+ def __init__(self, url: str) -> None:
280
+ self._url = url
281
+
282
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
283
+ try:
284
+ async with httpx.AsyncClient() as client:
285
+ resp = await client.get(self._url, timeout=10.0)
286
+ resp.raise_for_status()
287
+ data = resp.json()
288
+ except Exception:
289
+ return []
290
+
291
+ results: list[PluginSummary] = []
292
+ for plugin in data.get("plugins", []):
293
+ name: str = plugin.get("name", "")
294
+ description: str = plugin.get("description", "")
295
+ if query.lower() in name.lower() or query.lower() in description.lower():
296
+ results.append(
297
+ PluginSummary(
298
+ name=name,
299
+ description=description,
300
+ registry="custom",
301
+ version=str(plugin.get("version", "")),
302
+ )
303
+ )
304
+ if len(results) >= limit:
305
+ break
306
+ return results
307
+
308
+ async def get_details(self, name: str) -> PluginDetails | None:
309
+ try:
310
+ async with httpx.AsyncClient() as client:
311
+ resp = await client.get(self._url, timeout=10.0)
312
+ resp.raise_for_status()
313
+ data = resp.json()
314
+ except Exception:
315
+ return None
316
+
317
+ for plugin in data.get("plugins", []):
318
+ if plugin.get("name") == name:
319
+ return PluginDetails(
320
+ name=plugin.get("name", ""),
321
+ description=plugin.get("description", ""),
322
+ version=str(plugin.get("version", "")),
323
+ )
324
+ return None
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Unified registry — parallel search across all registered sources
329
+ # ---------------------------------------------------------------------------
330
+
331
+ class UnifiedRegistry(PluginRegistry):
332
+ """Aggregates multiple registries and searches them in parallel."""
333
+
334
+ def __init__(self, registries: dict[str, PluginRegistry]) -> None:
335
+ self._registries = registries
336
+
337
+ async def search(self, query: str, limit: int = 20) -> list[PluginSummary]:
338
+ if not self._registries:
339
+ return []
340
+
341
+ tasks = [reg.search(query, limit) for reg in self._registries.values()]
342
+ all_results: list[list[PluginSummary]] = await asyncio.gather(*tasks)
343
+
344
+ # Merge and deduplicate by name (first occurrence wins)
345
+ seen: set[str] = set()
346
+ merged: list[PluginSummary] = []
347
+ for batch in all_results:
348
+ for summary in batch:
349
+ if summary.name not in seen:
350
+ seen.add(summary.name)
351
+ merged.append(summary)
352
+
353
+ return merged
354
+
355
+ async def get_details(self, name: str) -> PluginDetails | None:
356
+ for reg in self._registries.values():
357
+ details = await reg.get_details(name)
358
+ if details is not None:
359
+ return details
360
+ return None
File without changes
llm_code/mcp/bridge.py ADDED
@@ -0,0 +1,87 @@
1
+ """McpToolBridge: wraps an MCP tool as a Tool ABC."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import concurrent.futures
6
+
7
+ from llm_code.mcp.client import McpClient
8
+ from llm_code.mcp.types import McpToolDefinition
9
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
10
+
11
+
12
+ class McpToolBridge(Tool):
13
+ """Adapts a remote MCP tool to the local Tool ABC."""
14
+
15
+ def __init__(
16
+ self,
17
+ server_name: str,
18
+ mcp_tool: McpToolDefinition,
19
+ client: McpClient,
20
+ ) -> None:
21
+ self._server_name = server_name
22
+ self._mcp_tool = mcp_tool
23
+ self._client = client
24
+
25
+ # ------------------------------------------------------------------
26
+ # Tool ABC — identity & schema
27
+ # ------------------------------------------------------------------
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ return f"mcp__{self._server_name}__{self._mcp_tool.name}"
32
+
33
+ @property
34
+ def description(self) -> str:
35
+ return self._mcp_tool.description
36
+
37
+ @property
38
+ def input_schema(self) -> dict:
39
+ return self._mcp_tool.input_schema
40
+
41
+ @property
42
+ def required_permission(self) -> PermissionLevel:
43
+ return PermissionLevel.FULL_ACCESS
44
+
45
+ # ------------------------------------------------------------------
46
+ # Tool ABC — behaviour flags
47
+ # ------------------------------------------------------------------
48
+
49
+ def is_read_only(self, args: dict) -> bool:
50
+ return bool((self._mcp_tool.annotations or {}).get("readOnly", False))
51
+
52
+ def is_destructive(self, args: dict) -> bool:
53
+ return bool((self._mcp_tool.annotations or {}).get("destructive", False))
54
+
55
+ def is_concurrency_safe(self, args: dict) -> bool:
56
+ return self.is_read_only(args)
57
+
58
+ # ------------------------------------------------------------------
59
+ # Tool ABC — execution
60
+ # ------------------------------------------------------------------
61
+
62
+ def execute(self, args: dict) -> ToolResult:
63
+ """Call the remote MCP tool synchronously."""
64
+ try:
65
+ mcp_result = self._run_async(self._client.call_tool(self._mcp_tool.name, args))
66
+ return ToolResult(output=mcp_result.content, is_error=mcp_result.is_error)
67
+ except Exception as exc: # noqa: BLE001
68
+ return ToolResult(output=str(exc), is_error=True)
69
+
70
+ # ------------------------------------------------------------------
71
+ # Internal helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ @staticmethod
75
+ def _run_async(coro): # type: ignore[type-arg]
76
+ """Run an async coroutine from a sync context, even inside an event loop."""
77
+ try:
78
+ loop = asyncio.get_running_loop()
79
+ except RuntimeError:
80
+ loop = None
81
+
82
+ if loop is not None and loop.is_running():
83
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
84
+ future = pool.submit(asyncio.run, coro)
85
+ return future.result()
86
+ else:
87
+ return asyncio.run(coro)
llm_code/mcp/client.py ADDED
@@ -0,0 +1,117 @@
1
+ """MCP client implementing JSON-RPC 2.0 over an McpTransport."""
2
+ from __future__ import annotations
3
+
4
+ import itertools
5
+ from typing import Any
6
+
7
+ from .transport import McpTransport
8
+ from .types import McpResource, McpServerInfo, McpToolDefinition, McpToolResult
9
+
10
+ _PROTOCOL_VERSION = "2024-11-05"
11
+
12
+
13
+ class McpClient:
14
+ """High-level MCP client built on top of an McpTransport."""
15
+
16
+ def __init__(self, transport: McpTransport) -> None:
17
+ self._transport = transport
18
+ self._id_counter = itertools.count(1)
19
+
20
+ # ------------------------------------------------------------------
21
+ # Public API
22
+ # ------------------------------------------------------------------
23
+
24
+ async def initialize(self) -> McpServerInfo:
25
+ """Send the MCP initialize request and return parsed server info."""
26
+ result = await self._request(
27
+ "initialize",
28
+ {
29
+ "protocolVersion": _PROTOCOL_VERSION,
30
+ "clientInfo": {"name": "llm-code", "version": "0.1.0"},
31
+ "capabilities": {},
32
+ },
33
+ )
34
+ server_info = result.get("serverInfo", {})
35
+ return McpServerInfo(
36
+ name=server_info.get("name", ""),
37
+ version=server_info.get("version", ""),
38
+ capabilities=result.get("capabilities", {}),
39
+ )
40
+
41
+ async def list_tools(self) -> list[McpToolDefinition]:
42
+ """Retrieve the list of tools exposed by the MCP server."""
43
+ result = await self._request("tools/list", {})
44
+ return [
45
+ McpToolDefinition(
46
+ name=tool["name"],
47
+ description=tool.get("description", ""),
48
+ input_schema=tool.get("inputSchema", {}),
49
+ annotations=tool.get("annotations"),
50
+ )
51
+ for tool in result.get("tools", [])
52
+ ]
53
+
54
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolResult:
55
+ """Call a named tool with the given arguments."""
56
+ result = await self._request(
57
+ "tools/call",
58
+ {"name": name, "arguments": arguments},
59
+ )
60
+ content_list = result.get("content", [])
61
+ text = ""
62
+ for item in content_list:
63
+ if item.get("type") == "text":
64
+ text = item.get("text", "")
65
+ break
66
+ is_error = bool(result.get("isError", False))
67
+ return McpToolResult(content=text, is_error=is_error)
68
+
69
+ async def list_resources(self) -> list[McpResource]:
70
+ """Retrieve the list of resources exposed by the MCP server."""
71
+ result = await self._request("resources/list", {})
72
+ return [
73
+ McpResource(
74
+ uri=resource["uri"],
75
+ name=resource.get("name", ""),
76
+ description=resource.get("description"),
77
+ mime_type=resource.get("mimeType"),
78
+ )
79
+ for resource in result.get("resources", [])
80
+ ]
81
+
82
+ async def read_resource(self, uri: str) -> str:
83
+ """Read the content of a resource by URI, returning text."""
84
+ result = await self._request("resources/read", {"uri": uri})
85
+ contents = result.get("contents", [])
86
+ for item in contents:
87
+ if item.get("type") == "text":
88
+ return item.get("text", "")
89
+ return ""
90
+
91
+ async def close(self) -> None:
92
+ """Close the underlying transport."""
93
+ await self._transport.close()
94
+
95
+ # ------------------------------------------------------------------
96
+ # Internal helpers
97
+ # ------------------------------------------------------------------
98
+
99
+ async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
100
+ """Build and send a JSON-RPC 2.0 request, returning the result dict."""
101
+ request_id = next(self._id_counter)
102
+ message: dict[str, Any] = {
103
+ "jsonrpc": "2.0",
104
+ "id": request_id,
105
+ "method": method,
106
+ "params": params,
107
+ }
108
+ await self._transport.send(message)
109
+ response = await self._transport.receive()
110
+
111
+ if "error" in response:
112
+ error = response["error"]
113
+ raise RuntimeError(
114
+ f"MCP error {error.get('code')}: {error.get('message', 'Unknown error')}"
115
+ )
116
+
117
+ return response.get("result", {})
llm_code/mcp/health.py ADDED
@@ -0,0 +1,120 @@
1
+ """MCP server health checking and background monitoring."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from llm_code.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ _HEALTH_CHECK_TIMEOUT = 5.0 # seconds
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class HealthStatus:
17
+ """Health status snapshot for a single MCP server."""
18
+
19
+ name: str
20
+ alive: bool
21
+ latency_ms: float
22
+ error: str | None
23
+ last_checked: float
24
+
25
+
26
+ class MCPHealthChecker:
27
+ """Check and monitor the health of MCP server connections."""
28
+
29
+ def __init__(self) -> None:
30
+ self._monitor_task: asyncio.Task | None = None # type: ignore[type-arg]
31
+ self._latest: dict[str, HealthStatus] = {}
32
+
33
+ # ------------------------------------------------------------------
34
+ # Public API
35
+ # ------------------------------------------------------------------
36
+
37
+ async def check_server(self, name: str, client) -> HealthStatus: # type: ignore[type-arg]
38
+ """Probe *client* with a listTools call (5 s timeout).
39
+
40
+ Returns a :class:`HealthStatus` reflecting whether the server is alive.
41
+ """
42
+ start = time.monotonic()
43
+ try:
44
+ await asyncio.wait_for(client.list_tools(), timeout=_HEALTH_CHECK_TIMEOUT)
45
+ latency_ms = (time.monotonic() - start) * 1000
46
+ status = HealthStatus(
47
+ name=name,
48
+ alive=True,
49
+ latency_ms=latency_ms,
50
+ error=None,
51
+ last_checked=time.time(),
52
+ )
53
+ except asyncio.TimeoutError:
54
+ latency_ms = (time.monotonic() - start) * 1000
55
+ status = HealthStatus(
56
+ name=name,
57
+ alive=False,
58
+ latency_ms=latency_ms,
59
+ error="timeout",
60
+ last_checked=time.time(),
61
+ )
62
+ logger.warning("MCP server '%s' health check timed out", name)
63
+ except Exception as exc: # noqa: BLE001
64
+ latency_ms = (time.monotonic() - start) * 1000
65
+ status = HealthStatus(
66
+ name=name,
67
+ alive=False,
68
+ latency_ms=latency_ms,
69
+ error=str(exc),
70
+ last_checked=time.time(),
71
+ )
72
+ logger.warning("MCP server '%s' health check failed: %s", name, exc)
73
+
74
+ self._latest[name] = status
75
+ return status
76
+
77
+ async def check_all(self, servers: dict) -> list[HealthStatus]: # type: ignore[type-arg]
78
+ """Check all servers concurrently.
79
+
80
+ *servers* maps server name → client.
81
+ """
82
+ if not servers:
83
+ return []
84
+ tasks = [self.check_server(name, client) for name, client in servers.items()]
85
+ return list(await asyncio.gather(*tasks))
86
+
87
+ def start_background_monitor(self, servers: dict, interval: float = 60.0) -> None: # type: ignore[type-arg]
88
+ """Start an asyncio background task that polls *servers* every *interval* seconds."""
89
+ if self._monitor_task is not None and not self._monitor_task.done():
90
+ return
91
+
92
+ async def _monitor() -> None:
93
+ while True:
94
+ previous = {name: s.alive for name, s in self._latest.items()}
95
+ await self.check_all(servers)
96
+ for name, status in self._latest.items():
97
+ was_alive = previous.get(name)
98
+ if was_alive is None:
99
+ continue
100
+ if was_alive and not status.alive:
101
+ logger.warning("MCP server '%s' became unhealthy: %s", name, status.error)
102
+ elif not was_alive and status.alive:
103
+ logger.info("MCP server '%s' recovered", name)
104
+ await asyncio.sleep(interval)
105
+
106
+ self._monitor_task = asyncio.ensure_future(_monitor())
107
+
108
+ def stop_monitor(self) -> None:
109
+ """Cancel the background monitoring task if running."""
110
+ if self._monitor_task is not None and not self._monitor_task.done():
111
+ self._monitor_task.cancel()
112
+ self._monitor_task = None
113
+
114
+ def get_status(self, name: str) -> HealthStatus | None:
115
+ """Return the most recent :class:`HealthStatus` for *name*, or ``None``."""
116
+ return self._latest.get(name)
117
+
118
+ def get_all_statuses(self) -> dict[str, HealthStatus]:
119
+ """Return all known health statuses."""
120
+ return dict(self._latest)