scinote-mcp
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 APIHard 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 toolsAuthentication
Two options (see TokenAuthentication concern in scinote-web):
Api-Key header (recommended to start): requires
CORE_API_KEY_ENABLED=trueon the SciNote server; each user has an API key (users.api_key). SetSCINOTE_API_KEY.JWT Bearer: a token whose
issmatches the server'score_api_token_iss. SetSCINOTE_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=trueWithout 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 idThe 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 400Running it
Two transports share one set of tool definitions (src/server.ts):
Command | Transport | Credential |
| stdio, for MCP Inspector and editor clients that spawn a child process | the one in |
| 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 | |
|
| Always-on facts about this server — the scope model, the |
| the | 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 -fOne 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=noneauth=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(searchnamespace :v1)Controllers (payload shapes + permitted params):
scinote-web/app/controllers/api/v1/*.rbSerializers (response shapes):
scinote-web/app/serializers/api/v1/*.rbTest 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.
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 typecheckpass.envfilled in;curlauth sanity check returns JSON, not 401npm run inspectopens andscinote_statusreturns versions
Accept: screenshot of MCP Inspector showing a successful scinote_status call.
M1 — Read the world (1 day) — done
list_tasksreturns the test task(s) of experiment 166get_task_stepsreturns the 21 checkpoints withcompletedflagsExtend
get_task_stepsto also surface checklists and their items (ids +checked) — theinclude=checklists,checklists.checklist_itemsdata arrives in the JSON:APIincludedarray
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_itemandcomplete_stepReturn 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_stockconsume_stockechoes item name, amount, and resulting stock in its confirmation; the tool description instructs the LLM to confirm with the user first — keep it that wayVerify 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_consumptionis the cumulative total for that task assignment; the ledger derives the delta. A tech saying "log another 20 mL" means a delta, soconsume_stockreads 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 matchingadd_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/HandsFreeChatfrom @mieweb/ui (start withreviewBeforeSend: 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-Keyon 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_consumptionfirst and set, don't addRate limiting, retries with backoff, structured logging
Testing tips
MCP Inspector (
npm run inspect) is your main harness — no LLM neededWatch the Rails log on the dev box (
tail -f ~/scinote-web/log/development.log) to see your requests hit controllersEvery 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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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