localcode 0.2.16__py3-none-any.whl → 0.3.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.
localcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.16"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,84 @@
1
+ """MCP (Model Context Protocol) client + tool-registration.
2
+
3
+ Lets a user add MCP servers to LocalCode in `~/.localcode/mcp.json`.
4
+ Each server's tools get auto-registered with the agent so the model
5
+ can call them like any other tool.
6
+
7
+ This package is built on the **official `mcp` Python SDK** (the
8
+ `modelcontextprotocol` project). The SDK is fully async (anyio), but
9
+ LocalCode's toolkit/agent are synchronous, so this package owns an
10
+ async↔sync bridge: a single dedicated background thread runs a
11
+ persistent asyncio event loop, and every public method submits a
12
+ coroutine to that loop via `asyncio.run_coroutine_threadsafe(...)`.
13
+ Each server's transport context + `ClientSession` are opened once and
14
+ kept alive on that loop for the process lifetime; they're torn down in
15
+ `shutdown_all()` / `MCPClient.close()`.
16
+
17
+ Config shape — `~/.localcode/mcp.json`, key `mcpServers`, per server:
18
+
19
+ {
20
+ "mcpServers": {
21
+ "filesystem": { # stdio (default transport)
22
+ "command": "npx",
23
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me"],
24
+ "env": {}
25
+ },
26
+ "remote-http": { # streamable HTTP
27
+ "transport": "http",
28
+ "url": "https://example.com/mcp",
29
+ "headers": {"Authorization": "Bearer ..."},
30
+ "oauth": true # optional, http/sse only
31
+ },
32
+ "remote-sse": { # legacy SSE
33
+ "transport": "sse",
34
+ "url": "https://example.com/sse",
35
+ "headers": {}
36
+ }
37
+ }
38
+ }
39
+
40
+ Transport is chosen by the `transport` field; it defaults to "stdio" so
41
+ existing stdio configs (no `transport` key) keep working unchanged.
42
+
43
+ We use `initialize`, `tools/list`, and `tools/call`. No prompts,
44
+ resources, sampling, or notifications — good enough for the common
45
+ "let the agent use my MCP server" case.
46
+
47
+ This is a package; the implementation is split across `_bridge`,
48
+ `_transports`, `_config`, and `client` submodules. The full public API
49
+ is re-exported here so `from localcode.mcp import ...` is unchanged.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ from ._bridge import _CONNECT_TIMEOUT, _DEFAULT_TIMEOUT, _EventLoopThread
54
+ from ._config import MCP_CONFIG_PATH, _config_path, load_mcp_config
55
+ from ._transports import _InMemoryTokenStorage, _build_oauth_provider
56
+ from .client import (
57
+ MCPClient,
58
+ _client_from_config,
59
+ _clients,
60
+ call,
61
+ connect_all,
62
+ dispatch_mcp_tool,
63
+ get_client,
64
+ list_connected,
65
+ mcp_tool_schemas,
66
+ shutdown_all,
67
+ )
68
+
69
+ __all__ = [
70
+ # config
71
+ "MCP_CONFIG_PATH",
72
+ "load_mcp_config",
73
+ # client
74
+ "MCPClient",
75
+ # registry
76
+ "connect_all",
77
+ "list_connected",
78
+ "get_client",
79
+ "call",
80
+ "shutdown_all",
81
+ # tool schemas / dispatch
82
+ "mcp_tool_schemas",
83
+ "dispatch_mcp_tool",
84
+ ]
@@ -0,0 +1,61 @@
1
+ """Async↔sync bridge for the MCP package.
2
+
3
+ The official `mcp` SDK is fully async (anyio), but LocalCode's
4
+ toolkit/agent are synchronous. This module owns a single dedicated
5
+ background thread running a persistent asyncio event loop; every public
6
+ MCP method submits a coroutine to that loop via
7
+ `asyncio.run_coroutine_threadsafe(...)`. Each server's transport context
8
+ + `ClientSession` are opened once and kept alive on that loop for the
9
+ process lifetime.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import concurrent.futures
15
+ import threading
16
+
17
+
18
+ # Default time budget (seconds) for a single round-trip to an MCP server.
19
+ _DEFAULT_TIMEOUT = 30.0
20
+ # Time budget for the initial connect (spawning a subprocess / TCP + TLS +
21
+ # initialize handshake can be slow, especially for `npx`/`uvx` cold starts).
22
+ _CONNECT_TIMEOUT = 60.0
23
+
24
+
25
+ class _EventLoopThread:
26
+ """A singleton background thread running a persistent asyncio loop.
27
+
28
+ All SDK coroutines run on this one loop, so a `ClientSession` opened on
29
+ it stays usable for the process lifetime. Sync callers submit work with
30
+ `run(coro, timeout)`.
31
+ """
32
+
33
+ _instance: "_EventLoopThread | None" = None
34
+ _instance_lock = threading.Lock()
35
+
36
+ def __init__(self) -> None:
37
+ self.loop = asyncio.new_event_loop()
38
+ self._thread = threading.Thread(
39
+ target=self._run_loop, name="localcode-mcp-loop", daemon=True
40
+ )
41
+ self._thread.start()
42
+
43
+ def _run_loop(self) -> None:
44
+ asyncio.set_event_loop(self.loop)
45
+ self.loop.run_forever()
46
+
47
+ @classmethod
48
+ def instance(cls) -> "_EventLoopThread":
49
+ with cls._instance_lock:
50
+ if cls._instance is None:
51
+ cls._instance = cls()
52
+ return cls._instance
53
+
54
+ def run(self, coro, timeout: float | None = None):
55
+ """Submit a coroutine to the loop and block for its result."""
56
+ fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
57
+ return fut.result(timeout=timeout)
58
+
59
+ def submit(self, coro) -> concurrent.futures.Future:
60
+ """Submit a coroutine without blocking; returns its Future."""
61
+ return asyncio.run_coroutine_threadsafe(coro, self.loop)
@@ -0,0 +1,33 @@
1
+ """Config loading for the MCP package.
2
+
3
+ Reads `~/.localcode/mcp.json` (key `mcpServers`), honoring LOCALCODE_HOME
4
+ like the rest of the app.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+
13
+ MCP_CONFIG_PATH = Path.home() / ".localcode" / "mcp.json"
14
+
15
+
16
+ def _config_path() -> Path:
17
+ """Resolve the mcp.json path, honoring LOCALCODE_HOME like the rest of
18
+ the app (config.get_home_dir). Falls back to ~/.localcode."""
19
+ override = os.environ.get("LOCALCODE_HOME")
20
+ if override:
21
+ return Path(override).expanduser() / "mcp.json"
22
+ return MCP_CONFIG_PATH
23
+
24
+
25
+ def load_mcp_config() -> dict[str, dict]:
26
+ """Read ~/.localcode/mcp.json. Returns the `mcpServers` dict."""
27
+ path = _config_path()
28
+ if not path.is_file():
29
+ return {}
30
+ try:
31
+ return json.loads(path.read_text()).get("mcpServers", {})
32
+ except Exception:
33
+ return {}
@@ -0,0 +1,124 @@
1
+ """Transport construction + best-effort OAuth for the MCP package.
2
+
3
+ `build_transport` returns the async context manager for a server's
4
+ configured transport (stdio / streamable-HTTP / SSE). OAuth is a
5
+ best-effort construction only: LocalCode runs headless, so the
6
+ authorization-code callback degrades with a clear error instead of
7
+ hanging — prefer a pre-minted token via `headers`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ # ── Official MCP SDK imports ─────────────────────────────────────────────
14
+ from mcp.client.stdio import StdioServerParameters, stdio_client
15
+ from mcp.client.streamable_http import streamablehttp_client
16
+ from mcp.client.sse import sse_client
17
+
18
+
19
+ class _InMemoryTokenStorage:
20
+ """A TokenStorage that keeps tokens/registration in process memory.
21
+
22
+ Tokens are NOT persisted across runs, so every process start triggers a
23
+ fresh OAuth flow. Good enough for a best-effort implementation; a real
24
+ deployment would persist these under ~/.localcode.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._tokens = None
29
+ self._client_info = None
30
+
31
+ async def get_tokens(self):
32
+ return self._tokens
33
+
34
+ async def set_tokens(self, tokens) -> None:
35
+ self._tokens = tokens
36
+
37
+ async def get_client_info(self):
38
+ return self._client_info
39
+
40
+ async def set_client_info(self, client_info) -> None:
41
+ self._client_info = client_info
42
+
43
+
44
+ def _build_oauth_provider(url: str):
45
+ """Build an SDK OAuthClientProvider for `url`, best-effort.
46
+
47
+ LIMITATION: a complete OAuth authorization-code flow needs an interactive
48
+ browser + a loopback redirect listener to capture the `code`. LocalCode
49
+ runs headless (CLI/agent), so we cannot drive that flow automatically.
50
+ We construct a real provider so the auth *config path* is exercised and
51
+ so a future interactive frontend could reuse it, but the callback handler
52
+ degrades with a clear, actionable error instead of hanging. For servers
53
+ that need OAuth, prefer passing a pre-minted token via `headers` (e.g.
54
+ {"Authorization": "Bearer ..."}) until interactive OAuth is wired up.
55
+ """
56
+ from mcp.client.auth import OAuthClientProvider
57
+ from mcp.shared.auth import OAuthClientMetadata
58
+
59
+ async def _redirect_handler(authorization_url: str) -> None:
60
+ # In an interactive frontend this would open a browser. Headless: noop.
61
+ # (The error is raised from the callback handler below.)
62
+ return None
63
+
64
+ async def _callback_handler() -> tuple[str, str | None]:
65
+ raise RuntimeError(
66
+ "OAuth for MCP requires an interactive browser flow, which is not "
67
+ "available in headless LocalCode. Configure the server with a "
68
+ "pre-minted token via `headers` (e.g. Authorization: Bearer ...) "
69
+ "instead of `oauth: true`."
70
+ )
71
+
72
+ return OAuthClientProvider(
73
+ server_url=url,
74
+ client_metadata=OAuthClientMetadata(
75
+ client_name="LocalCode",
76
+ redirect_uris=["http://localhost:8765/callback"],
77
+ grant_types=["authorization_code", "refresh_token"],
78
+ response_types=["code"],
79
+ ),
80
+ storage=_InMemoryTokenStorage(),
81
+ redirect_handler=_redirect_handler,
82
+ callback_handler=_callback_handler,
83
+ )
84
+
85
+
86
+ def build_transport(
87
+ *,
88
+ name: str,
89
+ transport: str,
90
+ command: str,
91
+ args: list[str],
92
+ env: dict[str, str],
93
+ url: str,
94
+ headers: dict[str, str],
95
+ oauth: bool,
96
+ ):
97
+ """Return the async context manager for the configured transport."""
98
+ if transport in ("stdio", ""):
99
+ if not command:
100
+ raise RuntimeError(
101
+ f"MCP server {name!r}: stdio transport needs a 'command'"
102
+ )
103
+ full_env = os.environ.copy()
104
+ full_env.update(env)
105
+ params = StdioServerParameters(command=command, args=args, env=full_env)
106
+ return stdio_client(params)
107
+ if transport in ("http", "streamable-http", "streamable_http"):
108
+ if not url:
109
+ raise RuntimeError(
110
+ f"MCP server {name!r}: http transport needs a 'url'"
111
+ )
112
+ auth = _build_oauth_provider(url) if oauth else None
113
+ return streamablehttp_client(url, headers=headers or None, auth=auth)
114
+ if transport == "sse":
115
+ if not url:
116
+ raise RuntimeError(
117
+ f"MCP server {name!r}: sse transport needs a 'url'"
118
+ )
119
+ auth = _build_oauth_provider(url) if oauth else None
120
+ return sse_client(url, headers=headers or None, auth=auth)
121
+ raise RuntimeError(
122
+ f"MCP server {name!r}: unknown transport {transport!r} "
123
+ f"(use 'stdio', 'http', or 'sse')"
124
+ )
@@ -0,0 +1,316 @@
1
+ """MCPClient + the process-wide registry of connected MCP servers.
2
+
3
+ `MCPClient` speaks one server connection through the official `mcp` SDK
4
+ (stdio / streamable-HTTP / SSE). The transport context manager and the
5
+ `ClientSession` are entered once inside a long-lived "runner" coroutine on
6
+ the shared event loop (anyio cancel scopes must be entered/exited on the
7
+ same task), and sync methods talk to the live session by submitting
8
+ coroutines to the loop.
9
+
10
+ The registry functions (`connect_all`/`list_connected`/`get_client`/
11
+ `shutdown_all`, plus `call`/`mcp_tool_schemas`/`dispatch_mcp_tool`) manage
12
+ a process-wide dict of connected clients.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import concurrent.futures
18
+
19
+ # ── Official MCP SDK imports ─────────────────────────────────────────────
20
+ from mcp import ClientSession
21
+
22
+ from ._bridge import _CONNECT_TIMEOUT, _DEFAULT_TIMEOUT, _EventLoopThread
23
+ from ._config import load_mcp_config
24
+ from ._transports import build_transport
25
+
26
+
27
+ # ── Client ───────────────────────────────────────────────────────────────
28
+ class MCPClient:
29
+ """One MCP server connection, spoken through the official SDK.
30
+
31
+ Supports stdio, streamable-HTTP, and SSE transports. The transport
32
+ context manager and the `ClientSession` are entered once inside a
33
+ long-lived "runner" coroutine on the shared event loop (anyio cancel
34
+ scopes must be entered/exited on the same task, so we keep that one task
35
+ alive until close()). Sync methods talk to the live session by
36
+ submitting coroutines to the loop.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ name: str,
42
+ *,
43
+ transport: str = "stdio",
44
+ command: str = "",
45
+ args: list[str] | None = None,
46
+ env: dict[str, str] | None = None,
47
+ url: str = "",
48
+ headers: dict[str, str] | None = None,
49
+ oauth: bool = False,
50
+ ) -> None:
51
+ self.name = name
52
+ self.transport = (transport or "stdio").lower()
53
+ self.command = command
54
+ self.args = args or []
55
+ self.env = env or {}
56
+ self.url = url
57
+ self.headers = headers or {}
58
+ self.oauth = oauth
59
+
60
+ self._bridge = _EventLoopThread.instance()
61
+ self._session: ClientSession | None = None
62
+ self._runner_future: concurrent.futures.Future | None = None
63
+ self._ready: concurrent.futures.Future | None = None
64
+ self._stop_event: asyncio.Event | None = None
65
+ self._closed = False
66
+ self._post_error: Exception | None = None
67
+
68
+ # -- transport factory -------------------------------------------------
69
+ def _make_transport(self):
70
+ """Return the async context manager for the configured transport."""
71
+ return build_transport(
72
+ name=self.name,
73
+ transport=self.transport,
74
+ command=self.command,
75
+ args=self.args,
76
+ env=self.env,
77
+ url=self.url,
78
+ headers=self.headers,
79
+ oauth=self.oauth,
80
+ )
81
+
82
+ # -- runner coroutine --------------------------------------------------
83
+ async def _runner(self) -> None:
84
+ """Open transport + session, signal ready, then idle until stop.
85
+
86
+ Everything (enter and exit of the async-with blocks) happens inside
87
+ this single task so anyio's cancel scopes stay task-bound.
88
+ """
89
+ self._stop_event = asyncio.Event()
90
+ try:
91
+ async with self._make_transport() as streams:
92
+ # stdio/sse yield (read, write); streamable-http yields
93
+ # (read, write, get_session_id) — take the first two.
94
+ read, write = streams[0], streams[1]
95
+ async with ClientSession(read, write) as session:
96
+ await session.initialize()
97
+ self._session = session
98
+ if self._ready is not None and not self._ready.done():
99
+ self._ready.set_result(True)
100
+ await self._stop_event.wait()
101
+ except Exception as exc: # noqa: BLE001 - surface to connecting thread
102
+ if self._ready is not None and not self._ready.done():
103
+ self._ready.set_exception(exc)
104
+ else:
105
+ self._post_error = exc
106
+ finally:
107
+ self._session = None
108
+
109
+ # -- lifecycle ---------------------------------------------------------
110
+ def connect(self, timeout: float = _CONNECT_TIMEOUT) -> None:
111
+ """Start the runner and block until the session is initialized."""
112
+ if self._session is not None:
113
+ return
114
+ self._ready = concurrent.futures.Future()
115
+ self._runner_future = self._bridge.submit(self._runner())
116
+ try:
117
+ self._ready.result(timeout=timeout)
118
+ except Exception as exc:
119
+ # Make sure a half-started runner gets cleaned up.
120
+ self.close()
121
+ raise RuntimeError(
122
+ f"MCP server {self.name!r} failed to connect: {exc}"
123
+ ) from exc
124
+
125
+ def initialize(self) -> None:
126
+ """Back-compat alias — connect if not already connected."""
127
+ self.connect()
128
+
129
+ def _require_session(self) -> ClientSession:
130
+ if self._session is None:
131
+ raise RuntimeError(f"MCP server {self.name!r} is not connected")
132
+ return self._session
133
+
134
+ # -- public sync API ---------------------------------------------------
135
+ def list_tools(self) -> list[dict]:
136
+ """Return the server's tools as plain dicts with `name`,
137
+ `description`, and `inputSchema` (JSONSchema)."""
138
+ if self._session is None:
139
+ self.connect()
140
+ session = self._require_session()
141
+ result = self._bridge.run(session.list_tools(), timeout=_DEFAULT_TIMEOUT)
142
+ tools: list[dict] = []
143
+ for tool in result.tools:
144
+ tools.append(
145
+ {
146
+ "name": tool.name,
147
+ "description": tool.description or "",
148
+ "inputSchema": tool.inputSchema
149
+ or {"type": "object", "properties": {}},
150
+ }
151
+ )
152
+ return tools
153
+
154
+ def call_tool(self, name: str, arguments: dict) -> str:
155
+ """Invoke a tool; return the concatenated text content blocks."""
156
+ if self._session is None:
157
+ self.connect()
158
+ session = self._require_session()
159
+ result = self._bridge.run(
160
+ session.call_tool(name, arguments or {}),
161
+ timeout=_DEFAULT_TIMEOUT,
162
+ )
163
+ out: list[str] = []
164
+ for block in result.content:
165
+ # TextContent blocks have type == "text" and a .text attr.
166
+ text = getattr(block, "text", None)
167
+ if text is not None and getattr(block, "type", None) == "text":
168
+ out.append(text)
169
+ joined = "\n".join(out)
170
+ if not joined:
171
+ return "(MCP tool returned no text content)"
172
+ if result.isError:
173
+ return f"MCP tool {name!r} reported an error:\n{joined}"
174
+ return joined
175
+
176
+ def health(self) -> tuple[bool, str]:
177
+ """Lightweight liveness check used by Toolkit.diagnostics()."""
178
+ if self._closed:
179
+ return False, "closed"
180
+ if self._post_error is not None:
181
+ return False, f"errored ({self._post_error})"
182
+ if self._session is None:
183
+ return False, "not connected"
184
+ if self._runner_future is not None and self._runner_future.done():
185
+ return False, "runner exited"
186
+ return True, f"running ({self.transport})"
187
+
188
+ def close(self) -> None:
189
+ """Tear down the session + transport on the shared loop."""
190
+ if self._closed:
191
+ return
192
+ self._closed = True
193
+ # Signal the runner to leave its idle wait so the async-with blocks
194
+ # exit cleanly on their own task.
195
+ if self._stop_event is not None:
196
+ try:
197
+ self._bridge.loop.call_soon_threadsafe(self._stop_event.set)
198
+ except Exception:
199
+ pass
200
+ if self._runner_future is not None:
201
+ try:
202
+ self._runner_future.result(timeout=10)
203
+ except Exception:
204
+ # Best-effort: cancel if it didn't exit in time.
205
+ try:
206
+ self._runner_future.cancel()
207
+ except Exception:
208
+ pass
209
+ self._session = None
210
+
211
+
212
+ # ── Process-wide registry ─────────────────────────────────────────────────
213
+ # Registry of connected MCP clients (name → MCPClient).
214
+ _clients: dict[str, MCPClient] = {}
215
+
216
+
217
+ def _client_from_config(name: str, cfg: dict) -> MCPClient:
218
+ """Build an MCPClient from one server's config dict (transport-aware)."""
219
+ transport = (cfg.get("transport") or "stdio").lower()
220
+ return MCPClient(
221
+ name=name,
222
+ transport=transport,
223
+ command=cfg.get("command", "") or "",
224
+ args=cfg.get("args", []) or [],
225
+ env=cfg.get("env", {}) or {},
226
+ url=cfg.get("url", "") or "",
227
+ headers=cfg.get("headers", {}) or {},
228
+ oauth=bool(cfg.get("oauth", False)),
229
+ )
230
+
231
+
232
+ def connect_all() -> tuple[int, list[str]]:
233
+ """Connect every configured MCP server. Returns (count_ok, errors)."""
234
+ errors: list[str] = []
235
+ config = load_mcp_config()
236
+ for name, server_cfg in config.items():
237
+ if name in _clients:
238
+ continue # already connected
239
+ try:
240
+ cli = _client_from_config(name, server_cfg or {})
241
+ cli.connect()
242
+ _clients[name] = cli
243
+ except Exception as e:
244
+ errors.append(f"{name}: {e}")
245
+ return len(_clients), errors
246
+
247
+
248
+ def get_client(name: str) -> "MCPClient | None":
249
+ """Return the connected MCPClient for `name`, or None if not connected."""
250
+ return _clients.get(name)
251
+
252
+
253
+ def list_connected() -> list[tuple[str, list[dict]]]:
254
+ """Return [(server_name, [tools])] for every connected MCP server."""
255
+ out = []
256
+ for name, cli in _clients.items():
257
+ try:
258
+ out.append((name, cli.list_tools()))
259
+ except Exception:
260
+ out.append((name, []))
261
+ return out
262
+
263
+
264
+ def call(server: str, tool_name: str, arguments: dict) -> str:
265
+ cli = _clients.get(server)
266
+ if cli is None:
267
+ return f"REJECTED: MCP server {server!r} is not connected."
268
+ try:
269
+ return cli.call_tool(tool_name, arguments)
270
+ except Exception as e:
271
+ return f"MCP call {server}.{tool_name} failed: {e}"
272
+
273
+
274
+ def shutdown_all() -> None:
275
+ """Tear down all MCP connections on app exit."""
276
+ for cli in _clients.values():
277
+ try:
278
+ cli.close()
279
+ except Exception:
280
+ pass
281
+ _clients.clear()
282
+
283
+
284
+ def mcp_tool_schemas() -> list[dict]:
285
+ """Return OpenAI-style tool schemas for every connected MCP
286
+ server's tools. Each tool is renamed `mcp_<server>_<tool>` so
287
+ multiple servers can expose tools with the same name without
288
+ collision."""
289
+ schemas = []
290
+ for server, tools in list_connected():
291
+ for t in tools:
292
+ name = f"mcp_{server}_{t.get('name', '')}"
293
+ schemas.append({
294
+ "type": "function",
295
+ "function": {
296
+ "name": name,
297
+ "description": (
298
+ f"[MCP {server}] " + (t.get("description") or "")
299
+ )[:1000],
300
+ "parameters": t.get("inputSchema", {"type": "object"}),
301
+ },
302
+ })
303
+ return schemas
304
+
305
+
306
+ def dispatch_mcp_tool(name: str, arguments: dict) -> str | None:
307
+ """If `name` is `mcp_<server>_<tool>`, dispatch to that server.
308
+ Returns None if not an MCP tool (so caller falls through to
309
+ normal tool dispatch)."""
310
+ if not name.startswith("mcp_"):
311
+ return None
312
+ rest = name[len("mcp_"):]
313
+ if "_" not in rest:
314
+ return None
315
+ server, tool = rest.split("_", 1)
316
+ return call(server, tool, arguments)
localcode/toolkit.py CHANGED
@@ -18,9 +18,9 @@ except ImportError:
18
18
  from .config import AppConfig
19
19
  from .context import IGNORE_DIRS, list_repo_files
20
20
  from .indexer import build_index, search_index
21
- # MCP client module removed in T0.9 purge nobody configured MCP servers,
22
- # the dispatch loop was dead code. Reintroduce as a proper plugin system
23
- # (T0.9-followup) if a real MCP use case lands.
21
+ # MCP tools are lazily wired in via ensure_mcp_tools() (the `mcp` module is
22
+ # imported there, on demand, so a missing/broken MCP setup never affects
23
+ # toolkit import). See ensure_mcp_tools() below.
24
24
  from .shell import run_shell
25
25
  from .undo import ChangeLog
26
26
 
@@ -1063,10 +1063,64 @@ class LocalCodeToolkit:
1063
1063
  """Plugin system removed — no-op."""
1064
1064
  pass
1065
1065
 
1066
- # MCP registration functions removed during T0.9 purge. Reintroduce
1067
- # alongside a real plugin system if needed; the prior implementation
1068
- # was never configured by any user, so keeping it as dead dispatch
1069
- # code was pure surface area for drift.
1066
+ @staticmethod
1067
+ def _make_mcp_handler(client: Any, tool_name: str) -> ToolHandler:
1068
+ """Build a handler bound to a specific client + tool name.
1069
+
1070
+ Defined as its own method (not a lambda inside a loop) so the
1071
+ client/tool_name are captured per-tool — no late-binding bug where
1072
+ every registered MCP tool would point at the last loop iteration.
1073
+ """
1074
+ def handler(args: dict[str, Any]) -> str:
1075
+ try:
1076
+ return client.call_tool(tool_name, args or {})
1077
+ except Exception as exc: # never let a bad MCP call crash dispatch
1078
+ return f"MCP tool {tool_name!r} call failed: {exc}"
1079
+ return handler
1080
+
1081
+ def ensure_mcp_tools(self) -> None:
1082
+ """Lazily connect configured MCP servers and register their tools.
1083
+
1084
+ Runs at most once per toolkit (guarded by self._mcp_loaded). Each
1085
+ connected server's tools are registered as `mcp_<server>_<tool>`
1086
+ LocalCodeTools so the model can call them like any builtin. Fully
1087
+ defensive: a bad/missing MCP server never crashes the toolkit.
1088
+ """
1089
+ if self._mcp_loaded:
1090
+ return
1091
+ self._mcp_loaded = True # set first so we only ever attempt once
1092
+ try:
1093
+ from . import mcp as _mcp
1094
+ except Exception as exc:
1095
+ self.mcp_errors.append(f"mcp import failed: {exc}")
1096
+ return
1097
+ try:
1098
+ _count, errors = _mcp.connect_all()
1099
+ if errors:
1100
+ self.mcp_errors.extend(errors)
1101
+ for server, tools in _mcp.list_connected():
1102
+ client = _mcp.get_client(server)
1103
+ if client is None:
1104
+ continue
1105
+ self.mcp_clients[server] = client
1106
+ for t in tools:
1107
+ tool_name = (t.get("name") or "").strip()
1108
+ if not tool_name:
1109
+ continue
1110
+ schema = t.get("inputSchema") or {"type": "object", "properties": {}}
1111
+ if not isinstance(schema, dict):
1112
+ schema = {"type": "object", "properties": {}}
1113
+ description = (
1114
+ f"[MCP {server}] " + (t.get("description") or "")
1115
+ ).strip()[:1000]
1116
+ self._register(LocalCodeTool(
1117
+ name=f"mcp_{server}_{tool_name}",
1118
+ description=description,
1119
+ parameters=schema,
1120
+ handler=self._make_mcp_handler(client, tool_name),
1121
+ ))
1122
+ except Exception as exc: # connect_all/list shouldn't raise, but be safe
1123
+ self.mcp_errors.append(f"mcp wiring failed: {exc}")
1070
1124
 
1071
1125
  # ── Public API ───────────────────────────────────────────────────────
1072
1126
 
@@ -1084,7 +1138,7 @@ class LocalCodeToolkit:
1084
1138
  compact: only core tools
1085
1139
  minimal: ultra-short descriptions for small models (saves ~500 tokens)
1086
1140
  """
1087
- # self.ensure_mcp_tools() removed during T0.9 purge
1141
+ self.ensure_mcp_tools()
1088
1142
  if compact:
1089
1143
  tools = [tool for tool in self.tools.values() if tool.name in self.CORE_TOOLS]
1090
1144
  else:
@@ -1117,7 +1171,7 @@ class LocalCodeToolkit:
1117
1171
  }
1118
1172
 
1119
1173
  def list_tool_names(self) -> list[str]:
1120
- # self.ensure_mcp_tools() removed during T0.9 purge
1174
+ self.ensure_mcp_tools()
1121
1175
  return sorted(self.tools.keys())
1122
1176
 
1123
1177
  def summarize_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[str]:
@@ -1145,7 +1199,7 @@ class LocalCodeToolkit:
1145
1199
  def execute_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, str]]:
1146
1200
  """Execute tool calls — read-only tools run concurrently, writes run serially."""
1147
1201
  from concurrent.futures import ThreadPoolExecutor, as_completed
1148
- # self.ensure_mcp_tools() removed during T0.9 purge
1202
+ self.ensure_mcp_tools()
1149
1203
 
1150
1204
  if len(tool_calls) <= 1:
1151
1205
  return self._execute_serial(tool_calls)
@@ -636,6 +636,7 @@ _SLASH_COMMANDS = [
636
636
  ("/status", "Show runtime: server health, current model, perf config"),
637
637
  ("/restart", "Restart the model server (use when /status shows 'unreachable')"),
638
638
  ("/mcp", "List or reload MCP servers from ~/.localcode/mcp.json"),
639
+ ("/skills", "List loaded skills and where they're from"),
639
640
  ("/model", "List available models / switch (e.g. /model qwen)"),
640
641
  ("/thinking", "Show / set hidden reasoning policy (off|auto)"),
641
642
  ("/sounds", "Toggle completion + approval notification sounds"),
@@ -1933,6 +1934,8 @@ class ChatScreen(Screen):
1933
1934
  self._restart_for_vision_change(reason="Server restarted")
1934
1935
  elif text == "/mcp" or text.startswith("/mcp "):
1935
1936
  self._handle_mcp_command(text)
1937
+ elif text == "/skills":
1938
+ self._handle_skills_command(text)
1936
1939
  elif text == "/voice" or text.startswith("/voice "):
1937
1940
  self._handle_voice_command(text)
1938
1941
  elif text == "/audio" or text.startswith("/audio "):
@@ -2223,6 +2226,30 @@ class ChatScreen(Screen):
2223
2226
  except Exception:
2224
2227
  pass
2225
2228
 
2229
+ def _handle_skills_command(self, text: str) -> None:
2230
+ """List the skills the agent can use, grouped by origin, with token cost.
2231
+
2232
+ Skills are auto-discovered markdown files (frontmatter + body). Add one
2233
+ by dropping a `.md` into ~/.localcode/skills/ (global) or
2234
+ ./.localcode/skills/ (project); user/project override bundled by name.
2235
+ """
2236
+ from pathlib import Path as _Path
2237
+ from ...skills import load_registry
2238
+ log = self.query_one("#chat-log", ChatLog)
2239
+ reg = load_registry(_Path.cwd())
2240
+ skills = sorted(reg.skills.values(), key=lambda s: (s.origin, s.name))
2241
+ if not skills:
2242
+ log.append_info(
2243
+ "No skills loaded. Drop a .md with `name:`/`description:` frontmatter "
2244
+ "into ~/.localcode/skills/ (global) or ./.localcode/skills/ (project)."
2245
+ )
2246
+ return
2247
+ log.append_info(f"{len(skills)} skill(s) loaded (auto-activated or via the skill tool):")
2248
+ for s in skills:
2249
+ tokens = max(1, (len(s.body) + len(s.description)) // 4)
2250
+ where = "(bundled)" if s.origin == "bundled" else str(s.source_path)
2251
+ log.append_info(f" {s.name} · {s.origin} · ~{tokens} tok · {where}")
2252
+
2226
2253
  def _handle_mcp_command(self, text: str) -> None:
2227
2254
  """List + reload MCP servers configured in ~/.localcode/mcp.json.
2228
2255
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localcode
3
- Version: 0.2.16
3
+ Version: 0.3.0
4
4
  Summary: High-performance AI coding on consumer hardware.
5
5
  Author: LocalCode contributors
6
6
  License-Expression: Apache-2.0
@@ -15,14 +15,13 @@ Classifier: Intended Audience :: Developers
15
15
  Classifier: Operating System :: MacOS
16
16
  Classifier: Operating System :: POSIX :: Linux
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.9
19
18
  Classifier: Programming Language :: Python :: 3.10
20
19
  Classifier: Programming Language :: Python :: 3.11
21
20
  Classifier: Programming Language :: Python :: 3.12
22
21
  Classifier: Programming Language :: Python :: 3.13
23
22
  Classifier: Topic :: Software Development
24
23
  Classifier: Topic :: Software Development :: Code Generators
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: certifi>=2024.0.0
@@ -31,6 +30,7 @@ Requires-Dist: hf_xet>=1.1.0
31
30
  Requires-Dist: httpx>=0.27.0
32
31
  Requires-Dist: huggingface_hub>=1.0.0
33
32
  Requires-Dist: jedi>=0.19.0
33
+ Requires-Dist: mcp>=1.28.0
34
34
  Requires-Dist: prompt_toolkit>=3.0.52
35
35
  Requires-Dist: rich>=13.7.1
36
36
  Requires-Dist: scikit-learn>=1.3.0
@@ -54,7 +54,7 @@ Dynamic: license-file
54
54
  <p align="center">
55
55
  <img src="https://img.shields.io/pypi/v/localcode?style=flat-square&color=7c4dff" alt="PyPI">
56
56
  <img src="https://img.shields.io/badge/license-Apache_2.0-4caf50?style=flat-square" alt="License">
57
- <img src="https://img.shields.io/badge/python-3.9+-3776ab?style=flat-square" alt="Python">
57
+ <img src="https://img.shields.io/badge/python-3.10+-3776ab?style=flat-square" alt="Python">
58
58
  <img src="https://img.shields.io/badge/platform-Apple%20Silicon-999999?style=flat-square" alt="Platform">
59
59
  </p>
60
60
 
@@ -108,7 +108,7 @@ We are building for a world of truly democratized AI — where everyone has acce
108
108
 
109
109
  - **Mac with Apple Silicon**
110
110
  - **16 GB RAM** minimum
111
- - **Python 3.9+**
111
+ - **Python 3.10+**
112
112
  - **~12 GB free disk** (10 GB model + server)
113
113
 
114
114
  ### Tested hardware
@@ -1,4 +1,4 @@
1
- localcode/__init__.py,sha256=SRPX8BmTXMUfGe0MxaIfwl6B0h5Hm73gVI0sJvHjKpg,50
1
+ localcode/__init__.py,sha256=9IFfTvFMTu1mt_TdJvM_vZAmNF_poHZSvDz-APZippc,49
2
2
  localcode/__main__.py,sha256=_4Tblte9F1KOpB-oGBQuTlaSN-IncLmhihqhBkpH3pU,69
3
3
  localcode/_subproc_env.py,sha256=eltq_kKvqGj22pIFivB5BZft9-s5fziBW0lsV9FhEV4,2383
4
4
  localcode/app.py,sha256=-zkxi06XeYno_y-N1DimFssqD6LnV8n_pbP0iB6odLg,90616
@@ -30,7 +30,6 @@ localcode/injection_defense.py,sha256=sggmoowHEAKYV3v-1PTWtv8KjekEL0kZ0-O03hb3Dm
30
30
  localcode/launcher.py,sha256=uDsIy66q-VexbvdIb7iwzYwDmmYhWRP10SNX4GiEWQ4,11462
31
31
  localcode/logging_utils.py,sha256=mFn_LO26IkT7OLxKs1ke4mND8BJeDLf-UULCYSSxyz0,920
32
32
  localcode/lsp.py,sha256=WTtdbdgforZdWMRqlzCQp2ndkKRbfIE1f5GfGeRH1xU,5939
33
- localcode/mcp.py,sha256=PeYUPG4ImYqir5eod9s6sHoiMdBl0_CTzo8ssYrclhE,8376
34
33
  localcode/memory_guard.py,sha256=Y4wUlPdV4zRzwkNmPVbCJCIBf40j_OrQ0rc7xGXGoNs,8815
35
34
  localcode/model_config.py,sha256=4iPw5M6tnjMvdILtWlRkOdE6QMFRJCAIYBmadc82ZjQ,14261
36
35
  localcode/model_families.py,sha256=mEBIoEMe-qL93Twxm-4mYv76dWEkKVp5CiQlY43mkWA,11203
@@ -61,7 +60,7 @@ localcode/thermal.py,sha256=q2A-HlIs-Uaqpf6bUswE2q9qbvdZCMKe6b9Fg--yHV4,6229
61
60
  localcode/thinking.py,sha256=Cv2vPMO94hIAtPO8DtvPiGlymrtG2wROBMDZoTLrBPc,2025
62
61
  localcode/tool_parsing.py,sha256=3jXSWvhXF6k1eau2o8RA0-zp0tfNb0VM0GkuwUGN57w,13565
63
62
  localcode/tool_router.py,sha256=bmCwTAhFqo8oadJZ-FDSTdEvfwu3fHok8qVOBbxnQG4,9832
64
- localcode/toolkit.py,sha256=MlhAGgSYVe0oMgWxIKUTlCDa1QbfeXClZFDVxgRXiFI,55467
63
+ localcode/toolkit.py,sha256=PEToa9AUjp4D1xcdSBk9DT-0bU0zGigKhBR6aiwf4OA,57843
65
64
  localcode/turn_diff.py,sha256=ls5soQdYthLaep3eP20Xc5giFAlAx0ObVAzz7egy6Jk,6101
66
65
  localcode/undo.py,sha256=jAVtelQM2MiWQjS0Q86BtuQHDR1FYMB976dzxlCNGic,3956
67
66
  localcode/verification.py,sha256=UExbGJSB0Ts_PB68yrrqpBAE84rfHfjPAgkELBDGdDs,3010
@@ -84,6 +83,11 @@ localcode/agent/tool_orchestration.py,sha256=QRK7U7lSrmWJ578dG44tq9b6DWdFI8Khpw0
84
83
  localcode/agent/turn_finalization.py,sha256=n5h0aA_EPDS8ZY6TBvbUo7qu2IkgEwnwcO0PKBd6930,4884
85
84
  localcode/bin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
86
85
  localcode/bin/llama-server,sha256=-_OT5uZUVhpaxSLsIkmfmyF88rvsu8ue1K8VaegNHW0,16378096
86
+ localcode/mcp/__init__.py,sha256=R3R0Grp25Xkwakyo23WPQ3mOXsWiSDh-KfUoS4Ws4XQ,2854
87
+ localcode/mcp/_bridge.py,sha256=s0GuyyWzO1IUfMCu6A9J7yRYXyYYnLLb7fAdu2PyxJk,2175
88
+ localcode/mcp/_config.py,sha256=8Al3ZoVnZTfO9jbVOueauywbZ8OCUW68PIEDemTJgqk,905
89
+ localcode/mcp/_transports.py,sha256=_6qLkpJpxQcqXwgM2Q_-cZAWaHLIGPI2MbuGPSa3DOU,4765
90
+ localcode/mcp/client.py,sha256=qOi19h7l5hN9Am6YvFNBZgdYD6KZ-rgJ7lNS5ZJAYp0,12147
87
91
  localcode/skills/debug.md,sha256=ENVZR8Do3H8Xe0dqhxCAgnprQNGsV0BOtoqiIry9cSE,1287
88
92
  localcode/skills/edit-verified.md,sha256=Jy4lOk7sDb16YzHcM1HMc2ejmo-KsSrvfvKAxz712Kk,1755
89
93
  localcode/skills/explain.md,sha256=NJpu_bht2UH6Sa3AMlhsKLlSiHfOn0WNARolcelQXe4,906
@@ -115,7 +119,7 @@ localcode/tui/__init__.py,sha256=Yfy9bvw3l1iY4FDhRX8qZVgnZLuYiq8lc9SDYl4cWwc,61
115
119
  localcode/tui/app.py,sha256=8LJkCjpUZQuCwNXtXBhoGJ2lMTl4MDl48japyDTJtMI,19317
116
120
  localcode/tui/bridge.py,sha256=urPPCqC_nisAYawWk3juvMI8IaoJfv4g7USpGOZOqQc,2803
117
121
  localcode/tui/screens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
118
- localcode/tui/screens/chat.py,sha256=QxvJJmrZu5iy-VSnx6NPp-Wg1ZrQ3DfO2bhrGo6GfoU,180759
122
+ localcode/tui/screens/chat.py,sha256=831h6IDZPFUWzoLigxaIihnxmJ4bdEgbwRs9wHE6ZVQ,182159
119
123
  localcode/tui/screens/mode_picker.py,sha256=cK_xNo7ytOwJXJwzUevMTcb5dwQKtjMfh60of-ntRz4,2347
120
124
  localcode/tui/screens/model_picker.py,sha256=pH5C4_ecxm3wPPydgMabgOhmn3DgffUMHiPuriCFsMk,35861
121
125
  localcode/tui/screens/setup.py,sha256=eyV2WsddXy12pBsp6t-2hKtXxHL1BitCPWPnbSKR2Ks,42478
@@ -127,9 +131,9 @@ localcode/tui/widgets/chat_log.py,sha256=T80JeW9257tTqNPRRhvfMLrVriDEgeB9URNCsUC
127
131
  localcode/tui/widgets/voice_visualizer.py,sha256=zOhrIxaMcg-F2Y8LvHJTddq227phmiTjtEyRbkx1XxU,4709
128
132
  localcode/tui/widgets/messages/__init__.py,sha256=952AZ1qVMGUIqPIry7mwxnYbMkgPlcY5FAwZtEa5YPg,967
129
133
  localcode/tui/widgets/messages/diff.py,sha256=fF7GMDSrJtVEzw1FfqlNMlg51MzOi4DwWIe9Oz6Bu6Y,8457
130
- localcode-0.2.16.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
131
- localcode-0.2.16.dist-info/METADATA,sha256=ljbgm7Jv0Cei_481MlRsBgIGAV8dED3OicbjWaDLexY,7834
132
- localcode-0.2.16.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
133
- localcode-0.2.16.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
134
- localcode-0.2.16.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
135
- localcode-0.2.16.dist-info/RECORD,,
134
+ localcode-0.3.0.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
135
+ localcode-0.3.0.dist-info/METADATA,sha256=SlZVGa5UtcFRpKq9hmOo1Ql1aJHWYaA_GrhMGiv2gDc,7813
136
+ localcode-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
137
+ localcode-0.3.0.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
138
+ localcode-0.3.0.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
139
+ localcode-0.3.0.dist-info/RECORD,,
localcode/mcp.py DELETED
@@ -1,242 +0,0 @@
1
- """MCP (Model Context Protocol) client + tool-registration.
2
-
3
- Lets a user add MCP servers to LocalCode in `~/.localcode/mcp.json`.
4
- Each server's tools get auto-registered with the agent so the model
5
- can call them like any other tool.
6
-
7
- Config shape (standard MCP server convention):
8
-
9
- {
10
- "mcpServers": {
11
- "filesystem": {
12
- "command": "npx",
13
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me"],
14
- "env": {}
15
- },
16
- "github": {
17
- "command": "uvx",
18
- "args": ["mcp-server-github"],
19
- "env": {"GITHUB_TOKEN": "ghp_..."}
20
- }
21
- }
22
- }
23
-
24
- For each server, we spawn the subprocess once at startup, speak the
25
- MCP JSON-RPC protocol over its stdin/stdout, and expose each declared
26
- tool as `mcp_<server>_<tool>` in the agent's tool list.
27
-
28
- This is a minimal viable client — covers `initialize`, `tools/list`,
29
- and `tools/call`. No prompts, resources, sampling, or notifications.
30
- Good enough for the common "let the agent use my filesystem MCP" case.
31
- """
32
- from __future__ import annotations
33
-
34
- import json
35
- import os
36
- import subprocess
37
- import threading
38
- from pathlib import Path
39
- from typing import Any
40
-
41
-
42
- MCP_CONFIG_PATH = Path.home() / ".localcode" / "mcp.json"
43
-
44
-
45
- class MCPClient:
46
- """One stdio-spoken MCP server connection."""
47
-
48
- def __init__(self, name: str, command: str, args: list[str], env: dict[str, str] | None = None):
49
- self.name = name
50
- self._proc: subprocess.Popen | None = None
51
- self._req_id = 0
52
- self._lock = threading.Lock()
53
- self._initialized = False
54
- full_env = os.environ.copy()
55
- if env:
56
- full_env.update(env)
57
- try:
58
- self._proc = subprocess.Popen(
59
- [command, *args],
60
- stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
61
- text=True, bufsize=1, env=full_env,
62
- )
63
- except FileNotFoundError as e:
64
- raise RuntimeError(f"MCP server {name!r} command not found: {command}") from e
65
-
66
- def _next_id(self) -> int:
67
- with self._lock:
68
- self._req_id += 1
69
- return self._req_id
70
-
71
- def _send(self, method: str, params: dict | None = None) -> dict:
72
- if self._proc is None or self._proc.poll() is not None:
73
- raise RuntimeError(f"MCP server {self.name!r} is not running")
74
- req = {
75
- "jsonrpc": "2.0",
76
- "id": self._next_id(),
77
- "method": method,
78
- "params": params or {},
79
- }
80
- with self._lock:
81
- self._proc.stdin.write(json.dumps(req) + "\n")
82
- self._proc.stdin.flush()
83
- # Read one line of JSON-RPC response. MCP servers may emit
84
- # notifications (no id) — skip those and keep reading.
85
- for _ in range(50):
86
- line = self._proc.stdout.readline()
87
- if not line:
88
- raise RuntimeError(f"MCP server {self.name!r} closed stdout")
89
- try:
90
- msg = json.loads(line)
91
- except json.JSONDecodeError:
92
- continue
93
- if "id" not in msg:
94
- continue # notification, not our response
95
- if msg.get("id") == req["id"]:
96
- if "error" in msg:
97
- raise RuntimeError(f"MCP {self.name}.{method}: {msg['error']}")
98
- return msg.get("result", {})
99
- raise RuntimeError(f"MCP server {self.name!r} response timeout")
100
-
101
- def initialize(self) -> None:
102
- if self._initialized:
103
- return
104
- self._send("initialize", {
105
- "protocolVersion": "2024-11-05",
106
- "capabilities": {},
107
- "clientInfo": {"name": "localcode", "version": "0.2"},
108
- })
109
- self._initialized = True
110
-
111
- def list_tools(self) -> list[dict]:
112
- """Return the list of tools the server exposes — each has a
113
- `name`, `description`, and `inputSchema` (JSONSchema)."""
114
- self.initialize()
115
- result = self._send("tools/list")
116
- return result.get("tools", [])
117
-
118
- def call_tool(self, name: str, arguments: dict) -> str:
119
- """Invoke a tool by name with JSON-encoded arguments. Returns
120
- the text content of the response."""
121
- self.initialize()
122
- result = self._send("tools/call", {"name": name, "arguments": arguments})
123
- content = result.get("content", [])
124
- # MCP spec: content is a list of {type, text|data|...} blocks.
125
- # We concatenate text blocks; ignore image/blob for now.
126
- out = []
127
- for block in content:
128
- if isinstance(block, dict) and block.get("type") == "text":
129
- out.append(block.get("text", ""))
130
- return "\n".join(out) or "(MCP tool returned no text content)"
131
-
132
- def close(self) -> None:
133
- if self._proc is not None and self._proc.poll() is None:
134
- try:
135
- self._proc.terminate()
136
- self._proc.wait(timeout=2)
137
- except Exception:
138
- try:
139
- self._proc.kill()
140
- except Exception:
141
- pass
142
-
143
-
144
- # Process-wide registry of connected MCP clients (name → MCPClient).
145
- _clients: dict[str, MCPClient] = {}
146
-
147
-
148
- def load_mcp_config() -> dict[str, dict]:
149
- """Read ~/.localcode/mcp.json. Returns the `mcpServers` dict."""
150
- if not MCP_CONFIG_PATH.is_file():
151
- return {}
152
- try:
153
- return json.loads(MCP_CONFIG_PATH.read_text()).get("mcpServers", {})
154
- except Exception:
155
- return {}
156
-
157
-
158
- def connect_all() -> tuple[int, list[str]]:
159
- """Spawn every configured MCP server. Returns (count_ok, errors)."""
160
- errors: list[str] = []
161
- config = load_mcp_config()
162
- for name, server_cfg in config.items():
163
- if name in _clients:
164
- continue # already connected
165
- try:
166
- cli = MCPClient(
167
- name=name,
168
- command=server_cfg.get("command", ""),
169
- args=server_cfg.get("args", []) or [],
170
- env=server_cfg.get("env", {}) or {},
171
- )
172
- cli.initialize()
173
- _clients[name] = cli
174
- except Exception as e:
175
- errors.append(f"{name}: {e}")
176
- return len(_clients), errors
177
-
178
-
179
- def list_connected() -> list[tuple[str, list[dict]]]:
180
- """Return [(server_name, [tools])] for every connected MCP server."""
181
- out = []
182
- for name, cli in _clients.items():
183
- try:
184
- out.append((name, cli.list_tools()))
185
- except Exception:
186
- out.append((name, []))
187
- return out
188
-
189
-
190
- def call(server: str, tool_name: str, arguments: dict) -> str:
191
- cli = _clients.get(server)
192
- if cli is None:
193
- return f"REJECTED: MCP server {server!r} is not connected."
194
- try:
195
- return cli.call_tool(tool_name, arguments)
196
- except Exception as e:
197
- return f"MCP call {server}.{tool_name} failed: {e}"
198
-
199
-
200
- def shutdown_all() -> None:
201
- """Tear down all MCP subprocesses on app exit."""
202
- for cli in _clients.values():
203
- try:
204
- cli.close()
205
- except Exception:
206
- pass
207
- _clients.clear()
208
-
209
-
210
- def mcp_tool_schemas() -> list[dict]:
211
- """Return OpenAI-style tool schemas for every connected MCP
212
- server's tools. Each tool is renamed `mcp_<server>_<tool>` so
213
- multiple servers can expose tools with the same name without
214
- collision."""
215
- schemas = []
216
- for server, tools in list_connected():
217
- for t in tools:
218
- name = f"mcp_{server}_{t.get('name', '')}"
219
- schemas.append({
220
- "type": "function",
221
- "function": {
222
- "name": name,
223
- "description": (
224
- f"[MCP {server}] " + (t.get("description") or "")
225
- )[:1000],
226
- "parameters": t.get("inputSchema", {"type": "object"}),
227
- },
228
- })
229
- return schemas
230
-
231
-
232
- def dispatch_mcp_tool(name: str, arguments: dict) -> str | None:
233
- """If `name` is `mcp_<server>_<tool>`, dispatch to that server.
234
- Returns None if not an MCP tool (so caller falls through to
235
- normal tool dispatch)."""
236
- if not name.startswith("mcp_"):
237
- return None
238
- rest = name[len("mcp_"):]
239
- if "_" not in rest:
240
- return None
241
- server, tool = rest.split("_", 1)
242
- return call(server, tool, arguments)