Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
dist-test/

# Environment
.env
Expand Down Expand Up @@ -29,3 +30,5 @@ coverage/
# Temporary files
*.tmp
*.bak

CHANGELOG.md
14 changes: 13 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
},
"scripts": {
"build": "tsc",
"build:test": "tsc -p tsconfig.test.json",
"start": "node dist/index.js",
"dev": "tsc --watch",
"inspect": "npx @anthropic-ai/mcp-inspector node dist/index.js",
"test": "npm run build:test && node dist-test/test/run-tests.js",
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
"demo": "node web/mcp-bridge.js",
"demo:simple": "node web/server.js"
},
Expand Down
14 changes: 2 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerAllTools } from "./tools/index.js";
import { registerAllResources } from "./resources/index.js";

const server = new McpServer({
name: "reactome",
version: "1.0.0",
});

// Register all tools and resources
registerAllTools(server);
registerAllResources(server);
import { createServer } from "./server.js";

// Start the server
async function main() {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Reactome MCP server running on stdio");
Expand Down
15 changes: 15 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAllTools } from "./tools/index.js";
import { registerAllResources } from "./resources/index.js";

export function createServer() {
const server = new McpServer({
name: "reactome",
version: "1.0.0",
});

registerAllTools(server);
registerAllResources(server);

return server;
}
14 changes: 8 additions & 6 deletions src/tools/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
Bin,
} from "../types/index.js";

const analysisTokenSchema = z.string().trim().min(1).describe("Analysis token from a previous analysis");

function formatPathwaySummary(pathway: PathwaySummary): string {
return [
`- **${pathway.name}** (${pathway.stId})`,
Expand Down Expand Up @@ -104,7 +106,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_get_analysis_result",
"Retrieve a previously computed analysis result using its token. Allows filtering and pagination.",
{
token: z.string().describe("Analysis token from a previous analysis"),
token: analysisTokenSchema,
species: z.string().optional().describe("Filter by species"),
sort_by: z.enum(["NAME", "TOTAL_ENTITIES", "FOUND_ENTITIES", "ENTITIES_PVALUE", "ENTITIES_FDR", "ENTITIES_RATIO"]).optional().default("ENTITIES_PVALUE").describe("Sort field"),
order: z.enum(["ASC", "DESC"]).optional().default("ASC").describe("Sort order"),
Expand Down Expand Up @@ -133,7 +135,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_analysis_found_entities",
"Get the identifiers that were found in a specific pathway from an analysis result.",
{
token: z.string().describe("Analysis token"),
token: analysisTokenSchema,
pathway: z.string().describe("Pathway stable ID (e.g., R-HSA-109582)"),
resource: z.string().optional().default("TOTAL").describe("Resource filter (TOTAL, UNIPROT, ENSEMBL, etc.)"),
},
Expand Down Expand Up @@ -169,7 +171,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_analysis_not_found",
"Get the list of identifiers that could not be mapped in an analysis.",
{
token: z.string().describe("Analysis token"),
token: analysisTokenSchema,
page: z.number().optional().default(1).describe("Page number"),
page_size: z.number().optional().default(100).describe("Results per page"),
},
Expand Down Expand Up @@ -197,7 +199,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_analysis_resources",
"Get a summary of the molecule types (resources) found in an analysis.",
{
token: z.string().describe("Analysis token"),
token: analysisTokenSchema,
},
async ({ token }) => {
const result = await analysisClient.get<ResourceSummary[]>(`/token/${token}/resources`);
Expand Down Expand Up @@ -244,7 +246,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_analysis_pathway_sizes",
"Get the distribution of pathway sizes (binned) from an analysis result.",
{
token: z.string().describe("Analysis token"),
token: analysisTokenSchema,
bin_size: z.number().optional().default(100).describe("Bin size for grouping pathway sizes"),
species: z.string().optional().describe("Filter by species"),
resource: z.string().optional().default("TOTAL").describe("Resource filter"),
Expand Down Expand Up @@ -275,7 +277,7 @@ export function registerAnalysisTools(server: McpServer) {
"reactome_filter_analysis_pathways",
"Filter an analysis result to only include specific pathways.",
{
token: z.string().describe("Analysis token"),
token: analysisTokenSchema,
pathways: z.array(z.string()).describe("List of pathway stable IDs to include"),
resource: z.string().optional().default("TOTAL").describe("Resource filter"),
p_value: z.number().optional().describe("p-value threshold"),
Expand Down
14 changes: 8 additions & 6 deletions src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface PathwaySearchResult {
species: string;
}

const searchQuerySchema = z.string().trim().min(1);

function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, '');
}
Expand Down Expand Up @@ -64,7 +66,7 @@ export function registerSearchTools(server: McpServer) {
"reactome_search",
"Search the Reactome knowledgebase for pathways, reactions, proteins, genes, compounds, and other entities.",
{
query: z.string().describe("Search term (gene name, protein, pathway name, disease, etc.)"),
query: searchQuerySchema.describe("Search term (gene name, protein, pathway name, disease, etc.)"),
species: z.string().optional().describe("Filter by species (e.g., 'Homo sapiens', 'Mus musculus')"),
types: z.array(z.string()).optional().describe("Filter by type (Pathway, Reaction, Protein, Gene, Complex, etc.)"),
compartments: z.array(z.string()).optional().describe("Filter by cellular compartment"),
Expand Down Expand Up @@ -115,7 +117,7 @@ export function registerSearchTools(server: McpServer) {
"reactome_search_paginated",
"Search Reactome with pagination support for browsing through large result sets.",
{
query: z.string().describe("Search term"),
query: searchQuerySchema.describe("Search term"),
page: z.number().optional().default(1).describe("Page number (1-based)"),
rows_per_page: z.number().optional().default(20).describe("Results per page"),
species: z.string().optional().describe("Filter by species"),
Expand Down Expand Up @@ -158,7 +160,7 @@ export function registerSearchTools(server: McpServer) {
"reactome_search_suggest",
"Get auto-complete suggestions for a search query.",
{
query: z.string().describe("Partial search term"),
query: searchQuerySchema.describe("Partial search term"),
},
async ({ query }) => {
const result = await contentClient.get<SuggestResult>("/search/suggest", { query });
Expand All @@ -184,7 +186,7 @@ export function registerSearchTools(server: McpServer) {
"reactome_search_spellcheck",
"Get spell-check suggestions for a search query.",
{
query: z.string().describe("Search term to check"),
query: searchQuerySchema.describe("Search term to check"),
},
async ({ query }) => {
const result = await contentClient.get<SpellcheckResult>("/search/spellcheck", { query });
Expand Down Expand Up @@ -212,7 +214,7 @@ export function registerSearchTools(server: McpServer) {
"reactome_search_facets",
"Get available facets (filters) for search results, either globally or for a specific query.",
{
query: z.string().optional().describe("Search term (optional, returns global facets if omitted)"),
query: searchQuerySchema.optional().describe("Search term (optional, returns global facets if omitted)"),
},
async ({ query }) => {
interface FacetResult {
Expand Down Expand Up @@ -309,7 +311,7 @@ export function registerSearchTools(server: McpServer) {
"Search for entities within a specific pathway diagram.",
{
diagram: z.string().describe("Pathway stable ID for the diagram"),
query: z.string().describe("Search term"),
query: searchQuerySchema.describe("Search term"),
include_interactors: z.boolean().optional().default(false).describe("Include interactors"),
},
async ({ diagram, query, include_interactors }) => {
Expand Down
47 changes: 47 additions & 0 deletions test/helpers/mcp-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type {
McpServer,
RegisteredResource,
RegisteredResourceTemplate,
RegisteredTool,
} from "@modelcontextprotocol/sdk/server/mcp.js";

type ServerInternals = {
_registeredResources: Record<string, RegisteredResource>;
_registeredResourceTemplates: Record<string, RegisteredResourceTemplate>;
_registeredTools: Record<string, RegisteredTool>;
validateToolInput(tool: RegisteredTool, args: unknown, toolName: string): Promise<unknown>;
executeToolHandler(tool: RegisteredTool, args: unknown, extra: Record<string, never>): Promise<unknown>;
};

export function getRegisteredTools(server: McpServer) {
return (server as unknown as ServerInternals)._registeredTools;
}

export function getRegisteredResources(server: McpServer) {
return (server as unknown as ServerInternals)._registeredResources;
}

export function getRegisteredResourceTemplates(server: McpServer) {
return (server as unknown as ServerInternals)._registeredResourceTemplates;
}

export async function invokeTool(server: McpServer, name: string, args: unknown) {
const internals = server as unknown as ServerInternals;
const tool = internals._registeredTools[name];

if (!tool) {
throw new Error(`Tool ${name} is not registered`);
}

const parsedArgs = await internals.validateToolInput(tool, args, name);
return internals.executeToolHandler(tool, parsedArgs, {});
}

export function stubMethod<T extends object, K extends keyof T>(target: T, key: K, replacement: T[K]) {
const original = target[key];
(target as T)[key] = replacement;

return () => {
(target as T)[key] = original;
};
}
83 changes: 83 additions & 0 deletions test/resources-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { contentClient } from "../src/clients/content.js";
import { createServer } from "../src/server.js";
import {
getRegisteredResources,
getRegisteredResourceTemplates,
stubMethod,
} from "./helpers/mcp-test-utils.js";

const emptyRequestContext = {} as Parameters<
ReturnType<typeof getRegisteredResources>[string]["readCallback"]
>[1];

test("reactome://database/info static resource returns JSON content", async () => {
const server = createServer();
const resources = getRegisteredResources(server);
const restoreGetText = stubMethod(
contentClient,
"getText",
(async (path: string) => {
if (path === "/data/database/name") {
return "Reactome";
}

if (path === "/data/database/version") {
return "92";
}

throw new Error(`Unexpected path ${path}`);
}) as typeof contentClient.getText
);

try {
const result = await resources["reactome://database/info"].readCallback(
new URL("reactome://database/info"),
emptyRequestContext
);
const content = result.contents[0] as { text: string; mimeType?: string };
const payload = JSON.parse(content.text);

assert.equal(content.mimeType, "application/json");
assert.deepEqual(payload, { name: "Reactome", version: 92 });
} finally {
restoreGetText();
}
});

test("reactome://pathway/{id} template returns pathway JSON for the requested identifier", async () => {
const server = createServer();
const templates = getRegisteredResourceTemplates(server);
const restoreGet = stubMethod(
contentClient,
"get",
(async (path: string) => {
assert.equal(path, "/data/query/enhanced/R-HSA-141409");

return {
dbId: 141409,
stId: "R-HSA-141409",
displayName: "Hemostasis",
schemaClass: "Pathway",
};
}) as typeof contentClient.get
);

try {
const result = await templates.pathway.readCallback(
new URL("reactome://pathway/R-HSA-141409"),
{ id: "R-HSA-141409" },
emptyRequestContext
);
const content = result.contents[0] as { uri: string; text: string; mimeType?: string };
const payload = JSON.parse(content.text);

assert.equal(content.uri, "reactome://pathway/R-HSA-141409");
assert.equal(content.mimeType, "application/json");
assert.equal(payload.stId, "R-HSA-141409");
assert.equal(payload.displayName, "Hemostasis");
} finally {
restoreGet();
}
});
3 changes: 3 additions & 0 deletions test/run-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import "./server-registration.test.js";
import "./tools-smoke.test.js";
import "./resources-smoke.test.js";
Loading