From a64ad6046fa3b87b2dcdbf92664e940c3cf55a9b Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 14 Sep 2026 19:47:53 +0000 Subject: [PATCH] Add ReactomeGSA tools: Reactome does do gene set analysis Reactome has two analysis services and this server knew about one. AnalysisService over-representation over a list of identifiers already here ReactomeGSA gene set analysis over an expression matrix new PADOG, Camera, ssGSEA, terapadog Camera is described by the service itself as "a gene set analysis algorithm similar to the classical GSEA algorithm". The gap was found the hard way. A Reactome chatbot, asked "I want to run a gsea with my list of genes", answered that the information was not available in the Reactome Knowledgebase and offered fgsea, bigomics and a YouTube tutorial. Reactome can do it, at gsa.reactome.org. Nothing here could reach the service. Five read-only tools: methods, data types, dataset search, examples, sources. **The descriptions carry the distinction that caused the confusion**, because a description is the whole of what a model reads before choosing a tool. Gene set analysis needs an expression matrix with sample groups; a user holding a list of gene names wants over-representation, whatever they called it. Both `reactome_gsa_methods` and `reactome_gsa_data_types` say so and name `reactome_analyze_identifiers` as the alternative. **Submitting an analysis is deliberately not here.** POST /analysis takes the whole expression matrix inline -- not something a chat user can paste, and not something to push through a tool result against a 40,000 character cap. What is reachable is covered instead: ReactomeGSA can load from Expression Atlas, Single Cell Expression Atlas, GREIN and GEO, so `reactome_gsa_search_datasets` lets someone with no data of their own be pointed at a published dataset. Running it is the web interface, the Galaxy tool, or the R package, and the tools say so. One trap worth the comment it carries: GSA's species filter wants the NAME. `species=9606` returns zero results with no error, while `species=Homo sapiens` returns 100 -- the exact opposite of the Content Service's eventsHierarchy, which wants the id and answers HTTP 500 for the name. Both the tool description and the empty-result message say which. Parameter prose is summarised to names only: ten parameters with a paragraph each is most of a context window spent before the question is answered. 53 -> 58 tools, 90 -> 101 tests, sweep covers all 58 with 21 content expectations and is clean. Coverage thresholds raised to match. Shapes verified against the live service 2026-09-14; the Galaxy CLI (reactome/reactome_galaxy) was the pointer to this service existing at all. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 ++ scripts/sweep-live.mjs | 8 ++ src/clients/gsa.ts | 51 ++++++++ src/config.ts | 17 +++ src/tools/gsa.ts | 277 +++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 2 + tests/gsa.test.ts | 203 ++++++++++++++++++++++++++++++ vitest.config.ts | 8 +- 8 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 src/clients/gsa.ts create mode 100644 src/tools/gsa.ts create mode 100644 tests/gsa.test.ts 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, }, }, },