diff --git a/CHANGELOG.md b/CHANGELOG.md index 3741ac8..fdc8455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ 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 +- **ReactomeGSA tools** — `reactome_gsa_methods`, `reactome_gsa_data_types`, `reactome_gsa_search_datasets`, `reactome_gsa_examples`, `reactome_gsa_sources`. Reactome has **two** analysis services and this server only knew about one: + + | | | | + |---|---|---| + | `AnalysisService` | over-representation over a list of identifiers | already here | + | **ReactomeGSA** (`gsa.reactome.org`) | gene set analysis over an expression matrix — PADOG, Camera, ssGSEA, terapadog | new | + + Camera is described by the service as *"a gene set analysis algorithm similar to the classical GSEA algorithm"*. The gap was found the hard way: a Reactome chatbot asked to "run a GSEA with my list of genes" replied that Reactome could not, and offered `fgsea` and a YouTube tutorial. It can — nothing here could reach the service that does it. + + The tool descriptions carry the distinction that caused the confusion, since a description is all a model reads before choosing: gene set analysis needs an expression matrix with sample groups, so a user holding only a list of gene names wants over-representation, whatever they called it. + + Submitting an analysis is deliberately absent. `POST /analysis` takes the whole expression matrix inline, which is neither something a chat user can paste nor something to push through a tool result. `reactome_gsa_search_datasets` covers the case that *is* reachable — Expression Atlas, Single Cell Expression Atlas, GREIN and GEO can be searched, so someone with no data of their own can still be pointed at a published dataset. - **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. diff --git a/scripts/sweep-live.mjs b/scripts/sweep-live.mjs index cfcd153..e76d987 100644 --- a/scripts/sweep-live.mjs +++ b/scripts/sweep-live.mjs @@ -49,6 +49,7 @@ const ARGS = { format: "png", type: "pathways", pathways: ["R-HSA-109581"], + keywords: "melanoma", }; /** @@ -67,6 +68,8 @@ const TOOL_ARGS = { // A correctly-spelled term legitimately returns nothing, which tells us // only that the call succeeded. A misspelling exercises the formatter. reactome_search_spellcheck: { query: "kinse" }, + // GSA wants the species NAME; a taxonomy id returns zero results silently. + reactome_gsa_search_datasets: { keywords: "melanoma", species: "Homo sapiens" }, reactome_psicquic_summary: { resource: "IntAct", accession: "P04637" }, reactome_psicquic_details: { resource: "IntAct", accession: "P04637" }, }; @@ -110,6 +113,11 @@ const EXPECT = { reactome_analyze_identifiers: ["R-HSA-"], reactome_complex_subunits: ["R-HSA-"], reactome_events_hierarchy: ["R-HSA-"], + reactome_gsa_methods: ["PADOG", "Camera", "reactome.org/gsa"], + reactome_gsa_data_types: ["rnaseq_counts", "reactome_analyze_identifiers"], + reactome_gsa_search_datasets: ["Homo sapiens"], + reactome_gsa_examples: ["EXAMPLE_"], + reactome_gsa_sources: ["Expression Atlas"], }; /** diff --git a/src/clients/gsa.ts b/src/clients/gsa.ts new file mode 100644 index 0000000..0fcff63 --- /dev/null +++ b/src/clients/gsa.ts @@ -0,0 +1,51 @@ +import { GSA_SERVICE_URL } from "../config.js"; +import { fetchWithRetry } from "./http.js"; + +/** + * ReactomeGSA (gsa.reactome.org), the gene set analysis service. + * + * Separate from the Analysis Service in every respect that matters: a + * different host, a different API, and a different analysis. Over-representation + * takes a list of identifiers; gene set analysis takes an expression matrix + * with sample groups. Conflating them is the mistake this client exists to stop + * the model making. + */ +export class GsaClient { + private baseUrl: string; + + constructor(baseUrl: string = GSA_SERVICE_URL) { + this.baseUrl = baseUrl; + } + + private buildUrl( + path: string, + params?: Record + ): URL { + const url = new URL(`${this.baseUrl}${path}`); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) url.searchParams.set(key, String(value)); + }); + } + return url; + } + + async get( + path: string, + params?: Record + ): Promise { + const response = await fetchWithRetry(this.buildUrl(path, params).toString(), { + service: "gsa", + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GSA Service error ${response.status}: ${text}`); + } + + return response.json() as Promise; + } +} + +export const gsaClient = new GsaClient(); diff --git a/src/config.ts b/src/config.ts index 5887603..421167b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,23 @@ export const CONTENT_SERVICE_URL = export const ANALYSIS_SERVICE_URL = process.env.REACTOME_ANALYSIS_SERVICE_URL ?? `${REACTOME_BASE_URL}/AnalysisService/`; +/** + * ReactomeGSA, which is a different service from the Analysis Service and does + * a different thing. + * + * AnalysisService over-representation over a list of identifiers + * ReactomeGSA gene set analysis over an expression matrix -- PADOG, + * Camera ("similar to the classical GSEA algorithm"), + * ssGSEA, terapadog + * + * Nothing here talked to it until now, which is why a user asking this server's + * chatbot to "run a GSEA" was told Reactome could not, and offered fgsea and a + * YouTube tutorial instead. Reactome can; it just was not reachable from here. + */ +export const GSA_SERVICE_URL = normalizeBaseUrl( + process.env.REACTOME_GSA_SERVICE_URL ?? "https://gsa.reactome.org/0.1" +); + export const DEFAULT_SPECIES = "Homo sapiens"; export const DEFAULT_PAGE_SIZE = 25; diff --git a/src/tools/gsa.ts b/src/tools/gsa.ts new file mode 100644 index 0000000..30ff460 --- /dev/null +++ b/src/tools/gsa.ts @@ -0,0 +1,277 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { gsaClient } from "../clients/gsa.js"; +import { nonEmptyString } from "../schemas.js"; + +/** + * ReactomeGSA — gene set analysis. + * + * These exist because of a real failure: asked to "run a GSEA with my list of + * genes", a Reactome chatbot answered that Reactome could not do that and + * offered fgsea, bigomics and a YouTube tutorial. Reactome does do it, at + * gsa.reactome.org, through this service. Nothing could reach it. + * + * The tool descriptions below carry the distinction that caused the confusion, + * because the description is the only part of this a model reads before + * choosing: + * + * over-representation a LIST of identifiers reactome_analyze_identifiers + * gene set analysis an EXPRESSION MATRIX here + * + * A user with only a gene list wants over-representation, whatever they call + * it. GSEA needs measurements per gene per sample and a grouping to compare. + * + * Submitting an analysis is deliberately not here: /analysis wants the whole + * expression matrix inline, which is neither something a chat user can paste + * nor something to push through a tool result. These tools tell the model what + * the service offers, help it find a public dataset, and let it explain how to + * run one. + */ + +/** GET /methods — verified 2026-09-14. */ +interface GsaMethod { + name: string; + description?: string; + data_types?: string[]; + parameters?: GsaParameter[]; +} + +interface GsaParameter { + name: string; + display_name?: string; + type?: string; + default?: string; + description?: string; + scope?: string; +} + +/** GET /types — verified 2026-09-14. */ +interface GsaDataType { + id: string; + name?: string; + description?: string; +} + +/** GET /data/examples — verified 2026-09-14. */ +interface GsaExample { + id: string; + title?: string; + description?: string; + type?: string; + group?: string; +} + +/** GET /data/sources — verified 2026-09-14. */ +interface GsaSource { + id: string; + name?: string; + description?: string; +} + +/** GET /data/search — verified 2026-09-14. */ +interface GsaSearchResult { + id: string; + title?: string; + description?: string; + species?: string; + resource_name?: string; + resource_loading_id?: string; + web_link?: string; +} + +const HOW_TO_RUN = + "To actually run one: the web interface at https://reactome.org/gsa/, or the " + + "Galaxy tool (reactome/reactome_galaxy), or the ReactomeGSA R package. All of " + + "them take the expression matrix as a file."; + +export function registerGsaTools(server: McpServer) { + server.tool( + "reactome_gsa_methods", + "List the gene set analysis methods Reactome offers (PADOG, Camera, ssGSEA, terapadog) " + + "through ReactomeGSA. Use when asked about GSEA, GSA, gene set analysis, or a named " + + "method. NOTE: gene set analysis needs an expression matrix with sample groups. If the " + + "user has only a list of gene or protein names, they want reactome_analyze_identifiers " + + "(over-representation) instead, whatever they called it.", + {}, + async () => { + const methods = await gsaClient.get("/methods"); + const list = Array.isArray(methods) ? methods : []; + + const lines = [ + "## Reactome gene set analysis methods", + "", + "Provided by **ReactomeGSA** (https://reactome.org/gsa/), which is a different", + "service from Reactome's over-representation analysis.", + "", + ]; + + for (const method of list) { + lines.push(`### ${method.name}`); + if (method.description) lines.push(method.description); + if (method.data_types?.length) { + lines.push(`**Accepts:** ${method.data_types.join(", ")}`); + } + // Parameter names only. Ten parameters each with a paragraph of prose + // is most of a context window spent before the question is answered. + const names = (method.parameters ?? []).map(p => p.name); + if (names.length > 0) { + lines.push(`**Parameters:** ${names.join(", ")}`); + } + lines.push(""); + } + + if (list.length === 0) lines.push("*No methods reported by the service.*"); + else lines.push(HOW_TO_RUN); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "reactome_gsa_data_types", + "List the kinds of experimental data ReactomeGSA can analyse (RNA-seq counts, " + + "normalised RNA-seq, proteomics, microarray, Ribo-seq). Use to tell a user whether " + + "their data is supported.", + {}, + async () => { + const types = await gsaClient.get("/types"); + const list = Array.isArray(types) ? types : []; + + const lines = [ + "## Data types ReactomeGSA accepts", + "", + "| id | name | description |", + "| --- | --- | --- |", + ...list.map( + t => `| \`${t.id}\` | ${t.name ?? ""} | ${(t.description ?? "").replace(/\|/g, "\\|")} |` + ), + ]; + + if (list.length === 0) lines.push("*No data types reported by the service.*"); + else { + lines.push( + "", + "Every one of these is an expression matrix: genes as rows, samples as", + "columns, plus a grouping that says which samples to compare. A bare list", + "of gene names is not any of them — that is over-representation analysis,", + "`reactome_analyze_identifiers`." + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "reactome_gsa_search_datasets", + "Search public expression datasets ReactomeGSA can load and analyse without the user " + + "uploading anything — Expression Atlas, Single Cell Expression Atlas, GREIN and GEO. " + + "Use when a user wants a gene set analysis but has no data of their own, or asks " + + "whether a published dataset is available.", + { + keywords: nonEmptyString.describe("Space-delimited search terms, e.g. 'melanoma RNA-seq'"), + species: nonEmptyString + .optional() + .describe( + "Species NAME, e.g. 'Homo sapiens'. This service wants the name; a taxonomy " + + "id such as 9606 silently returns zero results." + ), + limit: nonEmptyString + .optional() + .describe("How many results to show (default 15; the service returns up to 100)"), + }, + async ({ keywords, species, limit }) => { + const results = await gsaClient.get("/data/search", { + keywords, + species, + }); + const list = Array.isArray(results) ? results : []; + const max = Number(limit) > 0 ? Number(limit) : 15; + + const lines = [ + `## Public datasets matching "${keywords}"${species ? ` in ${species}` : ""}`, + `**Found:** ${list.length}`, + "", + ]; + + if (list.length === 0) { + lines.push( + "*No datasets found.*", + "", + "If a species filter was used, check it is a name such as 'Homo sapiens'", + "rather than a taxonomy id — this service returns nothing for an id." + ); + } else { + for (const result of list.slice(0, max)) { + lines.push(`### ${result.title ?? result.id}`); + lines.push( + `**ID:** ${result.id}` + + (result.species ? ` · **Species:** ${result.species}` : "") + + (result.resource_name ? ` · **Source:** ${result.resource_name}` : "") + ); + if (result.description) lines.push(result.description.slice(0, 300)); + if (result.web_link) lines.push(`<${result.web_link}>`); + lines.push(""); + } + if (list.length > max) { + lines.push(`... and ${list.length - max} more. Narrow the keywords to see others.`); + } + lines.push("", HOW_TO_RUN); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "reactome_gsa_examples", + "List ReactomeGSA's built-in example datasets. Use to show someone a gene set analysis " + + "they can try immediately without data of their own.", + {}, + async () => { + const examples = await gsaClient.get("/data/examples"); + const list = Array.isArray(examples) ? examples : []; + + const lines = ["## ReactomeGSA example datasets", ""]; + for (const example of list) { + lines.push( + `- **${example.title ?? example.id}** (\`${example.id}\`, ${example.type ?? "?"})` + ); + if (example.description) lines.push(` ${example.description.slice(0, 200)}`); + } + + if (list.length === 0) lines.push("*No examples reported by the service.*"); + else lines.push("", HOW_TO_RUN); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "reactome_gsa_sources", + "List the public data repositories ReactomeGSA can pull expression data from.", + {}, + async () => { + const sources = await gsaClient.get("/data/sources"); + const list = Array.isArray(sources) ? sources : []; + + const lines = [ + "## Where ReactomeGSA can load data from", + "", + ...list.map( + s => `- **${s.name ?? s.id}** (\`${s.id}\`)${s.description ? ` — ${s.description}` : ""}` + ), + ]; + + if (list.length === 0) lines.push("*No sources reported by the service.*"); + else { + lines.push( + "", + "Search them with `reactome_gsa_search_datasets`, so a user with no data of", + "their own can still have an analysis run on a published dataset." + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 9770199..a873662 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -10,6 +10,7 @@ import { registerSearchTools } from "./search.js"; import { registerEntityTools } from "./entity.js"; import { registerExportTools } from "./export.js"; import { registerInteractorTools } from "./interactors.js"; +import { registerGsaTools } from "./gsa.js"; import { registerCypherTools } from "./cypher.js"; import { isNeo4jConfigured } from "../clients/neo4j.js"; import { withNewRequestContext } from "../context.js"; @@ -60,6 +61,7 @@ export function registerAllTools(server: McpServer) { registerEntityTools(server); registerExportTools(server); registerInteractorTools(server); + registerGsaTools(server); // Graph database tools — only when NEO4J_URI is set if (isNeo4jConfigured()) { diff --git a/tests/gsa.test.ts b/tests/gsa.test.ts new file mode 100644 index 0000000..6d9fb1b --- /dev/null +++ b/tests/gsa.test.ts @@ -0,0 +1,203 @@ +/** + * ReactomeGSA tools. + * + * Every fixture is copied from the live service (2026-09-14). These exist + * because of a real failure: a Reactome chatbot, asked to "run a GSEA with my + * list of genes", answered that Reactome could not and offered fgsea and a + * YouTube tutorial. Reactome can. Nothing could reach the service that does it. + */ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; +import { createFakeServer, textOf, calledUrl } from "./helpers/fake-server.js"; +import { registerGsaTools } from "../src/tools/gsa.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("reactome gsa tools", () => { + let fetchSpy: MockInstance; + const fake = createFakeServer(); + registerGsaTools(fake.server); + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // GET /methods + const METHODS = [ + { + name: "PADOG", + description: + "Weighted gene set analysis method that down-weighs genes present in many pathways", + data_types: ["rnaseq_counts", "proteomics_int"], + parameters: [ + { name: "use_interactors", type: "bool", description: "x".repeat(300) }, + { name: "sample_groups", type: "string", description: "y".repeat(300) }, + ], + }, + { + name: "Camera", + description: "A gene set analysis algorithm similar to the classical GSEA algorithm", + data_types: ["rnaseq_counts"], + parameters: [], + }, + ]; + + it("names the methods and where to run them", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(METHODS)); + + const text = textOf(await fake.invoke("reactome_gsa_methods", {})); + + expect(text).toContain("PADOG"); + expect(text).toContain("Camera"); + expect(text).toContain("similar to the classical GSEA algorithm"); + // The answer a user actually needs: where to go to run one. + expect(text).toContain("reactome.org/gsa"); + expect(text).not.toContain("undefined"); + }); + + it("lists parameter names but not their prose", async () => { + // Ten parameters with a paragraph each is most of a context window spent + // before the question has been answered. + fetchSpy.mockResolvedValueOnce(jsonResponse(METHODS)); + + const text = textOf(await fake.invoke("reactome_gsa_methods", {})); + + expect(text).toContain("use_interactors, sample_groups"); + expect(text).not.toContain("x".repeat(50)); + expect(text.length).toBeLessThan(4000); + }); + + it("says so when the service reports no methods", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse([])); + expect(textOf(await fake.invoke("reactome_gsa_methods", {}))).toContain("No methods reported"); + }); + + // GET /types + it("explains that every accepted type is a matrix, not a gene list", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([ + { + id: "rnaseq_counts", + name: "RNA-seq (raw counts)", + description: "Raw read counts per gene", + }, + ]) + ); + + const text = textOf(await fake.invoke("reactome_gsa_data_types", {})); + + expect(text).toContain("rnaseq_counts"); + // The distinction that caused the original failure. + expect(text).toContain("reactome_analyze_identifiers"); + expect(text).not.toContain("undefined"); + }); + + // GET /data/search + const SEARCH = [ + { + id: "GSE50535", + title: "RNA-seq melanoma", + description: "Using a chromatin regulator-focused shRNA library...", + species: "Homo sapiens", + resource_name: "GREIN", + web_link: "https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE50535", + }, + ]; + + it("searches public datasets so a user with no data can still be helped", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(SEARCH)); + + const text = textOf( + await fake.invoke("reactome_gsa_search_datasets", { keywords: "melanoma" }) + ); + + expect(text).toContain("GSE50535"); + expect(text).toContain("GREIN"); + expect(text).not.toContain("undefined"); + }); + + it("passes the species through as a name", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(SEARCH)); + + await fake.invoke("reactome_gsa_search_datasets", { + keywords: "melanoma", + species: "Homo sapiens", + }); + + // This service wants the name; a taxonomy id returns zero results with no + // error -- the opposite of the Content Service's eventsHierarchy, which + // wants the id and answers 500 for the name. + const url = calledUrl(fetchSpy.mock.calls); + expect(url).toContain("species=Homo+sapiens"); + expect(url).toContain("keywords=melanoma"); + }); + + it("points at the species trap when a search comes back empty", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse([])); + + const text = textOf( + await fake.invoke("reactome_gsa_search_datasets", { keywords: "zzz", species: "9606" }) + ); + + expect(text).toContain("No datasets found"); + expect(text).toContain("taxonomy id"); + }); + + it("caps how many datasets it renders", async () => { + const many = Array.from({ length: 100 }, (_, i) => ({ + id: `GSE${i}`, + title: `Dataset ${i}`, + species: "Homo sapiens", + })); + fetchSpy.mockResolvedValueOnce(jsonResponse(many)); + + const text = textOf(await fake.invoke("reactome_gsa_search_datasets", { keywords: "cancer" })); + + expect(text).toContain("**Found:** 100"); + expect(text).toContain("and 85 more"); + expect(text).not.toContain("Dataset 90"); + }); + + // GET /data/examples and /data/sources + it("lists example datasets someone can try immediately", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([ + { + id: "EXAMPLE_MEL_RNA", + title: "Melanoma RNA-seq example", + type: "rnaseq_counts", + description: "RNA-seq analysis of melanoma associated B cells.", + }, + ]) + ); + + const text = textOf(await fake.invoke("reactome_gsa_examples", {})); + expect(text).toContain("EXAMPLE_MEL_RNA"); + expect(text).not.toContain("undefined"); + }); + + it("lists the repositories it can pull from", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([ + { id: "ebi_gxa", name: "Expression Atlas", description: "EBI's Expression Atlas" }, + ]) + ); + + const text = textOf(await fake.invoke("reactome_gsa_sources", {})); + expect(text).toContain("Expression Atlas"); + expect(text).toContain("ebi_gxa"); + expect(text).not.toContain("undefined"); + }); + + it("reports a service error rather than rendering an empty answer", async () => { + fetchSpy.mockResolvedValue(jsonResponse({ detail: "Not Found" }, 404)); + await expect(fake.invoke("reactome_gsa_methods", {})).rejects.toThrow(/GSA Service error 404/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bc00228..2e420fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,10 +16,10 @@ export default defineConfig({ // tools still have no test, which is how a token-parsing bug and nine // wrong field paths all shipped unnoticed. thresholds: { - lines: 52, - functions: 48, - branches: 45, - statements: 53, + lines: 57, + functions: 52, + branches: 48, + statements: 57, }, }, },