Skip to main content
Glama

scinote-mcp

MCP (Model Context Protocol) server that exposes SciNote ELN bench workflows as tools, so an AI assistant (e.g. Ozwell hands-free chat) can drive them by voice: "tick 'stain mixture combined'", "consume 20 mils from A1 aliquot 2", "what's my next step?".

Architecture (see also docs/setup-gingaguard.md in scinote-web):

Ozwell HandsFreeChat (tablet) → chat backend (LLM) → scinote-mcp (this) → SciNote REST API

Hard rule: every write goes through the SciNote REST API with the tech's own credentials. Never write to the SciNote database directly — API writes are what produce the audit trail, stock ledger, and permission checks. That property is the whole point of this server.

Setup

cd sidecar/scinote-mcp
npm install
cp .env.example .env    # then fill in values
npm run dev             # starts on stdio
npm run inspect         # opens MCP Inspector UI to poke at tools

Authentication

Two options (see TokenAuthentication concern in scinote-web):

  • Api-Key header (recommended to start): requires CORE_API_KEY_ENABLED=true on the SciNote server; each user has an API key (users.api_key). Set SCINOTE_API_KEY.

  • JWT Bearer: a token whose iss matches the server's core_api_token_iss. Set SCINOTE_JWT.

Both are gated by server-side flags. On the dev instance these are set on the scinote-web systemd user unit:

Environment=CORE_API_V1_ENABLED=true
Environment=CORE_API_KEY_ENABLED=true

Without CORE_API_V1_ENABLED, /api/v1/* returns 404 while /api/status still answers 200 — that combination means the routes were never mounted.

Mint a key for a user with:

bundle exec rails runner "puts User.find_by(email: 'admin@scinote.net').regenerate_api_key!"

Trying it out

npm run smoke drives the server the same way an MCP client does. Several tool/args pairs run against one server process, which is how you set the working scope before a scoped call:

npm run smoke                                        # list registered tools
npm run smoke -- scinote_status list_teams
npm run smoke -- set_scope '{"team":"Dalaly","project":"Polymicrobial","experiment":"GingiGuard Assay"}' list_tasks
npm run smoke -- set_scope '{...}' get_task_steps '{"taskId":"970"}'

Picking what to work on

There's no team/project/experiment in .env. The tech chooses at runtime — "work on the GingiGuard assay" — and set_scope resolves the name and remembers it, so nothing downstream has to say ids out loud.

That selection is keyed by the caller's credential rather than a connection, because HTTP requests are stateless here: each one builds a fresh server. Two techs on different runs therefore keep separate scopes, and a request that needs a scope it doesn't have answers with the tools to call instead of a 404.

scinote_status                       what's selected right now
list_teams / list_projects / list_experiments / list_inventories
set_scope { team?, project?, experiment? }   by name or id

The ids are nested, so choosing a team clears the project under it and choosing a project clears the experiment.

Sanity check your credentials:

curl -s -H "Api-Key: $SCINOTE_API_KEY" $SCINOTE_BASE_URL/api/v1/teams | head -c 400

Running it

Two transports share one set of tool definitions (src/server.ts):

Command

Transport

Credential

npm run dev (src/index.ts)

stdio, for MCP Inspector and editor clients that spawn a child process

the one in .env

npm run serve (src/http.ts)

Streamable HTTP on port 3001, for remote clients like Ozwell on a phone

per request, from the caller's headers

An MCP server binds to exactly one transport, so the HTTP listener builds a fresh server per request via createServer(). That also keeps concurrent techs from ever sharing a session.

The HTTP endpoint carries no ambient authority

A publicly reachable endpoint holding a shared admin key would hand anyone on the internet full write access to the ELN. So every HTTP request must present its own SciNote credential:

Authorization: Bearer <the tech's own API key, or a JWT>
   or
X-SciNote-Api-Key: <the tech's own key>

A bearer token shaped like a JWT (header.payload.signature) is forwarded as one; anything else is forwarded as an API key. SciNote's JWT decoder crashes on non-JWT input, so the shape has to decide before the token leaves here.

Requests without one get a 401. The credential travels to src/scinote.ts via an AsyncLocalStorage context, never a module global, so requests can't leak credentials into each other. SciNote stays the authorization boundary — the sidecar can't do anything the signed-in tech couldn't do in the browser, and the audit trail names a real person.

SCINOTE_MCP_ALLOW_SHARED_CREDENTIAL=true falls back to the .env credential for local testing. Never set it on a reachable deployment.

Behind nginx

The dev deployment is proxied at https://scinote-mcp.os.mieweb.org/ → port 3001. List every hostname the proxy forwards in SCINOTE_MCP_ALLOWED_HOSTS; anything else is rejected as a DNS rebinding attempt. Set SCINOTE_MCP_BIND=0.0.0.0 if nginx runs on a different host than this one (otherwise leave it on loopback).

location / {
    proxy_pass http://10.17.74.237:3001;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "";
    proxy_buffering off;          # required — MCP can stream responses
    proxy_cache off;
    proxy_read_timeout 3600s;
}

Smoke test the deployed endpoint:

curl -s https://scinote-mcp.os.mieweb.org/healthz
curl -s -X POST https://scinote-mcp.os.mieweb.org/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $SCINOTE_API_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Instructions travel with the server

A bench tech shouldn't have to paste a prompt to get a usable assistant, and a prompt pasted per client drifts out of step with the tools. So the guidance lives in src/instructions.ts and ships over the wire in two forms:

Where it goes

Use it for

SERVER_INSTRUCTIONS

instructions in the initialize result; clients paste it into the system prompt

Always-on facts about this server — the scope model, the ITEMS: convention, never invent a result

BENCH_RUN

the bench_run MCP prompt (prompts/list)

The hands-free loop, invoked by name when a run starts

The split is deliberate. Instructions are unconditional, so they stay short and factual. The read-one-step-and-wait loop is a workflow choice that shouldn't be forced on someone using these tools for something else, so it's a prompt the tech opts into.

curl -s -X POST https://scinote-mcp.os.mieweb.org/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $SCINOTE_API_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"prompts/list"}'

Client support for instructions varies, so anything the run genuinely depends on belongs in a tool description too — those are always read.

Logs

The dev deployment runs as a systemd user unit, ~/.config/systemd/user/scinote-mcp.service, alongside scinote-web and scinote-worker. It restarts on failure and comes back after a reboot (loginctl enable-linger is already on for this user).

systemctl --user restart scinote-mcp     # after a code change; tsx doesn't reload
journalctl --user -u scinote-mcp -f

One line per request — status, which kind of credential arrived (never its value), and the tool that was called:

2026-08-30T04:21:58.163Z POST /mcp 200 auth=bearer tools/call list_teams
2026-08-30T04:21:58.197Z POST /mcp 401 auth=none

auth=none on a 401 means no credential header arrived at all — check that the client is actually sending the one you configured.

Useful references

  • Route map: scinote-web/config/routes.rb (search namespace :v1)

  • Controllers (payload shapes + permitted params): scinote-web/app/controllers/api/v1/*.rb

  • Serializers (response shapes): scinote-web/app/serializers/api/v1/*.rb

  • Test data on the dev instance: team 14 / project 16 / experiment 166, task "Arm A1 - Run 1 (TEST)", inventory "GingiGuard Assay Reagents" (repository 2)

The API speaks JSON:API: collections are { data: [{ id, type, attributes, relationships }] }; writes send the same envelope.


Related MCP server: NGS360 MCP Server

Milestones

M0–M4 are done — all ten tools are implemented and verified against the dev instance. M5 is the remaining work. The notes below record what each milestone required and how it was accepted.

M0 — Environment (half a day) — done

  • npm install && npm run typecheck pass

  • .env filled in; curl auth sanity check returns JSON, not 401

  • npm run inspect opens and scinote_status returns versions

Accept: screenshot of MCP Inspector showing a successful scinote_status call.

M1 — Read the world (1 day) — done

  • list_tasks returns the test task(s) of experiment 166

  • get_task_steps returns the 21 checkpoints with completed flags

  • Extend get_task_steps to also surface checklists and their items (ids + checked) — the include=checklists,checklists.checklist_items data arrives in the JSON:API included array

Accept: get_task_steps output shows step P3.1 with its Actions checklist items and ids.

M2 — Execute a protocol (2 days) — done

  • Implement tick_checklist_item and complete_step

  • Return human confirmations ("Ticked 'Blower on, 5 min' — 2 of 3 actions done on P1.1"), not raw JSON

  • Error mapping: 403 → "you don't have permission", 404 → "that step doesn't exist", stale id → suggest re-running get_task_steps

Accept: from MCP Inspector, tick all actions on a step and complete it; the change is visible in the SciNote web UI and in the task's Activities feed attributed to your user.

M3 — Inventory + stock (2 days) — done

  • Implement list_task_items, assign_item, consume_stock

  • consume_stock echoes item name, amount, and resulting stock in its confirmation; the tool description instructs the LLM to confirm with the user first — keep it that way

  • Verify in SciNote: stock decrements, ledger row appears (item card → stock export), low-stock warning shows when you cross the threshold

Gotcha: the API's stock_consumption is the cumulative total for that task assignment; the ledger derives the delta. A tech saying "log another 20 mL" means a delta, so consume_stock reads the current total and sends the sum. Sending the raw amount would silently rewrite history.

Accept: full P3.1 flow via Inspector — assign "A1 - Aliquot 2", consume 20 mL, item shows 30 mL in SciNote with a ledger entry.

M4 — Search + results (1–2 days) — done

  • find_inventory_item: name match over inventory items ("a1 aliquot two" → row 63), reports stock. Voice input is sloppy, so the query and the item name are both normalized (lowercased, punctuation dropped, number words mapped to digits) before matching

  • add_result_note: create a text result on the task (results controller, v1; v2 has richer result elements if needed)

Accept: "find A1 aliquot 2" round-trips to the right row id; a result note appears on the task.

M5 — Ozwell wiring (separate app, 3–5 days)

Not in this repo. Stand up a small chat backend that:

  • Mounts HeyOzwell/HandsFreeChat from @mieweb/ui (start with reviewBeforeSend: true, transcription: "browser")

  • Connects an LLM to this MCP server (any MCP-capable client/orchestrator)

  • Carries the logged-in tech's SciNote credential per session — the HTTP transport reads it from Authorization: Bearer / X-SciNote-Api-Key on every request, so the client just has to forward it (see "The HTTP endpoint carries no ambient authority")

  • System prompt — the server ships its own, so no client has to be configured with one (see "Instructions travel with the server")

Accept: on a tablet, say "hey ozwell — disk stained and rinsed, done" and watch the step complete in SciNote.

Later / hardening

  • Task-scoped sessions ("I'm working Arm A1 Run 2" pins taskId so the tech never says ids)- Idempotency: repeating "consume 20 mL" must not double-log — read stock_consumption first and set, don't add

  • Rate limiting, retries with backoff, structured logging

Testing tips

  • MCP Inspector (npm run inspect) is your main harness — no LLM needed

  • Watch the Rails log on the dev box (tail -f ~/scinote-web/log/development.log) to see your requests hit controllers

  • Every write should be visible in three places: the SciNote UI, the task Activities feed, and (for stock) the item ledger. If any of the three is missing, something is wrong.

Related MCP Connectors

Related MCP Servers

  • -
    license
    Not graded
    quality
    Not graded
    maintenance
    Provides seamless integration between MCP-compatible AI assistants and n8n workflow automation, enabling intelligent management and automation of n8n workflows through natural language.
    5
    -
  • F
    license
    C
    quality
    B
    maintenance
    Exposes the NGS360 bioinformatics platform REST API as MCP tools, enabling AI assistants to manage sequencing runs, projects, workflows, and more through natural language.
    82
    -