Skip to content

Commit 7945ad0

Browse files
feat: add reactome_ai_explain tool without LLM dependency
1 parent 4ef719b commit 7945ad0

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

src/tools/ai-explain.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
import { contentClient } from "../clients/content.js";
4+
import type { SearchResult, Event, Pathway } from "../types/index.js";
5+
6+
/**
7+
* Strip HTML tags from search result text.
8+
*/
9+
function stripHtml(text: string): string {
10+
return text.replace(/<[^>]*>/g, "");
11+
}
12+
13+
/**
14+
* Search Reactome, fetch details for the top result, and build
15+
* a human-readable explanation entirely from the Reactome data.
16+
* No external LLM API required.
17+
*/
18+
async function buildExplanation(
19+
query: string,
20+
detailLevel: "brief" | "standard" | "detailed"
21+
): Promise<string> {
22+
// Step 1: Search Reactome
23+
const searchResult = await contentClient.get<SearchResult>("/search/query", {
24+
query,
25+
rows: 10,
26+
cluster: true,
27+
});
28+
29+
const entries = searchResult.results.flatMap((group) => group.entries);
30+
31+
if (entries.length === 0) {
32+
return [
33+
`## No Results Found`,
34+
"",
35+
`Reactome has no entries matching **"${query}"**.`,
36+
"",
37+
"**Tips:**",
38+
"- Try a gene symbol like `TP53` instead of a full name",
39+
"- Try a pathway name like `apoptosis` or `cell cycle`",
40+
"- Check spelling with the `reactome_search_spellcheck` tool",
41+
].join("\n");
42+
}
43+
44+
const lines: string[] = [];
45+
const topEntry = entries[0];
46+
47+
// Step 2: Fetch detailed info for the top result
48+
let detailed: Event | null = null;
49+
if (topEntry.stId) {
50+
try {
51+
detailed = await contentClient.get<Event>(
52+
`/data/query/enhanced/${encodeURIComponent(topEntry.stId)}`
53+
);
54+
} catch {
55+
// If detailed fetch fails, continue with search data only
56+
}
57+
}
58+
59+
// Step 3: Build the explanation header
60+
const name = detailed?.displayName || stripHtml(topEntry.name);
61+
lines.push(`## ${name}`);
62+
lines.push("");
63+
64+
// Basic metadata
65+
if (detailed) {
66+
lines.push(`| Property | Value |`);
67+
lines.push(`|----------|-------|`);
68+
lines.push(`| **Reactome ID** | ${detailed.stId} |`);
69+
lines.push(`| **Type** | ${detailed.schemaClass} |`);
70+
if (detailed.speciesName) {
71+
lines.push(`| **Species** | ${detailed.speciesName} |`);
72+
}
73+
if (detailed.isInDisease) {
74+
lines.push(`| **Disease pathway** | Yes |`);
75+
}
76+
if (detailed.hasDiagram) {
77+
lines.push(`| **Has diagram** | Yes |`);
78+
}
79+
lines.push("");
80+
}
81+
82+
// Step 4: Add the summary/description
83+
if (detailed?.summation && detailed.summation.length > 0) {
84+
const fullSummary = detailed.summation[0].text;
85+
// Strip HTML from summation text
86+
const cleanSummary = stripHtml(fullSummary);
87+
88+
if (detailLevel === "brief") {
89+
// Just first 2 sentences
90+
const sentences = cleanSummary.match(/[^.!?]+[.!?]+/g) || [cleanSummary];
91+
lines.push("### Summary");
92+
lines.push(sentences.slice(0, 2).join(" ").trim());
93+
} else {
94+
lines.push("### Description");
95+
lines.push(cleanSummary);
96+
}
97+
lines.push("");
98+
}
99+
100+
// Step 5: For standard/detailed — fetch sub-pathways and participants
101+
if (detailLevel !== "brief" && detailed?.stId) {
102+
// Try to get contained events (sub-pathways and reactions)
103+
if (detailed.schemaClass === "Pathway" || detailed.schemaClass === "TopLevelPathway") {
104+
try {
105+
const containedEvents = await contentClient.get<Event[]>(
106+
`/data/pathway/${encodeURIComponent(detailed.stId)}/containedEvents`
107+
);
108+
109+
const subPathways = containedEvents.filter(
110+
(e) => e.schemaClass === "Pathway"
111+
);
112+
const reactions = containedEvents.filter(
113+
(e) => e.schemaClass === "Reaction" || e.schemaClass === "BlackBoxEvent"
114+
);
115+
116+
if (subPathways.length > 0) {
117+
lines.push("### Sub-pathways");
118+
const limit = detailLevel === "detailed" ? 15 : 5;
119+
subPathways.slice(0, limit).forEach((p) => {
120+
lines.push(`- **${p.displayName}** (\`${p.stId}\`)`);
121+
});
122+
if (subPathways.length > limit) {
123+
lines.push(`- *...and ${subPathways.length - limit} more*`);
124+
}
125+
lines.push("");
126+
}
127+
128+
if (reactions.length > 0) {
129+
lines.push(`### Reactions`);
130+
lines.push(`This pathway involves **${reactions.length}** reactions.`);
131+
if (detailLevel === "detailed") {
132+
reactions.slice(0, 10).forEach((r) => {
133+
lines.push(`- ${r.displayName} (\`${r.stId}\`)`);
134+
});
135+
if (reactions.length > 10) {
136+
lines.push(`- *...and ${reactions.length - 10} more*`);
137+
}
138+
}
139+
lines.push("");
140+
}
141+
} catch {
142+
// Not a pathway or fetch failed — skip
143+
}
144+
}
145+
}
146+
147+
// Step 6: Literature references
148+
if (
149+
detailed?.literatureReference &&
150+
detailed.literatureReference.length > 0
151+
) {
152+
const limit = detailLevel === "brief" ? 2 : detailLevel === "standard" ? 3 : 5;
153+
lines.push("### Literature References");
154+
detailed.literatureReference.slice(0, limit).forEach((ref) => {
155+
if (ref.pubMedIdentifier) {
156+
lines.push(
157+
`- [${ref.displayName}](https://pubmed.ncbi.nlm.nih.gov/${ref.pubMedIdentifier})`
158+
);
159+
} else {
160+
lines.push(`- ${ref.displayName}`);
161+
}
162+
});
163+
if (detailed.literatureReference.length > limit) {
164+
lines.push(
165+
`- *...and ${detailed.literatureReference.length - limit} more references*`
166+
);
167+
}
168+
lines.push("");
169+
}
170+
171+
// Step 7: Related results from search
172+
if (detailLevel === "detailed" && entries.length > 1) {
173+
lines.push("### Related Entries in Reactome");
174+
entries.slice(1, 6).forEach((entry) => {
175+
const entryName = stripHtml(entry.name);
176+
lines.push(`- **${entryName}** (\`${entry.stId}\`) — ${entry.exactType}`);
177+
});
178+
lines.push("");
179+
}
180+
181+
// Step 8: Helpful next steps
182+
lines.push("### Explore Further");
183+
if (detailed?.stId) {
184+
lines.push(`- Use \`reactome_get_pathway\` with ID \`${detailed.stId}\` for full details`);
185+
if (detailed.hasDiagram) {
186+
lines.push(`- Use \`reactome_export_diagram\` with ID \`${detailed.stId}\` to get the pathway diagram`);
187+
}
188+
lines.push(`- Use \`reactome_participants\` with ID \`${detailed.stId}\` to see molecular participants`);
189+
}
190+
lines.push(
191+
`- Use \`reactome_search\` with query \`${query}\` to see all matching results`
192+
);
193+
194+
return lines.join("\n");
195+
}
196+
197+
export function registerAiExplainTools(server: McpServer) {
198+
server.tool(
199+
"reactome_ai_explain",
200+
"Get a clear, human-readable explanation of a biological concept, pathway, or entity using data from the Reactome knowledgebase. No API key required.",
201+
{
202+
query: z
203+
.string()
204+
.describe(
205+
'The biological concept or entity to explain (e.g., "TP53", "apoptosis", "cell cycle", "BRCA1")'
206+
),
207+
detail_level: z
208+
.enum(["brief", "standard", "detailed"])
209+
.optional()
210+
.default("standard")
211+
.describe(
212+
"How detailed the explanation should be: brief (summary only), standard (with sub-pathways), detailed (comprehensive with reactions and references)"
213+
),
214+
},
215+
async ({ query, detail_level }) => {
216+
try {
217+
const explanation = await buildExplanation(query, detail_level);
218+
return {
219+
content: [{ type: "text", text: explanation }],
220+
};
221+
} catch (error) {
222+
return {
223+
content: [
224+
{
225+
type: "text",
226+
text: `## Error\n\nFailed to fetch data from Reactome: ${error instanceof Error ? error.message : String(error)}`,
227+
},
228+
],
229+
};
230+
}
231+
}
232+
);
233+
}

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { registerSearchTools } from "./search.js";
99
import { registerEntityTools } from "./entity.js";
1010
import { registerExportTools } from "./export.js";
1111
import { registerInteractorTools } from "./interactors.js";
12+
import { registerAiExplainTools } from "./ai-explain.js";
1213

1314
export function registerAllTools(server: McpServer) {
1415
// Register tools from all modules
@@ -18,6 +19,7 @@ export function registerAllTools(server: McpServer) {
1819
registerEntityTools(server);
1920
registerExportTools(server);
2021
registerInteractorTools(server);
22+
registerAiExplainTools(server);
2123

2224
// Register utility tools directly here
2325
registerUtilityTools(server);

0 commit comments

Comments
 (0)