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).