diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc8455..2741731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ 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 +- **`reactome_preceding_events`** — what has to happen before a reaction or pathway, walked back several steps. Reactome models ordering on the *later* event, so this traverses `precedingEvent`; the forward direction is not symmetrically available (`followingEvent` appears only nested, as bare dbIds with no stable IDs). Containment was already covered by `reactome_pathway_contained_events` — "what is this pathway made of" — and ordering was not: "what leads up to this". Harvested from [reactome_chatbot#153](https://github.com/reactome/reactome_chatbot/pull/153) by @bhavyakeerthi3, which built a separate Content Service client inside the chatbot to do it. An event with nothing before it says so plainly, because an entry point is a real answer rather than a failed lookup. - **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: | | | | diff --git a/scripts/sweep-live.mjs b/scripts/sweep-live.mjs index e76d987..f340072 100644 --- a/scripts/sweep-live.mjs +++ b/scripts/sweep-live.mjs @@ -70,6 +70,7 @@ const TOOL_ARGS = { 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_preceding_events: { id: "R-HSA-69205", depth: 2 }, reactome_psicquic_summary: { resource: "IntAct", accession: "P04637" }, reactome_psicquic_details: { resource: "IntAct", accession: "P04637" }, }; @@ -118,6 +119,9 @@ const EXPECT = { reactome_gsa_search_datasets: ["Homo sapiens"], reactome_gsa_examples: ["EXAMPLE_"], reactome_gsa_sources: ["Expression Atlas"], + // An event with curated ordering; many events legitimately have none, so a + // tool that only ever says "nothing precedes this" would look healthy. + reactome_preceding_events: ["step back", "R-HSA-"], }; /** diff --git a/src/tools/pathway.ts b/src/tools/pathway.ts index 7e16790..d96d31f 100644 --- a/src/tools/pathway.ts +++ b/src/tools/pathway.ts @@ -285,6 +285,88 @@ export function registerPathwayTools(server: McpServer) { } ); + // What has to happen before this event + server.tool( + "reactome_preceding_events", + "Find the events that must occur before a given reaction or pathway — Reactome's " + + "event ordering. Use for mechanistic questions about sequence: what leads up to " + + "this, what triggers it, what comes earlier in the cascade. Walks back several " + + "steps, so it answers 'what is upstream of X' rather than only 'what is one step " + + "before X'. This is ordering, not containment: for what a pathway is made of, use " + + "reactome_pathway_contained_events.", + { + id: nonEmptyString.describe("Stable ID of a reaction or pathway, e.g. R-HSA-69205"), + depth: z + .number() + .int() + .min(1) + .max(5) + .optional() + .default(2) + .describe("How many steps back to walk (default 2). Each step multiplies the work."), + }, + async ({ id, depth }) => { + // Reactome models ordering on the *later* event: an event lists what + // precedes it. The forward direction is not symmetrically available -- + // `followingEvent` appears only nested, as bare dbIds with no stable IDs + // -- so this walks backwards, which is the direction the data supports. + const seen = new Set([id]); + const levels: Array> = []; + let frontier = [id]; + + for (let step = 0; step < depth && frontier.length > 0; step++) { + const found: Array<{ stId: string; displayName: string; schemaClass?: string }> = []; + for (const current of frontier) { + const event = await contentClient.get<{ + precedingEvent?: Array<{ stId?: string; displayName?: string; schemaClass?: string }>; + }>(`/data/query/${encodeURIComponent(current)}`); + + for (const preceding of event.precedingEvent ?? []) { + // A stable ID is what makes the answer usable; entries without one + // cannot be followed up and are not worth rendering. + if (!preceding.stId || seen.has(preceding.stId)) continue; + seen.add(preceding.stId); + found.push({ + stId: preceding.stId, + displayName: preceding.displayName ?? preceding.stId, + schemaClass: preceding.schemaClass, + }); + } + } + if (found.length === 0) break; + levels.push(found); + frontier = found.map(f => f.stId); + } + + const lines = [`## What happens before ${id}`, ""]; + + if (levels.length === 0) { + lines.push( + "*Nothing precedes this event in Reactome.*", + "", + "That is a real answer, not a lookup failure: many events are entry points,", + "and Reactome only records ordering where it is curated." + ); + } else { + levels.forEach((level, index) => { + lines.push(`### ${index + 1} step${index === 0 ? "" : "s"} back`); + for (const event of level) { + lines.push( + `- **${event.displayName}** (${event.stId})` + + (event.schemaClass ? ` [${event.schemaClass}]` : "") + ); + } + lines.push(""); + }); + lines.push( + `Ordering runs earliest-last: the deepest level above is furthest upstream of ${id}.` + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + // Get full events hierarchy server.tool( "reactome_events_hierarchy", diff --git a/tests/event-ordering.test.ts b/tests/event-ordering.test.ts new file mode 100644 index 0000000..873e3c9 --- /dev/null +++ b/tests/event-ordering.test.ts @@ -0,0 +1,139 @@ +/** + * Event ordering — what has to happen before something. + * + * Harvested from reactome/reactome_chatbot#153 by @bhavyakeerthi3, which built + * its own Content Service client inside the chatbot to do this. The idea was + * the valuable part: containment ("what is this pathway made of") was already + * covered and ordering ("what leads up to this") was not. + * + * Reactome models ordering on the *later* event: an event lists what precedes + * it. The forward direction is not symmetrically available -- `followingEvent` + * appears only nested, as bare dbIds with no stable IDs -- so this walks + * backwards, which is the direction the data supports. + */ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; +import { createFakeServer, textOf } from "./helpers/fake-server.js"; +import { registerPathwayTools } from "../src/tools/pathway.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** Shapes copied from GET /data/query/R-HSA-69205 on 2026-09-16. */ +const G1S = { + displayName: "G1/S-Specific Transcription", + schemaClass: "Pathway", + precedingEvent: [ + { + stId: "R-HSA-69227", + displayName: "Cyclin D:CDK4/6 phosphorylates RB1", + schemaClass: "Reaction", + }, + ], +}; + +const CYCLIN_D = { + displayName: "Cyclin D:CDK4/6 phosphorylates RB1", + precedingEvent: [ + { + stId: "R-HSA-8942836", + displayName: "CDK4/6:CCND complexes are activated", + schemaClass: "Reaction", + }, + { stId: "R-HSA-9659820", displayName: "RB1 translocates to the nucleus" }, + ], +}; + +describe("reactome_preceding_events", () => { + let fetchSpy: MockInstance; + const fake = createFakeServer(); + registerPathwayTools(fake.server); + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("walks back more than one step", async () => { + // The point of the tool: one step is "what is immediately before", which + // the raw object already gives. Several steps is the upstream cascade. + fetchSpy.mockResolvedValueOnce(jsonResponse(G1S)); + fetchSpy.mockResolvedValueOnce(jsonResponse(CYCLIN_D)); + + const text = textOf( + await fake.invoke("reactome_preceding_events", { id: "R-HSA-69205", depth: 2 }) + ); + + expect(text).toContain("1 step back"); + expect(text).toContain("R-HSA-69227"); + expect(text).toContain("2 steps back"); + expect(text).toContain("R-HSA-8942836"); + expect(text).not.toContain("undefined"); + }); + + it("stops at the requested depth", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(G1S)); + + const text = textOf( + await fake.invoke("reactome_preceding_events", { id: "R-HSA-69205", depth: 1 }) + ); + + expect(text).toContain("R-HSA-69227"); + expect(text).not.toContain("2 steps back"); + // One lookup, not two: depth is a budget, not a suggestion. + expect(fetchSpy.mock.calls.length).toBe(1); + }); + + it("says plainly when nothing precedes an event", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ displayName: "Apoptosis" })); + + const text = textOf(await fake.invoke("reactome_preceding_events", { id: "R-HSA-109581" })); + + // An entry point is a real answer. Rendering it as an empty list would + // read as a failed lookup. + expect(text).toContain("Nothing precedes this event"); + expect(text).toContain("not a lookup failure"); + }); + + it("does not revisit an event it has already seen", async () => { + // Reactome ordering can loop back; without the guard this recurses until + // the depth budget runs out, re-fetching the same events. + const a = { precedingEvent: [{ stId: "R-B", displayName: "B" }] }; + const b = { precedingEvent: [{ stId: "R-A", displayName: "A" }] }; + fetchSpy.mockResolvedValueOnce(jsonResponse(a)); + fetchSpy.mockResolvedValueOnce(jsonResponse(b)); + fetchSpy.mockResolvedValue(jsonResponse({})); + + const text = textOf(await fake.invoke("reactome_preceding_events", { id: "R-A", depth: 4 })); + + // R-A was the starting point; it must not reappear as its own ancestor. + // Counted over list entries only -- the id legitimately appears in the + // heading and in the closing sentence. + const listed = text.split("\n").filter(line => line.startsWith("- ") && line.includes("R-A")); + expect(listed).toEqual([]); + }); + + it("skips entries with no stable ID", async () => { + // An entry without one cannot be followed up, so rendering it gives the + // reader something they cannot act on. + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + precedingEvent: [{ displayName: "nameless" }, { stId: "R-X", displayName: "X" }], + }) + ); + + const text = textOf(await fake.invoke("reactome_preceding_events", { id: "R-1", depth: 1 })); + + expect(text).toContain("R-X"); + expect(text).not.toContain("nameless"); + }); + + it("rejects a depth outside the allowed range", () => { + expect(() => fake.invoke("reactome_preceding_events", { id: "R-1", depth: 99 })).toThrow(); + }); +});