nimbus-mcp 0.1.0__tar.gz
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.
- nimbus_mcp-0.1.0/.gitignore +1 -0
- nimbus_mcp-0.1.0/PKG-INFO +95 -0
- nimbus_mcp-0.1.0/README.md +81 -0
- nimbus_mcp-0.1.0/pyproject.toml +39 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/__init__.py +3 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/__main__.py +4 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/client.py +98 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/config.py +28 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/server.py +32 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/__init__.py +1 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/_guards.py +15 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/artifacts.py +61 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/build.py +29 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/discovery.py +106 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/live.py +147 -0
- nimbus_mcp-0.1.0/src/nimbus_mcp/tools/run.py +135 -0
- nimbus_mcp-0.1.0/tests/integration/test_e2e_smoke.py +50 -0
- nimbus_mcp-0.1.0/tests/integration/test_rest_drift.py +42 -0
- nimbus_mcp-0.1.0/tests/unit/test_client.py +68 -0
- nimbus_mcp-0.1.0/tests/unit/test_config.py +23 -0
- nimbus_mcp-0.1.0/tests/unit/test_tools_artifacts.py +119 -0
- nimbus_mcp-0.1.0/tests/unit/test_tools_build.py +62 -0
- nimbus_mcp-0.1.0/tests/unit/test_tools_discovery.py +150 -0
- nimbus_mcp-0.1.0/tests/unit/test_tools_live.py +128 -0
- nimbus_mcp-0.1.0/tests/unit/test_tools_run.py +189 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dist/
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nimbus-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server bridging AI agents to Nimbus BCI pipelines (local backend)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastmcp>=2.3
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.7
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == 'test'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# nimbus-mcp
|
|
16
|
+
|
|
17
|
+
MCP server that lets AI agents (Claude Code, Cursor, Claude Desktop) build, validate,
|
|
18
|
+
run, and analyze Nimbus BCI pipelines — and (explicitly confirmed) drive live EEG
|
|
19
|
+
streaming sessions — through your **local** Nimbus backend.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install nimbus-mcp # or: uvx nimbus-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
(Also installable from the repo: `pip install -e nimbus-studio/mcp`.)
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- A Nimbus backend running locally: the **desktop app**, or the dev server
|
|
32
|
+
(`cd nimbus-studio/backend-py && python -m nimbus_backend.server.app`) with `DEBUG=1`.
|
|
33
|
+
- The backend started with `MCP_LOCAL_KEY=<some-secret>` (never set this on Fly — it is
|
|
34
|
+
refused there).
|
|
35
|
+
|
|
36
|
+
## Configure the backend
|
|
37
|
+
|
|
38
|
+
Desktop/dev env (e.g. `backend-py/data/.env` or the dev shell):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
MCP_LOCAL_KEY=choose-a-long-random-string
|
|
42
|
+
DEBUG=1 # dev server only; the desktop app qualifies automatically
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
When enabling `MCP_LOCAL_KEY` on a machine connected to an untrusted network, also set
|
|
46
|
+
`HOST=127.0.0.1` on the backend — the default bind is `0.0.0.0`, so the key would
|
|
47
|
+
otherwise be accepted from the LAN.
|
|
48
|
+
|
|
49
|
+
## Run the server
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd nimbus-studio/mcp
|
|
53
|
+
python -m venv .venv && source .venv/bin/activate
|
|
54
|
+
pip install -e ".[test]"
|
|
55
|
+
NIMBUS_MCP_KEY=choose-a-long-random-string python -m nimbus_mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Env vars: `NIMBUS_API_URL` (default `http://127.0.0.1:8080`), `NIMBUS_MCP_KEY`
|
|
59
|
+
(must match `MCP_LOCAL_KEY`), `NIMBUS_EXPORT_DIR` (default `~/nimbus-exports`).
|
|
60
|
+
|
|
61
|
+
## Claude Code
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude mcp add nimbus -- <path-to-mcp-venv>/bin/python -m nimbus_mcp \
|
|
65
|
+
--env NIMBUS_MCP_KEY=choose-a-long-random-string
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Cursor / Claude Desktop (stdio)
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"nimbus": {
|
|
74
|
+
"command": "<path-to-mcp-venv>/bin/python",
|
|
75
|
+
"args": ["-m", "nimbus_mcp"],
|
|
76
|
+
"env": { "NIMBUS_MCP_KEY": "choose-a-long-random-string" }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Tools (20)
|
|
83
|
+
|
|
84
|
+
Discovery: `list_nodes`, `get_node_schema`, `list_templates`, `get_template`, `list_datasets`
|
|
85
|
+
Build: `validate_pipeline`, `validate_node_config`
|
|
86
|
+
Run: `run_pipeline` (non-blocking), `get_execution`, `list_executions`, `get_results`, `cancel_execution`
|
|
87
|
+
Artifacts: `list_artifacts`, `download_artifact`, `export_python`
|
|
88
|
+
Live: `list_devices`, `test_device`, `start_stream` (needs `confirm=true`),
|
|
89
|
+
`stream_status`, `stop_stream`
|
|
90
|
+
|
|
91
|
+
## Safety
|
|
92
|
+
|
|
93
|
+
`start_stream` refuses to run without `confirm=true` — it connects an EEG device and
|
|
94
|
+
starts a live session on a human. The `X-MCP-Key` path is machine-local only
|
|
95
|
+
(never accepted on Fly deployments).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# nimbus-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that lets AI agents (Claude Code, Cursor, Claude Desktop) build, validate,
|
|
4
|
+
run, and analyze Nimbus BCI pipelines — and (explicitly confirmed) drive live EEG
|
|
5
|
+
streaming sessions — through your **local** Nimbus backend.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install nimbus-mcp # or: uvx nimbus-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
(Also installable from the repo: `pip install -e nimbus-studio/mcp`.)
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- A Nimbus backend running locally: the **desktop app**, or the dev server
|
|
18
|
+
(`cd nimbus-studio/backend-py && python -m nimbus_backend.server.app`) with `DEBUG=1`.
|
|
19
|
+
- The backend started with `MCP_LOCAL_KEY=<some-secret>` (never set this on Fly — it is
|
|
20
|
+
refused there).
|
|
21
|
+
|
|
22
|
+
## Configure the backend
|
|
23
|
+
|
|
24
|
+
Desktop/dev env (e.g. `backend-py/data/.env` or the dev shell):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
MCP_LOCAL_KEY=choose-a-long-random-string
|
|
28
|
+
DEBUG=1 # dev server only; the desktop app qualifies automatically
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
When enabling `MCP_LOCAL_KEY` on a machine connected to an untrusted network, also set
|
|
32
|
+
`HOST=127.0.0.1` on the backend — the default bind is `0.0.0.0`, so the key would
|
|
33
|
+
otherwise be accepted from the LAN.
|
|
34
|
+
|
|
35
|
+
## Run the server
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd nimbus-studio/mcp
|
|
39
|
+
python -m venv .venv && source .venv/bin/activate
|
|
40
|
+
pip install -e ".[test]"
|
|
41
|
+
NIMBUS_MCP_KEY=choose-a-long-random-string python -m nimbus_mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Env vars: `NIMBUS_API_URL` (default `http://127.0.0.1:8080`), `NIMBUS_MCP_KEY`
|
|
45
|
+
(must match `MCP_LOCAL_KEY`), `NIMBUS_EXPORT_DIR` (default `~/nimbus-exports`).
|
|
46
|
+
|
|
47
|
+
## Claude Code
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add nimbus -- <path-to-mcp-venv>/bin/python -m nimbus_mcp \
|
|
51
|
+
--env NIMBUS_MCP_KEY=choose-a-long-random-string
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Cursor / Claude Desktop (stdio)
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"nimbus": {
|
|
60
|
+
"command": "<path-to-mcp-venv>/bin/python",
|
|
61
|
+
"args": ["-m", "nimbus_mcp"],
|
|
62
|
+
"env": { "NIMBUS_MCP_KEY": "choose-a-long-random-string" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Tools (20)
|
|
69
|
+
|
|
70
|
+
Discovery: `list_nodes`, `get_node_schema`, `list_templates`, `get_template`, `list_datasets`
|
|
71
|
+
Build: `validate_pipeline`, `validate_node_config`
|
|
72
|
+
Run: `run_pipeline` (non-blocking), `get_execution`, `list_executions`, `get_results`, `cancel_execution`
|
|
73
|
+
Artifacts: `list_artifacts`, `download_artifact`, `export_python`
|
|
74
|
+
Live: `list_devices`, `test_device`, `start_stream` (needs `confirm=true`),
|
|
75
|
+
`stream_status`, `stop_stream`
|
|
76
|
+
|
|
77
|
+
## Safety
|
|
78
|
+
|
|
79
|
+
`start_stream` refuses to run without `confirm=true` — it connects an EEG device and
|
|
80
|
+
starts a live session on a human. The `X-MCP-Key` path is machine-local only
|
|
81
|
+
(never accepted on Fly deployments).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nimbus-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server bridging AI agents to Nimbus BCI pipelines (local backend)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastmcp>=2.3",
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pydantic>=2.7",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
nimbus-mcp = "nimbus_mcp.server:main"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest>=8",
|
|
23
|
+
"pytest-asyncio>=0.23",
|
|
24
|
+
"ruff>=0.6",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/nimbus_mcp"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
target-version = "py310"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Thin HTTP client for the Nimbus backend, authenticated with X-MCP-Key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import McpConfig
|
|
10
|
+
|
|
11
|
+
_BACKEND_DOWN_HINT = (
|
|
12
|
+
"Cannot reach the Nimbus backend at {url}. "
|
|
13
|
+
"Start the desktop app (or the dev backend on :8080) and retry."
|
|
14
|
+
)
|
|
15
|
+
_AUTH_HINT = (
|
|
16
|
+
"Nimbus rejected the MCP key ({status}). Check that MCP_LOCAL_KEY on the backend "
|
|
17
|
+
"matches NIMBUS_MCP_KEY for this server, and that the backend runs locally "
|
|
18
|
+
"(desktop app or DEBUG=1) — the key is never accepted on Fly deployments."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class McpToolError(Exception):
|
|
23
|
+
"""Actionable error surfaced to the MCP client (agent-visible)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NimbusClient:
|
|
27
|
+
"""Blocking HTTP client; tools run in FastMCP's worker threads."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: McpConfig, transport: httpx.BaseTransport | None = None) -> None:
|
|
30
|
+
if not config.mcp_key:
|
|
31
|
+
raise McpToolError(
|
|
32
|
+
"NIMBUS_MCP_KEY is not set. Configure this MCP server with the same "
|
|
33
|
+
"value as MCP_LOCAL_KEY on the backend."
|
|
34
|
+
)
|
|
35
|
+
self.config = config
|
|
36
|
+
self._http = httpx.Client(
|
|
37
|
+
base_url=config.api_url,
|
|
38
|
+
headers={"X-MCP-Key": config.mcp_key},
|
|
39
|
+
timeout=300.0,
|
|
40
|
+
transport=transport,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
self._http.close()
|
|
45
|
+
|
|
46
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
47
|
+
return self._request("GET", path, params=params)
|
|
48
|
+
|
|
49
|
+
def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
|
|
50
|
+
return self._request("POST", path, json=json)
|
|
51
|
+
|
|
52
|
+
def get_bytes(self, path: str) -> bytes:
|
|
53
|
+
return self._request("GET", path, content=True)
|
|
54
|
+
|
|
55
|
+
def post_bytes(self, path: str, json: dict[str, Any] | None = None) -> bytes:
|
|
56
|
+
return self._request("POST", path, json=json, content=True)
|
|
57
|
+
|
|
58
|
+
def _request(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
path: str,
|
|
62
|
+
params: dict[str, Any] | None = None,
|
|
63
|
+
json: dict[str, Any] | None = None,
|
|
64
|
+
content: bool = False,
|
|
65
|
+
) -> Any:
|
|
66
|
+
try:
|
|
67
|
+
resp = self._http.request(method, path, params=params, json=json)
|
|
68
|
+
except httpx.HTTPError as err:
|
|
69
|
+
raise McpToolError(_BACKEND_DOWN_HINT.format(url=self.config.api_url)) from err
|
|
70
|
+
# 401 = the key was rejected by the auth layer. 403 is a policy denial
|
|
71
|
+
# (e.g. freemium limits) — let it fall through to the generic path so
|
|
72
|
+
# the backend's detail message surfaces.
|
|
73
|
+
if resp.status_code == 401:
|
|
74
|
+
raise McpToolError(_AUTH_HINT.format(status=resp.status_code))
|
|
75
|
+
if resp.status_code >= 400:
|
|
76
|
+
raise McpToolError(self._backend_error(resp))
|
|
77
|
+
return resp.content if content else self._json(resp)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _json(resp: httpx.Response) -> Any:
|
|
81
|
+
try:
|
|
82
|
+
return resp.json()
|
|
83
|
+
except ValueError:
|
|
84
|
+
return {"ok": False, "raw": resp.text[:500]}
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _backend_error(resp: httpx.Response) -> str:
|
|
88
|
+
body: Any = None
|
|
89
|
+
try:
|
|
90
|
+
body = resp.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass
|
|
93
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
94
|
+
if isinstance(detail, dict):
|
|
95
|
+
msg = detail.get("detail") or detail.get("title") or str(detail)
|
|
96
|
+
else:
|
|
97
|
+
msg = str(detail) if detail else resp.text[:300]
|
|
98
|
+
return f"Nimbus API error {resp.status_code}: {msg}"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Environment-driven configuration for the nimbus-mcp server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "http://127.0.0.1:8080"
|
|
11
|
+
DEFAULT_EXPORT_DIR = Path.home() / "nimbus-exports"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class McpConfig:
|
|
16
|
+
api_url: str
|
|
17
|
+
mcp_key: str
|
|
18
|
+
export_dir: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config(env: Mapping[str, str] | None = None) -> McpConfig:
|
|
22
|
+
"""Resolve config from ``env`` (defaults to ``os.environ``)."""
|
|
23
|
+
env = os.environ if env is None else env
|
|
24
|
+
return McpConfig(
|
|
25
|
+
api_url=env.get("NIMBUS_API_URL", DEFAULT_API_URL).rstrip("/"),
|
|
26
|
+
mcp_key=env.get("NIMBUS_MCP_KEY", ""),
|
|
27
|
+
export_dir=Path(env.get("NIMBUS_EXPORT_DIR", str(DEFAULT_EXPORT_DIR))).expanduser(),
|
|
28
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""FastMCP server assembly: one client, five tool modules, stdio transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from .client import NimbusClient
|
|
8
|
+
from .config import load_config
|
|
9
|
+
from .tools import artifacts, build, discovery, live, run
|
|
10
|
+
|
|
11
|
+
INSTRUCTIONS = """Nimbus Studio BCI tools. Typical flows:
|
|
12
|
+
1) Explore: list_nodes / get_node_schema / list_templates / get_template / list_datasets.
|
|
13
|
+
2) Build: compose a train graph ({nodes: [{id, type, config}], connections: [{from, to}]}),
|
|
14
|
+
validate_pipeline it, then run_pipeline (non-blocking) and poll get_execution.
|
|
15
|
+
3) Results: get_results (kappa, ITR, confusion matrix), list_artifacts / download_artifact,
|
|
16
|
+
export_python for a standalone zip.
|
|
17
|
+
4) Live (use with care): list_devices, test_device, then start_stream(confirm=true) only with
|
|
18
|
+
the user's explicit go-ahead — it connects an EEG device to a human session.
|
|
19
|
+
Note: expect filter/ASR warm-up periods and confidence to start low; signal quality matters."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_server(client: NimbusClient | None = None) -> FastMCP:
|
|
23
|
+
if client is None:
|
|
24
|
+
client = NimbusClient(load_config())
|
|
25
|
+
mcp = FastMCP("nimbus", instructions=INSTRUCTIONS)
|
|
26
|
+
for module in (discovery, build, run, artifacts, live):
|
|
27
|
+
module.register(mcp, client)
|
|
28
|
+
return mcp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
build_server().run() # stdio transport
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tool modules for nimbus-mcp. Each exposes register(mcp, client)."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared input guards for tool parameters interpolated into request paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..client import McpToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_segment(value: str, *, label: str = "execution id") -> str:
|
|
11
|
+
"""Validate a value used as a single path/URL segment (e.g. execution id)."""
|
|
12
|
+
# Path(value).name != value also rejects "." and any residual separator trick.
|
|
13
|
+
if not value or "/" in value or "\\" in value or ".." in value or Path(value).name != value:
|
|
14
|
+
raise McpToolError(f"Invalid {label} '{value}'.")
|
|
15
|
+
return value
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Artifact + export tools: list/download trained artifacts, export standalone Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from ..client import NimbusClient
|
|
11
|
+
from ._guards import safe_segment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_name(name: str) -> str:
|
|
15
|
+
# Same rejection rules as a path segment, labelled for artifact names.
|
|
16
|
+
return safe_segment(name, label="artifact name")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(mcp: FastMCP, client: NimbusClient) -> None:
|
|
20
|
+
@mcp.tool
|
|
21
|
+
def list_artifacts(execution_id: str) -> dict[str, Any]:
|
|
22
|
+
"""Trained artifacts (models/filters, e.g. *.pkl) saved by an execution."""
|
|
23
|
+
exec_id = safe_segment(execution_id)
|
|
24
|
+
payload = client.get(f"/api/executions/{exec_id}/artifacts")
|
|
25
|
+
return {
|
|
26
|
+
"executionId": execution_id,
|
|
27
|
+
"artifacts": [
|
|
28
|
+
{"name": a.get("name"), "size": a.get("size"), "subject": a.get("subject")}
|
|
29
|
+
for a in payload.get("artifacts", [])
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@mcp.tool
|
|
34
|
+
def download_artifact(execution_id: str, artifact_name: str) -> dict[str, Any]:
|
|
35
|
+
"""Download one artifact file to NIMBUS_EXPORT_DIR/executions/<id>/ and return its path."""
|
|
36
|
+
exec_id = safe_segment(execution_id)
|
|
37
|
+
artifact = _safe_name(artifact_name)
|
|
38
|
+
data = client.get_bytes(f"/api/executions/{exec_id}/artifacts/{artifact}")
|
|
39
|
+
dest_dir = client.config.export_dir / "executions" / exec_id
|
|
40
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
dest = dest_dir / artifact
|
|
42
|
+
dest.write_bytes(data)
|
|
43
|
+
return {"path": str(dest), "size": len(data)}
|
|
44
|
+
|
|
45
|
+
@mcp.tool
|
|
46
|
+
def export_python(train_graph: dict[str, Any], name: str | None = None) -> dict[str, Any]:
|
|
47
|
+
"""Export the pipeline as a standalone runnable Python bundle (zip saved locally)."""
|
|
48
|
+
body: dict[str, Any] = {"train": train_graph}
|
|
49
|
+
if name is not None:
|
|
50
|
+
body["name"] = name
|
|
51
|
+
data = client.post_bytes("/api/export/python", json=body)
|
|
52
|
+
out_dir = client.config.export_dir / "exports"
|
|
53
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
55
|
+
dest = out_dir / f"{stamp}-nimbus-export.zip"
|
|
56
|
+
suffix = 0
|
|
57
|
+
while dest.exists():
|
|
58
|
+
suffix += 1
|
|
59
|
+
dest = out_dir / f"{stamp}-nimbus-export-{suffix}.zip"
|
|
60
|
+
dest.write_bytes(data)
|
|
61
|
+
return {"path": str(dest), "size": len(data)}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Build tools: graph and node-config validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..client import NimbusClient
|
|
10
|
+
|
|
11
|
+
GRAPH_HELP = (
|
|
12
|
+
"ExecGraphSnapshot: {nodes: [{id, type, config}], connections: [{from, to}]}. "
|
|
13
|
+
"Build it from get_template(id).train or from scratch using list_nodes()."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(mcp: FastMCP, client: NimbusClient) -> None:
|
|
18
|
+
# NOTE: description is passed to @mcp.tool explicitly because
|
|
19
|
+
# `"""doc""" + GRAPH_HELP` is not a string literal and would not become __doc__.
|
|
20
|
+
@mcp.tool(description="Validate a pipeline graph before running. " + GRAPH_HELP)
|
|
21
|
+
def validate_pipeline(train_graph: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
return client.post("/api/validate-pipeline", json={"train": train_graph})
|
|
23
|
+
|
|
24
|
+
@mcp.tool
|
|
25
|
+
def validate_node_config(node_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Validate one node's config object against its schema (get_node_schema)."""
|
|
27
|
+
return client.post(
|
|
28
|
+
"/api/validate-node-config", json={"nodeType": node_type, "config": config}
|
|
29
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Discovery tools: node catalog, templates, datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..client import McpToolError, NimbusClient
|
|
10
|
+
from ._guards import safe_segment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(mcp: FastMCP, client: NimbusClient) -> None:
|
|
14
|
+
@mcp.tool
|
|
15
|
+
def list_nodes(category: str | None = None) -> dict[str, Any]:
|
|
16
|
+
"""List Nimbus pipeline node types (data, preprocessing, features, models...).
|
|
17
|
+
|
|
18
|
+
Use get_node_schema(node_type) for one node's full config schema and ports.
|
|
19
|
+
"""
|
|
20
|
+
payload = client.get("/api/node-types")
|
|
21
|
+
nodes = [
|
|
22
|
+
{
|
|
23
|
+
"id": n.get("id"),
|
|
24
|
+
"name": n.get("name"),
|
|
25
|
+
"category": n.get("category"),
|
|
26
|
+
"description": n.get("description", ""),
|
|
27
|
+
"inputs": [
|
|
28
|
+
{"name": p.get("name"), "type": p.get("type")} for p in n.get("inputs", [])
|
|
29
|
+
],
|
|
30
|
+
"outputs": [
|
|
31
|
+
{"name": p.get("name"), "type": p.get("type")} for p in n.get("outputs", [])
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
for n in payload.get("nodeTypes", [])
|
|
35
|
+
if category is None or n.get("category") == category
|
|
36
|
+
]
|
|
37
|
+
return {"count": len(nodes), "nodes": nodes}
|
|
38
|
+
|
|
39
|
+
@mcp.tool
|
|
40
|
+
def get_node_schema(node_type: str) -> dict[str, Any]:
|
|
41
|
+
"""Full config JSON schema + input/output ports for one node type."""
|
|
42
|
+
payload = client.get("/api/node-types")
|
|
43
|
+
for node in payload.get("nodeTypes", []):
|
|
44
|
+
if node.get("id") == node_type:
|
|
45
|
+
return {
|
|
46
|
+
"id": node_type,
|
|
47
|
+
"name": node.get("name"),
|
|
48
|
+
"configSchema": node.get("configSchema", {}),
|
|
49
|
+
"ports": {"inputs": node.get("inputs", []), "outputs": node.get("outputs", [])},
|
|
50
|
+
}
|
|
51
|
+
raise McpToolError(
|
|
52
|
+
f"Unknown node type '{node_type}'. Call list_nodes() for valid ids."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@mcp.tool
|
|
56
|
+
def list_templates() -> dict[str, Any]:
|
|
57
|
+
"""List built-in starter pipelines (MI/P300/SSVEP...). get_template(id) for the graph."""
|
|
58
|
+
payload = client.get("/api/templates")
|
|
59
|
+
templates = [
|
|
60
|
+
{
|
|
61
|
+
"id": t.get("id"),
|
|
62
|
+
"name": t.get("name"),
|
|
63
|
+
"description": t.get("description", ""),
|
|
64
|
+
"category": t.get("category"),
|
|
65
|
+
"expectedAccuracy": t.get("expectedAccuracy"),
|
|
66
|
+
}
|
|
67
|
+
for t in payload.get("templates", [])
|
|
68
|
+
]
|
|
69
|
+
return {"count": len(templates), "templates": templates}
|
|
70
|
+
|
|
71
|
+
@mcp.tool
|
|
72
|
+
def get_template(template_id: str) -> dict[str, Any]:
|
|
73
|
+
"""Full template incl. the 'train' execGraph needed by run_pipeline/validate_pipeline."""
|
|
74
|
+
tid = safe_segment(template_id, label="template id")
|
|
75
|
+
payload = client.get(f"/api/templates/{tid}")
|
|
76
|
+
template = payload.get("template", {})
|
|
77
|
+
return {
|
|
78
|
+
"id": template.get("id"),
|
|
79
|
+
"name": template.get("name"),
|
|
80
|
+
"description": template.get("description", ""),
|
|
81
|
+
"train": template.get("train"),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@mcp.tool
|
|
85
|
+
def list_datasets(only_on_disk: bool = True) -> dict[str, Any]:
|
|
86
|
+
"""Curated public EEG datasets (MOABB packs) available to pipelines."""
|
|
87
|
+
payload = client.get("/api/public-datasets/index")
|
|
88
|
+
datasets = []
|
|
89
|
+
for dataset_id, entry in payload.get("datasets", {}).items():
|
|
90
|
+
if only_on_disk and not entry.get("onDisk"):
|
|
91
|
+
continue
|
|
92
|
+
subjects = entry.get("subjects") or []
|
|
93
|
+
details = entry.get("details") or {}
|
|
94
|
+
datasets.append(
|
|
95
|
+
{
|
|
96
|
+
"id": dataset_id,
|
|
97
|
+
"label": entry.get("label"),
|
|
98
|
+
"paradigm": entry.get("paradigm"),
|
|
99
|
+
"onDisk": bool(entry.get("onDisk")),
|
|
100
|
+
"defaultSubject": entry.get("defaultSubject"),
|
|
101
|
+
"subjectsCount": len(subjects),
|
|
102
|
+
"channels": details.get("channels"),
|
|
103
|
+
"samplingRate": details.get("samplingRate"),
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return {"count": len(datasets), "datasets": datasets}
|