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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

| | | |
Expand Down
4 changes: 4 additions & 0 deletions scripts/sweep-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
};
Expand Down Expand Up @@ -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-"],
};

/**
Expand Down
82 changes: 82 additions & 0 deletions src/tools/pathway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([id]);
const levels: Array<Array<{ stId: string; displayName: string; schemaClass?: string }>> = [];
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",
Expand Down
139 changes: 139 additions & 0 deletions tests/event-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>;
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();
});
});