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
llm_code/mcp/manager.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""McpServerManager: lifecycle management for MCP server connections."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
from llm_code.logging import get_logger
|
|
8
|
+
from llm_code.mcp.bridge import McpToolBridge
|
|
9
|
+
from llm_code.mcp.client import McpClient
|
|
10
|
+
from llm_code.mcp.health import MCPHealthChecker
|
|
11
|
+
from llm_code.mcp.transport import HttpTransport, McpTransport, SseTransport, StdioTransport, WebSocketTransport
|
|
12
|
+
from llm_code.mcp.types import McpServerConfig
|
|
13
|
+
from llm_code.tools.registry import ToolRegistry
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
# Exponential backoff: 5s, 10s, 20s, 40s → capped at 60s
|
|
18
|
+
_BACKOFF_BASE = 5.0
|
|
19
|
+
_BACKOFF_MAX = 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _backoff_delay(attempt: int) -> float:
|
|
23
|
+
"""Return the delay in seconds for *attempt* (0-indexed), capped at _BACKOFF_MAX."""
|
|
24
|
+
return min(_BACKOFF_BASE * (2 ** attempt), _BACKOFF_MAX)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class McpServerManager:
|
|
28
|
+
"""Manages the lifecycle of MCP server connections and tool registration."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._transports: dict[str, McpTransport] = {}
|
|
32
|
+
self._clients: dict[str, McpClient] = {}
|
|
33
|
+
self._configs: dict[str, McpServerConfig] = {}
|
|
34
|
+
self._instructions: dict[str, str] = {}
|
|
35
|
+
self._health: MCPHealthChecker = MCPHealthChecker()
|
|
36
|
+
# Track consecutive reconnect failures for backoff
|
|
37
|
+
self._reconnect_failures: dict[str, int] = {}
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Connection management
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async def start_server(self, name: str, config: McpServerConfig) -> McpClient:
|
|
44
|
+
"""Start a single MCP server, returning an initialised McpClient."""
|
|
45
|
+
logger.debug("Starting MCP server: %s", name)
|
|
46
|
+
transport = self._build_transport(config)
|
|
47
|
+
await transport.start()
|
|
48
|
+
client = McpClient(transport)
|
|
49
|
+
info = await client.initialize()
|
|
50
|
+
self._transports[name] = transport
|
|
51
|
+
self._clients[name] = client
|
|
52
|
+
self._configs[name] = config
|
|
53
|
+
self._reconnect_failures[name] = 0
|
|
54
|
+
|
|
55
|
+
# Extract server instructions from capabilities if present
|
|
56
|
+
capabilities = info.capabilities or {}
|
|
57
|
+
instructions = capabilities.get("instructions", "")
|
|
58
|
+
if instructions:
|
|
59
|
+
self._instructions[name] = instructions
|
|
60
|
+
|
|
61
|
+
logger.debug("MCP server started: %s", name)
|
|
62
|
+
return client
|
|
63
|
+
|
|
64
|
+
async def start_all(self, configs: dict[str, McpServerConfig]) -> None:
|
|
65
|
+
"""Start all servers defined in *configs*, logging warnings on failure."""
|
|
66
|
+
for name, config in configs.items():
|
|
67
|
+
try:
|
|
68
|
+
await self.start_server(name, config)
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
logger.warning("Failed to start MCP server '%s': %s", name, exc)
|
|
71
|
+
warnings.warn(
|
|
72
|
+
f"Failed to start MCP server '{name}': {exc}",
|
|
73
|
+
stacklevel=2,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def stop_all(self) -> None:
|
|
77
|
+
"""Close all active clients and clear internal state."""
|
|
78
|
+
logger.debug("Stopping all MCP servers (%d active)", len(self._clients))
|
|
79
|
+
self._health.stop_monitor()
|
|
80
|
+
for name, client in list(self._clients.items()):
|
|
81
|
+
try:
|
|
82
|
+
await client.close()
|
|
83
|
+
logger.debug("MCP server stopped: %s", name)
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
logger.warning("Error stopping MCP server '%s': %s", name, exc)
|
|
86
|
+
self._clients.clear()
|
|
87
|
+
self._transports.clear()
|
|
88
|
+
|
|
89
|
+
def get_client(self, name: str) -> McpClient | None:
|
|
90
|
+
"""Return the client for *name*, or None if not registered."""
|
|
91
|
+
return self._clients.get(name)
|
|
92
|
+
|
|
93
|
+
def get_all_instructions(self) -> dict[str, str]:
|
|
94
|
+
"""Return a mapping of server name → instructions for all servers that provided them."""
|
|
95
|
+
return {k: v for k, v in self._instructions.items() if v}
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Health checking
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def health(self) -> MCPHealthChecker:
|
|
103
|
+
"""Return the :class:`MCPHealthChecker` instance."""
|
|
104
|
+
return self._health
|
|
105
|
+
|
|
106
|
+
async def check_server_health(self, name: str) -> bool:
|
|
107
|
+
"""Check health of a single server. Returns True if alive."""
|
|
108
|
+
client = self._clients.get(name)
|
|
109
|
+
if client is None:
|
|
110
|
+
return False
|
|
111
|
+
status = await self._health.check_server(name, client)
|
|
112
|
+
return status.alive
|
|
113
|
+
|
|
114
|
+
async def check_all_health(self): # type: ignore[return]
|
|
115
|
+
"""Check health of all connected servers concurrently."""
|
|
116
|
+
return await self._health.check_all(self._clients)
|
|
117
|
+
|
|
118
|
+
def start_health_monitor(self, interval: float = 60.0) -> None:
|
|
119
|
+
"""Start background health monitoring for all current clients."""
|
|
120
|
+
self._health.start_background_monitor(self._clients, interval=interval)
|
|
121
|
+
|
|
122
|
+
async def ensure_healthy(self, name: str) -> McpClient:
|
|
123
|
+
"""Return a healthy client for *name*, attempting reconnection once on failure.
|
|
124
|
+
|
|
125
|
+
Raises :class:`RuntimeError` if the server is not connected or cannot be
|
|
126
|
+
reconnected. Uses exponential backoff on repeated reconnect failures.
|
|
127
|
+
"""
|
|
128
|
+
client = self._clients.get(name)
|
|
129
|
+
if client is None:
|
|
130
|
+
raise RuntimeError(f"MCP server '{name}' is not connected")
|
|
131
|
+
|
|
132
|
+
# Quick health probe
|
|
133
|
+
status = await self._health.check_server(name, client)
|
|
134
|
+
if status.alive:
|
|
135
|
+
self._reconnect_failures[name] = 0
|
|
136
|
+
return client
|
|
137
|
+
|
|
138
|
+
# Server appears unhealthy — attempt one reconnect
|
|
139
|
+
logger.warning("MCP server '%s' is unhealthy (%s), attempting reconnect", name, status.error)
|
|
140
|
+
config = self._configs.get(name)
|
|
141
|
+
if config is None:
|
|
142
|
+
raise RuntimeError(f"No config stored for MCP server '{name}' — cannot reconnect")
|
|
143
|
+
|
|
144
|
+
failures = self._reconnect_failures.get(name, 0)
|
|
145
|
+
if failures > 0:
|
|
146
|
+
delay = _backoff_delay(failures - 1)
|
|
147
|
+
logger.debug("Backoff %.0fs before reconnecting '%s' (attempt %d)", delay, name, failures + 1)
|
|
148
|
+
await asyncio.sleep(delay)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Close stale connection
|
|
152
|
+
try:
|
|
153
|
+
await client.close()
|
|
154
|
+
except Exception: # noqa: BLE001
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
new_client = await self.start_server(name, config)
|
|
158
|
+
self._reconnect_failures[name] = 0
|
|
159
|
+
logger.info("MCP server '%s' reconnected successfully", name)
|
|
160
|
+
return new_client
|
|
161
|
+
except Exception as exc: # noqa: BLE001
|
|
162
|
+
self._reconnect_failures[name] = failures + 1
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"Failed to reconnect MCP server '{name}': {exc}"
|
|
165
|
+
) from exc
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Tool registration
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async def register_all_tools(self, registry: ToolRegistry) -> int:
|
|
172
|
+
"""Discover and register MCP tools from all active servers.
|
|
173
|
+
|
|
174
|
+
Returns the total number of tools registered.
|
|
175
|
+
"""
|
|
176
|
+
total = 0
|
|
177
|
+
for server_name, client in self._clients.items():
|
|
178
|
+
tools = await client.list_tools()
|
|
179
|
+
for mcp_tool in tools:
|
|
180
|
+
bridge = McpToolBridge(server_name, mcp_tool, client)
|
|
181
|
+
registry.register(bridge)
|
|
182
|
+
total += 1
|
|
183
|
+
return total
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Internal helpers
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _build_transport(config: McpServerConfig) -> McpTransport:
|
|
191
|
+
"""Build the appropriate transport from *config*.
|
|
192
|
+
|
|
193
|
+
Supported ``transport_type`` values:
|
|
194
|
+
- ``"stdio"`` (default) — subprocess stdin/stdout
|
|
195
|
+
- ``"http"`` — HTTP POST requests
|
|
196
|
+
- ``"sse"`` — Server-Sent Events over HTTP
|
|
197
|
+
- ``"ws"`` / ``"websocket"`` — WebSocket (requires ``websockets`` package)
|
|
198
|
+
"""
|
|
199
|
+
if config.transport_type == "http" and config.url:
|
|
200
|
+
return HttpTransport(url=config.url, headers=config.headers)
|
|
201
|
+
if config.transport_type == "sse" and config.url:
|
|
202
|
+
return SseTransport(url=config.url, headers=config.headers)
|
|
203
|
+
if config.transport_type in ("ws", "websocket") and config.url:
|
|
204
|
+
return WebSocketTransport(url=config.url, headers=config.headers)
|
|
205
|
+
if config.command:
|
|
206
|
+
return StdioTransport(
|
|
207
|
+
command=config.command,
|
|
208
|
+
args=config.args,
|
|
209
|
+
env=config.env,
|
|
210
|
+
)
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Cannot build transport from config: transport_type={config.transport_type!r}, "
|
|
213
|
+
f"command={config.command!r}, url={config.url!r}"
|
|
214
|
+
)
|
llm_code/mcp/oauth.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""OAuth 2.0 Authorization Code + PKCE flow for MCP server authentication."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import webbrowser
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class OAuthToken:
|
|
22
|
+
access_token: str
|
|
23
|
+
token_type: str = "Bearer"
|
|
24
|
+
expires_at: float = 0.0
|
|
25
|
+
refresh_token: str = ""
|
|
26
|
+
scope: str = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_expired(self) -> bool:
|
|
30
|
+
return time.time() >= self.expires_at - 60 # 60s buffer
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"access_token": self.access_token,
|
|
35
|
+
"token_type": self.token_type,
|
|
36
|
+
"expires_at": self.expires_at,
|
|
37
|
+
"refresh_token": self.refresh_token,
|
|
38
|
+
"scope": self.scope,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, data: dict) -> OAuthToken:
|
|
43
|
+
return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OAuthClient:
|
|
47
|
+
"""OAuth 2.0 with PKCE for MCP server authentication."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client_id: str,
|
|
52
|
+
authorization_url: str,
|
|
53
|
+
token_url: str,
|
|
54
|
+
redirect_uri: str = "http://localhost:9876/callback",
|
|
55
|
+
scope: str = "",
|
|
56
|
+
) -> None:
|
|
57
|
+
self._client_id = client_id
|
|
58
|
+
self._auth_url = authorization_url
|
|
59
|
+
self._token_url = token_url
|
|
60
|
+
self._redirect_uri = redirect_uri
|
|
61
|
+
self._scope = scope
|
|
62
|
+
self._token_dir = Path.home() / ".llm-code" / "tokens"
|
|
63
|
+
self._token_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
def get_token(self, server_name: str) -> OAuthToken | None:
|
|
66
|
+
"""Get a valid token, refreshing if needed."""
|
|
67
|
+
token = self._load_token(server_name)
|
|
68
|
+
if token and not token.is_expired:
|
|
69
|
+
return token
|
|
70
|
+
if token and token.refresh_token:
|
|
71
|
+
refreshed = self._refresh_token(token)
|
|
72
|
+
if refreshed:
|
|
73
|
+
self._save_token(server_name, refreshed)
|
|
74
|
+
return refreshed
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
async def authorize(self, server_name: str) -> OAuthToken:
|
|
78
|
+
"""Run the full OAuth PKCE flow."""
|
|
79
|
+
# Generate PKCE challenge
|
|
80
|
+
verifier = secrets.token_urlsafe(64)
|
|
81
|
+
challenge = (
|
|
82
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
83
|
+
.rstrip(b"=")
|
|
84
|
+
.decode()
|
|
85
|
+
)
|
|
86
|
+
state = secrets.token_urlsafe(32)
|
|
87
|
+
|
|
88
|
+
# Build authorization URL
|
|
89
|
+
params: dict[str, str] = {
|
|
90
|
+
"client_id": self._client_id,
|
|
91
|
+
"response_type": "code",
|
|
92
|
+
"redirect_uri": self._redirect_uri,
|
|
93
|
+
"state": state,
|
|
94
|
+
"code_challenge": challenge,
|
|
95
|
+
"code_challenge_method": "S256",
|
|
96
|
+
}
|
|
97
|
+
if self._scope:
|
|
98
|
+
params["scope"] = self._scope
|
|
99
|
+
|
|
100
|
+
auth_url = f"{self._auth_url}?{urllib.parse.urlencode(params)}"
|
|
101
|
+
|
|
102
|
+
# Open browser
|
|
103
|
+
print("Opening browser for authorization...")
|
|
104
|
+
print(f"If it doesn't open, visit: {auth_url}")
|
|
105
|
+
webbrowser.open(auth_url)
|
|
106
|
+
|
|
107
|
+
# Start local server to receive callback
|
|
108
|
+
code = await self._wait_for_callback(state)
|
|
109
|
+
|
|
110
|
+
# Exchange code for token
|
|
111
|
+
token = await self._exchange_code(code, verifier)
|
|
112
|
+
self._save_token(server_name, token)
|
|
113
|
+
return token
|
|
114
|
+
|
|
115
|
+
async def _exchange_code(self, code: str, verifier: str) -> OAuthToken:
|
|
116
|
+
"""Exchange authorization code for tokens."""
|
|
117
|
+
async with httpx.AsyncClient() as client:
|
|
118
|
+
resp = await client.post(
|
|
119
|
+
self._token_url,
|
|
120
|
+
data={
|
|
121
|
+
"grant_type": "authorization_code",
|
|
122
|
+
"client_id": self._client_id,
|
|
123
|
+
"code": code,
|
|
124
|
+
"redirect_uri": self._redirect_uri,
|
|
125
|
+
"code_verifier": verifier,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
data = resp.json()
|
|
130
|
+
|
|
131
|
+
return OAuthToken(
|
|
132
|
+
access_token=data["access_token"],
|
|
133
|
+
token_type=data.get("token_type", "Bearer"),
|
|
134
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
135
|
+
refresh_token=data.get("refresh_token", ""),
|
|
136
|
+
scope=data.get("scope", ""),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _refresh_token(self, token: OAuthToken) -> OAuthToken | None:
|
|
140
|
+
"""Refresh an expired token synchronously."""
|
|
141
|
+
try:
|
|
142
|
+
with httpx.Client() as client:
|
|
143
|
+
resp = client.post(
|
|
144
|
+
self._token_url,
|
|
145
|
+
data={
|
|
146
|
+
"grant_type": "refresh_token",
|
|
147
|
+
"client_id": self._client_id,
|
|
148
|
+
"refresh_token": token.refresh_token,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
data = resp.json()
|
|
153
|
+
return OAuthToken(
|
|
154
|
+
access_token=data["access_token"],
|
|
155
|
+
token_type=data.get("token_type", "Bearer"),
|
|
156
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
157
|
+
refresh_token=data.get("refresh_token", token.refresh_token),
|
|
158
|
+
scope=data.get("scope", token.scope),
|
|
159
|
+
)
|
|
160
|
+
except Exception: # noqa: BLE001
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
async def _wait_for_callback(self, expected_state: str) -> str:
|
|
164
|
+
"""Start a local HTTP server and wait for the OAuth callback."""
|
|
165
|
+
code_holder: list[str] = []
|
|
166
|
+
|
|
167
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
168
|
+
def do_GET(self) -> None: # noqa: N802
|
|
169
|
+
params = urllib.parse.parse_qs(
|
|
170
|
+
urllib.parse.urlparse(self.path).query
|
|
171
|
+
)
|
|
172
|
+
if (
|
|
173
|
+
params.get("state", [""])[0] == expected_state
|
|
174
|
+
and "code" in params
|
|
175
|
+
):
|
|
176
|
+
code_holder.append(params["code"][0])
|
|
177
|
+
self.send_response(200)
|
|
178
|
+
self.end_headers()
|
|
179
|
+
self.wfile.write(
|
|
180
|
+
b"Authorization successful! You can close this tab."
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
self.send_response(400)
|
|
184
|
+
self.end_headers()
|
|
185
|
+
self.wfile.write(b"Invalid callback.")
|
|
186
|
+
|
|
187
|
+
def log_message(self, *args: object) -> None: # noqa: ANN002
|
|
188
|
+
pass # Suppress log output
|
|
189
|
+
|
|
190
|
+
port = int(urllib.parse.urlparse(self._redirect_uri).port or 9876)
|
|
191
|
+
server = HTTPServer(("localhost", port), CallbackHandler)
|
|
192
|
+
|
|
193
|
+
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
194
|
+
thread.start()
|
|
195
|
+
|
|
196
|
+
# Wait for callback (max 120 seconds)
|
|
197
|
+
for _ in range(1200):
|
|
198
|
+
if code_holder:
|
|
199
|
+
break
|
|
200
|
+
await asyncio.sleep(0.1)
|
|
201
|
+
|
|
202
|
+
server.server_close()
|
|
203
|
+
|
|
204
|
+
if not code_holder:
|
|
205
|
+
raise TimeoutError("OAuth callback timed out")
|
|
206
|
+
return code_holder[0]
|
|
207
|
+
|
|
208
|
+
def _save_token(self, server_name: str, token: OAuthToken) -> None:
|
|
209
|
+
path = self._token_dir / f"{server_name}.json"
|
|
210
|
+
path.write_text(json.dumps(token.to_dict()))
|
|
211
|
+
|
|
212
|
+
def _load_token(self, server_name: str) -> OAuthToken | None:
|
|
213
|
+
path = self._token_dir / f"{server_name}.json"
|
|
214
|
+
if path.exists():
|
|
215
|
+
try:
|
|
216
|
+
return OAuthToken.from_dict(json.loads(path.read_text()))
|
|
217
|
+
except Exception: # noqa: BLE001
|
|
218
|
+
return None
|
|
219
|
+
return None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""MCP transport layer: ABC, StdioTransport, HttpTransport, SseTransport, WebSocketTransport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class McpTransport(ABC):
|
|
14
|
+
"""Abstract base class for MCP transports."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def start(self) -> None:
|
|
18
|
+
"""Start the transport (connect or launch subprocess)."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
22
|
+
"""Send a JSON message."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def receive(self) -> dict[str, Any]:
|
|
26
|
+
"""Receive and return the next JSON message."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
"""Close and clean up the transport."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StdioTransport(McpTransport):
|
|
34
|
+
"""MCP transport that communicates via subprocess stdin/stdout."""
|
|
35
|
+
|
|
36
|
+
RECEIVE_TIMEOUT = 30.0
|
|
37
|
+
CLOSE_WAIT_TIMEOUT = 5.0
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
command: str,
|
|
42
|
+
args: tuple[str, ...] = (),
|
|
43
|
+
env: dict[str, str] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.command = command
|
|
46
|
+
self.args = args
|
|
47
|
+
self.env = env
|
|
48
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
49
|
+
|
|
50
|
+
async def start(self) -> None:
|
|
51
|
+
"""Launch the subprocess with merged environment."""
|
|
52
|
+
merged_env = {**os.environ}
|
|
53
|
+
if self.env:
|
|
54
|
+
merged_env.update(self.env)
|
|
55
|
+
|
|
56
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
57
|
+
self.command,
|
|
58
|
+
*self.args,
|
|
59
|
+
stdin=asyncio.subprocess.PIPE,
|
|
60
|
+
stdout=asyncio.subprocess.PIPE,
|
|
61
|
+
stderr=asyncio.subprocess.PIPE,
|
|
62
|
+
env=merged_env,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
66
|
+
"""Write JSON + newline to subprocess stdin."""
|
|
67
|
+
if self._process is None or self._process.stdin is None:
|
|
68
|
+
raise RuntimeError("Transport not started")
|
|
69
|
+
data = json.dumps(message) + "\n"
|
|
70
|
+
self._process.stdin.write(data.encode())
|
|
71
|
+
await self._process.stdin.drain()
|
|
72
|
+
|
|
73
|
+
async def receive(self) -> dict[str, Any]:
|
|
74
|
+
"""Read a line from subprocess stdout with timeout, parse JSON."""
|
|
75
|
+
if self._process is None or self._process.stdout is None:
|
|
76
|
+
raise RuntimeError("Transport not started")
|
|
77
|
+
line = await asyncio.wait_for(
|
|
78
|
+
self._process.stdout.readline(),
|
|
79
|
+
timeout=self.RECEIVE_TIMEOUT,
|
|
80
|
+
)
|
|
81
|
+
return json.loads(line.decode().strip())
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Terminate subprocess gracefully, kill if needed."""
|
|
85
|
+
if self._process is None:
|
|
86
|
+
return
|
|
87
|
+
process = self._process
|
|
88
|
+
self._process = None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if process.stdin and not process.stdin.is_closing():
|
|
92
|
+
process.stdin.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
process.terminate()
|
|
98
|
+
except (ProcessLookupError, OSError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
await asyncio.wait_for(process.wait(), timeout=self.CLOSE_WAIT_TIMEOUT)
|
|
103
|
+
except asyncio.TimeoutError:
|
|
104
|
+
try:
|
|
105
|
+
process.kill()
|
|
106
|
+
except (ProcessLookupError, OSError):
|
|
107
|
+
pass
|
|
108
|
+
try:
|
|
109
|
+
await process.wait()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class HttpTransport(McpTransport):
|
|
115
|
+
"""MCP transport that communicates via HTTP POST requests."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
url: str,
|
|
120
|
+
headers: dict[str, str] | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.url = url
|
|
123
|
+
self.headers = headers
|
|
124
|
+
self._client: httpx.AsyncClient | None = None
|
|
125
|
+
self._last_response: dict[str, Any] | None = None
|
|
126
|
+
|
|
127
|
+
async def start(self) -> None:
|
|
128
|
+
"""Create the HTTP client."""
|
|
129
|
+
self._client = httpx.AsyncClient(headers=self.headers or {})
|
|
130
|
+
|
|
131
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
132
|
+
"""POST the JSON message to the server URL and store the response."""
|
|
133
|
+
if self._client is None:
|
|
134
|
+
raise RuntimeError("Transport not started")
|
|
135
|
+
response = await self._client.post(self.url, json=message)
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
self._last_response = response.json()
|
|
138
|
+
|
|
139
|
+
async def receive(self) -> dict[str, Any]:
|
|
140
|
+
"""Return the stored response from the last send()."""
|
|
141
|
+
if self._last_response is None:
|
|
142
|
+
raise RuntimeError("No response available; call send() first")
|
|
143
|
+
result = self._last_response
|
|
144
|
+
self._last_response = None
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
async def close(self) -> None:
|
|
148
|
+
"""Close the HTTP client."""
|
|
149
|
+
if self._client is None:
|
|
150
|
+
return
|
|
151
|
+
client = self._client
|
|
152
|
+
self._client = None
|
|
153
|
+
await client.aclose()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SseTransport(McpTransport):
|
|
157
|
+
"""SSE (Server-Sent Events) transport for MCP.
|
|
158
|
+
|
|
159
|
+
Server-Sent Events are server→client by nature; outbound messages are
|
|
160
|
+
sent via HTTP POST to the same URL, and responses are queued for
|
|
161
|
+
retrieval via ``receive()``.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
url: str,
|
|
167
|
+
headers: dict[str, str] | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._url = url
|
|
170
|
+
self._headers: dict[str, str] = headers or {}
|
|
171
|
+
self._client: httpx.AsyncClient | None = None
|
|
172
|
+
self._response_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
173
|
+
|
|
174
|
+
async def start(self) -> None:
|
|
175
|
+
"""Create the HTTP client with SSE accept header."""
|
|
176
|
+
self._client = httpx.AsyncClient(
|
|
177
|
+
headers={"Accept": "text/event-stream", **self._headers},
|
|
178
|
+
timeout=httpx.Timeout(30.0),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
182
|
+
"""Send a message via HTTP POST and enqueue the response."""
|
|
183
|
+
if self._client is None:
|
|
184
|
+
raise RuntimeError("Transport not started")
|
|
185
|
+
resp = await self._client.post(self._url, json=message)
|
|
186
|
+
resp.raise_for_status()
|
|
187
|
+
await self._response_queue.put(resp.json())
|
|
188
|
+
|
|
189
|
+
async def receive(self) -> dict[str, Any]:
|
|
190
|
+
"""Return the next queued response."""
|
|
191
|
+
return await self._response_queue.get()
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
"""Close the HTTP client."""
|
|
195
|
+
if self._client is None:
|
|
196
|
+
return
|
|
197
|
+
client = self._client
|
|
198
|
+
self._client = None
|
|
199
|
+
await client.aclose()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class WebSocketTransport(McpTransport):
|
|
203
|
+
"""WebSocket transport for MCP.
|
|
204
|
+
|
|
205
|
+
Requires the optional ``websockets`` package::
|
|
206
|
+
|
|
207
|
+
pip install websockets
|
|
208
|
+
# or: pip install llm-code[websocket]
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
url: str,
|
|
214
|
+
headers: dict[str, str] | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
self._url = url
|
|
217
|
+
self._headers: dict[str, str] = headers or {}
|
|
218
|
+
self._ws: Any = None # websockets.WebSocketClientProtocol
|
|
219
|
+
|
|
220
|
+
async def start(self) -> None:
|
|
221
|
+
"""Connect to the WebSocket server."""
|
|
222
|
+
try:
|
|
223
|
+
import websockets # type: ignore[import-untyped]
|
|
224
|
+
except ImportError as exc:
|
|
225
|
+
raise ImportError(
|
|
226
|
+
"WebSocket transport requires the 'websockets' package: "
|
|
227
|
+
"pip install websockets"
|
|
228
|
+
) from exc
|
|
229
|
+
|
|
230
|
+
self._ws = await websockets.connect(
|
|
231
|
+
self._url,
|
|
232
|
+
additional_headers=self._headers,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
236
|
+
"""Send a JSON message over the WebSocket."""
|
|
237
|
+
if self._ws is None:
|
|
238
|
+
raise RuntimeError("WebSocket not connected")
|
|
239
|
+
await self._ws.send(json.dumps(message))
|
|
240
|
+
|
|
241
|
+
async def receive(self) -> dict[str, Any]:
|
|
242
|
+
"""Receive and parse the next JSON message from the WebSocket."""
|
|
243
|
+
if self._ws is None:
|
|
244
|
+
raise RuntimeError("WebSocket not connected")
|
|
245
|
+
data = await self._ws.recv()
|
|
246
|
+
return json.loads(data) # type: ignore[no-any-return]
|
|
247
|
+
|
|
248
|
+
async def close(self) -> None:
|
|
249
|
+
"""Close the WebSocket connection."""
|
|
250
|
+
if self._ws is None:
|
|
251
|
+
return
|
|
252
|
+
ws = self._ws
|
|
253
|
+
self._ws = None
|
|
254
|
+
await ws.close()
|