Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions deploy/mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# The MCP server, built from a git ref rather than from a working checkout.
#
# The first version of this bind-mounted somebody's clone and ran `dist/`, which
# is gitignored. What that deploys is "whatever branch was checked out, compiled
# whenever anyone last ran a build" -- and the failure is silent, because the
# image, the compose file and the route all look exactly as intended. It had
# already happened once: a container up for four days was running code that no
# longer existed on disk, because a test run had rebuilt `dist/` from an
# unmerged branch as a side effect.
#
# So the revision is an argument. MCP_REF is a branch or tag, and the build
# clones exactly that -- nobody's checkout is part of the deployment. Note what
# that does and does not promise: `main` is a moving ref, so two builds of
# `main` are two revisions, deliberately. What is guaranteed is that the build
# follows the ref rather than a cached copy of it, and that the commit it
# actually used is recorded in /srv/REVISION where it can be read back.
#
# Lives here rather than in reactome-mcp because that repository has no
# Dockerfile at all -- checked, nothing tracked -- and this is the repository
# that now runs the service.

FROM node:22-slim AS build
ARG MCP_REF=main
ARG MCP_REPO=https://github.com/reactome/reactome-mcp.git
# Only used to resolve MCP_REF to a commit, so the clone below cannot be served
# from a stale layer. Unauthenticated, and this is a public repository.
ARG MCP_API=https://api.github.com/repos/reactome/reactome-mcp
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Resolve the ref to a commit *before* cloning, and keep the answer in a layer.
#
# Without this the build is stale by default and silent about it. `RUN git
# clone --branch main` is one fixed command string, so Docker caches its layer
# and reuses it for ever: once main moves, `docker compose build` rebuilds the
# revision from the first build and reports success. Verified rather than
# assumed -- a second `docker compose build mcp` with no changes reported CACHED
# for every step including the clone.
#
# `ADD` from a URL re-fetches on every build and its layer digest follows the
# content, so when main points somewhere new this file changes, this layer and
# everything after it are invalidated, and the clone runs again. When main has
# not moved the response is byte-identical and the build stays cached.
#
# It is the same defect as the bind-mount this replaced, one level up: a
# deployment whose revision is decided by something nobody looked at.
ADD ${MCP_API}/commits/${MCP_REF} /tmp/mcp-ref.json
RUN git clone --depth 1 --branch "${MCP_REF}" "${MCP_REPO}" .
# `npm ci` rather than `install`: the lockfile is the point of pinning a ref.
RUN npm ci
RUN npm run build
# The revision that was actually built, readable from the running container.
# Without it, "which commit is this serving" has no answer once the build host
# is gone.
RUN git rev-parse HEAD > /build/REVISION
RUN npm prune --omit=dev

FROM node:22-slim
WORKDIR /srv
ENV NODE_ENV=production
COPY --from=build /build/node_modules ./node_modules
COPY --from=build /build/dist ./dist
COPY --from=build /build/package.json ./package.json
COPY --from=build /build/REVISION ./REVISION
# Not root. It reads Reactome over HTTPS and writes nothing.
USER node
CMD ["node", "dist/http-server.js"]
80 changes: 80 additions & 0 deletions deploy/nginx/common/routes.conf
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,86 @@ location /chat/ {
proxy_read_timeout 3600s;
}

# The MCP server (reactome-mcp), when an environment runs one.
#
# Present in every environment that includes this file, and harmless where the
# server is not running: nothing listens, nginx answers 502, and no other route
# is affected. That is the deliberate shape -- "available through nginx when it
# is on" rather than a config edit each time it is turned on or off.
#
# Streamable HTTP, so the same three settings the chat needs: buffering off, the
# upgrade headers, and a long read timeout. A response here is a stream of
# events and a proxy that buffers turns it into a long silence followed by
# everything at once.
#
# `mcp-session-id` is the header the transport identifies a session by, and it
# has to survive in both directions -- the server issues it on initialise and
# the client returns it on every later call. It does, with nothing added here:
# nginx forwards request headers unchanged and only hides a short fixed list on
# the way back (Date, Server, X-Accel-*), which this is not on. There was a
# `proxy_pass_header mcp-session-id` here on the theory that it was needed;
# removing it and re-running the initialise call returned the session id just
# the same, so it was doing nothing but asserting a danger that does not exist.
#
# The server binds 127.0.0.1 by default and turns on DNS-rebinding protection
# for localhost hosts; that protection is what stops a web page in a visitor's
# browser driving it, and it is worth not defeating by binding wider. Give the
# upstream the loopback address and let nginx be the only thing in front.
location /mcp {
# Its own budget, and a small burst: a client legitimately sends several
# calls in a row while a model works through a task, but not a flood.
limit_req zone=mcp_rate burst=10 nodelay;
# What the rate limit above does NOT do, said here because it is easy to
# read one as the other: it bounds calls per second, not work per call.
# Those are different quantities, and `reactome_analyze_identifiers` is
# where they come apart -- it POSTs an identifier list to the Analysis
# Service, which does real work and stores a result, so a single permitted
# request could commission an arbitrarily large job. Two of those a second
# is not a small number.
#
# The server caps the list itself, which is the right place for it. This is
# a ceiling in the only unit this layer can see, so the bound does not
# depend on which version of the server is deployed.
#
# 256 KB, and deliberately not a number derived from the server's identifier
# cap. It does not bind today: the service rejects a body over 100 KiB --
# 102,400 bytes exactly, found by bisecting against the built image, and
# since confirmed from the other side by the team that owns it.
#
# That ceiling is express's default, reached through the SDK's
# `createMcpExpressApp`, and nothing in either repository names it. The
# service used to carry a line asking for 4 MB which never ran, because that
# parser was mounted behind the SDK's; the line has been removed rather than
# made to work, so 4 MB is not coming back.
#
# What is left for this to do is the part that does not depend on any of
# that: an 8.8 MB body is refused at the edge rather than read into the
# service (confirmed 413 through this configuration), and an SDK upgrade
# that moves a default nobody declared cannot raise the ceiling past this
# without somebody editing this line. A bound does not have to be the
# binding one to be worth having.
client_max_body_size 256k;
include /etc/nginx/common/upstream-proxy.conf;
# No trailing slash on either the location or the target, so the request
# URI is forwarded verbatim. The server serves POST/GET/DELETE on `/mcp`
# itself, not at its root, so the usual `location /mcp/` with
# `proxy_pass http://mcp/;` -- which strips the prefix -- sent every call to
# `/`, where nothing is mounted, and the transport saw a 404 it reports as a
# connection failure. The prefix form also means a client connecting to
# `/mcp` and one connecting to `/mcp/` both arrive.
#
# `/health` stays unreachable from outside, which is the other reason not to
# proxy the upstream's root: it reports that the process is up, and that is
# nobody else's business.
proxy_pass http://mcp;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# A tool call can be a slow Analysis Service request, but not an hour of
# one: the chat's 3600s is for a conversation held open, which this is not.
proxy_read_timeout 300s;
}

# The site itself.
location / {
include /etc/nginx/common/upstream-proxy.conf;
Expand Down
20 changes: 20 additions & 0 deletions deploy/nginx/dev.conf
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ upstream chatbot {
keepalive_requests 1000;
}

# The MCP server (reactome-mcp), reached at /mcp/. Loopback only: it binds
# 127.0.0.1 itself and turns on DNS-rebinding protection there, which is what
# stops a page in a visitor's browser driving it. nginx is the only thing in
# front of it.
#
# 502 when it is not running, which is the intended behaviour -- the route
# exists in every environment and the server is turned on per environment.
upstream mcp {
server 127.0.0.1:4320;
keepalive 16;
}

include /etc/nginx/common/websocket.conf;
include /etc/nginx/common/cloudflare-real-ip.conf;
include /etc/nginx/common/block-ai-crawlers.conf;
Expand All @@ -91,6 +103,14 @@ include /etc/nginx/common/block-all-automation.conf;
# only meaningful because the real-IP block above is included — without it every
# request appears to come from Cloudflare and one bucket covers the internet.
limit_req_zone $binary_remote_addr zone=dev_rate:10m rate=100r/s;

# A budget of its own, far tighter than the site's. Its README is explicit that
# the HTTP transport must sit behind something that rate-limits, and the reason
# is per-request cost rather than volume: `reactome_analyze_identifiers`
# submits a real job to the Analysis Service. The site's 100r/s is sized for
# page assets and would let one client queue analyses as fast as it can ask.
limit_req_zone $binary_remote_addr zone=mcp_rate:10m rate=2r/s;

limit_conn_zone $binary_remote_addr zone=dev_conn:10m;

# The retired hostnames stay retired, and so does anything else pointed here.
Expand Down
20 changes: 20 additions & 0 deletions deploy/nginx/production.conf
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ upstream chatbot {
keepalive_requests 1000;
}

# The MCP server (reactome-mcp), reached at /mcp/. Loopback only: it binds
# 127.0.0.1 itself and turns on DNS-rebinding protection there, which is what
# stops a page in a visitor's browser driving it. nginx is the only thing in
# front of it.
#
# 502 when it is not running, which is the intended behaviour -- the route
# exists in every environment and the server is turned on per environment.
upstream mcp {
server 127.0.0.1:4320;
keepalive 16;
}

include /etc/nginx/common/websocket.conf;
include /etc/nginx/common/cloudflare-real-ip.conf;
include /etc/nginx/common/block-ai-crawlers.conf;
Expand All @@ -99,6 +111,14 @@ include /etc/nginx/common/block-ai-crawlers.conf;
# Still present: a crawler that ignores robots.txt costs the same Neo4j queries
# whichever host it walks.
limit_req_zone $binary_remote_addr zone=prod_rate:20m rate=600r/s;

# A budget of its own, far tighter than the site's. Its README is explicit that
# the HTTP transport must sit behind something that rate-limits, and the reason
# is per-request cost rather than volume: `reactome_analyze_identifiers`
# submits a real job to the Analysis Service. The site's 100r/s is sized for
# page assets and would let one client queue analyses as fast as it can ask.
limit_req_zone $binary_remote_addr zone=mcp_rate:10m rate=2r/s;

limit_conn_zone $binary_remote_addr zone=prod_conn:20m;

server {
Expand Down
20 changes: 20 additions & 0 deletions deploy/nginx/release.conf
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,32 @@ upstream chatbot {
keepalive_requests 1000;
}

# The MCP server (reactome-mcp), reached at /mcp/. Loopback only: it binds
# 127.0.0.1 itself and turns on DNS-rebinding protection there, which is what
# stops a page in a visitor's browser driving it. nginx is the only thing in
# front of it.
#
# 502 when it is not running, which is the intended behaviour -- the route
# exists in every environment and the server is turned on per environment.
upstream mcp {
server 127.0.0.1:4320;
keepalive 16;
}

include /etc/nginx/common/websocket.conf;
include /etc/nginx/common/cloudflare-real-ip.conf;
include /etc/nginx/common/block-ai-crawlers.conf;
include /etc/nginx/common/block-all-automation.conf;

limit_req_zone $binary_remote_addr zone=rel_rate:10m rate=100r/s;

# A budget of its own, far tighter than the site's. Its README is explicit that
# the HTTP transport must sit behind something that rate-limits, and the reason
# is per-request cost rather than volume: `reactome_analyze_identifiers`
# submits a real job to the Analysis Service. The site's 100r/s is sized for
# page assets and would let one client queue analyses as fast as it can ask.
limit_req_zone $binary_remote_addr zone=mcp_rate:10m rate=2r/s;

limit_conn_zone $binary_remote_addr zone=rel_conn:10m;

server {
Expand Down
69 changes: 69 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,69 @@ services:
# renderer that fails only on large diagrams is the usual symptom.
shm_size: '1gb'

# The MCP server (reactome-mcp), which lets an AI assistant read Reactome
# through its own tools rather than through a browser.
#
# Here because until now it had no definition anywhere: it was a `docker run`
# somebody typed once, bind-mounting a git checkout at /srv and running the
# `dist/` inside it. `dist/` is gitignored, so what that served was whatever
# branch happened to be checked out, compiled whenever anyone last ran a
# build -- and a test run rebuilding it from an unmerged branch had already
# put code into the running container that no longer existed on disk.
#
# Built from a git ref instead, so the deployed revision is something someone
# chose. MCP_REF is a branch or tag; the container records the commit it was
# built from in /srv/REVISION.
mcp:
build:
context: .
dockerfile: deploy/mcp/Dockerfile
args:
MCP_REF: ${MCP_REF:-main}
restart: unless-stopped
# Loopback only, and emphatically not 0.0.0.0. Same reason as `render`
# above, and worse per request: a tool call here can submit a real job to
# the Analysis Service, so the only way in is through whatever fronts the
# site, which rate-limits it. The container this replaces set
# MCP_HTTP_HOST=0.0.0.0 safely because it was on a private compose network
# with nothing published; that is not safe under `network_mode: host`, so
# the binding is pinned here rather than inherited.
ports:
- '127.0.0.1:4320:4320'
# Two ways in, for two callers that cannot share one.
#
# nginx runs in host networking, so it reaches the published port above on
# 127.0.0.1. The chatbot is a container on its own private network and
# addresses this by name -- `REACTOME_MCP_URL=http://reactome_mcp:4320` --
# so the alias is what lets it keep doing that. Without it the chatbot would
# need reconfiguring to point at a host gateway, which is a change to
# somebody else's deployment to solve a problem on this side.
#
# The alias is the name of the hand-run container this replaces, on purpose:
# the swap is then invisible to the thing depending on it.
networks:
default: {}
reactome_beta:
aliases:
- reactome_mcp
environment:
# 0.0.0.0 *inside* the container, which the port mapping above then
# exposes on loopback only. A container binding its own loopback would be
# unreachable even from the host.
- MCP_HTTP_HOST=0.0.0.0
- MCP_HTTP_PORT=4320
# Which Reactome it answers from: this deployment's own, never another's.
# An MCP on beta that answered from production would describe a release
# nobody here is running.
- REACTOME_BASE_URL=${MCP_REACTOME_BASE_URL:-https://beta.reactome.org}
# NEO4J_URI and MCP_ALLOW_CYPHER are both deliberately absent, and it
# takes both to register the Cypher tools, the graph-schema resource and
# the instructions that advertise them. Arbitrary graph queries used to
# arrive as a side effect of setting a connection string -- the startup
# schema warm-up wants the same variable -- so an instance could acquire
# them without anyone deciding to. Neither belongs on an instance the
# public can reach.

content-node:
build:
context: .
Expand Down Expand Up @@ -161,6 +224,12 @@ services:
- ${LETSENCRYPT_DIR:-/etc/letsencrypt}:/etc/letsencrypt:ro
- ${CLOUDFLARE_CERT_DIR:-/etc/ssl/cloudflare}:/etc/ssl/cloudflare:ro

networks:
# Created by the chatbot's own stack, not by this one -- declared external so
# compose joins it rather than trying to own it, and never removes it.
reactome_beta:
external: true

volumes:
# Survives a container replacement, which is the point: a cached figure is a
# file read, and rebuilding the cache means paying for every render again.
Expand Down
5 changes: 5 additions & 0 deletions projects/website-angular/content/tools/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ category: "tools"
<span class="module-label">ReactomeFIViz</span>
<span class="module-desc">Cytoscape app for network-based pathway and functional-interaction analysis using Reactome data.</span>
</a>
<a class="module-card" href="/tools/reactome-mcp">
<span class="material-symbols-rounded module-icon">smart_toy</span>
<span class="module-label">Reactome MCP Server</span>
<span class="module-desc">Connect Claude or another AI assistant directly to Reactome: search, analyse and export without leaving the conversation.</span>
</a>
</div>
Loading
Loading