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,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()