lcode-cli 0.3.1__py3-none-any.whl → 0.4.1__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.
lcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.1"
lcode/agent.py CHANGED
@@ -15,12 +15,14 @@ from pathlib import Path
15
15
 
16
16
  from rich.console import Console
17
17
  from rich.markdown import Markdown
18
+ from rich.markup import escape
18
19
  from rich.panel import Panel
19
20
  from rich.text import Text
20
21
 
21
22
  from lcode import catalog, limits, sessions, web
22
23
  from lcode.checkpoints import Checkpoints
23
24
  from lcode.config import format_tokens
25
+ from lcode.mcp import McpManager
24
26
  from lcode.ollama import Ollama, OllamaError
25
27
  from lcode.permissions import Permissions
26
28
  from lcode.render import MarkdownStreamer
@@ -150,6 +152,8 @@ class Agent:
150
152
  self.tools = Toolbox(self)
151
153
  self.session_id = self.new_session_id()
152
154
  self.checkpoints = Checkpoints(self.console, settings.checkpoints)
155
+ self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
156
+ self._mcp_prompt = "" # the MCP part at the end of the system prompt
153
157
  self.session_name = ""
154
158
  self.session_title = ""
155
159
  self.ctx_used = 0
@@ -188,9 +192,32 @@ class Agent:
188
192
  return web.resolve_backend(self.settings.search_backend, self.settings.searxng_url)
189
193
 
190
194
  def tool_schemas(self) -> list[dict]:
191
- if self.settings.web == "off":
192
- return SCHEMAS
193
- return [*SCHEMAS, *([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
195
+ schemas = list(SCHEMAS)
196
+ if self.settings.web != "off":
197
+ schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
198
+ if self.mcp:
199
+ schemas += self.mcp.schemas(self.settings.context)
200
+ return schemas
201
+
202
+ def prepare_mcp(self) -> None:
203
+ """Before a request: wait for MCP servers still starting and describe them in the system prompt."""
204
+ if not self.mcp:
205
+ return
206
+ if self.mcp.pending:
207
+ with self.console.status(f"Starting MCP servers: {', '.join(self.mcp.pending)}", spinner="dots"):
208
+ self.mcp.wait()
209
+ for state in self.mcp.servers.values():
210
+ if state.status in ("failed", "login") and not state.reported:
211
+ state.reported = True
212
+ self.console.print(f"[yellow]MCP server {state.name}: {escape(state.error)}[/] [dim](/mcp)[/]")
213
+ self.mcp.refresh_changed()
214
+ section = self.mcp.prompt_section(self.settings.context)
215
+ if section != self._mcp_prompt:
216
+ content = self.messages[0]["content"]
217
+ if self._mcp_prompt and content.endswith(self._mcp_prompt):
218
+ content = content[: -len(self._mcp_prompt)]
219
+ self.messages[0]["content"] = content + section
220
+ self._mcp_prompt = section
194
221
 
195
222
  def web_prompt(self) -> str:
196
223
  if self.settings.web == "off":
@@ -203,6 +230,7 @@ class Agent:
203
230
 
204
231
  def reset(self) -> None:
205
232
  self.messages = [{"role": "system", "content": self.system_prompt()}]
233
+ self._mcp_prompt = ""
206
234
  self.tools.read_mtimes.clear()
207
235
  self.ctx_used = len(self.messages[0]["content"]) // 3
208
236
 
@@ -254,6 +282,7 @@ class Agent:
254
282
  else:
255
283
  note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
256
284
  self.messages = [{"role": "system", "content": self.system_prompt()}, *data.get("messages", [])[1:]]
285
+ self._mcp_prompt = ""
257
286
  self.session_id = info.id
258
287
  self.checkpoints.load(info.id, data.get("checkpoints") or [])
259
288
  self.session_name = data.get("name", "")
@@ -415,6 +444,15 @@ class Agent:
415
444
  return f"glob({args.get('pattern', '')})"
416
445
  if name == "list_dir":
417
446
  return f"list_dir({args.get('path', '.')})"
447
+ if self.mcp and self.mcp.owns(name):
448
+ if name == "mcp_find_tools":
449
+ return f"mcp_find_tools({args.get('query', '')!r})"
450
+ try:
451
+ state, tool, arguments = self.mcp.resolve(name, args)
452
+ except Exception:
453
+ return name
454
+ preview = json.dumps(arguments, ensure_ascii=False)
455
+ return f"{state.name} › {tool['name']}({preview if len(preview) <= 80 else preview[:79] + '…'})"
418
456
  return name
419
457
 
420
458
  def run_turn(self, user_text: str) -> None:
@@ -429,6 +467,7 @@ class Agent:
429
467
  self.checkpoints.before_change(self.cwd)
430
468
 
431
469
  def _run_turn(self, user_text: str) -> None:
470
+ self.prepare_mcp()
432
471
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text)})
433
472
  malformed = 0
434
473
  for _ in range(MAX_STEPS_PER_TURN):
lcode/bench.py CHANGED
@@ -850,31 +850,33 @@ def run_task(
850
850
  ollama: Ollama, settings: Settings, task: Task, timeout: float, log: Console, keep: bool, usage: dict
851
851
  ) -> TaskResult:
852
852
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
853
- for rel, content in task.files.items():
854
- (folder / rel).parent.mkdir(parents=True, exist_ok=True)
855
- (folder / rel).write_text(content)
856
- agent = Agent(ollama, settings, folder, console=log)
857
- error = None
858
- started = time.monotonic()
859
- try:
860
- with time_limit(timeout):
861
- agent.run_turn(task.prompt)
862
- except TaskTimeout:
863
- error = f"timed out after {timeout:.0f}s"
864
- except OllamaError as e:
865
- error = str(e).splitlines()[0][:200]
866
- seconds = time.monotonic() - started
867
- for key, value in agent.usage.items():
868
- usage[key] = usage.get(key, 0) + value
869
- answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
870
853
  try:
871
- check = task.check(folder, answer)
872
- except Exception as e: # a check must never crash the benchmark
873
- check = Check(False, f"check failed: {type(e).__name__}: {e}")
874
- if keep:
875
- (folder / "lcode-bench.log").write_text(log.file.getvalue() if isinstance(log.file, io.StringIO) else "")
876
- else:
877
- shutil.rmtree(folder, ignore_errors=True)
854
+ for rel, content in task.files.items():
855
+ (folder / rel).parent.mkdir(parents=True, exist_ok=True)
856
+ (folder / rel).write_text(content)
857
+ agent = Agent(ollama, settings, folder, console=log)
858
+ error = None
859
+ started = time.monotonic()
860
+ try:
861
+ with time_limit(timeout):
862
+ agent.run_turn(task.prompt)
863
+ except TaskTimeout:
864
+ error = f"timed out after {timeout:.0f}s"
865
+ except OllamaError as e:
866
+ error = str(e).splitlines()[0][:200]
867
+ seconds = time.monotonic() - started
868
+ for key, value in agent.usage.items():
869
+ usage[key] = usage.get(key, 0) + value
870
+ answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
871
+ try:
872
+ check = task.check(folder, answer)
873
+ except Exception as e: # a check must never crash the benchmark
874
+ check = Check(False, f"check failed: {type(e).__name__}: {e}")
875
+ finally: # also when Ctrl+C stops the task
876
+ if keep:
877
+ (folder / "lcode-bench.log").write_text(log.file.getvalue() if isinstance(log.file, io.StringIO) else "")
878
+ else:
879
+ shutil.rmtree(folder, ignore_errors=True)
878
880
  return TaskResult(
879
881
  id=task.id,
880
882
  passed=check.passed and error is None,
@@ -1010,7 +1012,7 @@ def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, lis
1010
1012
  return "—" if run.error else value
1011
1013
 
1012
1014
  rows += [
1013
- ("Passed", [cell(r, f"{r.passed}/{len(r.tasks)}") for r in runs]),
1015
+ ("Passed", [cell(r, f"{r.passed}/{len(r.tasks)}" + (" (stopped)" if r.interrupted else "")) for r in runs]),
1014
1016
  ("Time", [cell(r, duration(r.seconds)) for r in runs]),
1015
1017
  ("Generation", [cell(r, f"{r.generation_tps:.1f} tok/s" if r.generation_tps else "?") for r in runs]),
1016
1018
  ("Prompt reading", [cell(r, f"{r.prompt_tps:,.0f} tok/s" if r.prompt_tps else "?") for r in runs]),
lcode/cli.py CHANGED
@@ -310,6 +310,19 @@ def cmd_doctor(args) -> None:
310
310
  bool(shutil.which("rg")) or None,
311
311
  )
312
312
  line("git", "found" if shutil.which("git") else "not found", bool(shutil.which("git")) or None)
313
+ from lcode.mcp import config as mcp_config
314
+
315
+ try:
316
+ servers = mcp_config.load_user()
317
+ names = ", ".join(n for n, raw in servers.items() if not (isinstance(raw, dict) and raw.get("disabled")))
318
+ line(
319
+ "MCP", f"{names} (check them with lcode mcp list)" if names else "no servers (see lcode mcp catalog)", True
320
+ )
321
+ except ValueError as e:
322
+ line("MCP", str(e), False)
323
+ ok = False
324
+ node = shutil.which("npx")
325
+ line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
313
326
  sys.exit(0 if ok else 1)
314
327
 
315
328
 
@@ -367,7 +380,14 @@ def cmd_bench(args) -> None:
367
380
  console.print(f"[bold]lcode bench[/] · {hw.describe()} · Ollama {version}")
368
381
  console.print("[dim]Each task runs in a new temporary folder with every permission granted and web access off.[/]")
369
382
  names = args.models or [cfg["model"]]
370
- others = [m.get("name", "?") for m in ollama.running()]
383
+ resolved: dict[str, tuple[str, ModelSpec | None] | NotInstalled] = {}
384
+ for name in names:
385
+ try:
386
+ resolved[name] = resolve_model(ollama, name)
387
+ except NotInstalled as e:
388
+ resolved[name] = e
389
+ benched = {r[0] for r in resolved.values() if isinstance(r, tuple)}
390
+ others = [m.get("name", "?") for m in ollama.running() if m.get("name", "").removesuffix(":latest") not in benched]
371
391
  if others:
372
392
  console.print(
373
393
  f"[yellow]Already loaded in Ollama: {', '.join(others)}. Models that share the GPU run slower; "
@@ -375,12 +395,12 @@ def cmd_bench(args) -> None:
375
395
  )
376
396
  runs = []
377
397
  for i, name in enumerate(names):
378
- try:
379
- model, spec = resolve_model(ollama, name)
380
- except NotInstalled as e:
381
- console.print(f"\n[yellow]Skipping {name}: {e}[/]")
382
- runs.append(bench.ModelRun(name, name, error=str(e)))
398
+ found = resolved[name]
399
+ if isinstance(found, NotInstalled):
400
+ console.print(f"\n[yellow]Skipping {name}: {found}[/]")
401
+ runs.append(bench.ModelRun(name, name, error=str(found)))
383
402
  continue
403
+ model, spec = found
384
404
  ctx, note = choose_context(ollama, model, spec, context, hw)
385
405
  if note:
386
406
  console.print(f"[yellow]{model}, context {format_tokens(ctx)}: {note}[/]")
@@ -467,14 +487,22 @@ def cmd_chat(args) -> None:
467
487
  checkpoints=cfg["checkpoints"],
468
488
  )
469
489
  agent = Agent(ollama, settings, cwd, console=console)
470
- repl(agent, prompt=args.prompt, hardware=hw, cont=args.cont, resume=args.resume)
490
+ if not args.no_mcp:
491
+ from lcode.mcp.commands import start_session
492
+
493
+ agent.mcp = start_session(cwd, cfg["mcp_tools"], console, interactive=not args.prompt)
494
+ try:
495
+ repl(agent, prompt=args.prompt, hardware=hw, cont=args.cont, resume=args.resume)
496
+ finally:
497
+ if agent.mcp:
498
+ agent.mcp.close()
471
499
 
472
500
 
473
501
  def build_parser() -> argparse.ArgumentParser:
474
502
  parser = argparse.ArgumentParser(
475
503
  prog="lcode",
476
504
  description="A local-first terminal coding agent powered by open-weight models via Ollama.",
477
- epilog="Subcommands: lcode setup | models | doctor | config | bench (lcode <subcommand> --help). "
505
+ epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp (lcode <subcommand> --help). "
478
506
  "Docs: https://nasser1941.github.io/lcode/",
479
507
  )
480
508
  parser.add_argument("-p", "--prompt", help="run one request non-interactively and exit")
@@ -493,6 +521,7 @@ def build_parser() -> argparse.ArgumentParser:
493
521
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
494
522
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
495
523
  parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
524
+ parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
496
525
  parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
497
526
  parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
498
527
  return parser
@@ -527,10 +556,26 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
527
556
  p.add_argument("-v", "--verbose", action="store_true", help="show the model working, like a normal session")
528
557
  p.add_argument("--list", action="store_true", help="list the tasks and exit")
529
558
  subs["bench"] = p
559
+ from lcode.mcp.commands import build_parser as mcp_parser
560
+
561
+ subs["mcp"] = mcp_parser()
530
562
  return subs
531
563
 
532
564
 
533
- COMMANDS = {"setup": cmd_setup, "models": cmd_models, "doctor": cmd_doctor, "config": cmd_config, "bench": cmd_bench}
565
+ def cmd_mcp(args) -> None:
566
+ from lcode.mcp.commands import run
567
+
568
+ sys.exit(run(args, console))
569
+
570
+
571
+ COMMANDS = {
572
+ "setup": cmd_setup,
573
+ "models": cmd_models,
574
+ "doctor": cmd_doctor,
575
+ "config": cmd_config,
576
+ "bench": cmd_bench,
577
+ "mcp": cmd_mcp,
578
+ }
534
579
 
535
580
 
536
581
  def main(argv: list[str] | None = None) -> None:
lcode/config.py CHANGED
@@ -24,6 +24,7 @@ DEFAULT_MODEL = "qwen3.6-35b"
24
24
  PERMISSION_MODES = ("ask", "auto-edit", "yolo")
25
25
  WEB_MODES = ("on", "ask", "off")
26
26
  SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
27
+ MCP_TOOL_MODES = ("auto", "direct", "search")
27
28
 
28
29
  # key -> (default, type, help)
29
30
  SETTINGS: dict[str, tuple[object, type, str]] = {
@@ -38,6 +39,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
38
39
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
39
40
  "searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
40
41
  "checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
42
+ "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
41
43
  }
42
44
  ENV_OVERRIDES = {
43
45
  "LCODE_MODEL": "model",
@@ -97,6 +99,8 @@ def coerce(key: str, value: object) -> object:
97
99
  if value not in WEB_MODES:
98
100
  raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
99
101
  return value
102
+ if key == "mcp_tools" and value not in MCP_TOOL_MODES:
103
+ raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
100
104
  if key == "search_backend" and value not in SEARCH_BACKENDS:
101
105
  raise ConfigError(f"search_backend must be one of {', '.join(SEARCH_BACKENDS)}")
102
106
  if key == "permission_mode" and value not in PERMISSION_MODES:
lcode/mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Model Context Protocol support: connect lcode to MCP servers and offer their tools to the model."""
2
+
3
+ from lcode.mcp.manager import McpManager
4
+
5
+ __all__ = ["McpManager"]