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.

F
license - not found
Not graded
quality - not tested
B
maintenance

Maintenance

0Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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

View all related MCP servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/BioNanomics/scinote-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server