diff --git a/.gitignore b/.gitignore index 55ea50a..6867d74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-test/ # Environment .env @@ -29,3 +30,5 @@ coverage/ # Temporary files *.tmp *.bak + +CHANGELOG.md diff --git a/package-lock.json b/package-lock.json index 9d63d8e..a7bc55a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "reactome-mcp", "version": "1.0.0", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.25.0" @@ -389,6 +389,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -585,6 +586,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1142,6 +1153,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index dc0f456..17f6eee 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/index.ts b/src/index.ts index 1b1a2a6..4e9b38a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..d000f63 --- /dev/null +++ b/src/server.ts @@ -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; +} diff --git a/src/tools/analysis.ts b/src/tools/analysis.ts index 52a81b4..0ac104f 100644 --- a/src/tools/analysis.ts +++ b/src/tools/analysis.ts @@ -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})`, @@ -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"), @@ -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.)"), }, @@ -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"), }, @@ -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(`/token/${token}/resources`); @@ -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"), @@ -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"), diff --git a/src/tools/search.ts b/src/tools/search.ts index 9e88059..81c3c3a 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -18,6 +18,8 @@ interface PathwaySearchResult { species: string; } +const searchQuerySchema = z.string().trim().min(1); + function stripHtml(text: string): string { return text.replace(/<[^>]*>/g, ''); } @@ -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"), @@ -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"), @@ -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("/search/suggest", { query }); @@ -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("/search/spellcheck", { query }); @@ -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 { @@ -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 }) => { diff --git a/test/helpers/mcp-test-utils.ts b/test/helpers/mcp-test-utils.ts new file mode 100644 index 0000000..9c3c218 --- /dev/null +++ b/test/helpers/mcp-test-utils.ts @@ -0,0 +1,47 @@ +import type { + McpServer, + RegisteredResource, + RegisteredResourceTemplate, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; + +type ServerInternals = { + _registeredResources: Record; + _registeredResourceTemplates: Record; + _registeredTools: Record; + validateToolInput(tool: RegisteredTool, args: unknown, toolName: string): Promise; + executeToolHandler(tool: RegisteredTool, args: unknown, extra: Record): Promise; +}; + +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(target: T, key: K, replacement: T[K]) { + const original = target[key]; + (target as T)[key] = replacement; + + return () => { + (target as T)[key] = original; + }; +} diff --git a/test/resources-smoke.test.ts b/test/resources-smoke.test.ts new file mode 100644 index 0000000..3b27863 --- /dev/null +++ b/test/resources-smoke.test.ts @@ -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[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(); + } +}); diff --git a/test/run-tests.ts b/test/run-tests.ts new file mode 100644 index 0000000..3f64cbb --- /dev/null +++ b/test/run-tests.ts @@ -0,0 +1,3 @@ +import "./server-registration.test.js"; +import "./tools-smoke.test.js"; +import "./resources-smoke.test.js"; diff --git a/test/server-registration.test.ts b/test/server-registration.test.ts new file mode 100644 index 0000000..0f18502 --- /dev/null +++ b/test/server-registration.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "../src/server.js"; +import { + getRegisteredResources, + getRegisteredResourceTemplates, + getRegisteredTools, +} from "./helpers/mcp-test-utils.js"; + +test("createServer registers representative tools, resources, and templates", () => { + const server = createServer(); + + const tools = getRegisteredTools(server); + const resources = getRegisteredResources(server); + const templates = getRegisteredResourceTemplates(server); + + assert.ok(Object.keys(tools).length > 0); + assert.ok(Object.keys(resources).length > 0); + assert.ok(Object.keys(templates).length > 0); + + assert.ok(tools.reactome_search); + assert.ok(tools.reactome_get_pathway); + assert.ok(tools.reactome_get_analysis_result); + + assert.ok(resources["reactome://species"]); + assert.ok(resources["reactome://database/info"]); + + assert.ok(templates.pathway); + assert.ok(templates.analysis); + assert.equal(templates.pathway.resourceTemplate.uriTemplate.toString(), "reactome://pathway/{id}"); +}); diff --git a/test/tools-smoke.test.ts b/test/tools-smoke.test.ts new file mode 100644 index 0000000..4a34760 --- /dev/null +++ b/test/tools-smoke.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analysisClient } from "../src/clients/analysis.js"; +import { contentClient } from "../src/clients/content.js"; +import { createServer } from "../src/server.js"; +import { invokeTool, stubMethod } from "./helpers/mcp-test-utils.js"; + +test("reactome_search returns formatted search results from the content client", async () => { + const server = createServer(); + const restore = stubMethod( + contentClient, + "get", + (async (path: string, params?: Record) => { + assert.equal(path, "/search/query"); + assert.equal(params?.query, "BRCA1"); + assert.equal(params?.rows, 25); + assert.equal(params?.cluster, true); + + return { + results: [ + { + entriesCount: 1, + entries: [ + { + dbId: 123, + stId: "R-HSA-123", + exactType: "Pathway", + name: "BRCA1 DNA repair", + species: ["Homo sapiens"], + summation: "

DNA repair pathway summary.

", + }, + ], + }, + ], + }; + }) as typeof contentClient.get + ); + + try { + const result = await invokeTool(server, "reactome_search", { query: "BRCA1" }); + const text = (result as { content: Array<{ text: string }> }).content[0].text; + + assert.match(text, /## Search Results for "BRCA1"/); + assert.match(text, /\*\*BRCA1 DNA repair\*\* \(R-HSA-123\)/); + assert.match(text, /DNA repair pathway summary\./); + assert.doesNotMatch(text, /|

/); + } finally { + restore(); + } +}); + +test("reactome_get_pathway returns formatted pathway details", async () => { + const server = createServer(); + const restore = stubMethod( + contentClient, + "get", + (async (path: string) => { + assert.equal(path, "/data/query/enhanced/R-HSA-199420"); + + return { + dbId: 199420, + stId: "R-HSA-199420", + displayName: "Apoptosis", + schemaClass: "Pathway", + speciesName: "Homo sapiens", + hasDiagram: true, + isInDisease: false, + summation: [{ text: "Programmed cell death pathway." }], + }; + }) as typeof contentClient.get + ); + + try { + const result = await invokeTool(server, "reactome_get_pathway", { id: "R-HSA-199420" }); + const text = (result as { content: Array<{ text: string }> }).content[0].text; + + assert.match(text, /## Apoptosis/); + assert.match(text, /\*\*Stable ID:\*\* R-HSA-199420/); + assert.match(text, /\*\*Has diagram:\*\* Yes/); + assert.match(text, /Programmed cell death pathway\./); + } finally { + restore(); + } +}); + +test("reactome_get_analysis_result returns formatted analysis output from the analysis client", async () => { + const server = createServer(); + const restore = stubMethod( + analysisClient, + "get", + (async (path: string, params?: Record) => { + assert.equal(path, "/token/mock-token"); + assert.equal(params?.sortBy, "ENTITIES_PVALUE"); + assert.equal(params?.order, "ASC"); + assert.equal(params?.page, 1); + assert.equal(params?.pageSize, 25); + + return { + token: "mock-token", + summary: { + type: "OVERREPRESENTATION", + species: "Homo sapiens", + speciesName: "Homo sapiens", + }, + pathwaysFound: 1, + identifiersNotFound: 0, + pathways: [ + { + stId: "R-HSA-109581", + name: "Apoptosis", + entities: { + found: 3, + total: 100, + ratio: 0.03, + pValue: 0.00012, + fdr: 0.001, + }, + reactions: { + found: 2, + total: 30, + }, + }, + ], + }; + }) as typeof analysisClient.get + ); + + try { + const result = await invokeTool(server, "reactome_get_analysis_result", { token: "mock-token" }); + const text = (result as { content: Array<{ text: string }> }).content[0].text; + + assert.match(text, /## Analysis Result/); + assert.match(text, /\*\*Token:\*\* mock-token/); + assert.match(text, /\*\*Apoptosis\*\* \(R-HSA-109581\)/); + assert.match(text, /p-value: 1\.20e-4, FDR: 1\.00e-3/); + } finally { + restore(); + } +}); + +test("reactome_search rejects missing required query input", async () => { + const server = createServer(); + + await assert.rejects( + () => invokeTool(server, "reactome_search", {}), + /Invalid arguments for tool reactome_search/ + ); +}); + +test("reactome_get_analysis_result rejects blank analysis tokens", async () => { + const server = createServer(); + + await assert.rejects( + () => invokeTool(server, "reactome_get_analysis_result", { token: " " }), + /Invalid arguments for tool reactome_get_analysis_result/ + ); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..3cb5d55 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist", "dist-test"] +}