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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
llm_code/mcp/__init__.py
ADDED
|
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)
|