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.
@@ -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,3 @@
1
+ """nimbus-mcp: MCP tools for Nimbus BCI pipelines (local backend bridge)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from nimbus_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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}