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 +1 -1
- localcode/mcp/__init__.py +84 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +124 -0
- localcode/mcp/client.py +316 -0
- localcode/toolkit.py +64 -10
- localcode/tui/screens/chat.py +27 -0
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/METADATA +5 -5
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/RECORD +14 -10
- localcode/mcp.py +0 -242
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/WHEEL +0 -0
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/entry_points.txt +0 -0
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.2.16.dist-info → localcode-0.3.0.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
|
@@ -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
|
+
]
|
localcode/mcp/_bridge.py
ADDED
|
@@ -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)
|
localcode/mcp/_config.py
ADDED
|
@@ -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
|
+
)
|
localcode/mcp/client.py
ADDED
|
@@ -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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1202
|
+
self.ensure_mcp_tools()
|
|
1149
1203
|
|
|
1150
1204
|
if len(tool_calls) <= 1:
|
|
1151
1205
|
return self._execute_serial(tool_calls)
|
localcode/tui/screens/chat.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
131
|
-
localcode-0.
|
|
132
|
-
localcode-0.
|
|
133
|
-
localcode-0.
|
|
134
|
-
localcode-0.
|
|
135
|
-
localcode-0.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|