lcode-cli 0.10.0__py3-none-any.whl → 0.11.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.
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.10.0"
3
+ __version__ = "0.11.0"
lcode/mcp/catalog.py CHANGED
@@ -99,6 +99,10 @@ def build(preset: Preset, answers: dict[str, str | None]) -> dict:
99
99
  server = copy.deepcopy(preset.server)
100
100
  for spec in preset.inputs:
101
101
  value = answers.get(spec.var)
102
+ if value and spec.var.endswith(("_URL", "_HOST")):
103
+ value = value.rstrip("/") # the URL may have a path appended, e.g. ${METABASE_URL}/api/metabase-mcp
104
+ if "://" not in value:
105
+ value = "https://" + value # people type metabase.example.com
102
106
  if value:
103
107
  server = _replace(server, spec.var, value)
104
108
  elif spec.optional and not os.environ.get(spec.var):
lcode/mcp/catalog.toml CHANGED
@@ -7,7 +7,7 @@
7
7
  # requires commands that must be installed.
8
8
  # login sign in through the browser (OAuth) after adding.
9
9
  #
10
- # Commands and URLs were checked against each project's documentation in September 2026.
10
+ # Commands and URLs were checked against each project's documentation in September and October 2026.
11
11
 
12
12
  [atlassian]
13
13
  name = "Atlassian"
@@ -224,3 +224,59 @@ homepage = "https://developers.notion.com/docs/mcp"
224
224
  login = true
225
225
  setup = "Sign in with your Notion account in the browser."
226
226
  server = { type = "http", url = "https://mcp.notion.com/mcp" }
227
+
228
+ [metabase]
229
+ name = "Metabase"
230
+ description = "Explore your data and ask questions through Metabase's semantic layer, with your Metabase permissions"
231
+ homepage = "https://www.metabase.com/docs/latest/ai/mcp"
232
+ login = true
233
+ setup = """Needs Metabase 60 or later with its MCP server switched on: an admin turns it on under Admin > AI > MCP.
234
+ Sign in with your Metabase account in the browser."""
235
+ server = { type = "http", url = "${METABASE_URL}/api/metabase-mcp" }
236
+
237
+ [[metabase.inputs]]
238
+ var = "METABASE_URL"
239
+ prompt = "Metabase address, e.g. https://metabase.example.com"
240
+
241
+ [encord]
242
+ name = "Encord"
243
+ description = "Projects, datasets, ontologies, labeling progress, tasks and labels (read-only)"
244
+ homepage = "https://nasser1941.github.io/lcode/mcp/#encord"
245
+ requires = ["uvx"]
246
+ setup = """Encord has no MCP server of its own, so this one comes with lcode (it uses the Encord SDK). It signs in with an SSH
247
+ key registered in Encord: Settings > Public keys. Read-only: add --allow-writes to its args in mcp.json to let the
248
+ model assign tasks and set priorities; lcode still asks before every call."""
249
+
250
+ [encord.server]
251
+ command = "uvx"
252
+ args = ["--python", "3.12", "--from", "lcode-cli[encord]", "lcode-mcp-encord"]
253
+ env = { ENCORD_SSH_KEY_FILE = "${ENCORD_SSH_KEY_FILE}" }
254
+
255
+ [[encord.inputs]]
256
+ var = "ENCORD_SSH_KEY_FILE"
257
+ prompt = "Path to the SSH private key registered in Encord"
258
+ default = "~/.ssh/id_ed25519"
259
+
260
+ [valohai]
261
+ name = "Valohai"
262
+ description = "ML executions with their logs, metrics and outputs, and pipelines (read-only)"
263
+ homepage = "https://nasser1941.github.io/lcode/mcp/#valohai"
264
+ requires = ["uvx"]
265
+ setup = """Valohai has no MCP server of its own, so this one comes with lcode. It needs an API token: My Profile >
266
+ Authentication > Manage tokens. Read-only: add --allow-writes to its args in mcp.json to let the model start and stop
267
+ executions; lcode still asks before every call."""
268
+
269
+ [valohai.server]
270
+ command = "uvx"
271
+ args = ["--python", "3.12", "--from", "lcode-cli", "lcode-mcp-valohai"]
272
+ env = { VALOHAI_TOKEN = "${VALOHAI_TOKEN}", VALOHAI_HOST = "${VALOHAI_HOST}" }
273
+
274
+ [[valohai.inputs]]
275
+ var = "VALOHAI_TOKEN"
276
+ prompt = "Valohai API token"
277
+ secret = true
278
+
279
+ [[valohai.inputs]]
280
+ var = "VALOHAI_HOST"
281
+ prompt = "Valohai address (Enter for app.valohai.com; for a self-hosted installation, its URL)"
282
+ optional = true
lcode/mcp/server.py ADDED
@@ -0,0 +1,152 @@
1
+ """A small MCP server over stdio, for the servers lcode ships itself (lcode.servers).
2
+
3
+ It speaks both eras of MCP, like lcode's client: the legacy `initialize` handshake and the modern
4
+ stateless `server/discover`. Tools are plain functions that return text; raising ToolFailure turns
5
+ into an error result the model can read. Tools that change something are only listed when the
6
+ server is started with --allow-writes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import traceback
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import IO
17
+
18
+ from lcode.mcp.protocol import LEGACY_VERSIONS, METHOD_NOT_FOUND, MODERN_VERSIONS
19
+
20
+ INVALID_PARAMS = -32602
21
+ MAX_RESULT = 60_000 # characters per tool result; long listings are cut with a note
22
+
23
+
24
+ class ToolFailure(Exception):
25
+ """A tool couldn't do its job; the message goes to the model."""
26
+
27
+
28
+ @dataclass
29
+ class Tool:
30
+ name: str
31
+ description: str
32
+ properties: dict
33
+ required: list[str]
34
+ fn: Callable[..., str]
35
+ writes: bool = False
36
+
37
+ def definition(self) -> dict:
38
+ return {
39
+ "name": self.name,
40
+ "description": self.description,
41
+ "inputSchema": {"type": "object", "properties": self.properties, "required": self.required},
42
+ "annotations": {"readOnlyHint": not self.writes, "destructiveHint": self.writes},
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class Server:
48
+ name: str
49
+ version: str
50
+ instructions: str = ""
51
+ allow_writes: bool = False
52
+ tools: dict[str, Tool] = field(default_factory=dict)
53
+
54
+ def tool(self, name: str, description: str, properties: dict | None = None, required=(), writes: bool = False):
55
+ """Register a function as a tool (a decorator)."""
56
+
57
+ def register(fn: Callable[..., str]) -> Callable[..., str]:
58
+ self.tools[name] = Tool(name, description, properties or {}, list(required), fn, writes)
59
+ return fn
60
+
61
+ return register
62
+
63
+ def listed(self) -> list[Tool]:
64
+ return [t for t in self.tools.values() if self.allow_writes or not t.writes]
65
+
66
+ # -- JSON-RPC
67
+ def handle(self, message: dict) -> dict | None:
68
+ """The response to one message (None for notifications)."""
69
+ method, params, ident = message.get("method"), message.get("params") or {}, message.get("id")
70
+ if ident is None:
71
+ return None # a notification, such as notifications/initialized
72
+ try:
73
+ result = self.dispatch(str(method), params if isinstance(params, dict) else {})
74
+ except _RpcError as e:
75
+ return {"jsonrpc": "2.0", "id": ident, "error": {"code": e.code, "message": str(e)}}
76
+ return {"jsonrpc": "2.0", "id": ident, "result": result}
77
+
78
+ def dispatch(self, method: str, params: dict) -> dict:
79
+ info = {"name": self.name, "version": self.version}
80
+ if method == "initialize":
81
+ asked = str(params.get("protocolVersion") or "")
82
+ return {
83
+ "protocolVersion": asked if asked in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
84
+ "capabilities": {"tools": {}},
85
+ "serverInfo": info,
86
+ "instructions": self.instructions,
87
+ }
88
+ if method == "server/discover":
89
+ return {
90
+ "supportedVersions": list(MODERN_VERSIONS),
91
+ "capabilities": {"tools": {}},
92
+ "instructions": self.instructions,
93
+ "_meta": {"io.modelcontextprotocol/serverInfo": info},
94
+ }
95
+ if method == "ping":
96
+ return {}
97
+ if method == "tools/list":
98
+ return {"tools": [t.definition() for t in self.listed()]}
99
+ if method == "tools/call":
100
+ return self.call(str(params.get("name", "")), params.get("arguments") or {})
101
+ raise _RpcError(METHOD_NOT_FOUND, f"unknown method {method}")
102
+
103
+ def call(self, name: str, arguments: dict) -> dict:
104
+ tool = self.tools.get(name)
105
+ if tool is None or tool not in self.listed():
106
+ hint = " (start the server with --allow-writes to enable it)" if tool else ""
107
+ return _text(f"Unknown tool {name}{hint}", error=True)
108
+ if not isinstance(arguments, dict):
109
+ raise _RpcError(INVALID_PARAMS, "arguments must be an object")
110
+ unknown = set(arguments) - set(tool.properties)
111
+ missing = [r for r in tool.required if arguments.get(r) in (None, "")]
112
+ if unknown or missing:
113
+ problems = [f"unknown argument(s): {', '.join(sorted(unknown))}"] if unknown else []
114
+ problems += [f"missing: {', '.join(missing)}"] if missing else []
115
+ return _text(f"Bad arguments for {name}: {'; '.join(problems)}", error=True)
116
+ try:
117
+ text = tool.fn(**arguments)
118
+ except ToolFailure as e:
119
+ return _text(str(e), error=True)
120
+ except Exception as e: # report, don't crash the server
121
+ traceback.print_exc(file=sys.stderr)
122
+ return _text(f"{type(e).__name__}: {e}", error=True)
123
+ if len(text) > MAX_RESULT:
124
+ text = text[:MAX_RESULT] + "\n… (cut off: ask for less, e.g. with a smaller limit or a filter)"
125
+ return _text(text)
126
+
127
+ def serve(self, stdin: IO[str] | None = None, stdout: IO[str] | None = None) -> None:
128
+ """Answer newline-delimited JSON-RPC on stdin/stdout until stdin closes."""
129
+ stdin, stdout = stdin or sys.stdin, stdout or sys.stdout
130
+ for line in stdin:
131
+ line = line.strip()
132
+ if not line:
133
+ continue
134
+ try:
135
+ message = json.loads(line)
136
+ except json.JSONDecodeError:
137
+ response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
138
+ else:
139
+ response = self.handle(message) if isinstance(message, dict) else None
140
+ if response is not None:
141
+ stdout.write(json.dumps(response) + "\n")
142
+ stdout.flush()
143
+
144
+
145
+ class _RpcError(Exception):
146
+ def __init__(self, code: int, message: str):
147
+ super().__init__(message)
148
+ self.code = code
149
+
150
+
151
+ def _text(text: str, error: bool = False) -> dict:
152
+ return {"content": [{"type": "text", "text": text}], "isError": error}
@@ -0,0 +1 @@
1
+ """MCP servers that lcode ships for services that have none of their own."""
@@ -0,0 +1,285 @@
1
+ """An MCP server for Encord: projects, datasets, ontologies, workflow progress, tasks and labels.
2
+
3
+ Encord has a Python SDK but no MCP server, so lcode ships this one, built on the SDK
4
+ (`pip install encord`; `uvx --from "lcode-cli[encord]" lcode-mcp-encord` brings it along). It signs in
5
+ with an SSH key registered in Encord (Settings > Public keys): ENCORD_SSH_KEY_FILE names the key
6
+ file, or ENCORD_SSH_KEY holds the key itself. It's read-only unless started with --allow-writes,
7
+ which adds assigning tasks and setting their priority.
8
+
9
+ Run: lcode-mcp-encord [--allow-writes] (lcode mcp add encord sets it up)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ from collections import Counter
19
+
20
+ from lcode import __version__
21
+ from lcode.mcp.server import Server, ToolFailure
22
+
23
+ MAX_ROWS = 20_000 # label rows read for progress counts
24
+ UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
25
+
26
+
27
+ def _title(value) -> str:
28
+ return getattr(value, "title", None) or str(value or "")
29
+
30
+
31
+ def _status(row) -> str:
32
+ status = getattr(row, "annotation_task_status", None) or getattr(row, "label_status", None)
33
+ return str(getattr(status, "value", status) or "")
34
+
35
+
36
+ def _stage(row) -> str:
37
+ return _title(getattr(row, "workflow_graph_node", None)) or "(no workflow stage)"
38
+
39
+
40
+ def build(client, allow_writes: bool = False) -> Server:
41
+ """The server for an EncordUserClient (or anything with the same methods, in tests)."""
42
+ server = Server(
43
+ "encord",
44
+ __version__,
45
+ "Encord data and annotation platform: projects (annotation work on datasets, with an ontology and a "
46
+ "workflow of stages such as Annotate and Review), datasets, tasks (one per data unit) and labels.",
47
+ allow_writes,
48
+ )
49
+ project_arg = {"type": "string", "description": "Project title or project hash"}
50
+
51
+ def find_project(name: str):
52
+ name = name.strip()
53
+ if UUID.match(name):
54
+ return client.get_project(name)
55
+ found = list(client.list_projects(title_eq=name)) or list(client.list_projects(title_like=f"%{name}%"))
56
+ if not found:
57
+ raise ToolFailure(f"no project {name!r}; list_projects shows the ones you can see")
58
+ if len(found) > 1 and not any(p.title == name for p in found):
59
+ raise ToolFailure(f"{len(found)} projects match {name!r}: {', '.join(p.title for p in found[:10])}")
60
+ return next((p for p in found if p.title == name), found[0])
61
+
62
+ def rows(p, **filters) -> list:
63
+ return list(p.list_label_rows_v2(include_workflow_graph_node=True, **filters))[:MAX_ROWS]
64
+
65
+ @server.tool(
66
+ "list_projects",
67
+ "List the Encord projects you can see.",
68
+ {"title_contains": {"type": "string", "description": "Only projects whose title contains this"}},
69
+ )
70
+ def list_projects(title_contains: str = "") -> str:
71
+ found = list(client.list_projects(title_like=f"%{title_contains}%" if title_contains else None))
72
+ if not found:
73
+ return "No projects."
74
+ return "\n".join(
75
+ f"{p.title} hash={p.project_hash} created {str(getattr(p, 'created_at', ''))[:10]}"
76
+ + (f"\n {p.description[:150]}" if getattr(p, "description", "") else "")
77
+ for p in found
78
+ )
79
+
80
+ @server.tool(
81
+ "list_datasets",
82
+ "List the Encord datasets you can see.",
83
+ {"title_contains": {"type": "string", "description": "Only datasets whose title contains this"}},
84
+ )
85
+ def list_datasets(title_contains: str = "") -> str:
86
+ found = client.get_datasets(title_like=f"%{title_contains}%" if title_contains else None)
87
+ if not found:
88
+ return "No datasets."
89
+ lines = []
90
+ for entry in found:
91
+ d = entry.get("dataset") if isinstance(entry, dict) else entry
92
+ lines.append(f"{d.title} hash={d.dataset_hash} edited {str(getattr(d, 'last_edited_at', ''))[:10]}")
93
+ return "\n".join(lines)
94
+
95
+ @server.tool(
96
+ "get_project",
97
+ "A project's description, datasets, ontology (object and classification classes) and workflow stages.",
98
+ {"project": project_arg},
99
+ ["project"],
100
+ )
101
+ def get_project(project: str) -> str:
102
+ p = find_project(project)
103
+ parts = [f"{p.title} hash={p.project_hash}"]
104
+ if getattr(p, "description", ""):
105
+ parts.append(p.description)
106
+ datasets = [f"{d.title} ({d.dataset_hash})" for d in p.list_datasets()]
107
+ parts.append(f"datasets: {', '.join(datasets) or 'none'}")
108
+ parts.append(ontology_text(p))
109
+ try:
110
+ stages = [f"{s.title} ({type(s).__name__.removesuffix('Stage').lower()})" for s in p.workflow.stages]
111
+ parts.append(f"workflow stages: {', '.join(stages)}")
112
+ except Exception: # projects without a workflow
113
+ pass
114
+ return "\n".join(parts)
115
+
116
+ def ontology_text(p) -> str:
117
+ structure = p.ontology_structure
118
+ objects = [
119
+ f"{o.name} ({getattr(getattr(o, 'shape', ''), 'value', getattr(o, 'shape', ''))})"
120
+ for o in structure.objects
121
+ ]
122
+ classifications = []
123
+ for c in structure.classifications:
124
+ attributes = getattr(c, "attributes", []) or []
125
+ classifications.append(attributes[0].name if attributes else str(getattr(c, "feature_node_hash", "?")))
126
+ return (
127
+ f"ontology objects: {', '.join(objects) or 'none'}\n"
128
+ f"ontology classifications: {', '.join(classifications) or 'none'}"
129
+ )
130
+
131
+ @server.tool(
132
+ "workflow_progress",
133
+ "How many tasks are in each workflow stage, and their statuses: a project's labeling progress.",
134
+ {"project": project_arg},
135
+ ["project"],
136
+ )
137
+ def workflow_progress(project: str) -> str:
138
+ p = find_project(project)
139
+ found = rows(p)
140
+ if not found:
141
+ return "No data units in this project."
142
+ stages = Counter(_stage(r) for r in found)
143
+ statuses = Counter((_stage(r), _status(r)) for r in found)
144
+ lines = [f"{len(found)} data units" + (" (stopped counting here)" if len(found) >= MAX_ROWS else "")]
145
+ for stage, count in stages.most_common():
146
+ detail = ", ".join(f"{s or 'no status'} {n}" for (st, s), n in statuses.items() if st == stage)
147
+ lines.append(f"{stage}: {count} ({detail})")
148
+ return "\n".join(lines)
149
+
150
+ @server.tool(
151
+ "list_tasks",
152
+ "List data units (tasks) of a project with their workflow stage, status and last edit.",
153
+ {
154
+ "project": project_arg,
155
+ "stage": {"type": "string", "description": "Only tasks in this workflow stage, e.g. Review"},
156
+ "title_contains": {"type": "string", "description": "Only data units whose title contains this"},
157
+ "limit": {"type": "integer", "description": "How many (default 50, at most 500)"},
158
+ },
159
+ ["project"],
160
+ )
161
+ def list_tasks(project: str, stage: str = "", title_contains: str = "", limit: int = 50) -> str:
162
+ p = find_project(project)
163
+ filters = {}
164
+ if stage:
165
+ filters["workflow_graph_node_title_eq"] = stage
166
+ if title_contains:
167
+ filters["data_title_like"] = f"%{title_contains}%"
168
+ found = rows(p, **filters)[: max(1, min(int(limit), 500))]
169
+ if not found:
170
+ return "No tasks match."
171
+ return "\n".join(
172
+ f"{r.data_title} stage={_stage(r)} status={_status(r) or '-'} "
173
+ f"edited {str(getattr(r, 'last_edited_at', '') or '')[:16]} data_hash={r.data_hash}"
174
+ for r in found
175
+ )
176
+
177
+ @server.tool(
178
+ "get_labels",
179
+ "The labels on one data unit: how many objects and classifications of each class, and the label JSON.",
180
+ {
181
+ "project": project_arg,
182
+ "data_title": {"type": "string", "description": "The data unit's title (file name), from list_tasks"},
183
+ },
184
+ ["project", "data_title"],
185
+ )
186
+ def get_labels(project: str, data_title: str) -> str:
187
+ p = find_project(project)
188
+ found = rows(p, data_title_eq=data_title)
189
+ if not found:
190
+ raise ToolFailure(f"no data unit titled {data_title!r} in {p.title}")
191
+ row = found[0]
192
+ row.initialise_labels()
193
+ objects = Counter(o.ontology_item.name for o in row.get_object_instances())
194
+ classifications = Counter(_classification_name(c) for c in row.get_classification_instances())
195
+ summary = [
196
+ f"{row.data_title} stage={_stage(row)} status={_status(row) or '-'}",
197
+ f"objects: {', '.join(f'{k} ×{v}' for k, v in objects.items()) or 'none'}",
198
+ f"classifications: {', '.join(f'{k} ×{v}' for k, v in classifications.items()) or 'none'}",
199
+ ]
200
+ return "\n".join(summary) + "\n\n" + json.dumps(row.to_encord_dict(), default=str)[:40_000]
201
+
202
+ @server.tool(
203
+ "get_ontology",
204
+ "A project's ontology: object classes with their shapes, and classifications.",
205
+ {"project": project_arg},
206
+ ["project"],
207
+ )
208
+ def get_ontology(project: str) -> str:
209
+ return ontology_text(find_project(project))
210
+
211
+ @server.tool(
212
+ "assign_task",
213
+ "Assign the task of one data unit, in a workflow stage, to a person (by email).",
214
+ {
215
+ "project": project_arg,
216
+ "stage": {"type": "string", "description": "The workflow stage, e.g. Annotate"},
217
+ "data_title": {"type": "string"},
218
+ "assignee": {"type": "string", "description": "The person's email in Encord"},
219
+ },
220
+ ["project", "stage", "data_title", "assignee"],
221
+ writes=True,
222
+ )
223
+ def assign_task(project: str, stage: str, data_title: str, assignee: str) -> str:
224
+ p = find_project(project)
225
+ try:
226
+ workflow_stage = p.workflow.get_stage(name=stage)
227
+ except Exception as e:
228
+ raise ToolFailure(f"no workflow stage {stage!r} in {p.title}") from e
229
+ tasks = list(workflow_stage.get_tasks(data_title=data_title))
230
+ if not tasks:
231
+ raise ToolFailure(f"no task for {data_title!r} in the {stage} stage")
232
+ for task in tasks:
233
+ task.assign(assignee)
234
+ return f"Assigned {len(tasks)} task(s) for {data_title} in {stage} to {assignee}."
235
+
236
+ @server.tool(
237
+ "set_priority",
238
+ "Set the priority of a data unit's task (0 to 1; higher is worked on first).",
239
+ {"project": project_arg, "data_title": {"type": "string"}, "priority": {"type": "number"}},
240
+ ["project", "data_title", "priority"],
241
+ writes=True,
242
+ )
243
+ def set_priority(project: str, data_title: str, priority: float) -> str:
244
+ if not 0 <= float(priority) <= 1:
245
+ raise ToolFailure("priority must be between 0 and 1")
246
+ p = find_project(project)
247
+ found = rows(p, data_title_eq=data_title)
248
+ if not found:
249
+ raise ToolFailure(f"no data unit titled {data_title!r} in {p.title}")
250
+ for row in found:
251
+ row.set_priority(float(priority))
252
+ return f"Set the priority of {data_title} to {float(priority)}."
253
+
254
+ return server
255
+
256
+
257
+ def _classification_name(instance) -> str:
258
+ classification = getattr(instance, "ontology_item", None)
259
+ attributes = getattr(classification, "attributes", None) or []
260
+ return attributes[0].name if attributes else str(getattr(classification, "feature_node_hash", "?"))
261
+
262
+
263
+ def main(argv: list[str] | None = None) -> None:
264
+ parser = argparse.ArgumentParser(prog="lcode-mcp-encord", description="MCP server for Encord (stdio).")
265
+ parser.add_argument("--allow-writes", action="store_true", help="also offer assigning tasks and setting priorities")
266
+ args = parser.parse_args(argv)
267
+ try:
268
+ from encord import EncordUserClient
269
+ except ImportError:
270
+ parser.error('needs the Encord SDK: run it with uvx --from "lcode-cli[encord]" lcode-mcp-encord')
271
+ if os.environ.get("ENCORD_SSH_KEY_FILE"):
272
+ key_file = os.path.expanduser(os.environ["ENCORD_SSH_KEY_FILE"])
273
+ if not os.path.isfile(key_file):
274
+ parser.error(f"ENCORD_SSH_KEY_FILE is {key_file}, which doesn't exist")
275
+ os.environ["ENCORD_SSH_KEY_FILE"] = key_file
276
+ elif not os.environ.get("ENCORD_SSH_KEY"):
277
+ parser.error("set ENCORD_SSH_KEY_FILE to the path of an SSH key registered in Encord (Settings > Public keys)")
278
+ client = EncordUserClient.create_with_ssh_private_key(
279
+ domain=os.environ.get("ENCORD_DOMAIN") or "https://api.encord.com"
280
+ )
281
+ build(client, args.allow_writes).serve()
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
@@ -0,0 +1,329 @@
1
+ """An MCP server for Valohai: projects, executions, their logs, metrics and outputs, and pipelines.
2
+
3
+ Valohai has a REST API but no MCP server, so lcode ships this one. It needs an API token
4
+ (VALOHAI_TOKEN, or VH_API_TOKEN as in Valohai's docs; create one under My Profile > Authentication)
5
+ and talks to https://app.valohai.com unless VALOHAI_HOST names a self-hosted installation.
6
+ It's read-only unless started with --allow-writes, which adds starting and stopping executions.
7
+
8
+ Run: lcode-mcp-valohai [--allow-writes] (lcode mcp add valohai sets it up)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+
17
+ import requests
18
+
19
+ from lcode import __version__
20
+ from lcode.mcp.server import Server, ToolFailure
21
+
22
+ DEFAULT_HOST = "https://app.valohai.com"
23
+ TIMEOUT = 60
24
+
25
+
26
+ class Valohai:
27
+ def __init__(self, host: str, token: str):
28
+ host = host.strip().rstrip("/")
29
+ self.base = ("" if "://" in host else "https://") + host + "/api/v0/"
30
+ self.session = requests.Session()
31
+ self.session.headers.update({"Authorization": f"Token {token}", "Accept": "application/json"})
32
+
33
+ def request(self, method: str, path: str, **kwargs) -> dict:
34
+ url = path if path.startswith("http") else self.base + path.lstrip("/")
35
+ try:
36
+ r = self.session.request(method, url, timeout=TIMEOUT, **kwargs)
37
+ except requests.RequestException as e:
38
+ raise ToolFailure(f"can't reach Valohai: {e}") from e
39
+ if r.status_code in (401, 403):
40
+ raise ToolFailure("Valohai refused the token: check VALOHAI_TOKEN (My Profile > Authentication)")
41
+ if r.status_code == 404:
42
+ raise ToolFailure(f"not found in Valohai: {path}")
43
+ if r.status_code >= 400:
44
+ raise ToolFailure(f"Valohai error {r.status_code}: {r.text[:500]}")
45
+ return r.json() if r.content else {}
46
+
47
+ def all(self, path: str, params: dict, limit: int) -> list[dict]:
48
+ """Up to `limit` results of a paginated listing."""
49
+ found: list[dict] = []
50
+ data = self.request("GET", path, params={**params, "limit": min(limit, 100)})
51
+ while True:
52
+ found += data.get("results") or []
53
+ if len(found) >= limit or not data.get("next"):
54
+ return found[:limit]
55
+ data = self.request("GET", data["next"])
56
+
57
+ def project(self, name_or_id: str) -> dict:
58
+ """A project by id, name or owner/name."""
59
+ wanted = name_or_id.strip().lower()
60
+ projects = self.all("projects/", {}, 1000)
61
+ for p in projects:
62
+ owner = (p.get("owner") or {}).get("username", "")
63
+ if wanted in (
64
+ str(p.get("id", "")).lower(),
65
+ str(p.get("name", "")).lower(),
66
+ f"{owner}/{p.get('name')}".lower(),
67
+ ):
68
+ return p
69
+ names = ", ".join(sorted(str(p.get("name")) for p in projects)[:30])
70
+ raise ToolFailure(f"no project {name_or_id!r}; projects: {names or 'none'}")
71
+
72
+ def execution(self, project: str, counter: int) -> dict:
73
+ p = self.project(project)
74
+ return self.request("GET", f"executions/{p['id']}:{int(counter)}/", params={"exclude": "events"})
75
+
76
+
77
+ def _when(value: str | None) -> str:
78
+ return (value or "")[:16].replace("T", " ")
79
+
80
+
81
+ def _stream(event: dict) -> str:
82
+ return str(event.get("stream", "")).removeprefix("std")[:3] # out, err or sta(tus)
83
+
84
+
85
+ def _duration(seconds) -> str:
86
+ if not isinstance(seconds, (int, float)):
87
+ return ""
88
+ minutes, secs = divmod(int(seconds), 60)
89
+ hours, minutes = divmod(minutes, 60)
90
+ return f"{hours}h{minutes:02d}m" if hours else f"{minutes}m{secs:02d}s"
91
+
92
+
93
+ def build(api: Valohai, allow_writes: bool = False) -> Server:
94
+ server = Server(
95
+ "valohai",
96
+ __version__,
97
+ "Valohai ML platform: projects, executions (runs of a step from valohai.yaml), their logs, metrics "
98
+ "(metadata) and outputs, and pipelines. Executions are identified by project and counter (#12).",
99
+ allow_writes,
100
+ )
101
+ project_arg = {"type": "string", "description": "Project name, owner/name or id"}
102
+ counter_arg = {"type": "integer", "description": "The execution's counter (the #number in Valohai)"}
103
+
104
+ @server.tool(
105
+ "list_projects",
106
+ "List the Valohai projects you can see, with their execution counts.",
107
+ {"name_contains": {"type": "string", "description": "Only projects whose name contains this"}},
108
+ )
109
+ def list_projects(name_contains: str = "") -> str:
110
+ projects = [
111
+ p for p in api.all("projects/", {}, 1000) if name_contains.lower() in str(p.get("name", "")).lower()
112
+ ]
113
+ if not projects:
114
+ return "No projects."
115
+ lines = []
116
+ for p in projects:
117
+ owner = (p.get("owner") or {}).get("username", "")
118
+ name = f"{owner}/{p.get('name')}" if owner else str(p.get("name"))
119
+ lines.append(
120
+ f"{name} id={p.get('id')} executions={p.get('execution_count', '?')}"
121
+ f" last run {_when(p.get('last_execution_ctime')) or 'never'}"
122
+ )
123
+ return "\n".join(lines)
124
+
125
+ @server.tool(
126
+ "list_executions",
127
+ "List a project's executions, newest first: counter, status, step, duration, and the error for failed ones.",
128
+ {
129
+ "project": project_arg,
130
+ "status": {"type": "string", "enum": ["queued", "started", "completed", "error", "stopped", "crashed"]},
131
+ "limit": {"type": "integer", "description": "How many (default 20, at most 200)"},
132
+ },
133
+ ["project"],
134
+ )
135
+ def list_executions(project: str, status: str = "", limit: int = 20) -> str:
136
+ p = api.project(project)
137
+ params = {"project": p["id"], "ordering": "-counter"}
138
+ if status:
139
+ params["status"] = status
140
+ executions = api.all("executions/", params, max(1, min(int(limit), 200)))
141
+ if not executions:
142
+ return "No executions."
143
+ lines = []
144
+ for e in executions:
145
+ line = (
146
+ f"#{e.get('counter')} {e.get('status')} step={e.get('step')} "
147
+ f"{_duration(e.get('duration'))} {_when(e.get('ctime'))}"
148
+ )
149
+ if e.get("error_text"):
150
+ line += f"\n error: {e['error_text'][:300]}"
151
+ lines.append(line)
152
+ return "\n".join(lines)
153
+
154
+ @server.tool(
155
+ "get_execution",
156
+ "One execution: status, step, commit, parameters, inputs, latest metrics (metadata), outputs and link.",
157
+ {"project": project_arg, "counter": counter_arg},
158
+ ["project", "counter"],
159
+ )
160
+ def get_execution(project: str, counter: int) -> str:
161
+ e = api.execution(project, counter)
162
+ commit = e.get("commit")
163
+ commit = commit.get("identifier") if isinstance(commit, dict) else commit
164
+ outputs = [o.get("name") for o in e.get("outputs") or [] if isinstance(o, dict)]
165
+ parts = [
166
+ f"#{e.get('counter')} {e.get('status')} step={e.get('step')} commit={commit}",
167
+ f"started {_when(e.get('ctime'))} duration {_duration(e.get('duration'))}",
168
+ f"link: {(e.get('urls') or {}).get('display', '')}",
169
+ ]
170
+ if e.get("error_text"):
171
+ parts.append(f"error: {e['error_text']}")
172
+ for key in ("parameters", "inputs", "cumulative_metadata"):
173
+ if e.get(key):
174
+ label = "metrics (latest values)" if key == "cumulative_metadata" else key
175
+ parts.append(f"{label}: {json.dumps(e[key], indent=1, default=str)}")
176
+ if outputs:
177
+ parts.append(f"outputs ({len(outputs)}): {', '.join(outputs[:50])}")
178
+ return "\n".join(parts)
179
+
180
+ @server.tool(
181
+ "get_execution_logs",
182
+ "The last lines of an execution's log (stdout and stderr, with timestamps).",
183
+ {
184
+ "project": project_arg,
185
+ "counter": counter_arg,
186
+ "limit": {"type": "integer", "description": "How many lines (default 200, at most 2000)"},
187
+ "stream": {"type": "string", "enum": ["stdout", "stderr", "status"], "description": "Only this stream"},
188
+ },
189
+ ["project", "counter"],
190
+ )
191
+ def get_execution_logs(project: str, counter: int, limit: int = 200, stream: str = "") -> str:
192
+ e = api.execution(project, counter)
193
+ data = api.request("GET", f"{e['url'].rstrip('/')}/events/", params={"limit": max(1, min(int(limit), 2000))})
194
+ fetched = data.get("events") or []
195
+ events = [ev for ev in fetched if not stream or ev.get("stream") == stream]
196
+ if not events:
197
+ return "No log lines."
198
+ lines = [
199
+ f"{str(ev.get('time', ''))[11:19]} {_stream(ev)} {str(ev.get('message', '')).rstrip()}" for ev in events
200
+ ]
201
+ if data.get("truncated"):
202
+ only = f", {stream} only" if stream else ""
203
+ lines.insert(0, f"(from the last {len(fetched)} of {data.get('total', '?')} lines{only})")
204
+ return "\n".join(lines)
205
+
206
+ @server.tool(
207
+ "list_outputs",
208
+ "The files an execution produced (datums), with sizes and their datum:// URIs for use as inputs.",
209
+ {"project": project_arg, "counter": counter_arg},
210
+ ["project", "counter"],
211
+ )
212
+ def list_outputs(project: str, counter: int) -> str:
213
+ e = api.execution(project, counter)
214
+ outputs = api.all("data/", {"output_execution": e["id"]}, 500)
215
+ if not outputs:
216
+ return "No outputs."
217
+ return "\n".join(f"{o.get('name')} {o.get('size', '?')} bytes datum://{o.get('id')}" for o in outputs)
218
+
219
+ @server.tool(
220
+ "compare_executions",
221
+ "Compare the latest metrics (metadata) and parameters of several executions side by side.",
222
+ {
223
+ "project": project_arg,
224
+ "counters": {"type": "array", "items": {"type": "integer"}, "description": "Execution counters, 2 to 20"},
225
+ },
226
+ ["project", "counters"],
227
+ )
228
+ def compare_executions(project: str, counters: list) -> str:
229
+ if not 1 < len(counters) <= 20:
230
+ raise ToolFailure("give 2 to 20 execution counters")
231
+ rows = []
232
+ for counter in counters:
233
+ e = api.execution(project, int(counter))
234
+ rows.append((counter, e.get("status"), e.get("parameters") or {}, e.get("cumulative_metadata") or {}))
235
+ keys = sorted({k for _, _, _, m in rows for k in m})
236
+ params = sorted({k for _, _, p, _ in rows for k in p if len({json.dumps(r[2].get(k)) for r in rows}) > 1})
237
+ header = ["execution", "status", *params, *keys]
238
+ lines = ["\t".join(header)]
239
+ for counter, status, p, m in rows:
240
+ lines.append(
241
+ "\t".join(
242
+ [
243
+ f"#{counter}",
244
+ str(status),
245
+ *(str(p.get(k, "")) for k in params),
246
+ *(str(m.get(k, "")) for k in keys),
247
+ ]
248
+ )
249
+ )
250
+ return "\n".join(lines) + ("\n(only parameters that differ are shown)" if params else "")
251
+
252
+ @server.tool(
253
+ "list_pipelines",
254
+ "List a project's pipelines, newest first, with their status.",
255
+ {"project": project_arg, "limit": {"type": "integer", "description": "How many (default 20)"}},
256
+ ["project"],
257
+ )
258
+ def list_pipelines(project: str, limit: int = 20) -> str:
259
+ p = api.project(project)
260
+ pipelines = api.all("pipelines/", {"project": p["id"], "ordering": "-counter"}, max(1, min(int(limit), 200)))
261
+ if not pipelines:
262
+ return "No pipelines."
263
+ return "\n".join(
264
+ f"={pl.get('counter')} {pl.get('status')} {pl.get('title') or ''} {_when(pl.get('ctime'))}"
265
+ for pl in pipelines
266
+ )
267
+
268
+ @server.tool(
269
+ "start_execution",
270
+ "Start an execution of a step defined in the project's valohai.yaml.",
271
+ {
272
+ "project": project_arg,
273
+ "step": {"type": "string", "description": "The step's name in valohai.yaml"},
274
+ "commit": {"type": "string", "description": "The commit to run (as Valohai knows it, e.g. a git SHA)"},
275
+ "parameters": {"type": "object", "description": "Parameter values, by name"},
276
+ "inputs": {"type": "object", "description": "Input URLs or datum:// URIs, by input name (lists allowed)"},
277
+ "title": {"type": "string"},
278
+ },
279
+ ["project", "step", "commit"],
280
+ writes=True,
281
+ )
282
+ def start_execution(
283
+ project: str,
284
+ step: str,
285
+ commit: str,
286
+ parameters: dict | None = None,
287
+ inputs: dict | None = None,
288
+ title: str = "",
289
+ ) -> str:
290
+ p = api.project(project)
291
+ payload = {"project": p["id"], "step": step, "commit": commit, "parameters": parameters or {}}
292
+ if inputs:
293
+ payload["inputs"] = {k: v if isinstance(v, list) else [v] for k, v in inputs.items()}
294
+ if title:
295
+ payload["title"] = title
296
+ e = api.request("POST", "executions/", json=payload)
297
+ return f"Started execution #{e.get('counter')} ({e.get('status')}): {(e.get('urls') or {}).get('display', '')}"
298
+
299
+ @server.tool(
300
+ "stop_execution",
301
+ "Stop a queued or running execution.",
302
+ {"project": project_arg, "counter": counter_arg},
303
+ ["project", "counter"],
304
+ writes=True,
305
+ )
306
+ def stop_execution(project: str, counter: int) -> str:
307
+ e = api.execution(project, counter)
308
+ stop = (e.get("urls") or {}).get("stop")
309
+ if not stop:
310
+ raise ToolFailure(f"execution #{counter} can't be stopped (status {e.get('status')})")
311
+ api.request("POST", stop)
312
+ return f"Asked Valohai to stop execution #{counter}."
313
+
314
+ return server
315
+
316
+
317
+ def main(argv: list[str] | None = None) -> None:
318
+ parser = argparse.ArgumentParser(prog="lcode-mcp-valohai", description="MCP server for Valohai (stdio).")
319
+ parser.add_argument("--allow-writes", action="store_true", help="also offer starting and stopping executions")
320
+ args = parser.parse_args(argv)
321
+ token = os.environ.get("VALOHAI_TOKEN") or os.environ.get("VH_API_TOKEN")
322
+ if not token:
323
+ parser.error("set VALOHAI_TOKEN to a Valohai API token (My Profile > Authentication)")
324
+ api = Valohai(os.environ.get("VALOHAI_HOST") or DEFAULT_HOST, token)
325
+ build(api, args.allow_writes).serve()
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -30,6 +30,8 @@ Requires-Dist: prompt-toolkit>=3.0.43
30
30
  Requires-Dist: requests>=2.31
31
31
  Requires-Dist: rich>=13.7
32
32
  Requires-Dist: tomli>=2.0; python_version < '3.11'
33
+ Provides-Extra: encord
34
+ Requires-Dist: encord>=0.1.200; extra == 'encord'
33
35
  Description-Content-Type: text/markdown
34
36
 
35
37
  <p align="center">
@@ -95,7 +97,7 @@ through [Ollama](https://ollama.com), and when it needs current information it c
95
97
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
96
98
  with tool calling.
97
99
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
98
- Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes and more with one command
100
+ Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes, Metabase, Encord, Valohai and more with one command
99
101
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
100
102
  - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
101
103
  vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
@@ -199,8 +201,10 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
199
201
 
200
202
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
201
203
 
202
- - [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
203
- - Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
204
+ - [Smarter context for small windows](https://github.com/nasser1941/lcode/issues/46)
205
+ - [Code intelligence with language servers](https://github.com/nasser1941/lcode/issues/44)
206
+ - [A repository map and semantic search with local embeddings](https://github.com/nasser1941/lcode/issues/45)
207
+ - [Hooks and permission rules](https://github.com/nasser1941/lcode/issues/43)
204
208
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
205
209
 
206
210
  ## Contributing
@@ -1,4 +1,4 @@
1
- lcode/__init__.py,sha256=Vt4u1brdsy5UMBysZVsHXtLIwN6S7GISlXj5GAGQI1w,118
1
+ lcode/__init__.py,sha256=i-okekpLRmfoVYVspTCLPm0ObNE0H6AiUYLOm67-GZM,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/agent.py,sha256=5vSYZYFg-5doSF1yx3YRWI8hKSMCxPnSNzJiIwicY_8,40626
4
4
  lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
@@ -25,17 +25,21 @@ lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
25
25
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
26
26
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
27
27
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
28
- lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
29
- lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
28
+ lcode/mcp/catalog.py,sha256=I_O10N7cBl0ZW6O7x2kwKn89pvEXUXODUh8FsPXnB-E,3790
29
+ lcode/mcp/catalog.toml,sha256=1KYskueMO35cdBGZbMPF7hnrzyE01QN_xeqlF6oNmTc,11540
30
30
  lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
31
31
  lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
32
32
  lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
33
33
  lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
34
34
  lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
35
35
  lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
36
+ lcode/mcp/server.py,sha256=Ru6CEpaFoweB_RPgIUMwB147GHDjMvylxQ54s-fJQhw,6242
36
37
  lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
37
- lcode_cli-0.10.0.dist-info/METADATA,sha256=qlFYNWlZjDePi7kqZ2JnRUVasJODV0Emqu6SDFhKvhI,11487
38
- lcode_cli-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
39
- lcode_cli-0.10.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
40
- lcode_cli-0.10.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
41
- lcode_cli-0.10.0.dist-info/RECORD,,
38
+ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
39
+ lcode/servers/encord.py,sha256=FzOINlIbQ9woL-x88nOiuptOtIxvPqse5IroXkT39iE,12648
40
+ lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
41
+ lcode_cli-0.11.0.dist-info/METADATA,sha256=OH_2_AWP3TysK3gUIkyWitRrcyfRm5jSNxrkMc60xWA,11549
42
+ lcode_cli-0.11.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
43
+ lcode_cli-0.11.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
44
+ lcode_cli-0.11.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
45
+ lcode_cli-0.11.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ lcode = lcode.cli:main
3
+ lcode-mcp-encord = lcode.servers.encord:main
4
+ lcode-mcp-valohai = lcode.servers.valohai:main
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- lcode = lcode.cli:main