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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ All notable changes to this project are documented here. This project adheres to
- `fetchWithRetry` rethrew `lastError`, typed `unknown`, so a non-`Error` rejection reached callers as something they could not read `.message` off.

### Added
- **Streamable HTTP transport**, alongside stdio. `MCP_HTTP_PORT=4320 node dist/http-server.js`. stdio remains the default and is untouched — every existing client is configured to spawn it. This is what a hosted instance needs, because a reverse proxy cannot front a process that talks over stdin/stdout.

Each session gets its own server instance, built by the `createServer()` factory. Idle sessions are reaped (`MCP_SESSION_TTL_MS`, 30 min) and concurrency is capped (`MCP_MAX_SESSIONS`, 256), so a client that never sends `DELETE` cannot accumulate servers until the process dies.

`MCP_HTTP_HOST` defaults to `127.0.0.1`, which also enables the SDK's DNS-rebinding protection. That default is deliberate: the Reactome origin has already been taken down once by crawlers on public `/ContentService/exporter/*` URLs, and an MCP endpoint is the same shape of risk and worse per request.
- **A cap on how much text one tool may return** (`MAX_TOOL_RESPONSE_CHARS`, default 40,000; override by env). Every tool result is spent from the caller's context window, and the size of several of these is driven by the ID asked about rather than by anything the tool decides: `reactome_events_hierarchy` rendered ~86 KB (~22,000 tokens) in a single call and `reactome_query` on Metabolism ~60 KB. The cap is applied once, in the wrapper every tool handler already passes through, rather than in 56 places — and it announces the cut rather than truncating silently, because a model that cannot see it was truncated reports the partial answer as the whole one.
- **Spec Kit.** `.specify/` with a constitution written from failures this repository actually had, and `specs/` for design decisions. Spec 001 records what was found about response shapes; spec 002 states the transport and hosting question rather than answering it. The Spec Kit skills under `.claude/skills/` are tracked deliberately — people clone this repository and point an agent at it.
- **A live sweep.** `npm run sweep` calls all 53 tools against the live services. It checks for `undefined`, `[object Object]` and empty bodies, and — because marker-grepping cannot see a field that was dropped cleanly — asserts expected content for 16 tools whose arguments are known to return data. Runs weekly and on demand, not in CI, since a red run there can mean Reactome changed rather than this repository did.
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,53 @@ Only registered when `NEO4J_URI` is set. Designed for curators running the [`rea
| `reactome://top-pathways/{species}` | Top-level pathways for a species |
| `reactome://events-hierarchy/{species}` | Full event hierarchy for a species |

## Transports

**stdio** is the default and the one every existing client uses:

```bash
node dist/index.js
```

**Streamable HTTP** is for a hosted instance, because a reverse proxy cannot
front a process that talks over stdin/stdout:

```bash
MCP_HTTP_PORT=4320 node dist/http-server.js
# or: MCP_HTTP_PORT=4320 npm run start:http
```

| variable | default | |
|---|---|---|
| `MCP_HTTP_PORT` | *(unset)* | required to serve HTTP |
| `MCP_HTTP_HOST` | `127.0.0.1` | see below before changing |
| `MCP_SESSION_TTL_MS` | `1800000` | idle session reaped after 30 min |
| `MCP_MAX_SESSIONS` | `256` | concurrent session ceiling |

Endpoints: `POST /mcp` (initialize, then requests), `GET /mcp` (server stream),
`DELETE /mcp` (end session), `GET /health`.

Each session gets its **own** server instance, so two clients cannot interleave
on shared state. Idle sessions are reaped and the session count is capped, so a
client that never sends `DELETE` cannot accumulate servers until the process
dies.

### Why it binds to loopback

`MCP_HTTP_HOST` defaults to `127.0.0.1`, and that is a deliberate default rather
than a placeholder. The Reactome site already learned this the expensive way:
crawlers on the public `/ContentService/exporter/*` URLs exhausted Tomcat's heap
and took the origin down, which is why the sibling render service on that box
binds loopback only and is reached through the site's own origin.

An MCP endpoint is the same shape of risk and worse per request —
`reactome_analyze_identifiers` submits a real job to the Analysis Service. Put
it behind something that rate-limits before binding it anywhere else.

When the host is a loopback address the SDK also turns on DNS-rebinding
protection, which is what stops a page in someone's browser from driving a
server bound to their own machine. Binding to `0.0.0.0` turns that off.

## Development

```bash
Expand Down
90 changes: 90 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"type": "module",
"main": "dist/index.js",
"bin": {
"reactome-mcp": "dist/index.js"
"reactome-mcp": "dist/index.js",
"reactome-mcp-http": "dist/http-server.js"
},
"files": [
"dist/",
Expand All @@ -30,7 +31,8 @@
"test:coverage": "vitest run --coverage",
"sweep": "node scripts/sweep-live.mjs",
"check": "npm run lint && npm run format:check && npm run typecheck && npm run typecheck:lint-compiler && npm run build && npm test",
"typecheck:lint-compiler": "node node_modules/typescript/bin/tsc --noEmit -p tsconfig.eslint.json"
"typecheck:lint-compiler": "node node_modules/typescript/bin/tsc --noEmit -p tsconfig.eslint.json",
"start:http": "node dist/http-server.js"
},
"keywords": [
"mcp",
Expand All @@ -43,11 +45,13 @@
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
"neo4j-driver": "^6.2.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/express": "^5.0.6",
"@types/node": "^26.5.1",
"@vitest/coverage-v8": "^5.0.0",
"eslint": "^10.10.0",
Expand Down
47 changes: 42 additions & 5 deletions specs/002-transport-and-hosting/spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 002 — Transports and hosting

**Status:** open — the design question is stated here, not settled
**Status:** transport settled 2026-09-14; where it runs is still open
**Date:** 2026-09-14
**Constitution:** Principles IV, V

Expand Down Expand Up @@ -30,19 +30,56 @@ can only make the calls a browser can. A test asserts the gate holds.
**Analysis runs in the Analysis Service.** The server submits identifiers, holds
the token, and formats the reply.

## Settled 2026-09-14: the transport exists

Streamable HTTP ships alongside stdio. stdio stays the default and is unchanged.

- `MCP_HTTP_PORT=4320 node dist/http-server.js`
- `POST /mcp`, `GET /mcp`, `DELETE /mcp`, `GET /health`
- one server per session, built by `createServer()`
- idle sessions reaped at 30 min, concurrency capped at 256
- binds `127.0.0.1` unless told otherwise, which also turns on the SDK's
DNS-rebinding protection

This was the blocker for everything below: a reverse proxy cannot front a
process that speaks over stdin/stdout, so there was previously nothing to host.

### The brief for whoever adds it to the website repo

That repository already runs this exact pattern. `render` is a sibling Node
service in the same compose file, bound loopback-only, reached through the
site's own origin; `serve-prod.js` reads the proxy table from `proxy.conf.js`,
so beta and the dev server both proxy it the same way; and
`deploy/apache/beta-chat-proxy.conf` shows how a service gets a path on the
beta vhost.

So the work is three small things, not a design exercise:

1. a compose service running `dist/http-server.js` with `MCP_HTTP_PORT` set,
published on `127.0.0.1` only -- copy what `render` does
2. an entry in `proxy.conf.js` so the origin forwards a path to it
3. an Apache stanza on beta, modelled on `beta-chat-proxy.conf`

**Loopback only to begin with.** Not because public access is wrong, but
because it is a separate decision that needs rate limiting attached, and the
comments on the render service record why: crawlers on the public
`/ContentService/exporter/*` URLs exhausted Tomcat's heap and took the origin
down. An MCP endpoint is the same shape of risk and worse per request --
`reactome_analyze_identifiers` submits a real job to the Analysis Service.

## What is open

1. **Who adds Streamable HTTP, and when.** The SDK provides
`StreamableHTTPServerTransport`. The work is small; the operational
commitment is not.
1. ~~**Who adds Streamable HTTP, and when.**~~ *Settled: it is in, see above.*

2. **Where a hosted instance runs.** Spinning it up alongside the Angular
website has been raised. That would put it behind infrastructure that already
exists, with a team that already operates it.

3. **Whether it is public.** A public endpoint needs rate limiting, abuse
handling, and an answer for what happens when Reactome's own services are
slow — this server would become a new way to load them.
slow — this server would become a new way to load them. The default binding
makes not-public the path of least resistance, which is the right way round
for a decision of this shape.

4. **npm publishing.** Deferred by decision, to be settled in one pass with the
website and the other Reactome repositories rather than piecemeal.
Expand Down
26 changes: 26 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,29 @@ export const MAX_TOOL_RESPONSE_CHARS = parsePositiveInt(
process.env.MAX_TOOL_RESPONSE_CHARS,
40_000
);

/**
* HTTP transport. Off unless a port is set -- stdio stays the default, because
* that is what every existing user's client is configured for.
*
* The host defaults to loopback deliberately. The Reactome site already learned
* this lesson the expensive way: crawlers on the public `/ContentService/
* exporter/*` URLs exhausted Tomcat's heap and took the origin down, which is
* why the sibling render service on that box binds 127.0.0.1 only and is
* reached through the site's own origin. An MCP endpoint is the same shape of
* risk and worse per request -- `reactome_analyze_identifiers` submits a real
* job to the Analysis Service.
*
* Binding elsewhere is possible and deliberate: set MCP_HTTP_HOST. Do that
* behind something that rate-limits.
*/
export const MCP_HTTP_PORT = process.env.MCP_HTTP_PORT
? parsePositiveInt(process.env.MCP_HTTP_PORT, 0)
: undefined;
export const MCP_HTTP_HOST = process.env.MCP_HTTP_HOST ?? "127.0.0.1";

/** How long an idle session is kept before its server is torn down. */
export const MCP_SESSION_TTL_MS = parsePositiveInt(process.env.MCP_SESSION_TTL_MS, 30 * 60_000);

/** Ceiling on concurrent sessions, so a client loop cannot exhaust memory. */
export const MCP_MAX_SESSIONS = parsePositiveInt(process.env.MCP_MAX_SESSIONS, 256);
43 changes: 43 additions & 0 deletions src/http-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* HTTP entrypoint. `src/index.ts` remains the stdio one and is unchanged --
* every existing user has a client configured to spawn it, and that must keep
* working exactly as before.
*
* MCP_HTTP_PORT=4320 node dist/http-server.js
*
* Binds 127.0.0.1 unless MCP_HTTP_HOST says otherwise. See config.ts for why
* that default is what it is.
*/
import { startHttpServer } from "./http.js";
import { logger } from "./logger.js";
import { MCP_HTTP_PORT, MCP_HTTP_HOST, NEO4J_URI } from "./config.js";
import { fetchGraphSchema } from "./graph/schema.js";

const port = MCP_HTTP_PORT;

if (!port) {
logger.error("MCP_HTTP_PORT is not set", {
hint: "Set MCP_HTTP_PORT to serve over HTTP, or run dist/index.js for stdio.",
});
process.exit(1);
}

startHttpServer(port, MCP_HTTP_HOST)
.then(() => {
// Same warm-up the stdio entrypoint does: apoc.meta.schema() samples 3M
// nodes and takes 15-30s, so the first caller should not pay for it.
if (NEO4J_URI) {
fetchGraphSchema().catch((err: unknown) => {
logger.warn("graph schema prefetch failed; will retry on first use", {
error: err instanceof Error ? err.message : String(err),
});
});
}
})
.catch((error: unknown) => {
logger.error("fatal error starting http server", {
error: error instanceof Error ? error.message : String(error),
});
process.exit(1);
});
Loading