From 11c61d4bc00c442923845ae70661b65cff118108 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 15:49:02 +0000 Subject: [PATCH 1/7] Route the MCP server through nginx, and document it Two halves of making reactome-mcp reachable. **The route.** `/mcp/` in the shared routes file, so it exists in every environment that includes it and needs no config edit when the server is turned on or off: nothing listening means 502 and nothing else is affected. Streamable HTTP needs the same three settings the chat needs -- buffering off, upgrade headers, a long read timeout -- because a proxy that buffers turns a stream of events into a long silence followed by everything at once. `mcp-session-id` is passed back explicitly: the server issues it on initialise and the client returns it on every later call, so a session id the client never receives means every call starts a new session and the per-session server instances accumulate until the process dies. It gets a rate limit of its own at 2r/s rather than the site's 100r/s, which is sized for page assets. The server's own configuration says 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 upstream is loopback, because the server binds 127.0.0.1 and turns on DNS-rebinding protection there, and that protection is what stops a page in a visitor's browser driving it. Checked with `nginx -t` in the same image compose runs: dev, production and release all pass. production and release needed stand-in certificates to get past the TLS stanza on this host, which is why they had not been checked. **The page.** content/tools/reactome-mcp.mdx and a card on the tools index, written against the server's actual source rather than its README: sixty-two tools in six groups, and the install instructions say clone-and-build because the package is **not published to npm** -- `npx reactome-mcp` 404s today, so documenting it would have sent every reader into a dead end. Two things deliberately not written down. The hosted endpoint is not described as available, because until the server is switched on that would document a 502. And `NEO4J_URI` stays unset wherever this is public: the Cypher tools register only when it is set, and their own guard says it is "a guardrail, not a security boundary" written for a curator-facing case. Leaving it unset removes those tools entirely, which is the enforcement. Co-Authored-By: Claude Opus 5 --- deploy/nginx/common/routes.conf | 38 ++++++++++ deploy/nginx/dev.conf | 20 ++++++ deploy/nginx/production.conf | 20 ++++++ deploy/nginx/release.conf | 20 ++++++ .../website-angular/content/tools/index.mdx | 5 ++ .../content/tools/reactome-mcp.mdx | 72 +++++++++++++++++++ 6 files changed, 175 insertions(+) create mode 100644 projects/website-angular/content/tools/reactome-mcp.mdx diff --git a/deploy/nginx/common/routes.conf b/deploy/nginx/common/routes.conf index 0c74e9cd..c8d7367b 100644 --- a/deploy/nginx/common/routes.conf +++ b/deploy/nginx/common/routes.conf @@ -106,6 +106,44 @@ 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 +# must survive in *both* directions -- the server issues it on initialise and +# the client returns it on every later call. nginx forwards request headers +# unchanged, but `proxy_pass_header` is needed for the response, because a +# session id the client never receives means every call starts a new session +# and the per-session server instances accumulate until the process dies. +# +# 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; + include /etc/nginx/common/upstream-proxy.conf; + proxy_pass http://mcp/; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_pass_header mcp-session-id; + 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/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..daef61ee --- /dev/null +++ b/projects/website-angular/content/tools/reactome-mcp.mdx @@ -0,0 +1,72 @@ +--- +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 + +There are over sixty 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." + +### 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). From b118f2e2d875b961c6e3e74deb2337a793a6cecf Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 16:11:05 +0000 Subject: [PATCH 2/7] Proxy the MCP at the path it actually serves, and define it in compose Three corrections to the route, two of them mine and found by running it. **It would have 404'd on every call.** `location /mcp/` with `proxy_pass http://mcp/;` strips the prefix, and the server mounts POST, GET and DELETE on `/mcp` rather than at its root -- so every request arrived where nothing is listening, and the transport reports that as a connection failure rather than as a 404. The prefix form with no trailing slash on either side forwards the URI verbatim, and means a client connecting to `/mcp` and one connecting to `/mcp/` both arrive. Verified by running the server and driving a real `initialize` call through nginx: 200, with a session id. **`proxy_pass_header mcp-session-id` did nothing.** The comment beside it said it was what stopped every call starting a new session. nginx hides only a short fixed list on the way back and this is not on it; removing the directive and repeating the call returned the session id just the same. A line that asserts a danger it does not prevent is worse than no line, so it is gone and the comment says what was measured. **`/health` is not reachable through it**, 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. Confirmed 404. **Compose.** The server had no definition anywhere -- it was a `docker run` somebody typed once, bind-mounting a git checkout at /srv. That works until the box reboots or the checkout moves, and nothing records what it was run with. Bound to 127.0.0.1, unlike the container it replaces, which set MCP_HTTP_HOST=0.0.0.0 safely because it sat on a private compose network with nothing published. Under `network_mode: host` that is a different statement entirely, so the binding is pinned here rather than inherited. `NEO4J_URI` and `MCP_ALLOW_CYPHER` are both absent, and it now takes both to register the Cypher tools. They used to arrive as a side effect of setting a connection string -- which the startup schema warm-up also wants -- so an instance could acquire arbitrary graph queries without anyone deciding to. The page says 59 tools rather than the 62 name literals in the source: the difference is exactly the three Cypher ones, which do not register here. nginx -t passes for dev, production and release. An earlier run of this check reported dev failing, which was the stand-in certificates having the wrong filenames rather than the config -- the harness, not the thing measured. Co-Authored-By: Claude Opus 5 --- deploy/nginx/common/routes.conf | 28 +++++++++---- docker-compose.yml | 41 +++++++++++++++++++ .../content/tools/reactome-mcp.mdx | 8 +++- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/deploy/nginx/common/routes.conf b/deploy/nginx/common/routes.conf index c8d7367b..a301fdf0 100644 --- a/deploy/nginx/common/routes.conf +++ b/deploy/nginx/common/routes.conf @@ -119,25 +119,37 @@ location /chat/ { # everything at once. # # `mcp-session-id` is the header the transport identifies a session by, and it -# must survive in *both* directions -- the server issues it on initialise and -# the client returns it on every later call. nginx forwards request headers -# unchanged, but `proxy_pass_header` is needed for the response, because a -# session id the client never receives means every call starts a new session -# and the per-session server instances accumulate until the process dies. +# 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/ { +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; include /etc/nginx/common/upstream-proxy.conf; - proxy_pass http://mcp/; + # 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_pass_header mcp-session-id; 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. diff --git a/docker-compose.yml b/docker-compose.yml index 2ab5e211..81f73862 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,47 @@ 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. That works until + # the box reboots or the checkout moves, and nothing records what it was run + # with. A compose entry is the smallest thing that makes it reproducible. + # + # The image is built from a checkout rather than pulled, because the package + # is not published to npm. MCP_SRC points at it; a deployment that has the + # repository somewhere else sets that rather than editing this. + mcp: + image: node:22-slim + working_dir: /srv + command: ['node', 'dist/http-server.js'] + 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' + environment: + - 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. 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. + volumes: + - ${MCP_SRC:-../reactome-mcp}:/srv:ro + content-node: build: context: . diff --git a/projects/website-angular/content/tools/reactome-mcp.mdx b/projects/website-angular/content/tools/reactome-mcp.mdx index daef61ee..4cb62a82 100644 --- a/projects/website-angular/content/tools/reactome-mcp.mdx +++ b/projects/website-angular/content/tools/reactome-mcp.mdx @@ -17,7 +17,7 @@ the website uses. It reads Reactome; it does not change it. ### What it can do -There are over sixty tools, in six groups: +It registers 59 tools, in six groups: * **Search and browse** — find pathways, reactions and entities by name or identifier, and walk the event hierarchy. @@ -59,6 +59,12 @@ Then ask your assistant things like: * "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 From f51747a228ca78763241ff7473f898e3a70e9628 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 16:29:11 +0000 Subject: [PATCH 3/7] Build the MCP from a chosen revision, not from somebody's checkout The compose service I added bind-mounted a working clone and ran the `dist/` inside it. `dist/` is gitignored, so 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. Not hypothetical: a container that had been up for four days was serving code that no longer existed on disk, because a test run had rebuilt `dist/` from an unmerged feature branch as a side effect. Bringing my version up would have promoted unreviewed working-tree code into the deployed server with nothing looking wrong. Found by the chatbot session reviewing what I wrote, which is the half of a review I cannot do for myself. So the revision is an argument. `MCP_REF` is a branch or tag, the build clones exactly that, and nobody's checkout is part of the deployment. The commit is written to /srv/REVISION during the build, because "which revision is this serving" otherwise has no answer once the build host is gone. The Dockerfile is here rather than in reactome-mcp because that repository has none -- nothing tracked, checked rather than assumed. Verified by building and running it: /srv/REVISION is f2dd566, which is that repository's main to the character; /health reports neo4jEnabled false and cypherEnabled false; it runs as `node` rather than root; and the running server advertises exactly 59 tools with no cypher among them, which is the number the page claims and was until now taken on trust. Not brought up. Defining the service and starting it are different decisions. Co-Authored-By: Claude Opus 5 --- deploy/mcp/Dockerfile | 47 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 30 ++++++++++++++++----------- 2 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 deploy/mcp/Dockerfile diff --git a/deploy/mcp/Dockerfile b/deploy/mcp/Dockerfile new file mode 100644 index 00000000..fc5d9c60 --- /dev/null +++ b/deploy/mcp/Dockerfile @@ -0,0 +1,47 @@ +# 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 tag, branch or commit, and the +# build clones exactly that. Nobody's checkout is part of the deployment, and +# `docker compose build` twice from the same ref gives the same server. +# +# 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 +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +# --depth 1 against a ref name; a full clone then checkout would be needed for a +# bare commit sha, which is why MCP_REF is documented as a branch or tag. +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/docker-compose.yml b/docker-compose.yml index 81f73862..1b5e6776 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,17 +78,21 @@ services: # 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. That works until - # the box reboots or the checkout moves, and nothing records what it was run - # with. A compose entry is the smallest thing that makes it reproducible. + # 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. # - # The image is built from a checkout rather than pulled, because the package - # is not published to npm. MCP_SRC points at it; a deployment that has the - # repository somewhere else sets that rather than editing this. + # 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: - image: node:22-slim - working_dir: /srv - command: ['node', 'dist/http-server.js'] + 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 @@ -100,6 +104,9 @@ services: ports: - '127.0.0.1:4320:4320' 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. @@ -107,13 +114,12 @@ services: # 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. Arbitrary graph queries used to + # 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. - volumes: - - ${MCP_SRC:-../reactome-mcp}:/srv:ro content-node: build: From 1dbd49be2c683efa5b0244171b7dc775d5b21c24 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 16:43:27 +0000 Subject: [PATCH 4/7] Bound work per call, not just calls per second The rate limit above bounds calls per second. It says nothing about how much work one call asks for, and `reactome_analyze_identifiers` is where those two quantities 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. My comment named per-request cost as the reason for the tight rate and then bounded frequency, which reads as though the cost per call were bounded too. Raised by the chatbot session. `client_max_body_size` is that bound in the only unit this layer can see. Measuring it rather than reading it turned up something worth having. The server rejects a body over 100 KiB -- 102,400 bytes exactly, confirmed by bisecting against the built image -- while its own code asks for 4 MB. That line never runs: the SDK's `createMcpExpressApp` mounts `express.json()` with no limit before it, so express's 100 KB default parses the body and the 4 MB parser behind it never sees one. Two body parsers, and the first one wins. So 256 KB does not bind today, and is deliberately not derived from the 10,000-identifier cap the server is adding -- that list is about 157 KB, which this transport already refuses. It is the outer bound for the day the parser ordering is fixed and 4 MB would otherwise become the real ceiling, and it is the cheap rejection: an 8.8 MB body is refused at the edge rather than read into the service. Both confirmed 413 through this configuration. Co-Authored-By: Claude Opus 5 --- deploy/nginx/common/routes.conf | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/deploy/nginx/common/routes.conf b/deploy/nginx/common/routes.conf index a301fdf0..acae9d87 100644 --- a/deploy/nginx/common/routes.conf +++ b/deploy/nginx/common/routes.conf @@ -135,6 +135,33 @@ 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 rather than a number derived from the identifier cap, because the + # two ends disagree about what the ceiling even is. Measured against the + # built image rather than read: the server rejects a body over **100 KiB** + # (102,400 bytes exactly -- 102,392 is accepted, 102,992 is a 413). Its own + # code asks for 4 MB, and that line never runs: the SDK's + # `createMcpExpressApp` has already mounted `express.json()` with no limit, + # so express's 100 KB default parses the body first and the 4 MB parser + # behind it never sees one. + # + # So this does not bind today; the server is stricter. It is here as the + # outer bound for the day that is fixed, when 4 MB would otherwise become + # the real ceiling -- millions of identifiers in one call -- and as the + # cheap rejection, since an 8.8 MB body is refused here rather than being + # read into the service. Both sizes confirmed 413 through this config. + 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` From 46b51b8ac5294838adc84095b85a2cee3a34ecde Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 16:52:26 +0000 Subject: [PATCH 5/7] Keep the body cap, drop the reason that stopped being true The comment justified 256 KB partly as the outer bound for the day the service's 4 MB parser started working. It will not: the line asking for 4 MB has been deleted rather than fixed, because controlling that limit would mean not using the SDK's express app and copying its DNS-rebinding protection into that repository, which is a worse trade than accepting a ceiling. So the reason is rewritten to the part that survives. The cap still refuses an 8.8 MB body at the edge rather than letting it be read into the service, and it still stops an SDK upgrade raising a ceiling that nothing in either repository declares -- 100 KiB is express's default reached through `createMcpExpressApp`, chosen by nobody. A bound does not have to be the binding one to be worth having, but it does have to say why it is there. The 102,400-byte boundary now has two independent measurements from opposite directions: mine by bisecting bodies against the built image, theirs by bisecting identifier counts against a running server. Their cap is 3,000 identifiers, about 69 KB, which fits under it with room. Co-Authored-By: Claude Opus 5 --- deploy/nginx/common/routes.conf | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/deploy/nginx/common/routes.conf b/deploy/nginx/common/routes.conf index acae9d87..0d339f5b 100644 --- a/deploy/nginx/common/routes.conf +++ b/deploy/nginx/common/routes.conf @@ -147,20 +147,23 @@ location /mcp { # 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 rather than a number derived from the identifier cap, because the - # two ends disagree about what the ceiling even is. Measured against the - # built image rather than read: the server rejects a body over **100 KiB** - # (102,400 bytes exactly -- 102,392 is accepted, 102,992 is a 413). Its own - # code asks for 4 MB, and that line never runs: the SDK's - # `createMcpExpressApp` has already mounted `express.json()` with no limit, - # so express's 100 KB default parses the body first and the 4 MB parser - # behind it never sees one. + # 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. # - # So this does not bind today; the server is stricter. It is here as the - # outer bound for the day that is fixed, when 4 MB would otherwise become - # the real ceiling -- millions of identifiers in one call -- and as the - # cheap rejection, since an 8.8 MB body is refused here rather than being - # read into the service. Both sizes confirmed 413 through this config. + # 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 From 295278e466e3ab70bddd00e872e4286a34d88571 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 18:17:03 +0000 Subject: [PATCH 6/7] Make the build follow the ref instead of a cached copy of it `RUN git clone --branch main` is one fixed command string, so Docker caches that layer and reuses it for ever. Once main moves, `docker compose build` rebuilds the revision from the first build and reports success -- which is the bind-mount defect this Dockerfile replaced, one level up: a deployment whose revision is decided by something nobody looked at. Verified rather than reasoned: a second `docker compose build mcp` with nothing changed reported CACHED for every step, the clone included. `ADD` from the commits API re-fetches each build and its layer digest follows the content, so a moved ref invalidates the clone and everything after it while an unmoved one stays cached. Both halves confirmed -- the first build after adding it re-ran the ADD and the clone, the next was cached throughout. The claim in the previous message needed narrowing too. `main` is a moving ref, so two builds of `main` are two revisions, deliberately. What is guaranteed is that a build follows the ref rather than a stale copy, and that the commit used is recorded in /srv/REVISION where it can be read back. The repository has no tags, so there is nothing more fixed to default to. Co-Authored-By: Claude Opus 5 --- deploy/mcp/Dockerfile | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/deploy/mcp/Dockerfile b/deploy/mcp/Dockerfile index fc5d9c60..95be3691 100644 --- a/deploy/mcp/Dockerfile +++ b/deploy/mcp/Dockerfile @@ -8,9 +8,12 @@ # 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 tag, branch or commit, and the -# build clones exactly that. Nobody's checkout is part of the deployment, and -# `docker compose build` twice from the same ref gives the same server. +# 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 @@ -19,12 +22,30 @@ 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 -# --depth 1 against a ref name; a full clone then checkout would be needed for a -# bare commit sha, which is why MCP_REF is documented as a branch or tag. +# 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 From 6db1e76429654aa7382b3a11800c64fa20a9dcd8 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 21 Sep 2026 18:30:27 +0000 Subject: [PATCH 7/7] Let the chatbot keep addressing the MCP by the name it already uses Two callers that cannot share one route in. nginx runs in host networking and reaches the published port on 127.0.0.1. The chatbot is a container on its own private network and addresses the server by name -- `REACTOME_MCP_URL=http://reactome_mcp:4320` -- so this joins that network and takes `reactome_mcp` as an alias. The alias is the hand-run container's name on purpose. The alternative was reconfiguring the chatbot to reach a host gateway, which is a change to somebody else's deployment to solve a problem on this side, and one more thing that has to be remembered when either end moves. With the alias the swap is invisible to the thing that depends on it. The network is declared external because the chatbot's stack created it: compose joins it rather than trying to own it, and will not remove it. Co-Authored-By: Claude Opus 5 --- docker-compose.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1b5e6776..ec6a6456 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,22 @@ services: # 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 @@ -208,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.