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 +1 -1
- lcode/mcp/catalog.py +4 -0
- lcode/mcp/catalog.toml +57 -1
- lcode/mcp/server.py +152 -0
- lcode/servers/__init__.py +1 -0
- lcode/servers/encord.py +285 -0
- lcode/servers/valohai.py +329 -0
- {lcode_cli-0.10.0.dist-info → lcode_cli-0.11.0.dist-info}/METADATA +8 -4
- {lcode_cli-0.10.0.dist-info → lcode_cli-0.11.0.dist-info}/RECORD +12 -8
- lcode_cli-0.11.0.dist-info/entry_points.txt +4 -0
- lcode_cli-0.10.0.dist-info/entry_points.txt +0 -2
- {lcode_cli-0.10.0.dist-info → lcode_cli-0.11.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.10.0.dist-info → lcode_cli-0.11.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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."""
|
lcode/servers/encord.py
ADDED
|
@@ -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()
|
lcode/servers/valohai.py
ADDED
|
@@ -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.
|
|
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
|
-
- [
|
|
203
|
-
-
|
|
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=
|
|
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=
|
|
29
|
-
lcode/mcp/catalog.toml,sha256=
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
lcode_cli-0.
|
|
41
|
-
lcode_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|