Skip to main content
Glama
README.md
# nimbus-mcp

<!-- mcp-name: io.github.nimbusbci/nimbus-mcp -->

<!-- smithery badge: their /badge/ endpoint 500s platform-wide (2026-10-07); shields.io image + link keeps the badge rendering -->
[![smithery badge](https://img.shields.io/badge/smithery-nimbusbci%2Fnimbus--mcp-2EA043)](https://smithery.ai/servers/nimbusbci/nimbus-mcp)
[![M8ven Verified](https://m8ven.ai/badge/mcp/nimbusbci-nimbus-mcp-1xjm62?variant=verified&v=01611e09ab598e1f20772a0fc6bd1b96)](https://m8ven.ai/mcp/nimbusbci-nimbus-mcp-1xjm62?s=readme)

MCP server that lets AI agents (Claude Code, Cursor, Claude Desktop) build, validate,
run, and analyze Nimbus BCI pipelines — upload their own EEG data, persist pipelines
into studio projects, run multi-configuration experiment campaigns, watch live EEG
sessions, and (explicitly confirmed) start live streaming — through your **local**
Nimbus backend **or the hosted deployment, with one `nimbus-mcp login`**.

## Why nimbus-mcp (October 2026 field)

The BCI-MCP niche grew four other servers in 2026. They are honest,
focused tools — and each covers a slice of what nimbus-mcp does end-to-end:

| Capability | nimbus-mcp | bci-mcp | eeg-mcp | mne-mcp | ephys-mcp |
|---|---|---|---|---|---|
| Build + validate + run BCI pipelines | ✅ 63 tools | — | — | — | — |
| Train on real data (MOABB packs, uploads) | ✅ | — | — | file I/O only | NWB/DANDI |
| Bring-your-own model code (`python_model`) | ✅ local + cloud sandbox | — | — | — | — |
| Benchmark leaderboard + server-side LOSO scoring | ✅ | — | — | — | — |
| Agent-visible figures (PNG content blocks) | ✅ `plots.*` | — | — | ✅ | ✅ |
| Live hardware sessions on a human | ✅ confirm-gated | ✅ | ✅ | — | — |
| Modelless live band powers (focus/relaxation) | ✅ `stream.telemetry` | ✅ | ✅ | — | — |
| Guided subject calibration | ✅ `calibration.*` | — | — | — | — |
| Neurofeedback protocol sessions | ✅ `protocol.*` | — | — | — | — |
| BIDS export (sha256 manifests) | ✅ `bids.*` | — | — | — | — |
| Cluster-aware group statistics | ✅ `stats.grouped` | — | — | — | partial |
| Transport | stdio + hosted HTTP gateway | stdio + HTTP | stdio | stdio | stdio |
| Validation evidence | [arXiv:2610.13894](https://arxiv.org/abs/2610.13894) — parity study, 3 datasets / 6 pipelines / 2 protocols | band-ratio heuristics | — | — | — |

Scope honesty (deliberate non-goals): **no stimulation output** (eeg-mcp's
triple-interlocked `send_stim_event` is genuinely novel; we don't drive
stimulators), **no spike/ephys data** (ephys-mcp covers intracortical
recording — different data plane), **no source localization / full MNE
parity** (mne-mcp is a deep offline MNE wrapper; `plots.*` covers the
figures agents need on Nimbus data).

## Install

```bash
pip install nimbus-mcp   # or: uvx nimbus-mcp
```

(Also installable from the repo: `pip install -e nimbus-studio/mcp`.)

**Zero-hardware demo**: [examples/demo.md](examples/demo.md) — a copy-paste
agent prompt that runs the whole loop (explore → inspect → figures → train →
confusion figure → leaderboard → stats) on public MOABB data, no device.

## Authentication

Three ways to give the server a credential — tried in this order at startup:

1. **`nimbus-mcp login` (recommended — hosted API, no token pasting).** A
   device-code login: the command prints a URL and an 8-character code, opens
   your browser, you approve in Nimbus Studio, and the minted token is stored
   at `~/.nimbus/credentials.json` (0600) and picked up automatically on every
   future start.

   ```bash
   nimbus-mcp login     # options: --api-url URL, --ttl-days 7..90, --name NAME
   nimbus-mcp status    # doctor: credential source, plan, quota, days-to-expiry, live probe
   nimbus-mcp logout    # remove the stored credential
   ```

   After a login, MCP client configs need no secret at all:

   ```json
   {
     "mcpServers": {
       "nimbus": {
         "command": "uvx",
         "args": ["nimbus-mcp"],
         "env": { "NIMBUS_API_URL": "https://nimbus-studio.fly.dev" }
       }
     }
   }
   ```

2. **Desktop app — zero config.** Just have the Nimbus Studio desktop app
   running: its local key file is auto-discovered (macOS `~/Library/Application
   Support/Nimbus Studio/mcp-key.json`, Linux `~/.config/Nimbus Studio/…`,
   Windows `%APPDATA%\Nimbus Studio\…`) and the server talks to the local
   backend at `http://127.0.0.1:8080`. Nothing to paste or configure.

3. **Environment variables (advanced / CI).** `NIMBUS_TOKEN` (a hosted API
   token `nimb_…` minted in Nimbus Studio → Account → API tokens) or
   `NIMBUS_TOKEN_FILE` (a 0600 JSON file `{"token": "…"}` — keeps the secret
   out of process env and MCP configs), or the local pair
   `NIMBUS_MCP_KEY` / `NIMBUS_MCP_KEY_FILE` (must match `MCP_LOCAL_KEY` on a
   local backend — see below). Explicit env always beats files on disk.
   A `NIMBUS_TOKEN` / `NIMBUS_TOKEN_FILE` token with `NIMBUS_API_URL` unset
   talks to the hosted API (`https://nimbus-studio.fly.dev`); set
   `NIMBUS_API_URL` to point it anywhere else.

**No credential anywhere?** That is the **Local Free tier** — it works. The
server starts anonymously against the local backend (desktop app or dev
backend on `http://127.0.0.1:8080`): templates, your own data, local
execution/plots/stats, BIDS export and local-only storage need **no Nimbus
Studio login at all**. Login is requested only at first *cloud* value — a
tool that needs an identity (leaderboard submission, verified/cross-machine
experiment history, hosted compute, cloud sync, collaboration) returns
`{ok: false, loginRequired: true, trigger, reason, howToAuthenticate}`
naming exactly why, instead of a blanket gate. Hosted Pro features
(verified leaderboard submissions, `foundation.compare`, big campaigns,
team features) return `{ok: false, tierGate: true, requiredTier: "pro"}`
on free accounts; exports (BIDS etc.) are never gated at any tier. If a
local call fails because no local backend/identity exists, the response is
the local-mode variant of `{ok: false, setupRequired: true, message,
options}` (start the desktop app / set a dev key — still no login). A
hosted token rejected mid-session (expired or revoked) returns the classic
setup shape, including how many days ago it expired.

### Checking who you are

```
account.whoami() → {userId, email, plan: {isPro, pioneerAccess},
            freeRuns: {monthlyLimit, remaining},
            token: {name, kind, scopes, fullAccess, expiresAt, daysLeft}
                   | {masked, kind, scopes, fullAccess, expiresAt, daysLeft}  # gateway header token
                   | null,
            source}                                      # store | env | token_file | header | …
```

Call `account.whoami()` from the agent to see the account, plan, this month's free-run
quota, and (in token mode) the token's days-to-expiry; `nimbus-mcp status` is
the terminal equivalent with a live backend probe.

### What a hosted token means

- **The token IS you.** Requests run under your account: executions appear in your
  studio history and **your plan's quotas and limits apply** — there is no separate
  agent allowance. When the free monthly quota is exhausted, run errors carry the
  upgrade link `https://studio.nimbusbci.com/pricing?reason=mcp-quota`.
- **CPU-only in v0.4.** Token-authenticated runs do not hydrate cloud GPUs.
- **Rotation.** Tokens live at most 90 days (30 by default). Plan changes are
  snapshotted at mint time — after an upgrade, re-login (or revoke and re-create
  the token) to pick up the new plan. An expired token surfaces as setup
  guidance with the day count, not a dead end.

### Headless agents and CI (API keys)

For CI pipelines and unattended agents, mint a **CI key** in Nimbus Studio →
Account → API tokens: choose type **CI / headless**, a lifetime of 90, 180 or
365 days, and pick its scopes. **CI keys are a Pro feature** (Pro plan or
Pioneer access); Free accounts can still use personal tokens.

| Scope | Allows |
|---|---|
| `read` | GET/list/describe, plus compute-only POSTs (validate, plots, stats, export) |
| `run` | start/cancel executions and experiments (consumes your quota) |
| `write` | create/update/delete projects, pipelines, uploads, settings |
| `leaderboard:submit` | leaderboard submissions |

CI keys default to `read` + `run`; a full-access CI key is an explicit choice
(the **Full access** checkbox, or `"fullAccess": true` on
`POST /api/me/api-tokens`). Personal tokens keep the old default: no scope
selected = full access, and every token minted before scopes existed keeps
working unchanged. The backend enforces scopes on every request; a missing
scope answers `403 nimbus.auth.token_scope_insufficient`. `account.whoami` and
`nimbus-mcp status` show the key's kind, scopes and days left.

Store the key as a CI secret and pass it as `NIMBUS_TOKEN` (no `NIMBUS_API_URL`
needed — a token defaults to hosted Nimbus). GitHub Actions example:

```yaml
- name: Nimbus smoke run
  env:
    NIMBUS_TOKEN: ${{ secrets.NIMBUS_CI_KEY }}
  run: |
    pipx install nimbus-mcp
    nimbus-mcp status          # fails fast if the key is expired or revoked
```

Or call the hosted gateway `https://nimbus-mcp.fly.dev/mcp` with the header
`Authorization: Bearer $NIMBUS_TOKEN`. Rotate before expiry (the Account page
flags keys within 14 days of expiring) and revoke a leaked key immediately —
revocation takes effect on the next request.

Plan and limits for API keys:

- Plan is re-checked live: every request made with an API key looks up your
  current Clerk Billing plan (cached for about a minute), so cancelling Pro
  removes Pro features from existing keys too — not just from new ones.
- If the owner of a CI key is no longer Pro, the key is refused on every
  request with `403 nimbus.auth.ci_key_requires_pro`. Resubscribing makes it
  work again (no need to re-mint); otherwise revoke it and switch to a
  personal token.
- You can have at most **20 active** (unrevoked, unexpired) API tokens;
  minting one more answers `409 nimbus.me.api_token_limit_reached`. Tokens
  created by MCP OAuth sign-in do not count toward this limit.

## Privacy & telemetry

Local Free mode needs no account and phones home nothing by default.
Anonymous, content-free usage telemetry (tool names, success/failure and
error codes, duration buckets — never your data, file paths, or code) is
**opt-in**: you are asked once in the first tool response of a fresh
install, and `nimbus-mcp telemetry on|off|status` (or
`NIMBUS_MCP_TELEMETRY=on|off`) controls it anytime. With telemetry off
there are zero telemetry network calls, and everything local keeps
working. Full statement: [PRIVACY.md](PRIVACY.md). A quiet PyPI version
check at startup (cached 24 h, silent when current or offline) adds a
one-time `versionNotice` to a tool response when your client is behind
the hosted gateway's surface.

## Requirements (local mode)

- A Nimbus backend running locally: the **desktop app**, or the dev server
  (`cd nimbus-studio/backend-py && python -m nimbus_backend.server.app`) with `DEBUG=1`.
- The backend started with `MCP_LOCAL_KEY=<some-secret>` (never set this on Fly — it is
  refused there).
- Desktop app users: open **Settings → MCP & Agents** — no manual key setup (the app
  creates the key, injects it into its backend, and hands you copy-ready configs).

**No desktop app required.** Acquisition is fully in-backend: a headless local
backend + `nimbus-mcp` can drive real hardware (BrainFlow boards, BrainBit over
BLE, LSL, PiEEG) and synthetic boards, with zero Electron:

```bash
git clone <this repo> && cd NimbusStudio/nimbus-studio/backend-py
pip install -e ".[streaming]"            # brainflow + pyneurosdk2 + pylsl extras
export DESKTOP_LOCAL_BACKEND=true MCP_LOCAL_KEY=<secret> MCP_LOCAL_USER_ID=user_…
python -m nimbus_backend.server.app      # loopback backend with device radios
# another shell: nimbus-mcp against http://127.0.0.1:8080 with NIMBUS_MCP_KEY=<secret>
```

The same `stream.start → stream.telemetry` flow then works with OR without a
trained model (modelless streams report live signal quality + focus/relaxation
indicators).

## Configure the backend

Desktop/dev env (e.g. `backend-py/data/.env` or the dev shell):

```bash
MCP_LOCAL_KEY=choose-a-long-random-string
MCP_LOCAL_USER_ID=user_your_clerk_user_id
DEBUG=1   # dev server only; the desktop app qualifies automatically
```

`MCP_LOCAL_USER_ID` sets the principal the MCP key authenticates as. Set it to your
own Clerk user id (`user_…`) so everything the agent creates — projects, saved
pipelines, executions — appears in your studio UI as yours. Pick one owner and stick
with it: switching the id mid-life splits ownership of agent-created work across two
principals, and neither identity then sees the whole history.

Watchdog default: streaming sessions started through MCP are auto-stopped after
15 minutes with no one watching (every `stream.status` / `stream.telemetry` poll
resets the timer). Pass `idle_timeout_sec=0` to `stream.start` to disable it for a
session.

When enabling `MCP_LOCAL_KEY` on a machine connected to an untrusted network, also set
`HOST=127.0.0.1` on the backend. The `0.0.0.0` default (`settings.host`) applies to the
bare dev server (`python -m nimbus_backend.server.app`), so with it the key would
otherwise be accepted from the LAN; `backend-py/scripts/run_server.py` already defaults
to `127.0.0.1`, and the desktop app pins loopback itself.

## Run the server

```bash
cd nimbus-studio/mcp
python -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
NIMBUS_MCP_KEY=choose-a-long-random-string python -m nimbus_mcp
```

Env vars: `NIMBUS_API_URL` (always wins when set; otherwise the store's
`api_url` after a login, `https://nimbus-studio.fly.dev` for a
`NIMBUS_TOKEN` / `NIMBUS_TOKEN_FILE` token, else `http://127.0.0.1:8080`), `NIMBUS_TOKEN` / `NIMBUS_TOKEN_FILE` (hosted API
token — see **Authentication**), `NIMBUS_MCP_KEY` (must match `MCP_LOCAL_KEY`),
`NIMBUS_MCP_KEY_FILE` (path to a 0600 JSON file `{"key": "…"}` — the desktop
app's one-click MCP setup writes it; consulted only when `NIMBUS_MCP_KEY` is
unset), `NIMBUS_EXPORT_DIR` (default `~/nimbus-exports`). With none of the
token/key vars set, the login store and then the desktop key file are
auto-discovered; with nothing found, the server runs in setup mode (every tool
returns onboarding guidance).

## Hosted gateway (streamable HTTP)

`nimbus-mcp serve` runs the same 63 tools over streamable HTTP instead of
stdio — for remote MCP clients, registries (Smithery lists URL-based
servers), and browser-side clients:

```bash
nimbus-mcp serve --port 8080   # env: NIMBUS_MCP_HOST/PORT/PATH
```

The gateway binds **127.0.0.1 by default** — exposing it (`--host 0.0.0.0`)
is explicit opt-in; combine that with the per-request tokens below (the
gateway itself holds no credential to gate access with).

The gateway holds **no** credential itself: each request's Nimbus API token
arrives as a header, so one shared deployment serves many users as
themselves. Calls without a header get setup guidance (add the header, or
install locally via `uvx`).

```json
{
  "mcpServers": {
    "nimbus": {
      "type": "http",
      "url": "https://nimbus-mcp.fly.dev/mcp",
      "headers": { "X-Nimbus-Token": "nimb_…  (Account → API tokens)" }
    }
  }
}
```

(A gateway started with `NIMBUS_TOKEN` in the environment uses it as the
fallback for headerless calls — single-tenant self-hosting.)

### OAuth sign-in (opt-in, `NIMBUS_MCP_OAUTH=1`)

With `NIMBUS_MCP_OAUTH=1` the gateway is also an OAuth 2.1 authorization
server (MCP authorization spec), so clients like Claude and Cursor can connect
with just the URL — no pasted token:

- `/.well-known/oauth-protected-resource/mcp` and
  `/.well-known/oauth-authorization-server` metadata;
- dynamic client registration (`/register`, stored by the backend);
- `/authorize` (PKCE S256) sends the browser to Studio
  `/mcp/authorize`, where the user signs in and chooses Allow / Deny;
- `/token` exchanges the code for a normal `nimb_` token (30 days, no
  refresh token; scopes `read`, `run`, `write`, `leaderboard:submit`, or full
  access when all/none are requested). Re-authorizing the same client
  revokes that client's previous token, and every OAuth token is listed (and
  revocable) under Account → API tokens.

When the flag is on, requests with no token get the spec `401` with a
`WWW-Authenticate: Bearer resource_metadata=…` header (that is what starts the
client's sign-in), so headerless `tools/list` is no longer public. Existing
`X-Nimbus-Token` / `Authorization: Bearer nimb_…` headers keep working.

| Variable | Where | Meaning |
| --- | --- | --- |
| `NIMBUS_MCP_OAUTH` | gateway | `1` turns OAuth on (default off) |
| `MCP_OAUTH_GATEWAY_SECRET` | gateway **and** backend | shared secret (≥ 32 chars) for the gateway-only backend routes and for signing the consent request |
| `NIMBUS_MCP_PUBLIC_URL` | gateway | issuer / public base URL (default `https://nimbus-mcp.fly.dev`; must be HTTPS except on loopback) |
| `NIMBUS_STUDIO_URL` | gateway | where `/mcp/authorize` lives (default `https://studio.nimbusbci.com`) |
| `NIMBUS_API_URL` | gateway | backend base URL |

On Fly: `fly secrets set MCP_OAUTH_GATEWAY_SECRET=… NIMBUS_MCP_OAUTH=1` for the
gateway app, and the same `MCP_OAUTH_GATEWAY_SECRET` on the backend. With the
secret unset the backend answers the OAuth routes with `503
nimbus.mcp.oauth_not_configured`.

## Claude Code

```bash
# --env flags go BEFORE the -- separator (everything after it is the literal
# server command, so the after-form would feed --env to python/uvx):
claude mcp add nimbus --env NIMBUS_MCP_KEY=choose-a-long-random-string \
  -- <path-to-mcp-venv>/bin/python -m nimbus_mcp
```

## Cursor / Claude Desktop (stdio)

```json
{
  "mcpServers": {
    "nimbus": {
      "command": "<path-to-mcp-venv>/bin/python",
      "args": ["-m", "nimbus_mcp"],
      "env": { "NIMBUS_MCP_KEY": "choose-a-long-random-string" }
    }
  }
}
```

## Tools (63)

Auth: `account.whoami` (account, plan, quota, token expiry)
Discovery: `catalog.nodes`, `catalog.node_schema`, `catalog.templates`, `catalog.template`, `catalog.datasets`, `catalog.leaderboard`
Data: `data.upload`, `data.delete_upload` (needs `confirm=true`)
Inspect: `data.inspect_dataset`, `data.inspect_file` (EDA: channels, class balance, band powers, PSD)
Build: `pipeline.validate`, `pipeline.validate_node`
Run: `execution.run` (non-blocking), `execution.get`, `execution.list`, `execution.results`, `execution.cancel`, `execution.delete` (needs `confirm=true`)

Run summaries (`execution.get` / `execution.list`) carry two dataset fields: `dataset` is the human-readable display label (e.g. `BCIIV2a`), and `datasetId` is the catalog id (e.g. `BNCI2014_001`) that other tools accept (`data.inspect_dataset`, `public_data.dataset` in a graph). `title` is the run name shown in Studio.
Campaigns: `experiment.run` (non-blocking, 1-25 paced runs), `experiment.get`, `experiment.delete` (needs `confirm=true`)
Artifacts: `execution.artifacts`, `execution.download_artifact`, `pipeline.export`
Live: `device.list`, `device.test`, `stream.start` (needs `confirm=true`),
`stream.status`, `stream.telemetry`, `stream.stop`
Calibration: `calibration.start` (needs `confirm=true`), `calibration.status`,
`calibration.pause`, `calibration.resume`, `calibration.train`
Neurofeedback protocols: `protocol.validate`/`protocol.save` (versioned specs),
`protocol.start_assignment` (confirm-gated; simulated sources free, live hardware Pro),
`protocol.session_status`/`pause`/`resume`/`stop`, `protocol.export_stats`
(paired pre/post stats + figure + CSV)
Projects: `project.create`, `project.list`, `project.save`, `project.load`, `project.rename`, `project.delete` (needs `confirm=true`)
Python: `python.validate` (static contract check for BYO model code — no execution)
Leaderboard: `leaderboard.submit` (BYO python_model → server-side LOSO scoring,
202 + `submissionId`; `track="pretrained_few_trial"` = efficiency track:
100-trial-capped LOSO, torch code must declare `PARAMS_TRAINED`),
`leaderboard.status` (poll one submission, owner-only),
`leaderboard.mine` (your submissions, newest first)
BIDS: `bids.export_dataset` (pack / upload / recording → BIDS-layout zip;
continuous → BIDS-raw, epoched → BIDS-derivatives), `bids.export_execution`
(results bundle: metrics, participants.tsv, protocol, pipeline snapshot)
Plots: `plots.confusion`, `plots.dataset` (psd / topomap / bandpower /
spectrogram), `plots.erp` (per-class butterfly), `plots.leaderboard` (CI
whisker bars, BYO highlighted; `track=pretrained_few_trial` overlays the
classical LOSO baseline side-by-side) — PNG figures the assistant can SEE
Stats: `stats.grouped` (cluster-aware per-subject accuracies across runs:
t-interval + t-test vs chance)

Not sure which pipeline to build? `catalog.leaderboard()` ranks benchmarked pipelines
per dataset (`meanAccuracyPct` desc, 95% CI) under the canonical `within_session`
protocol — agents pick templates by ranking there and pull the winner with
`catalog.template(pipelineId)`.

## Look at your data first (and at your results)

Before building any pipeline, agents can *see* the data: channels, sampling
rate, trial/class balance, per-channel µV stats, canonical band powers, and a
PSD overview — for a public dataset or a file on disk.

> "Inspect BNCI2014_001 subject S01 before we pick a pipeline."

```python
data.inspect_dataset(dataset="BNCI2014_001", subject="S01", mode="all")
# → {channels: {count: 22, names: [...], flatlined: []}, samplingRate: 250,
#    trials: {count: 288, classLabels: [...], classCounts: {...}},
#    channelStats: [...], bandPowers: {...}, psd: {...},
#    computedFrom: {nSamples: ..., sampleStrategy: "stratified_sample_seed42_4_of_20"}}
```

`subject` is required (e.g. `"S01"`; get the list via `catalog.datasets`) and a
comma-list like `"S01,S03"` loads a cohort; `mode` is `training |
evaluation | all`.

The same works for files: `data.inspect_file` picks the source from the path
shape. An ABSOLUTE path reads the file from disk (local backend only —
desktop app / MCP local mode: no upload step, the data never leaves the
machine); a RELATIVE path — the one `data.upload` returns — describes the
uploaded file on ANY backend (hosted or local):

> "Look at ~/recordings/session-01.edf and tell me if the montage is sane."

```python
data.inspect_file(path="/Users/you/recordings/session-01.edf")  # absolute → local file
data.inspect_file(path="uploads/<user>/session-01.edf")  # data.upload path → upload
data.inspect_file(path="uploads/<user>/eeg.csv", sampling_rate=250.0)  # CSV/TSV/TXT need a rate
```

CSV/TSV/TXT carry no sampling rate, so `sampling_rate` (Hz) is required for
them: without it the tool fails fast with `nimbus.mcp.sampling_rate_required`
before any request is made.

On a hosted backend absolute paths are refused — `data.inspect_file` then returns
guidance (`data.upload` the file and pass the returned path back, or point
the server at a local backend) instead of a dead end.

Why inspect first: **class balance drives stratification** (imbalanced classes
skew accuracy), and **flatlined channels mean a montage/reference problem**
worth fixing before training. And a units caveat: the loader **assumes
volts** — a µV-native CSV reads 1e6x too large; set `unitsScale` (e.g.
`1e-6` with `units: "uV"`) in the pipeline's `custom_data` config when
needed.

The same data — and your results — can be rendered as **PNG figures the
assistant can look at**, not just JSON: `plots.dataset` (PSD / band-power
heatmap / scalp topomap / spectrogram), `plots.erp` (per-class evoked
butterfly — check class structure before training anything),
`plots.confusion` (after a run: which classes are actually confused),
`plots.leaderboard` (CI whisker bars per dataset, BYO submissions
highlighted). `save=true` also writes the PNG into `NIMBUS_EXPORT_DIR/plots/`.

```python
plots.confusion(execution_id="exec_123")  # → image + per-class accuracy
plots.dataset(kind="psd", source="dataset", dataset="BNCI2014_001", subject="S01")
stats.grouped(execution_ids=["exec_123", "exec_124"])  # cluster-aware t-interval + p
```

## Uploading data

Bring your own recordings instead of (or alongside) the public datasets.

> "I have a `.edf` recording at `~/recordings/session-01.edf` — upload it and build
> a pipeline around it."

The agent calls `data.upload(file_path=…)`, which registers the file with the
backend and returns the stored path; that path goes into a `custom_data` node's
config (`{"filePath": "<path>", "format": "edf", …}`) for `pipeline.validate` /
`execution.run` / `experiment.run`. For plain CSV/TSV/TXT without embedded
metadata, pass `sampling_rate` (Hz) — the backend silently assumes 250 Hz
otherwise; `format` overrides extension-based detection.

## Experiment campaigns

One `experiment.run` call = a paced sweep of 1-25 pipelines (at most 2 training
runs in flight) with aggregated metrics, instead of the agent babysitting 25
individual `execution.run` polls.

> "Compare CSP-LDA vs EEGNet on BNCI2014_001 across subjects 1-3."

The agent builds six train graphs, calls
`experiment.run(runs=[{name: "csp-lda-s1", train_graph: …}, …])`, gets an
`experimentId` back immediately, then polls `experiment.get(experiment_id)` until
status is `completed` — per-run status and, at the end,
`aggregates` like `{"kappa": {"mean": 0.61, "std": 0.08, "best": {name, value}}}`
(mean/std/best over completed runs only).

## Working with projects

Agent builds, human inspects. Pipelines the agent saves land in real studio
projects, so you can open the canvas and see exactly what ran.

> "Save this pipeline as a project called 'motor-imagery-baseline' — I'll review
> it in the studio."

`project.create(name)` makes the container, `project.save(project_id,
train_graph, expected_revision=…)` writes the graph (layout auto-generated;
pass `expected_revision` from `project.load` so concurrent edits conflict
instead of silently overwriting) and `project.load(project_id)` reads it back
for editing or re-running.
With `MCP_LOCAL_USER_ID` set to your user id, the project shows up in **your**
studio project list.

## Watching a live session

While a streaming session runs, the agent can watch its telemetry and tell you
what it sees.

> "Watch my focus session and tell me when signal quality drops."

The agent polls `stream.telemetry(session_id)` — latest prediction, the recent
window, signal quality (`meanChannelQuality`, `snrDb`, `artifactProbability`) and
running stats — and warns when quality degrades. Each poll also resets the idle
watchdog, so a session under active watch is never auto-stopped; an abandoned one
is shut down after 15 minutes.

## Calibrating a subject

Guided calibration turns a person wearing the device into their own training
data: a cue-guided session records labelled trials, and the agent then trains
the subject's own classifier from the recording.

> "Run a 10-trials-per-class MI calibration on my BrainBit, then train my
> personal classifier when it's done."

The five-tool flow (all non-blocking):

```python
# 1. Start (confirm-gated): fetches the paradigm's calibration template
#    (mi / p300 / sart / target_hit), patches in device + trial count, and
#    starts the session. The Studio app shows the cues on its calibration
#    dashboard automatically.
calibration.start(paradigm="mi", trials_per_class=10, confirm=true)
# → {started: true, executionId: "…"}

# 2. Poll: phase (baseline → imagery trials → complete), current trial, cue,
#    progress tally, and an ETA estimate from the timing SSOT.
calibration.status(execution_id="…")
# → {phase: "imagery", currentTrial: {index: 7, total: 20, class: "Left Hand",
#    cue: "← LEFT"}, progress: {trialsDone: 6, …}, estimatedRemainingSec: 105.0, …}

# 3./4. Pacing between trials (cues hold; resume anytime).
calibration.pause(execution_id="…")  # calibration.resume(...) to continue

# 5. When phase == "complete" the snapshot carries the recording:
#    calibration: {uploadId, path, filename, format}. Train from it.
calibration.train(execution_id="…")
# → {trainExecutionId: "…", calibrationUploadId: "…"} → poll execution.get,
#    then execution.results for the metrics (kappa, accuracy, …).
```

`mi` trains on the default `mi_headband_csp_lda` template (CAR → 8-30 Hz
bandpass → CSP → LDA) and `p300` on `p300_hardware_xdawn_lda` (xDAWN → LDA on
the recorded flash epochs); `sart` / `target_hit` have no default train
template — pass an explicit `template_id` (from `catalog.templates`) or your
own `train_graph` to `calibration.train`. Either way the first data node is
rewired onto the recorded upload (`custom_data` source pinned to the
`uploadId`), preserving the template's training config.

`calibration.start` refuses to run without `confirm=true` — like `stream.start`,
the device goes on a human's head; call `device.test` first.

Pro plan required — calibration workflows and custom-data training are
freemium-gated (free-tier requests are 403 by the node policy). Local
`X-MCP-Key` principals pass when the desktop app flags the signed-in Pro
session (`MCP_LOCAL_IS_PRO`); otherwise the node policy applies to them too.

The calibrate→train handoff resolves on every backend, desktop-local included:
recordings made with the current build carry upload↔execution linkage in the
desktop store. Recordings from older builds predate that linkage — rerun
`calibration.start` for those (the tool's status hint says the same).

## Safety

`stream.start` refuses to run without `confirm=true` — it connects an EEG device and
starts a live session on a human. The `X-MCP-Key` path is machine-local only
(never accepted on Fly deployments); hosted mode authenticates with a personal
`Authorization: Bearer` token from `nimbus-mcp login` (see **Authentication**
above). Sessions started via MCP are stopped automatically after 15 idle
minutes (see the watchdog note above).

TDQS

B3.2/5.0

Scored across 32 tools

Disambiguation4/5

Most tools target distinct actions/resources, but a few pairs overlap: stream_status vs get_live_session both poll a live session, and inspect_dataset vs inspect_file give near-identical exploratory summaries for different sources. Descriptions help disambiguate, but an agent may still hesitate between them.

Naming Consistency4/5

Names are predominantly consistent snake_case verb_noun (list_nodes, get_template, run_pipeline). Minor deviations like whoami and stream_status break the exact verb_noun pattern but remain readable and conventional.

Tool Count2/5

32 tools is heavy for a single MCP server; several subdomains (streaming, pipelines, datasets, projects, artifacts) could be consolidated. While each tool has a purpose, the set exceeds the 25-tool threshold where navigation becomes cumbersome.

Completeness4/5

The surface covers major workflows: device streaming, pipeline building/validation/execution, experiments, datasets, projects, and artifacts. Minor gaps exist (no delete/update for projects, pipelines, or artifacts), but agents can work around them.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive