diff --git a/deploy/mcp/Dockerfile b/deploy/mcp/Dockerfile
new file mode 100644
index 00000000..95be3691
--- /dev/null
+++ b/deploy/mcp/Dockerfile
@@ -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"]
diff --git a/deploy/nginx/common/routes.conf b/deploy/nginx/common/routes.conf
index 0c74e9cd..0d339f5b 100644
--- a/deploy/nginx/common/routes.conf
+++ b/deploy/nginx/common/routes.conf
@@ -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;
diff --git a/deploy/nginx/dev.conf b/deploy/nginx/dev.conf
index 945b5e9f..fb7dc8c0 100644
--- a/deploy/nginx/dev.conf
+++ b/deploy/nginx/dev.conf
@@ -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;
@@ -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.
diff --git a/deploy/nginx/production.conf b/deploy/nginx/production.conf
index a95155c5..527cf3fd 100644
--- a/deploy/nginx/production.conf
+++ b/deploy/nginx/production.conf
@@ -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;
@@ -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 {
diff --git a/deploy/nginx/release.conf b/deploy/nginx/release.conf
index c0eba1eb..f22fb80e 100644
--- a/deploy/nginx/release.conf
+++ b/deploy/nginx/release.conf
@@ -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 {
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ab5e211..ec6a6456 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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: .
@@ -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.
diff --git a/projects/website-angular/content/tools/index.mdx b/projects/website-angular/content/tools/index.mdx
index 0d500728..e0afcd6a 100644
--- a/projects/website-angular/content/tools/index.mdx
+++ b/projects/website-angular/content/tools/index.mdx
@@ -39,4 +39,9 @@ category: "tools"
ReactomeFIViz
Cytoscape app for network-based pathway and functional-interaction analysis using Reactome data.
+
+ smart_toy
+ Reactome MCP Server
+ Connect Claude or another AI assistant directly to Reactome: search, analyse and export without leaving the conversation.
+
diff --git a/projects/website-angular/content/tools/reactome-mcp.mdx b/projects/website-angular/content/tools/reactome-mcp.mdx
new file mode 100644
index 00000000..4cb62a82
--- /dev/null
+++ b/projects/website-angular/content/tools/reactome-mcp.mdx
@@ -0,0 +1,78 @@
+---
+title: "Reactome MCP Server"
+category: "tools"
+---
+
+## Reactome MCP Server
+
+The Reactome MCP server connects an AI assistant — Claude, or any other client that
+speaks the [Model Context Protocol](https://modelcontextprotocol.io/) — directly to
+Reactome. Instead of describing a pathway to the assistant, you let it look the
+pathway up: search the knowledgebase, read reactions and entities, run a pathway
+enrichment analysis on a list of identifiers, and export diagrams, SBGN, SBML or a
+PDF report.
+
+It is an open-source wrapper around the same public Content and Analysis Services
+the website uses. It reads Reactome; it does not change it.
+
+### What it can do
+
+It registers 59 tools, in six groups:
+
+ * **Search and browse** — find pathways, reactions and entities by name or
+ identifier, and walk the event hierarchy.
+ * **Entities** — complex subunits, what a molecule is a component of, its other
+ forms, and the diagrams it appears in.
+ * **Analysis** — submit identifiers for pathway enrichment, then read the found
+ and not-found entities, pathway sizes and filtered results.
+ * **Interactors** — interaction partners and the pathways they implicate.
+ * **Export** — diagrams as PNG, SVG, JPG or GIF, plus SBGN, SBML, PDF reports and
+ analysis results as CSV or JSON.
+ * **ReactomeGSA** — methods, data types and example datasets for gene-set analysis.
+
+### Using it
+
+The server is on GitHub at
+[reactome/reactome-mcp](https://github.com/reactome/reactome-mcp). Clone and build
+it, then point your assistant at the built entry point.
+
+For Claude Desktop, add this to your configuration file:
+
+```json
+{
+ "mcpServers": {
+ "reactome": {
+ "command": "node",
+ "args": ["/path/to/reactome-mcp/dist/index.js"]
+ }
+ }
+}
+```
+
+The server runs on your own machine and talks to Reactome's public services over
+HTTPS, the same ones your browser uses. Set `REACTOME_BASE_URL` if you want it to
+read from somewhere other than `https://reactome.org`.
+
+Then ask your assistant things like:
+
+ * "What pathways is TP53 involved in?"
+ * "Run an enrichment analysis on these gene symbols and tell me what comes up."
+ * "Export the SBGN for pathway R-HSA-1640170."
+
+Three further tools run Cypher queries directly against the graph database.
+They are off unless you both point the server at a database and explicitly turn
+them on, and they are intended for curators working against their own instance
+rather than for reading the public knowledgebase — everything above works
+without them.
+
+### Things worth knowing
+
+An assistant using these tools is reading Reactome correctly, but it is still an
+assistant: it can summarise what it read inaccurately, and it can be confidently
+wrong about anything it did not look up. Treat what it tells you as a pointer into
+Reactome rather than as a citable source, and follow the stable identifiers it
+gives you back to the pathway pages here.
+
+Analyses you submit through the server are sent to Reactome's Analysis Service
+exactly as they would be from this website, and are subject to the same
+[privacy notice](/about/privacy-notice).