├── src ├── types-global │ ├── declarations.d.ts │ └── errors.ts ├── mcp-server │ ├── transports │ │ ├── stdio │ │ │ ├── index.ts │ │ │ └── stdioTransport.ts │ │ ├── http │ │ │ ├── index.ts │ │ │ ├── httpTypes.ts │ │ │ ├── httpErrorHandler.ts │ │ │ └── mcpTransportMiddleware.ts │ │ ├── auth │ │ │ ├── index.ts │ │ │ ├── lib │ │ │ │ ├── authTypes.ts │ │ │ │ ├── authContext.ts │ │ │ │ └── authUtils.ts │ │ │ ├── strategies │ │ │ │ ├── authStrategy.ts │ │ │ │ ├── oauthStrategy.ts │ │ │ │ └── jwtStrategy.ts │ │ │ ├── authFactory.ts │ │ │ └── authMiddleware.ts │ │ └── core │ │ │ ├── baseTransportManager.ts │ │ │ ├── headerUtils.ts │ │ │ ├── transportTypes.ts │ │ │ └── honoNodeBridge.ts │ ├── tools │ │ ├── pubmedFetchContents │ │ │ ├── index.ts │ │ │ └── registration.ts │ │ ├── pubmedResearchAgent │ │ │ ├── index.ts │ │ │ ├── logic │ │ │ │ ├── index.ts │ │ │ │ └── outputTypes.ts │ │ │ ├── logic.ts │ │ │ └── registration.ts │ │ ├── pubmedSearchArticles │ │ │ ├── index.ts │ │ │ └── registration.ts │ │ ├── pubmedArticleConnections │ │ │ ├── index.ts │ │ │ ├── logic │ │ │ │ ├── types.ts │ │ │ │ ├── index.ts │ │ │ │ └── citationFormatter.ts │ │ │ └── registration.ts │ │ └── pubmedGenerateChart │ │ │ ├── index.ts │ │ │ └── registration.ts │ └── server.ts ├── utils │ ├── scheduling │ │ ├── index.ts │ │ └── scheduler.ts │ ├── network │ │ ├── index.ts │ │ └── fetchWithTimeout.ts │ ├── metrics │ │ ├── index.ts │ │ └── tokenCounter.ts │ ├── parsing │ │ ├── index.ts │ │ ├── dateParser.ts │ │ └── jsonParser.ts │ ├── security │ │ └── index.ts │ ├── internal │ │ ├── index.ts │ │ ├── performance.ts │ │ └── requestContext.ts │ ├── telemetry │ │ └── semconv.ts │ └── index.ts └── services │ └── NCBI │ ├── parsing │ ├── index.ts │ └── xmlGenericHelpers.ts │ └── core │ ├── ncbiConstants.ts │ ├── ncbiRequestQueueManager.ts │ ├── ncbiService.ts │ └── ncbiCoreApiClient.ts ├── .ncurc.json ├── examples ├── generate_pubmed_chart │ ├── bar_chart.png │ ├── line_chart.png │ ├── pie_chart.png │ ├── polar_chart.png │ ├── radar_chart.png │ ├── scatter_plot.png │ └── doughnut_chart.png ├── pubmed_article_connections_1.md ├── pubmed_article_connections_2.md └── pubmed_search_articles_example.md ├── tsconfig.typedoc.json ├── .dockerignore ├── typedoc.json ├── mcp.json ├── repomix.config.json ├── .github ├── workflows │ └── publish.yml └── FUNDING.yml ├── eslint.config.js ├── tsdoc.json ├── tsconfig.json ├── Dockerfile ├── smithery.yaml ├── scripts ├── clean.ts └── make-executable.ts ├── server.json ├── package.json ├── .gitignore └── docs └── tree.md /src/types-global/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "citation-js"; 2 | -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": ["chrono-node", "dotenv", "zod", "@hono/node-server", "hono"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/bar_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/bar_chart.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/line_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/line_chart.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/pie_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/pie_chart.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/polar_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/polar_chart.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/radar_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/radar_chart.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/scatter_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/scatter_plot.png -------------------------------------------------------------------------------- /examples/generate_pubmed_chart/doughnut_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyanheads/pubmed-mcp-server/HEAD/examples/generate_pubmed_chart/doughnut_chart.png -------------------------------------------------------------------------------- /tsconfig.typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "." 5 | }, 6 | "include": ["src/**/*", "scripts/**/*.ts"] 7 | // The 'exclude' is also inherited. 8 | } 9 | -------------------------------------------------------------------------------- /src/mcp-server/transports/stdio/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the Stdio transport module. 3 | * @module src/mcp-server/transports/stdio/index 4 | */ 5 | 6 | export { startStdioTransport } from "./stdioTransport.js"; 7 | -------------------------------------------------------------------------------- /src/utils/scheduling/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the scheduling module. 3 | * Exports the singleton schedulerService for application-wide use. 4 | * @module src/utils/scheduling 5 | */ 6 | 7 | export * from "./scheduler.js"; 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .npm 4 | .nyc_output 5 | coverage 6 | .git 7 | .gitignore 8 | README.md 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | dist 15 | *.log 16 | .DS_Store 17 | Thumbs.db -------------------------------------------------------------------------------- /src/utils/network/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for network utilities. 3 | * @module src/utils/network/index 4 | */ 5 | 6 | export * from "./fetchWithTimeout.js"; 7 | export type { FetchWithTimeoutOptions } from "./fetchWithTimeout.js"; // Explicitly re-exporting type 8 | -------------------------------------------------------------------------------- /src/utils/metrics/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for metrics-related utility modules. 3 | * This file re-exports utilities for collecting and processing metrics, 4 | * such as token counting. 5 | * @module src/utils/metrics 6 | */ 7 | 8 | export * from "./tokenCounter.js"; 9 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedFetchContents/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the pubmed_fetch_contents tool. 3 | * Exports the tool's registration function. 4 | * @module src/mcp-server/tools/pubmedFetchContents/index 5 | */ 6 | 7 | export { registerPubMedFetchContentsTool } from "./registration.js"; 8 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedResearchAgent/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the pubmed_research_agent tool. 3 | * Exports the tool's registration function. 4 | * @module src/mcp-server/tools/pubmedResearchAgent/index 5 | */ 6 | 7 | export { registerPubMedResearchAgentTool } from "./registration.js"; 8 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedSearchArticles/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the pubmedSearchArticles tool. 3 | * Exports the tool's registration function. 4 | * @module src/mcp-server/tools/pubmedSearchArticles/index 5 | */ 6 | 7 | export { registerPubMedSearchArticlesTool } from "./registration.js"; 8 | -------------------------------------------------------------------------------- /src/utils/parsing/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for parsing utility modules. 3 | * This file re-exports utilities related to parsing various data formats, 4 | * such as JSON and dates. 5 | * @module src/utils/parsing 6 | */ 7 | 8 | export * from "./dateParser.js"; 9 | export * from "./jsonParser.js"; 10 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedArticleConnections/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the pubmedArticleConnections tool. 3 | * Exports the registration function for this tool. 4 | * @module src/mcp-server/tools/pubmedArticleConnections/index 5 | */ 6 | 7 | export { registerPubMedArticleConnectionsTool } from "./registration.js"; 8 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedGenerateChart/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel export for the 'pubmed_generate_chart' tool. 3 | * This file re-exports the registration function for the tool, 4 | * making it easier to import and register with the MCP server. 5 | * @module src/mcp-server/tools/pubmedGenerateChart/index 6 | */ 7 | export * from "./registration.js"; 8 | -------------------------------------------------------------------------------- /src/mcp-server/transports/http/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the HTTP transport module. 3 | * @module src/mcp-server/transports/http/index 4 | */ 5 | 6 | export { createHttpApp, startHttpTransport } from "./httpTransport.js"; 7 | export { httpErrorHandler } from "./httpErrorHandler.js"; 8 | export type { HonoNodeBindings } from "./httpTypes.js"; 9 | -------------------------------------------------------------------------------- /src/utils/security/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for security-related utility modules. 3 | * This file re-exports utilities for input sanitization, rate limiting, 4 | * and ID generation. 5 | * @module src/utils/security 6 | */ 7 | 8 | export * from "./idGenerator.js"; 9 | export * from "./rateLimiter.js"; 10 | export * from "./sanitization.js"; 11 | -------------------------------------------------------------------------------- /src/services/NCBI/parsing/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for NCBI XML parsing helper utilities. 3 | * Re-exports functions from more specific parser modules. 4 | * @module src/services/NCBI/parsing/index 5 | */ 6 | 7 | export * from "./xmlGenericHelpers.js"; 8 | export * from "./pubmedArticleStructureParser.js"; 9 | export * from "./eSummaryResultParser.js"; 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["src", "scripts"], 4 | "entryPointStrategy": "expand", 5 | "out": "docs/api", 6 | "readme": "README.md", 7 | "name": "mcp-ts-template API Documentation", 8 | "includeVersion": true, 9 | "excludePrivate": true, 10 | "excludeProtected": true, 11 | "excludeInternal": true, 12 | "theme": "default" 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/internal/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for internal utility modules. 3 | * This file re-exports core internal utilities related to error handling, 4 | * logging, and request context management. 5 | * @module src/utils/internal 6 | */ 7 | 8 | export * from "./errorHandler.js"; 9 | export * from "./logger.js"; 10 | export * from "./performance.js"; 11 | export * from "./requestContext.js"; 12 | -------------------------------------------------------------------------------- /mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "pubmed-mcp-server": { 4 | "command": "npx", 5 | "args": ["@cyanheads/pubmed-mcp-server"], 6 | "env": { 7 | "MCP_LOG_LEVEL": "debug", 8 | "MCP_TRANSPORT_TYPE": "http", 9 | "MCP_HTTP_PORT": "3017", 10 | "NCBI_API_KEY": "YOUR_NCBI_API_KEY_HERE", 11 | "MCP_HTTP_HOST": "0.0.0.0", 12 | "MCP_SESSION_MODE": "stateless" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedResearchAgent/logic/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel export file for the pubmed_research_agent tool's core logic. 3 | * @module pubmedResearchAgent/logic/index 4 | */ 5 | 6 | export * from "./inputSchema.js"; 7 | export * from "./outputTypes.js"; 8 | export * from "./planOrchestrator.js"; 9 | // Individual section prompt generators are not typically exported directly from here, 10 | // as they are used internally by the planOrchestrator. 11 | -------------------------------------------------------------------------------- /repomix.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "output": { 3 | "filePath": "repomix-output.xml", 4 | "style": "xml", 5 | "removeComments": false, 6 | "removeEmptyLines": false, 7 | "topFilesLength": 5, 8 | "showLineNumbers": false, 9 | "copyToClipboard": false 10 | }, 11 | "include": [], 12 | "ignore": { 13 | "useGitignore": true, 14 | "useDefaultPatterns": true, 15 | "customPatterns": [".clinerules"] 16 | }, 17 | "security": { 18 | "enableSecurityCheck": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/telemetry/semconv.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines local OpenTelemetry semantic convention constants to ensure 3 | * stability and avoid dependency conflicts with different versions of 4 | * `@opentelemetry/semantic-conventions`. 5 | * @module src/utils/telemetry/semconv 6 | */ 7 | 8 | /** 9 | * The method or function name, or equivalent (usually rightmost part of the code unit's name). 10 | */ 11 | export const ATTR_CODE_FUNCTION = "code.function"; 12 | 13 | /** 14 | * The "namespace" within which `code.function` is defined. 15 | * Usually the qualified class or module name, etc. 16 | */ 17 | export const ATTR_CODE_NAMESPACE = "code.namespace"; 18 | -------------------------------------------------------------------------------- /src/mcp-server/transports/http/httpTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines custom types for the Hono HTTP transport layer. 3 | * @module src/mcp-server/transports/http/httpTypes 4 | */ 5 | 6 | import type { IncomingMessage, ServerResponse } from "http"; 7 | 8 | /** 9 | * Extends Hono's Bindings to include the raw Node.js request and response objects. 10 | * This is necessary for integrating with libraries like the MCP SDK that 11 | * need to write directly to the response stream. 12 | * 13 | * As per `@hono/node-server`, the response object is available on `c.env.outgoing`. 14 | */ 15 | export type HonoNodeBindings = { 16 | incoming: IncomingMessage; 17 | outgoing: ServerResponse; 18 | }; 19 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the auth module. 3 | * Exports core utilities and middleware strategies for easier imports. 4 | * @module src/mcp-server/transports/auth/index 5 | */ 6 | 7 | export { authContext } from "./lib/authContext.js"; 8 | export { withRequiredScopes } from "./lib/authUtils.js"; 9 | export type { AuthInfo } from "./lib/authTypes.js"; 10 | 11 | export { createAuthStrategy } from "./authFactory.js"; 12 | export { createAuthMiddleware } from "./authMiddleware.js"; 13 | export type { AuthStrategy } from "./strategies/authStrategy.js"; 14 | export { JwtStrategy } from "./strategies/jwtStrategy.js"; 15 | export { OauthStrategy } from "./strategies/oauthStrategy.js"; 16 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/lib/authTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Shared types for authentication middleware. 3 | * @module src/mcp-server/transports/auth/core/auth.types 4 | */ 5 | 6 | import type { AuthInfo as SdkAuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; 7 | 8 | /** 9 | * Defines the structure for authentication information derived from a token. 10 | * It extends the base SDK type to include common optional claims. 11 | */ 12 | export type AuthInfo = SdkAuthInfo & { 13 | subject?: string; 14 | }; 15 | 16 | // The declaration for `http.IncomingMessage` is no longer needed here, 17 | // as the new architecture avoids direct mutation where possible and handles 18 | // the attachment within the Hono context. 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npm 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | build-and-publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: "20.x" 19 | registry-url: "https://registry.npmjs.org" 20 | cache: "npm" 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Build 26 | run: npm run build 27 | 28 | - name: Publish to npm 29 | run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: cyanheads 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: cyanheads 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginJs from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | const combinedGlobals = { ...globals.browser, ...globals.node }; 6 | const trimmedGlobals = Object.fromEntries( 7 | Object.entries(combinedGlobals).map(([key, value]) => [key.trim(), value]), 8 | ); 9 | 10 | export default [ 11 | { 12 | ignores: ["coverage/", "tests/", "dist/", "build/", "node_modules/"], 13 | }, 14 | { languageOptions: { globals: trimmedGlobals } }, 15 | pluginJs.configs.recommended, 16 | ...tseslint.configs.recommended, 17 | { 18 | rules: { 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { 22 | argsIgnorePattern: "^_", 23 | varsIgnorePattern: "^_", 24 | caughtErrorsIgnorePattern: "^_", 25 | }, 26 | ], 27 | }, 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/strategies/authStrategy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines the interface for all authentication strategies. 3 | * This interface establishes a contract for verifying authentication tokens, 4 | * ensuring that any authentication method (JWT, OAuth, etc.) can be used 5 | * interchangeably by the core authentication middleware. 6 | * @module src/mcp-server/transports/auth/strategies/AuthStrategy 7 | */ 8 | import type { AuthInfo } from "../lib/authTypes.js"; 9 | 10 | export interface AuthStrategy { 11 | /** 12 | * Verifies an authentication token. 13 | * @param token The raw token string extracted from the request. 14 | * @returns A promise that resolves with the AuthInfo on successful verification. 15 | * @throws {McpError} if the token is invalid, expired, or fails verification for any reason. 16 | */ 17 | verify(token: string): Promise; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Barrel file for the utils module. 3 | * This file re-exports all utilities from their categorized subdirectories, 4 | * providing a single entry point for accessing utility functions. 5 | * @module src/utils 6 | */ 7 | 8 | // Re-export all utilities from their categorized subdirectories 9 | export * from "./internal/index.js"; 10 | export * from "./metrics/index.js"; 11 | export * from "./parsing/index.js"; 12 | export * from "./security/index.js"; 13 | export * from "./network/index.js"; 14 | export * from "./scheduling/index.js"; 15 | 16 | // It's good practice to have index.ts files in each subdirectory 17 | // that export the contents of that directory. 18 | // Assuming those will be created or already exist. 19 | // If not, this might need adjustment to export specific files, e.g.: 20 | // export * from './internal/errorHandler.js'; 21 | // export * from './internal/logger.js'; 22 | // ... etc. 23 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedArticleConnections/logic/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Shared type definitions for the pubmedArticleConnections tool logic. 3 | * @module src/mcp-server/tools/pubmedArticleConnections/logic/types 4 | */ 5 | 6 | import type { PubMedArticleConnectionsInput } from "./index.js"; 7 | 8 | // Helper type for enriched related articles 9 | export interface RelatedArticle { 10 | pmid: string; 11 | title?: string; 12 | authors?: string; // e.g., "Smith J, Doe A" 13 | score?: number; // From ELink, if available 14 | linkUrl: string; 15 | } 16 | 17 | export interface CitationOutput { 18 | ris?: string; 19 | bibtex?: string; 20 | apa_string?: string; 21 | mla_string?: string; 22 | } 23 | 24 | export interface ToolOutputData { 25 | sourcePmid: string; 26 | relationshipType: PubMedArticleConnectionsInput["relationshipType"]; 27 | relatedArticles: RelatedArticle[]; 28 | citations: CitationOutput; 29 | retrievedCount: number; 30 | eUtilityUrl?: string; // ELink or EFetch URL 31 | message?: string; // For errors or additional info 32 | } 33 | -------------------------------------------------------------------------------- /tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", 3 | "tagDefinitions": [ 4 | { 5 | "tagName": "@fileoverview", 6 | "syntaxKind": "modifier" 7 | }, 8 | { 9 | "tagName": "@module", 10 | "syntaxKind": "modifier" 11 | }, 12 | { 13 | "tagName": "@type", 14 | "syntaxKind": "modifier" 15 | }, 16 | { 17 | "tagName": "@typedef", 18 | "syntaxKind": "block" 19 | }, 20 | { 21 | "tagName": "@function", 22 | "syntaxKind": "block" 23 | }, 24 | { 25 | "tagName": "@template", 26 | "syntaxKind": "modifier" 27 | }, 28 | { 29 | "tagName": "@property", 30 | "syntaxKind": "block" 31 | }, 32 | { 33 | "tagName": "@class", 34 | "syntaxKind": "block" 35 | }, 36 | { 37 | "tagName": "@static", 38 | "syntaxKind": "modifier" 39 | }, 40 | { 41 | "tagName": "@private", 42 | "syntaxKind": "modifier" 43 | }, 44 | { 45 | "tagName": "@constant", 46 | "syntaxKind": "block" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/services/NCBI/core/ncbiConstants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Constants and shared type definitions for NCBI E-utility interactions. 3 | * @module src/services/NCBI/core/ncbiConstants 4 | */ 5 | 6 | export const NCBI_EUTILS_BASE_URL = 7 | "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"; 8 | 9 | /** 10 | * Interface for common NCBI E-utility request parameters. 11 | * Specific E-utilities will have additional parameters. 12 | */ 13 | export interface NcbiRequestParams { 14 | db?: string; // Target database (e.g., "pubmed", "pmc"). Optional for EInfo to list all databases. 15 | [key: string]: string | number | undefined; // Allows for other E-utility specific parameters 16 | } 17 | 18 | /** 19 | * Interface for options controlling how NCBI requests are made and responses are handled. 20 | */ 21 | export interface NcbiRequestOptions { 22 | retmode?: "xml" | "json" | "text"; // Desired response format 23 | rettype?: string; // Specific type of data to return (e.g., "abstract", "medline") 24 | usePost?: boolean; // Hint to use HTTP POST for large payloads (e.g., many IDs) 25 | returnRawXml?: boolean; // If true and retmode is 'xml', returns the raw XML string instead of parsed object (after error checking) 26 | } 27 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/lib/authContext.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines the AsyncLocalStorage context for authentication information. 3 | * This module provides a mechanism to store and retrieve authentication details 4 | * (like scopes and client ID) across asynchronous operations, making it available 5 | * from the middleware layer down to the tool and resource handlers without 6 | * drilling props. 7 | * 8 | * @module src/mcp-server/transports/auth/core/authContext 9 | */ 10 | 11 | import { AsyncLocalStorage } from "async_hooks"; 12 | import type { AuthInfo } from "./authTypes.js"; 13 | 14 | /** 15 | * Defines the structure of the store used within the AsyncLocalStorage. 16 | * It holds the authentication information for the current request context. 17 | */ 18 | interface AuthStore { 19 | authInfo: AuthInfo; 20 | } 21 | 22 | /** 23 | * An instance of AsyncLocalStorage to hold the authentication context (`AuthStore`). 24 | * This allows `authInfo` to be accessible throughout the async call chain of a request 25 | * after being set in the authentication middleware. 26 | * 27 | * @example 28 | * // In middleware: 29 | * await authContext.run({ authInfo }, next); 30 | * 31 | * // In a deeper handler: 32 | * const store = authContext.getStore(); 33 | * const scopes = store?.authInfo.scopes; 34 | */ 35 | export const authContext = new AsyncLocalStorage(); 36 | -------------------------------------------------------------------------------- /src/mcp-server/transports/core/baseTransportManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Abstract base class for transport managers. 3 | * @module src/mcp-server/transports/core/baseTransportManager 4 | */ 5 | 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 7 | import type { IncomingHttpHeaders } from "http"; 8 | import { 9 | logger, 10 | RequestContext, 11 | requestContextService, 12 | } from "../../../utils/index.js"; 13 | import { TransportManager, TransportResponse } from "./transportTypes.js"; 14 | 15 | /** 16 | * Abstract base class for transport managers, providing common functionality. 17 | */ 18 | export abstract class BaseTransportManager implements TransportManager { 19 | protected readonly createServerInstanceFn: () => Promise; 20 | 21 | constructor(createServerInstanceFn: () => Promise) { 22 | const context = requestContextService.createRequestContext({ 23 | operation: "BaseTransportManager.constructor", 24 | managerType: this.constructor.name, 25 | }); 26 | logger.debug("Initializing transport manager.", context); 27 | this.createServerInstanceFn = createServerInstanceFn; 28 | } 29 | 30 | abstract handleRequest( 31 | headers: IncomingHttpHeaders, 32 | body: unknown, 33 | context: RequestContext, 34 | sessionId?: string, 35 | ): Promise; 36 | 37 | abstract shutdown(): Promise; 38 | } 39 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedResearchAgent/logic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Core logic invocation for the pubmed_research_agent tool. 3 | * This tool generates a structured research plan outline with instructive placeholders, 4 | * designed to be completed by a calling LLM (the MCP Client). 5 | * @module pubmedResearchAgent/logic 6 | */ 7 | 8 | import { 9 | logger, 10 | RequestContext, 11 | requestContextService, 12 | sanitizeInputForLogging, 13 | } from "../../../utils/index.js"; 14 | import { 15 | generateFullResearchPlanOutline, 16 | PubMedResearchAgentInput, 17 | PubMedResearchPlanGeneratedOutput, 18 | } from "./logic/index.js"; 19 | 20 | export async function pubmedResearchAgentLogic( 21 | input: PubMedResearchAgentInput, 22 | parentRequestContext: RequestContext, 23 | ): Promise { 24 | const operationContext = requestContextService.createRequestContext({ 25 | parentRequestId: parentRequestContext.requestId, 26 | operation: "pubmedResearchAgentLogicExecution", 27 | input: sanitizeInputForLogging(input), 28 | }); 29 | 30 | logger.info( 31 | `Executing 'pubmed_research_agent' to generate research plan outline. Keywords: ${input.research_keywords.join( 32 | ", ", 33 | )}`, 34 | operationContext, 35 | ); 36 | 37 | const researchPlanOutline = generateFullResearchPlanOutline( 38 | input, 39 | operationContext, 40 | ); 41 | 42 | logger.notice("Successfully generated research plan outline.", { 43 | ...operationContext, 44 | projectTitle: input.project_title_suggestion, 45 | }); 46 | 47 | return researchPlanOutline; 48 | } 49 | -------------------------------------------------------------------------------- /src/mcp-server/transports/core/headerUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a utility for converting HTTP headers between Node.js 3 | * and Web Standards formats, ensuring compliance and correctness. 4 | * @module src/mcp-server/transports/core/headerUtils 5 | */ 6 | 7 | import type { OutgoingHttpHeaders } from "http"; 8 | 9 | /** 10 | * Converts Node.js-style OutgoingHttpHeaders to a Web-standard Headers object. 11 | * 12 | * This function is critical for interoperability between Node.js's `http` module 13 | * and Web APIs like Fetch and Hono. It correctly handles multi-value headers 14 | * (e.g., `Set-Cookie`), which Node.js represents as an array of strings, by 15 | * using the `Headers.append()` method. Standard single-value headers are set 16 | * using `Headers.set()`. 17 | * 18 | * @param nodeHeaders - The Node.js-style headers object to convert. 19 | * @returns A Web-standard Headers object. 20 | */ 21 | export function convertNodeHeadersToWebHeaders( 22 | nodeHeaders: OutgoingHttpHeaders, 23 | ): Headers { 24 | const webHeaders = new Headers(); 25 | for (const [key, value] of Object.entries(nodeHeaders)) { 26 | // Skip undefined headers, which are valid in Node.js but not in Web Headers. 27 | if (value === undefined) { 28 | continue; 29 | } 30 | 31 | if (Array.isArray(value)) { 32 | // For arrays, append each value to support multi-value headers. 33 | for (const v of value) { 34 | webHeaders.append(key, String(v)); 35 | } 36 | } else { 37 | // For single values, set the header, overwriting any existing value. 38 | webHeaders.set(key, String(value)); 39 | } 40 | } 41 | return webHeaders; 42 | } 43 | -------------------------------------------------------------------------------- /examples/pubmed_article_connections_1.md: -------------------------------------------------------------------------------- 1 | Tool Call Arguments: 2 | 3 | ```json 4 | { 5 | "sourcePmid": "39704040", 6 | "relationshipType": "pubmed_similar_articles", 7 | "maxRelatedResults": 3 8 | } 9 | ``` 10 | 11 | Tool Response: 12 | 13 | ```json 14 | { 15 | "sourcePmid": "39704040", 16 | "relationshipType": "pubmed_similar_articles", 17 | "relatedArticles": [ 18 | { 19 | "pmid": "38728204", 20 | "title": "Ciita Regulates Local and Systemic Immune Responses in a Combined rAAV-α-synuclein and Preformed Fibril-Induced Rat Model for Parkinson's Disease.", 21 | "authors": "Fredlund F, Jimenez-Ferrer I, Grabert K, et al.", 22 | "score": 34156797, 23 | "linkUrl": "https://pubmed.ncbi.nlm.nih.gov/38728204/" 24 | }, 25 | { 26 | "pmid": "27147665", 27 | "title": "Inhibition of the JAK/STAT Pathway Protects Against α-Synuclein-Induced Neuroinflammation and Dopaminergic Neurodegeneration.", 28 | "authors": "Qin H, Buckley JA, Li X, et al.", 29 | "score": 33315411, 30 | "linkUrl": "https://pubmed.ncbi.nlm.nih.gov/27147665/" 31 | }, 32 | { 33 | "pmid": "39652643", 34 | "title": "Transmission of peripheral blood α-synuclein fibrils exacerbates synucleinopathy and neurodegeneration in Parkinson's disease by endothelial Lag3 endocytosis.", 35 | "authors": "Duan Q, Zhang Q, Jiang S, et al.", 36 | "score": 33247981, 37 | "linkUrl": "https://pubmed.ncbi.nlm.nih.gov/39652643/" 38 | } 39 | ], 40 | "citations": {}, 41 | "retrievedCount": 3, 42 | "eUtilityUrl": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi?dbfrom=pubmed&db=pubmed&id=39704040&retmode=xml&cmd=neighbor_score" 43 | } 44 | ``` 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Target modern JavaScript 4 | "target": "ES2022", 5 | 6 | // Use modern Node.js module system 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | 10 | // Enable all strict type checking 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | 14 | // Module interop for CommonJS compatibility 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | 18 | // Output configuration 19 | "outDir": "./dist", 20 | "rootDir": "./src", 21 | "declaration": true, 22 | "declarationMap": true, 23 | "sourceMap": true, 24 | 25 | // Import helpers to reduce bundle size 26 | "importHelpers": true, 27 | 28 | // Skip type checking of declaration files 29 | "skipLibCheck": true, 30 | 31 | // Ensure consistent file naming 32 | "forceConsistentCasingInFileNames": true, 33 | 34 | // Enable experimental decorators if needed 35 | "experimentalDecorators": true, 36 | "emitDecoratorMetadata": true, 37 | 38 | // Node.js specific 39 | "lib": ["ES2022"], 40 | "types": ["node"], 41 | 42 | // Error on unused locals and parameters 43 | "noUnusedLocals": true, 44 | "noUnusedParameters": true, 45 | 46 | // Ensure void returns are handled 47 | "noImplicitReturns": true, 48 | "noFallthroughCasesInSwitch": true, 49 | 50 | // Modern resolution features 51 | "resolveJsonModule": true, 52 | "allowJs": false 53 | }, 54 | "include": ["src/**/*"], 55 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"], 56 | "ts-node": { 57 | "esm": true, 58 | "experimentalSpecifierResolution": "node" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/pubmed_article_connections_2.md: -------------------------------------------------------------------------------- 1 | Tool Call Arguments: 2 | 3 | ```json 4 | { 5 | "sourcePmid": "39704040", 6 | "relationshipType": "citation_formats", 7 | "citationStyles": ["ris", "bibtex", "apa_string", "mla_string"] 8 | } 9 | ``` 10 | 11 | Tool Response: 12 | 13 | ```json 14 | { 15 | "sourcePmid": "39704040", 16 | "relationshipType": "citation_formats", 17 | "relatedArticles": [], 18 | "citations": { 19 | "ris": "TY - JOUR\nAU - Bellini, Gabriele\nAU - D'Antongiovanni, Vanessa\nAU - Palermo, Giovanni\nAU - Antonioli, Luca\nAU - Fornai, Matteo\nAU - Ceravolo, Roberto\nAU - Bernardini, Nunzia\nAU - Derkinderen, Pascal\nAU - Pellegrini, Carolina\nTI - α-Synuclein in Parkinson's Disease: From Bench to Bedside.\nJO - Medicinal research reviews\nVL - 45\nIS - 3\nSP - 909\nEP - 946\nPY - 2025\nDO - 10.1002/med.22091\nUR - https://pubmed.ncbi.nlm.nih.gov/39704040\nER - \n", 20 | "bibtex": "@article{Bellini2025,\n author = {Bellini, Gabriele and D'Antongiovanni, Vanessa and Palermo, Giovanni and Antonioli, Luca and Fornai, Matteo and Ceravolo, Roberto and Bernardini, Nunzia and Derkinderen, Pascal and Pellegrini, Carolina},\n title = {α-Synuclein in Parkinson's Disease: From Bench to Bedside.},\n journal = {Medicinal research reviews},\n year = {2025},\n volume = {45},\n number = {3},\n pages = {909--946},\n month = {may},\n doi = {10.1002/med.22091},\n pmid = {39704040}\n}\n", 21 | "apa_string": "Bellini, G., D'Antongiovanni, V., Palermo, G., Antonioli, L., Fornai, M., Ceravolo, R., Bernardini, N., Derkinderen, P., Pellegrini & C.. (2025). α-Synuclein in Parkinson's Disease: From Bench to Bedside.. Medicinal research reviews, 45(3), 909-946. https://doi.org/10.1002/med.22091", 22 | "mla_string": "Bellini, Gabriele, et al. \"α-Synuclein in Parkinson's Disease: From Bench to Bedside..\" Medicinal research reviews, vol. 45, no. 3, May. 2025, pp. 909–946. PubMed Central, doi:10.1002/med.22091." 23 | }, 24 | "retrievedCount": 1, 25 | "eUtilityUrl": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=39704040&retmode=xml" 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/authFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Factory for creating an authentication strategy based on configuration. 3 | * This module centralizes the logic for selecting and instantiating the correct 4 | * authentication strategy, promoting loose coupling and easy extensibility. 5 | * @module src/mcp-server/transports/auth/authFactory 6 | */ 7 | import { config } from "../../../config/index.js"; 8 | import { logger, requestContextService } from "../../../utils/index.js"; 9 | import { AuthStrategy } from "./strategies/authStrategy.js"; 10 | import { JwtStrategy } from "./strategies/jwtStrategy.js"; 11 | import { OauthStrategy } from "./strategies/oauthStrategy.js"; 12 | 13 | /** 14 | * Creates and returns an authentication strategy instance based on the 15 | * application's configuration (`config.mcpAuthMode`). 16 | * 17 | * @returns An instance of a class that implements the `AuthStrategy` interface, 18 | * or `null` if authentication is disabled (`none`). 19 | * @throws {Error} If the auth mode is unknown or misconfigured. 20 | */ 21 | export function createAuthStrategy(): AuthStrategy | null { 22 | const context = requestContextService.createRequestContext({ 23 | operation: "createAuthStrategy", 24 | authMode: config.mcpAuthMode, 25 | }); 26 | logger.info("Creating authentication strategy...", context); 27 | 28 | switch (config.mcpAuthMode) { 29 | case "jwt": 30 | logger.debug("Instantiating JWT authentication strategy.", context); 31 | return new JwtStrategy(); 32 | case "oauth": 33 | logger.debug("Instantiating OAuth authentication strategy.", context); 34 | return new OauthStrategy(); 35 | case "none": 36 | logger.info("Authentication is disabled ('none' mode).", context); 37 | return null; // No authentication 38 | default: 39 | // This ensures that if a new auth mode is added to the config type 40 | // but not to this factory, we get a compile-time or runtime error. 41 | logger.error( 42 | `Unknown authentication mode: ${config.mcpAuthMode}`, 43 | context, 44 | ); 45 | throw new Error(`Unknown authentication mode: ${config.mcpAuthMode}`); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Build Stage ---- 2 | # Use a modern, secure Node.js Alpine image. 3 | # Alpine is lightweight, which reduces the attack surface. 4 | FROM node:23-alpine AS build 5 | 6 | # Set the working directory inside the container. 7 | WORKDIR /usr/src/app 8 | 9 | # Install build-time dependencies for native modules, especially node-canvas. 10 | # This includes python, make, and g++ for compilation, and dev libraries for canvas. 11 | RUN apk add --no-cache \ 12 | python3 \ 13 | make \ 14 | g++ \ 15 | cairo-dev \ 16 | jpeg-dev \ 17 | pango-dev \ 18 | giflib-dev 19 | 20 | # Copy package definitions to leverage Docker layer caching. 21 | COPY package.json package-lock.json* ./ 22 | 23 | # Install all npm dependencies. `npm ci` is used for reproducible builds. 24 | RUN npm ci 25 | 26 | # Copy the rest of the application source code. 27 | COPY . . 28 | 29 | # Compile TypeScript to JavaScript. 30 | RUN npm run build 31 | 32 | # ---- Production Stage ---- 33 | # Start from a fresh, minimal Node.js Alpine image for the final image. 34 | FROM node:23-alpine AS production 35 | 36 | WORKDIR /usr/src/app 37 | 38 | # Set the environment to production for optimized performance. 39 | ENV NODE_ENV=production 40 | 41 | # Install only the runtime dependencies for node-canvas. 42 | # This keeps the final image smaller than including the -dev packages. 43 | RUN apk add --no-cache \ 44 | cairo \ 45 | jpeg \ 46 | pango \ 47 | giflib 48 | 49 | # Create a non-root user and group for enhanced security. 50 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 51 | 52 | # Create and set permissions for the log directory. 53 | RUN mkdir -p /var/log/pubmed-mcp-server && chown -R appuser:appgroup /var/log/pubmed-mcp-server 54 | 55 | # Copy build artifacts from the build stage. 56 | # This includes the compiled code and production node_modules. 57 | COPY --from=build /usr/src/app/dist ./dist 58 | COPY --from=build /usr/src/app/node_modules ./node_modules 59 | COPY --from=build /usr/src/app/package.json ./ 60 | 61 | # Switch to the non-root user. 62 | USER appuser 63 | 64 | # Expose the port the server will listen on. 65 | # The PORT variable is typically provided by the deployment environment (e.g., Smithery). 66 | ENV MCP_HTTP_PORT=${PORT:-3017} 67 | EXPOSE ${MCP_HTTP_PORT} 68 | 69 | # Set runtime environment variables. 70 | ENV MCP_HTTP_HOST=0.0.0.0 71 | ENV MCP_TRANSPORT_TYPE=http 72 | ENV MCP_SESSION_MODE=stateless 73 | ENV MCP_LOG_LEVEL=info 74 | ENV LOGS_DIR=/var/log/pubmed-mcp-server 75 | ENV MCP_AUTH_MODE=none 76 | ENV MCP_FORCE_CONSOLE_LOGGING=true 77 | 78 | # The command to start the server. 79 | CMD ["node", "dist/index.js"] 80 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/lib/authUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides utility functions for authorization, specifically for 3 | * checking token scopes against required permissions for a given operation. 4 | * @module src/mcp-server/transports/auth/core/authUtils 5 | */ 6 | 7 | import { BaseErrorCode, McpError } from "../../../../types-global/errors.js"; 8 | import { logger, requestContextService } from "../../../../utils/index.js"; 9 | import { authContext } from "./authContext.js"; 10 | 11 | /** 12 | * Checks if the current authentication context contains all the specified scopes. 13 | * This function is designed to be called within tool or resource handlers to 14 | * enforce scope-based access control. It retrieves the authentication information 15 | * from `authContext` (AsyncLocalStorage). 16 | * 17 | * @param requiredScopes - An array of scope strings that are mandatory for the operation. 18 | * @throws {McpError} Throws an error with `BaseErrorCode.INTERNAL_ERROR` if the 19 | * authentication context is missing, which indicates a server configuration issue. 20 | * @throws {McpError} Throws an error with `BaseErrorCode.FORBIDDEN` if one or 21 | * more required scopes are not present in the validated token. 22 | */ 23 | export function withRequiredScopes(requiredScopes: string[]): void { 24 | const operationName = "withRequiredScopesCheck"; 25 | const initialContext = requestContextService.createRequestContext({ 26 | operation: operationName, 27 | requiredScopes, 28 | }); 29 | 30 | logger.debug("Performing scope authorization check.", initialContext); 31 | 32 | const store = authContext.getStore(); 33 | 34 | if (!store || !store.authInfo) { 35 | logger.crit( 36 | "Authentication context is missing in withRequiredScopes. This is a server configuration error.", 37 | initialContext, 38 | ); 39 | // This is a server-side logic error; the auth middleware should always populate this. 40 | throw new McpError( 41 | BaseErrorCode.INTERNAL_ERROR, 42 | "Authentication context is missing. This indicates a server configuration error.", 43 | { 44 | ...initialContext, 45 | error: "AuthStore not found in AsyncLocalStorage.", 46 | }, 47 | ); 48 | } 49 | 50 | const { scopes: grantedScopes, clientId, subject } = store.authInfo; 51 | const grantedScopeSet = new Set(grantedScopes); 52 | 53 | const missingScopes = requiredScopes.filter( 54 | (scope) => !grantedScopeSet.has(scope), 55 | ); 56 | 57 | const finalContext = { 58 | ...initialContext, 59 | grantedScopes, 60 | clientId, 61 | subject, 62 | }; 63 | 64 | if (missingScopes.length > 0) { 65 | const errorContext = { ...finalContext, missingScopes }; 66 | logger.warning( 67 | "Authorization failed: Missing required scopes.", 68 | errorContext, 69 | ); 70 | throw new McpError( 71 | BaseErrorCode.FORBIDDEN, 72 | `Insufficient permissions. Missing required scopes: ${missingScopes.join(", ")}`, 73 | errorContext, 74 | ); 75 | } 76 | 77 | logger.debug("Scope authorization successful.", finalContext); 78 | } 79 | -------------------------------------------------------------------------------- /src/services/NCBI/parsing/xmlGenericHelpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Generic helper functions for parsing XML data, particularly 3 | * structures from fast-xml-parser. 4 | * @module src/services/NCBI/parsing/xmlGenericHelpers 5 | */ 6 | 7 | /** 8 | * Ensures that the input is an array. If it's not an array, it wraps it in one. 9 | * Handles undefined or null by returning an empty array. 10 | * @param item - The item to ensure is an array. 11 | * @returns An array containing the item, or an empty array if item is null/undefined. 12 | * @template T - The type of the items in the array. 13 | */ 14 | export function ensureArray(item: T | T[] | undefined | null): T[] { 15 | if (item === undefined || item === null) { 16 | return []; 17 | } 18 | return Array.isArray(item) ? item : [item]; 19 | } 20 | 21 | /** 22 | * Safely extracts text content from an XML element, which might be a string or an object with a "#text" property. 23 | * Handles cases where #text might be a number or boolean by converting to string. 24 | * @param element - The XML element (string, object with #text, or undefined). 25 | * @param defaultValue - The value to return if text cannot be extracted. Defaults to an empty string. 26 | * @returns The text content or the default value. 27 | */ 28 | export function getText(element: unknown, defaultValue = ""): string { 29 | if (element === undefined || element === null) { 30 | return defaultValue; 31 | } 32 | if (typeof element === "string") { 33 | return element; 34 | } 35 | if (typeof element === "number" || typeof element === "boolean") { 36 | return String(element); // Handle direct number/boolean elements 37 | } 38 | if (typeof element === "object") { 39 | const obj = element as Record; 40 | if (obj["#text"] !== undefined) { 41 | const val = obj["#text"]; 42 | if (typeof val === "string") return val; 43 | if (typeof val === "number" || typeof val === "boolean") 44 | return String(val); 45 | } 46 | } 47 | return defaultValue; 48 | } 49 | 50 | /** 51 | * Safely extracts an attribute value from an XML element. 52 | * Assumes attributes are prefixed with "@_" by fast-xml-parser. 53 | * @param element - The XML element object. 54 | * @param attributeName - The name of the attribute (e.g., "_UI", "_MajorTopicYN", without the "@_" prefix). 55 | * @param defaultValue - The value to return if the attribute is not found. Defaults to an empty string. 56 | * @returns The attribute value or the default value. 57 | */ 58 | export function getAttribute( 59 | element: unknown, 60 | attributeName: string, // e.g., "UI", "MajorTopicYN" 61 | defaultValue = "", 62 | ): string { 63 | const fullAttributeName = `@_${attributeName}`; // As per fast-xml-parser config 64 | if (element && typeof element === "object") { 65 | const obj = element as Record; 66 | const val = obj[fullAttributeName]; 67 | if (typeof val === "string") return val; 68 | if (typeof val === "boolean") return String(val); // Convert boolean attributes to string 69 | if (typeof val === "number") return String(val); // Convert number attributes to string 70 | } 71 | return defaultValue; 72 | } 73 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration for a custom Docker container deployment. 2 | 3 | # Defines how to start the server once the container is built. 4 | startCommand: 5 | # Specifies that the server communicates over HTTP. 6 | type: "http" 7 | # Defines the configuration variables the server expects. 8 | # These are passed as environment variables or query parameters. 9 | configSchema: 10 | type: "object" 11 | properties: 12 | NCBI_API_KEY: 13 | type: "string" 14 | description: "API key for the NCBI service." 15 | NCBI_TOOL_IDENTIFIER: 16 | type: "string" 17 | description: "Tool identifier for the NCBI service." 18 | NCBI_ADMIN_EMAIL: 19 | type: "string" 20 | description: "Admin email for the NCBI service." 21 | MCP_LOG_LEVEL: 22 | type: "string" 23 | default: "info" 24 | description: "Minimum logging level." 25 | MCP_TRANSPORT_TYPE: 26 | type: "string" 27 | enum: ["stdio", "http"] 28 | default: "http" 29 | description: "MCP communication transport ('stdio' or 'http')." 30 | MCP_HTTP_PORT: 31 | type: "integer" 32 | default: 3017 33 | description: "HTTP server port (if MCP_TRANSPORT_TYPE is 'http')." 34 | MCP_HTTP_HOST: 35 | type: "string" 36 | default: "0.0.0.0" 37 | description: "HTTP server host to bind to." 38 | MCP_SESSION_MODE: 39 | type: "string" 40 | enum: ["stateless", "stateful", "auto"] 41 | default: "stateless" 42 | description: "Server session management mode." 43 | MCP_AUTH_MODE: 44 | type: "string" 45 | enum: ["none", "jwt", "oauth"] 46 | default: "none" 47 | description: "Server authentication mode." 48 | MCP_FORCE_CONSOLE_LOGGING: 49 | type: "boolean" 50 | default: false 51 | description: "Force console logging, even in non-TTY environments like Docker." 52 | # Specifies the build configuration for the Smithery Docker container. 53 | commandFunction: |- 54 | (config) => ({ 55 | command: 'node', 56 | args: ['build/index.js'], 57 | env: { 58 | NCBI_API_KEY: config.NCBI_API_KEY, 59 | NCBI_TOOL_IDENTIFIER: config.NCBI_TOOL_IDENTIFIER, 60 | NCBI_ADMIN_EMAIL: config.NCBI_ADMIN_EMAIL, 61 | MCP_LOG_LEVEL: config.MCP_LOG_LEVEL, 62 | MCP_TRANSPORT_TYPE: config.MCP_TRANSPORT_TYPE, 63 | MCP_HTTP_PORT: config.MCP_HTTP_PORT, 64 | MCP_HTTP_HOST: config.MCP_HTTP_HOST, 65 | MCP_SESSION_MODE: config.MCP_SESSION_MODE, 66 | MCP_AUTH_MODE: config.MCP_AUTH_MODE, 67 | MCP_FORCE_CONSOLE_LOGGING: config.MCP_FORCE_CONSOLE_LOGGING 68 | } 69 | }) 70 | # Provides an example configuration for users. 71 | exampleConfig: 72 | NCBI_API_KEY: "your_ncbi_api_key" 73 | NCBI_TOOL_IDENTIFIER: "@cyanheads/pubmed-mcp-server" 74 | NCBI_ADMIN_EMAIL: "your_email@example.com" 75 | MCP_LOG_LEVEL: "debug" 76 | MCP_TRANSPORT_TYPE: "http" 77 | MCP_HTTP_PORT: 3017 78 | MCP_HTTP_HOST: "0.0.0.0" 79 | MCP_SESSION_MODE: "stateless" 80 | MCP_AUTH_MODE: "none" 81 | MCP_FORCE_CONSOLE_LOGGING: true 82 | -------------------------------------------------------------------------------- /scripts/clean.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @fileoverview Utility script to clean build artifacts and temporary directories. 5 | * @module scripts/clean 6 | * By default, it removes the 'dist' and 'logs' directories. 7 | * Custom directories can be specified as command-line arguments. 8 | * Works on all platforms using Node.js path normalization. 9 | * 10 | * @example 11 | * // Add to package.json: 12 | * // "scripts": { 13 | * // "clean": "ts-node --esm scripts/clean.ts", 14 | * // "rebuild": "npm run clean && npm run build" 15 | * // } 16 | * 17 | * // Run with default directories: 18 | * // npm run clean 19 | * 20 | * // Run with custom directories: 21 | * // ts-node --esm scripts/clean.ts temp coverage 22 | */ 23 | 24 | import { rm, access } from "fs/promises"; 25 | import { join } from "path"; 26 | 27 | /** 28 | * Represents the result of a clean operation for a single directory. 29 | * @property dir - The name of the directory targeted for cleaning. 30 | * @property status - Indicates if the cleaning was successful or skipped. 31 | * @property reason - If skipped, the reason why. 32 | */ 33 | interface CleanResult { 34 | dir: string; 35 | status: "success" | "skipped"; 36 | reason?: string; 37 | } 38 | 39 | /** 40 | * Asynchronously checks if a directory exists at the given path. 41 | * @param dirPath - The absolute or relative path to the directory. 42 | * @returns A promise that resolves to `true` if the directory exists, `false` otherwise. 43 | */ 44 | async function directoryExists(dirPath: string): Promise { 45 | try { 46 | await access(dirPath); 47 | return true; 48 | } catch { 49 | return false; 50 | } 51 | } 52 | 53 | /** 54 | * Main function to perform the cleaning operation. 55 | * It reads command line arguments for target directories or uses defaults ('dist', 'logs'). 56 | * Reports the status of each cleaning attempt. 57 | */ 58 | const clean = async (): Promise => { 59 | try { 60 | let dirsToClean: string[] = ["dist", "logs"]; 61 | const args = process.argv.slice(2); 62 | 63 | if (args.length > 0) { 64 | dirsToClean = args; 65 | } 66 | 67 | console.log(`Attempting to clean directories: ${dirsToClean.join(", ")}`); 68 | 69 | const results = await Promise.allSettled( 70 | dirsToClean.map(async (dir): Promise => { 71 | const dirPath = join(process.cwd(), dir); 72 | 73 | const exists = await directoryExists(dirPath); 74 | 75 | if (!exists) { 76 | return { dir, status: "skipped", reason: "does not exist" }; 77 | } 78 | 79 | await rm(dirPath, { recursive: true, force: true }); 80 | return { dir, status: "success" }; 81 | }), 82 | ); 83 | 84 | results.forEach((result) => { 85 | if (result.status === "fulfilled") { 86 | const { dir, status, reason } = result.value; 87 | if (status === "success") { 88 | console.log(`Successfully cleaned directory: ${dir}`); 89 | } else { 90 | console.log(`Skipped cleaning directory ${dir}: ${reason}.`); 91 | } 92 | } else { 93 | // The error here is the actual error object from the rejected promise 94 | console.error( 95 | `Error cleaning a directory (details below):\n`, 96 | result.reason, 97 | ); 98 | } 99 | }); 100 | } catch (error) { 101 | console.error( 102 | "An unexpected error occurred during the clean script execution:", 103 | error instanceof Error ? error.message : error, 104 | ); 105 | process.exit(1); 106 | } 107 | }; 108 | 109 | clean(); 110 | -------------------------------------------------------------------------------- /examples/pubmed_search_articles_example.md: -------------------------------------------------------------------------------- 1 | Tool Call Arguments: 2 | 3 | ```json 4 | { 5 | "queryTerm": "neuroinflammation AND (Alzheimer's OR Parkinson's) AND microglia", 6 | "maxResults": 15, 7 | "sortBy": "pub_date", 8 | "dateRange": { 9 | "minDate": "2023/01/01", 10 | "maxDate": "2024/12/31", 11 | "dateType": "pdat" 12 | }, 13 | "filterByPublicationTypes": ["Review", "Journal Article"], 14 | "fetchBriefSummaries": 5 15 | } 16 | ``` 17 | 18 | Tool Response: 19 | 20 | ```json 21 | { 22 | "searchParameters": { 23 | "queryTerm": "neuroinflammation AND (Alzheimer's OR Parkinson's) AND microglia", 24 | "maxResults": 15, 25 | "sortBy": "pub_date", 26 | "dateRange": { 27 | "minDate": "2023/01/01", 28 | "maxDate": "2024/12/31", 29 | "dateType": "pdat" 30 | }, 31 | "filterByPublicationTypes": ["Review", "Journal Article"], 32 | "fetchBriefSummaries": 5 33 | }, 34 | "effectiveESearchTerm": "neuroinflammation AND (Alzheimer's OR Parkinson's) AND microglia AND (2023/01/01[pdat] : 2024/12/31[pdat]) AND (\"Review\"[Publication Type] OR \"Journal Article\"[Publication Type])", 35 | "totalFound": 1290, 36 | "retrievedPmidCount": 15, 37 | "pmids": [ 38 | 39715098, 39359093, 39704040, 39653749, 39648189, 39075895, 40256246, 39 | 39761611, 39726135, 39719687, 39718073, 39514171, 39433702, 39400857, 40 | 39029776 41 | ], 42 | "briefSummaries": [ 43 | { 44 | "pmid": "39715098", 45 | "title": "The compound (E)-2-(3,4-dihydroxystyryl)-3-hydroxy-4H-pyran-4-one alleviates neuroinflammation and cognitive impairment in a mouse model of Alzheimer's disease.", 46 | "authors": "Liu X, Wu W, Li X, et al.", 47 | "source": "Neural Regen Res", 48 | "doi": "", 49 | "pubDate": "2025-11-01", 50 | "epubDate": "2024-07-10" 51 | }, 52 | { 53 | "pmid": "39359093", 54 | "title": "The cGAS-STING-interferon regulatory factor 7 pathway regulates neuroinflammation in Parkinson's disease.", 55 | "authors": "Zhou S, Li T, Zhang W, et al.", 56 | "source": "Neural Regen Res", 57 | "doi": "", 58 | "pubDate": "2025-08-01", 59 | "epubDate": "2024-06-03" 60 | }, 61 | { 62 | "pmid": "39704040", 63 | "title": "α-Synuclein in Parkinson's Disease: From Bench to Bedside.", 64 | "authors": "Bellini G, D'Antongiovanni V, Palermo G, et al.", 65 | "source": "Med Res Rev", 66 | "doi": "", 67 | "pubDate": "2026-05-20", 68 | "epubDate": "2024-12-20" 69 | }, 70 | { 71 | "pmid": "39653749", 72 | "title": "Neuroinflammation in Alzheimer disease.", 73 | "authors": "Heneka MT, van der Flier WM, Jessen F, et al.", 74 | "source": "Nat Rev Immunol", 75 | "doi": "", 76 | "pubDate": "2026-05-20", 77 | "epubDate": "2024-12-09" 78 | }, 79 | { 80 | "pmid": "39648189", 81 | "title": "Unveiling the Involvement of Herpes Simplex Virus-1 in Alzheimer's Disease: Possible Mechanisms and Therapeutic Implications.", 82 | "authors": "Chauhan P, Begum MY, Narapureddy BR, et al.", 83 | "source": "Mol Neurobiol", 84 | "doi": "", 85 | "pubDate": "2026-05-20", 86 | "epubDate": "2024-12-09" 87 | } 88 | ], 89 | "eSearchUrl": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=neuroinflammation+AND+%28Alzheimer%27s+OR+Parkinson%27s%29+AND+microglia+AND+%282023%2F01%2F01%5Bpdat%5D+%3A+2024%2F12%2F31%5Bpdat%5D%29+AND+%28%22Review%22%5BPublication+Type%5D+OR+%22Journal+Article%22%5BPublication+Type%5D%29&retmax=15&sort=pub_date&usehistory=y", 90 | "eSummaryUrl": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&version=2.0&retmode=xml&WebEnv=MCID_6832175795dfc79c7001d173&query_key=1&retmax=5" 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines a unified Hono middleware for authentication. 3 | * This middleware is strategy-agnostic. It extracts a Bearer token, 4 | * delegates verification to the provided authentication strategy, and 5 | * populates the async-local storage context with the resulting auth info. 6 | * @module src/mcp-server/transports/auth/authMiddleware 7 | */ 8 | import type { HttpBindings } from "@hono/node-server"; 9 | import type { Context, Next } from "hono"; 10 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 11 | import { 12 | ErrorHandler, 13 | logger, 14 | requestContextService, 15 | } from "../../../utils/index.js"; 16 | import { authContext } from "./lib/authContext.js"; 17 | import type { AuthStrategy } from "./strategies/authStrategy.js"; 18 | 19 | /** 20 | * Creates a Hono middleware function that enforces authentication using a given strategy. 21 | * 22 | * @param strategy - An instance of a class that implements the `AuthStrategy` interface. 23 | * @returns A Hono middleware function. 24 | */ 25 | export function createAuthMiddleware(strategy: AuthStrategy) { 26 | return async function authMiddleware( 27 | c: Context<{ Bindings: HttpBindings }>, 28 | next: Next, 29 | ) { 30 | const context = requestContextService.createRequestContext({ 31 | operation: "authMiddleware", 32 | method: c.req.method, 33 | path: c.req.path, 34 | }); 35 | 36 | logger.debug("Initiating authentication check.", context); 37 | 38 | const authHeader = c.req.header("Authorization"); 39 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 40 | logger.warning("Authorization header missing or invalid.", context); 41 | throw new McpError( 42 | BaseErrorCode.UNAUTHORIZED, 43 | "Missing or invalid Authorization header. Bearer scheme required.", 44 | context, 45 | ); 46 | } 47 | 48 | const token = authHeader.substring(7); 49 | if (!token) { 50 | logger.warning( 51 | "Bearer token is missing from Authorization header.", 52 | context, 53 | ); 54 | throw new McpError( 55 | BaseErrorCode.UNAUTHORIZED, 56 | "Authentication token is missing.", 57 | context, 58 | ); 59 | } 60 | 61 | logger.debug( 62 | "Extracted Bearer token, proceeding to verification.", 63 | context, 64 | ); 65 | 66 | try { 67 | const authInfo = await strategy.verify(token); 68 | 69 | const authLogContext = { 70 | ...context, 71 | clientId: authInfo.clientId, 72 | subject: authInfo.subject, 73 | scopes: authInfo.scopes, 74 | }; 75 | logger.info( 76 | "Authentication successful. Auth context populated.", 77 | authLogContext, 78 | ); 79 | 80 | // Run the next middleware in the chain within the populated auth context. 81 | await authContext.run({ authInfo }, next); 82 | } catch (error) { 83 | // The strategy is expected to throw an McpError. 84 | // We re-throw it here to be caught by the global httpErrorHandler. 85 | logger.warning("Authentication verification failed.", { 86 | ...context, 87 | error: error instanceof Error ? error.message : String(error), 88 | }); 89 | 90 | // Ensure consistent error handling 91 | throw ErrorHandler.handleError(error, { 92 | operation: "authMiddlewareVerification", 93 | context, 94 | rethrow: true, // Rethrow to be caught by Hono's global error handler 95 | errorCode: BaseErrorCode.UNAUTHORIZED, // Default to unauthorized if not more specific 96 | }); 97 | } 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /src/mcp-server/transports/stdio/stdioTransport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Handles the setup and connection for the Stdio MCP transport. 3 | * Implements the MCP Specification 2025-03-26 for stdio transport. 4 | * This transport communicates directly over standard input (stdin) and 5 | * standard output (stdout), typically used when the MCP server is launched 6 | * as a child process by a host application. 7 | * 8 | * Specification Reference: 9 | * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#stdio 10 | * 11 | * --- Authentication Note --- 12 | * As per the MCP Authorization Specification (2025-03-26, Section 1.2), 13 | * STDIO transports SHOULD NOT implement HTTP-based authentication flows. 14 | * Authorization is typically handled implicitly by the host application 15 | * controlling the server process. This implementation follows that guideline. 16 | * 17 | * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification} 18 | * @module src/mcp-server/transports/stdioTransport 19 | */ 20 | 21 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 22 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 23 | import { ErrorHandler, logger, RequestContext } from "../../../utils/index.js"; 24 | 25 | /** 26 | * Connects a given `McpServer` instance to the Stdio transport. 27 | * This function initializes the SDK's `StdioServerTransport`, which manages 28 | * communication over `process.stdin` and `process.stdout` according to the 29 | * MCP stdio transport specification. 30 | * 31 | * MCP Spec Points Covered by SDK's `StdioServerTransport`: 32 | * - Reads JSON-RPC messages (requests, notifications, responses, batches) from stdin. 33 | * - Writes JSON-RPC messages to stdout. 34 | * - Handles newline delimiters and ensures no embedded newlines in output messages. 35 | * - Ensures only valid MCP messages are written to stdout. 36 | * 37 | * Logging via the `logger` utility MAY result in output to stderr, which is 38 | * permitted by the spec for logging purposes. 39 | * 40 | * @param server - The `McpServer` instance. 41 | * @param parentContext - The logging and tracing context from the calling function. 42 | * @returns A promise that resolves when the Stdio transport is successfully connected. 43 | * @throws {Error} If the connection fails during setup. 44 | */ 45 | export async function startStdioTransport( 46 | server: McpServer, 47 | parentContext: RequestContext, 48 | ): Promise { 49 | const operationContext = { 50 | ...parentContext, 51 | operation: "connectStdioTransport", 52 | transportType: "Stdio", 53 | }; 54 | logger.info("Attempting to connect stdio transport...", operationContext); 55 | 56 | try { 57 | logger.debug("Creating StdioServerTransport instance...", operationContext); 58 | const transport = new StdioServerTransport(); 59 | 60 | logger.debug( 61 | "Connecting McpServer instance to StdioServerTransport...", 62 | operationContext, 63 | ); 64 | await server.connect(transport); 65 | 66 | logger.info( 67 | "MCP Server connected and listening via stdio transport.", 68 | operationContext, 69 | ); 70 | if (process.stdout.isTTY) { 71 | console.log( 72 | `\n🚀 MCP Server running in STDIO mode.\n (MCP Spec: 2025-03-26 Stdio Transport)\n`, 73 | ); 74 | } 75 | } catch (err) { 76 | // Let the ErrorHandler log the error with all context, then rethrow. 77 | throw ErrorHandler.handleError(err, { 78 | operation: "connectStdioTransport", 79 | context: operationContext, 80 | critical: true, 81 | rethrow: true, 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedGenerateChart/registration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Registers the 'pubmed_generate_chart' tool with the MCP server. 3 | * This tool now accepts parameterized input for generating charts. 4 | * @module src/mcp-server/tools/pubmedGenerateChart/registration 5 | */ 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 7 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 9 | import { 10 | ErrorHandler, 11 | logger, 12 | RequestContext, 13 | requestContextService, 14 | } from "../../../utils/index.js"; 15 | import { 16 | PubMedGenerateChartInput, 17 | PubMedGenerateChartInputSchema, 18 | pubmedGenerateChartLogic, 19 | } from "./logic.js"; 20 | 21 | export async function registerPubMedGenerateChartTool( 22 | server: McpServer, 23 | ): Promise { 24 | const operation = "registerPubMedGenerateChartTool"; 25 | const toolName = "pubmed_generate_chart"; 26 | const toolDescription = 27 | "Generates a customizable chart (PNG) from structured data. Supports various plot types and requires data values and field mappings for axes. Returns a Base64-encoded PNG image."; 28 | const context = requestContextService.createRequestContext({ operation }); 29 | 30 | await ErrorHandler.tryCatch( 31 | async () => { 32 | server.tool( 33 | toolName, 34 | toolDescription, 35 | PubMedGenerateChartInputSchema.shape, 36 | async ( 37 | input: PubMedGenerateChartInput, 38 | mcpProvidedContext: unknown, 39 | ): Promise => { 40 | const richContext: RequestContext = 41 | requestContextService.createRequestContext({ 42 | parentRequestId: context.requestId, 43 | operation: "pubmedGenerateChartToolHandler", 44 | mcpToolContext: mcpProvidedContext, 45 | input, 46 | }); 47 | 48 | try { 49 | const result = await pubmedGenerateChartLogic(input, richContext); 50 | return { 51 | content: [ 52 | { 53 | type: "image", 54 | data: result.base64Data, 55 | mimeType: "image/png", 56 | }, 57 | ], 58 | isError: false, 59 | }; 60 | } catch (error) { 61 | const handledError = ErrorHandler.handleError(error, { 62 | operation: "pubmedGenerateChartToolHandler", 63 | context: richContext, 64 | input, 65 | rethrow: false, 66 | }); 67 | 68 | const mcpError = 69 | handledError instanceof McpError 70 | ? handledError 71 | : new McpError( 72 | BaseErrorCode.INTERNAL_ERROR, 73 | "An unexpected error occurred while generating the chart.", 74 | { 75 | originalErrorName: handledError.name, 76 | originalErrorMessage: handledError.message, 77 | }, 78 | ); 79 | 80 | return { 81 | content: [ 82 | { 83 | type: "text", 84 | text: JSON.stringify({ 85 | error: { 86 | code: mcpError.code, 87 | message: mcpError.message, 88 | details: mcpError.details, 89 | }, 90 | }), 91 | }, 92 | ], 93 | isError: true, 94 | }; 95 | } 96 | }, 97 | ); 98 | logger.notice(`Tool '${toolName}' registered.`, context); 99 | }, 100 | { 101 | operation, 102 | context, 103 | errorCode: BaseErrorCode.INITIALIZATION_FAILED, 104 | critical: true, 105 | }, 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/mcp-server/transports/http/httpErrorHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Centralized error handler for the Hono HTTP transport. 3 | * This middleware intercepts errors that occur during request processing, 4 | * standardizes them using the application's ErrorHandler utility, and 5 | * formats them into a consistent JSON-RPC error response. 6 | * @module src/mcp-server/transports/httpErrorHandler 7 | */ 8 | 9 | import { Context } from "hono"; 10 | import { StatusCode } from "hono/utils/http-status"; 11 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 12 | import { 13 | ErrorHandler, 14 | logger, 15 | requestContextService, 16 | } from "../../../utils/index.js"; 17 | import { HonoNodeBindings } from "./httpTypes.js"; 18 | 19 | /** 20 | * A centralized error handling middleware for Hono. 21 | * This function is registered with `app.onError()` and will catch any errors 22 | * thrown from preceding middleware or route handlers. 23 | * 24 | * @param err - The error that was thrown. 25 | * @param c - The Hono context object for the request. 26 | * @returns A Response object containing the formatted JSON-RPC error. 27 | */ 28 | export const httpErrorHandler = async ( 29 | err: Error, 30 | c: Context<{ Bindings: HonoNodeBindings }>, 31 | ): Promise => { 32 | const context = requestContextService.createRequestContext({ 33 | operation: "httpErrorHandler", 34 | path: c.req.path, 35 | method: c.req.method, 36 | }); 37 | logger.debug("HTTP error handler invoked.", context); 38 | 39 | const handledError = ErrorHandler.handleError(err, { 40 | operation: "httpTransport", 41 | context, 42 | }); 43 | 44 | let status: StatusCode = 500; 45 | if (handledError instanceof McpError) { 46 | switch (handledError.code) { 47 | case BaseErrorCode.NOT_FOUND: 48 | status = 404; 49 | break; 50 | case BaseErrorCode.UNAUTHORIZED: 51 | status = 401; 52 | break; 53 | case BaseErrorCode.FORBIDDEN: 54 | status = 403; 55 | break; 56 | case BaseErrorCode.VALIDATION_ERROR: 57 | case BaseErrorCode.INVALID_INPUT: 58 | status = 400; 59 | break; 60 | case BaseErrorCode.CONFLICT: 61 | status = 409; 62 | break; 63 | case BaseErrorCode.RATE_LIMITED: 64 | status = 429; 65 | break; 66 | default: 67 | status = 500; 68 | } 69 | } 70 | logger.debug(`Mapping error to HTTP status ${status}.`, { 71 | ...context, 72 | status, 73 | errorCode: (handledError as McpError).code, 74 | }); 75 | 76 | // Attempt to get the request ID from the body, but don't fail if it's not there or unreadable. 77 | let requestId: string | number | null = null; 78 | // Only attempt to read the body if it hasn't been consumed already. 79 | if (c.req.raw.bodyUsed === false) { 80 | try { 81 | const body = await c.req.json(); 82 | requestId = body?.id || null; 83 | logger.debug("Extracted JSON-RPC request ID from body.", { 84 | ...context, 85 | jsonRpcId: requestId, 86 | }); 87 | } catch { 88 | logger.warning( 89 | "Could not parse request body to extract JSON-RPC ID.", 90 | context, 91 | ); 92 | // Ignore parsing errors, requestId will remain null 93 | } 94 | } else { 95 | logger.debug( 96 | "Request body already consumed, cannot extract JSON-RPC ID.", 97 | context, 98 | ); 99 | } 100 | 101 | const errorCode = 102 | handledError instanceof McpError ? handledError.code : -32603; 103 | 104 | c.status(status); 105 | const errorResponse = { 106 | jsonrpc: "2.0", 107 | error: { 108 | code: errorCode, 109 | message: handledError.message, 110 | }, 111 | id: requestId, 112 | }; 113 | logger.info(`Sending formatted error response for request.`, { 114 | ...context, 115 | status, 116 | errorCode, 117 | jsonRpcId: requestId, 118 | }); 119 | return c.json(errorResponse); 120 | }; 121 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedSearchArticles/registration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Registration for the pubmed_search_articles MCP tool. 3 | * @module src/mcp-server/tools/pubmedSearchArticles/registration 4 | */ 5 | 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 7 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 9 | import { 10 | ErrorHandler, 11 | logger, 12 | RequestContext, 13 | requestContextService, 14 | } from "../../../utils/index.js"; 15 | import { 16 | PubMedSearchArticlesInput, 17 | PubMedSearchArticlesInputSchema, 18 | pubmedSearchArticlesLogic, 19 | } from "./logic.js"; 20 | 21 | /** 22 | * Registers the pubmed_search_articles tool with the MCP server. 23 | * @param server - The McpServer instance. 24 | */ 25 | export async function registerPubMedSearchArticlesTool( 26 | server: McpServer, 27 | ): Promise { 28 | const operation = "registerPubMedSearchArticlesTool"; 29 | const toolName = "pubmed_search_articles"; 30 | const toolDescription = 31 | "Searches PubMed for articles using a query term and optional filters (max results, sort, date range, publication types). Uses NCBI ESearch to find PMIDs and ESummary (optional) for brief summaries. Returns a JSON object with search parameters, ESearch term, result counts, PMIDs, optional summaries, and E-utility URLs."; 32 | const context = requestContextService.createRequestContext({ operation }); 33 | 34 | await ErrorHandler.tryCatch( 35 | async () => { 36 | server.tool( 37 | toolName, 38 | toolDescription, 39 | PubMedSearchArticlesInputSchema.shape, 40 | async ( 41 | input: PubMedSearchArticlesInput, 42 | mcpProvidedContext: unknown, 43 | ): Promise => { 44 | const richContext: RequestContext = 45 | requestContextService.createRequestContext({ 46 | parentRequestId: context.requestId, 47 | operation: "pubmedSearchArticlesToolHandler", 48 | mcpToolContext: mcpProvidedContext, 49 | input, 50 | }); 51 | 52 | try { 53 | const result = await pubmedSearchArticlesLogic(input, richContext); 54 | return { 55 | content: [ 56 | { type: "text", text: JSON.stringify(result, null, 2) }, 57 | ], 58 | isError: false, 59 | }; 60 | } catch (error) { 61 | const handledError = ErrorHandler.handleError(error, { 62 | operation: "pubmedSearchArticlesToolHandler", 63 | context: richContext, 64 | input, 65 | rethrow: false, 66 | }); 67 | 68 | const mcpError = 69 | handledError instanceof McpError 70 | ? handledError 71 | : new McpError( 72 | BaseErrorCode.INTERNAL_ERROR, 73 | "An unexpected error occurred while searching PubMed articles.", 74 | { 75 | originalErrorName: handledError.name, 76 | originalErrorMessage: handledError.message, 77 | }, 78 | ); 79 | 80 | return { 81 | content: [ 82 | { 83 | type: "text", 84 | text: JSON.stringify({ 85 | error: { 86 | code: mcpError.code, 87 | message: mcpError.message, 88 | details: mcpError.details, 89 | }, 90 | }), 91 | }, 92 | ], 93 | isError: true, 94 | }; 95 | } 96 | }, 97 | ); 98 | logger.notice(`Tool '${toolName}' registered.`, context); 99 | }, 100 | { 101 | operation, 102 | context, 103 | errorCode: BaseErrorCode.INITIALIZATION_FAILED, 104 | critical: true, 105 | }, 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", 3 | "name": "io.github.cyanheads/pubmed-mcp-server", 4 | "description": "Comprehensive PubMed MCP Server to search, retrieve, and analyze biomedical literature from NCBI.", 5 | "status": "active", 6 | "repository": { 7 | "url": "https://github.com/cyanheads/pubmed-mcp-server", 8 | "source": "github" 9 | }, 10 | "website_url": "https://github.com/cyanheads/pubmed-mcp-server#readme", 11 | "version": "1.4.4", 12 | "packages": [ 13 | { 14 | "registry_type": "npm", 15 | "registry_base_url": "https://registry.npmjs.org", 16 | "identifier": "@cyanheads/pubmed-mcp-server", 17 | "version": "1.4.4", 18 | "runtime_hint": "node", 19 | "package_arguments": [ 20 | { 21 | "type": "positional", 22 | "value": "dist/index.js" 23 | } 24 | ], 25 | "environment_variables": [ 26 | { 27 | "name": "MCP_TRANSPORT_TYPE", 28 | "description": "Specifies the transport mechanism for the server.", 29 | "format": "string", 30 | "is_required": true, 31 | "default": "stdio" 32 | }, 33 | { 34 | "name": "MCP_LOG_LEVEL", 35 | "description": "Sets the minimum log level for output (e.g., 'debug', 'info', 'warn').", 36 | "format": "string", 37 | "is_required": false, 38 | "default": "info" 39 | }, 40 | { 41 | "name": "NCBI_API_KEY", 42 | "description": "Your NCBI API key for higher rate limits.", 43 | "format": "string", 44 | "is_required": false 45 | } 46 | ], 47 | "transport": { 48 | "type": "stdio" 49 | } 50 | }, 51 | { 52 | "registry_type": "npm", 53 | "registry_base_url": "https://registry.npmjs.org", 54 | "identifier": "@cyanheads/pubmed-mcp-server", 55 | "version": "1.4.4", 56 | "runtime_hint": "node", 57 | "package_arguments": [ 58 | { 59 | "type": "positional", 60 | "value": "dist/index.js" 61 | } 62 | ], 63 | "environment_variables": [ 64 | { 65 | "name": "MCP_TRANSPORT_TYPE", 66 | "description": "Specifies the transport mechanism for the server.", 67 | "format": "string", 68 | "is_required": true, 69 | "default": "http" 70 | }, 71 | { 72 | "name": "MCP_HTTP_HOST", 73 | "description": "The host for the HTTP server.", 74 | "format": "string", 75 | "is_required": false, 76 | "default": "localhost" 77 | }, 78 | { 79 | "name": "MCP_HTTP_PORT", 80 | "description": "The port for the HTTP server.", 81 | "format": "string", 82 | "is_required": false, 83 | "default": "3017" 84 | }, 85 | { 86 | "name": "MCP_HTTP_ENDPOINT_PATH", 87 | "description": "The endpoint path for MCP requests.", 88 | "format": "string", 89 | "is_required": false, 90 | "default": "/mcp" 91 | }, 92 | { 93 | "name": "MCP_AUTH_MODE", 94 | "description": "Authentication mode: 'none', 'jwt', or 'oauth'.", 95 | "format": "string", 96 | "is_required": false, 97 | "default": "none" 98 | }, 99 | { 100 | "name": "MCP_LOG_LEVEL", 101 | "description": "Sets the minimum log level (e.g., 'debug', 'info', 'warn').", 102 | "format": "string", 103 | "is_required": false, 104 | "default": "info" 105 | }, 106 | { 107 | "name": "NCBI_API_KEY", 108 | "description": "Your NCBI API key for higher rate limits.", 109 | "format": "string", 110 | "is_required": false 111 | } 112 | ], 113 | "transport": { 114 | "type": "streamable-http", 115 | "url": "http://localhost:3017/mcp" 116 | } 117 | } 118 | ], 119 | "mcpName": "io.github.cyanheads/pubmed-mcp-server" 120 | } -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedFetchContents/registration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Registration for the pubmed_fetch_contents MCP tool. 3 | * @module src/mcp-server/tools/pubmedFetchContents/registration 4 | */ 5 | 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 7 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 9 | import { 10 | ErrorHandler, 11 | logger, 12 | RequestContext, 13 | requestContextService, 14 | } from "../../../utils/index.js"; 15 | import { 16 | PubMedFetchContentsInput, 17 | PubMedFetchContentsInputSchema, 18 | pubMedFetchContentsLogic, 19 | } from "./logic.js"; 20 | 21 | /** 22 | * Registers the pubmed_fetch_contents tool with the MCP server. 23 | * @param server - The McpServer instance. 24 | */ 25 | export async function registerPubMedFetchContentsTool( 26 | server: McpServer, 27 | ): Promise { 28 | const operation = "registerPubMedFetchContentsTool"; 29 | const toolName = "pubmed_fetch_contents"; 30 | const toolDescription = 31 | "Fetches detailed information from PubMed using NCBI EFetch. Can be used with a direct list of PMIDs or with queryKey/webEnv from an ESearch history entry. Supports pagination (retstart, retmax) when using history. Available 'detailLevel' options: 'abstract_plus' (parsed details), 'full_xml' (raw PubMedArticle XML), 'medline_text' (MEDLINE format), or 'citation_data' (minimal citation data). Returns a JSON object containing results, any PMIDs not found (if applicable), and EFetch details."; 32 | 33 | const context = requestContextService.createRequestContext({ operation }); 34 | 35 | await ErrorHandler.tryCatch( 36 | async () => { 37 | server.tool( 38 | toolName, 39 | toolDescription, 40 | PubMedFetchContentsInputSchema._def.schema.shape, 41 | async ( 42 | input: PubMedFetchContentsInput, 43 | toolContext: unknown, 44 | ): Promise => { 45 | const richContext: RequestContext = 46 | requestContextService.createRequestContext({ 47 | parentRequestId: context.requestId, 48 | operation: "pubMedFetchContentsToolHandler", 49 | mcpToolContext: toolContext, 50 | input, 51 | }); 52 | 53 | try { 54 | const result = await pubMedFetchContentsLogic(input, richContext); 55 | return { 56 | content: [{ type: "text", text: result.content }], 57 | isError: false, 58 | }; 59 | } catch (error) { 60 | const handledError = ErrorHandler.handleError(error, { 61 | operation: "pubMedFetchContentsToolHandler", 62 | context: richContext, 63 | input, 64 | rethrow: false, 65 | }); 66 | 67 | const mcpError = 68 | handledError instanceof McpError 69 | ? handledError 70 | : new McpError( 71 | BaseErrorCode.INTERNAL_ERROR, 72 | "An unexpected error occurred while fetching PubMed content.", 73 | { 74 | originalErrorName: handledError.name, 75 | originalErrorMessage: handledError.message, 76 | }, 77 | ); 78 | 79 | return { 80 | content: [ 81 | { 82 | type: "text", 83 | text: JSON.stringify({ 84 | error: { 85 | code: mcpError.code, 86 | message: mcpError.message, 87 | details: mcpError.details, 88 | }, 89 | }), 90 | }, 91 | ], 92 | isError: true, 93 | }; 94 | } 95 | }, 96 | ); 97 | 98 | logger.notice(`Tool '${toolName}' registered.`, context); 99 | }, 100 | { 101 | operation, 102 | context, 103 | errorCode: BaseErrorCode.INITIALIZATION_FAILED, 104 | critical: true, 105 | }, 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/utils/internal/performance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a utility for performance monitoring of tool execution. 3 | * This module introduces a higher-order function to wrap tool logic, measure its 4 | * execution time, and log a structured metrics event. 5 | * @module src/utils/internal/performance 6 | */ 7 | 8 | import { SpanStatusCode, trace } from "@opentelemetry/api"; 9 | import { 10 | ATTR_CODE_FUNCTION, 11 | ATTR_CODE_NAMESPACE, 12 | } from "../telemetry/semconv.js"; 13 | import { config } from "../../config/index.js"; 14 | import { McpError } from "../../types-global/errors.js"; 15 | import { logger } from "./logger.js"; 16 | import { RequestContext } from "./requestContext.js"; 17 | 18 | /** 19 | * Calculates the size of a payload in bytes. 20 | * @param payload - The payload to measure. 21 | * @returns The size in bytes. 22 | * @private 23 | */ 24 | function getPayloadSize(payload: unknown): number { 25 | if (!payload) return 0; 26 | try { 27 | const stringified = JSON.stringify(payload); 28 | return Buffer.byteLength(stringified, "utf8"); 29 | } catch { 30 | return 0; // Could not stringify 31 | } 32 | } 33 | 34 | /** 35 | * A higher-order function that wraps a tool's core logic to measure its performance 36 | * and log a structured metrics event upon completion. 37 | * 38 | * @template T The expected return type of the tool's logic function. 39 | * @param toolLogicFn - The asynchronous tool logic function to be executed and measured. 40 | * @param context - The request context for the operation, used for logging and tracing. 41 | * @param inputPayload - The input payload to the tool for size calculation. 42 | * @returns A promise that resolves with the result of the tool logic function. 43 | * @throws Re-throws any error caught from the tool logic function after logging the failure. 44 | */ 45 | export async function measureToolExecution( 46 | toolLogicFn: () => Promise, 47 | context: RequestContext & { toolName: string }, 48 | inputPayload: unknown, 49 | ): Promise { 50 | const tracer = trace.getTracer( 51 | config.openTelemetry.serviceName, 52 | config.openTelemetry.serviceVersion, 53 | ); 54 | const { toolName } = context; 55 | 56 | return tracer.startActiveSpan(`tool_execution:${toolName}`, async (span) => { 57 | span.setAttributes({ 58 | [ATTR_CODE_FUNCTION]: toolName, 59 | [ATTR_CODE_NAMESPACE]: "mcp-tools", 60 | "mcp.tool.input_bytes": getPayloadSize(inputPayload), 61 | }); 62 | 63 | const startTime = process.hrtime.bigint(); 64 | let isSuccess = false; 65 | let errorCode: string | undefined; 66 | let outputPayload: T | undefined; 67 | 68 | try { 69 | const result = await toolLogicFn(); 70 | isSuccess = true; 71 | outputPayload = result; 72 | span.setStatus({ code: SpanStatusCode.OK }); 73 | span.setAttribute("mcp.tool.output_bytes", getPayloadSize(outputPayload)); 74 | return result; 75 | } catch (error) { 76 | if (error instanceof McpError) { 77 | errorCode = error.code; 78 | } else if (error instanceof Error) { 79 | errorCode = "UNHANDLED_ERROR"; 80 | } else { 81 | errorCode = "UNKNOWN_ERROR"; 82 | } 83 | 84 | if (error instanceof Error) { 85 | span.recordException(error); 86 | } 87 | span.setStatus({ 88 | code: SpanStatusCode.ERROR, 89 | message: error instanceof Error ? error.message : String(error), 90 | }); 91 | 92 | throw error; 93 | } finally { 94 | const endTime = process.hrtime.bigint(); 95 | const durationMs = Number(endTime - startTime) / 1_000_000; 96 | 97 | span.setAttributes({ 98 | "mcp.tool.duration_ms": parseFloat(durationMs.toFixed(2)), 99 | "mcp.tool.success": isSuccess, 100 | }); 101 | if (errorCode) { 102 | span.setAttribute("mcp.tool.error_code", errorCode); 103 | } 104 | 105 | span.end(); 106 | 107 | logger.info("Tool execution finished.", { 108 | ...context, 109 | metrics: { 110 | durationMs: parseFloat(durationMs.toFixed(2)), 111 | isSuccess, 112 | errorCode, 113 | inputBytes: getPayloadSize(inputPayload), 114 | outputBytes: getPayloadSize(outputPayload), 115 | }, 116 | }); 117 | } 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /src/utils/network/fetchWithTimeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a utility function to make fetch requests with a specified timeout. 3 | * @module src/utils/network/fetchWithTimeout 4 | */ 5 | 6 | import { logger } from "../internal/logger.js"; // Adjusted import path 7 | import type { RequestContext } from "../internal/requestContext.js"; // Adjusted import path 8 | import { McpError, BaseErrorCode } from "../../types-global/errors.js"; 9 | 10 | /** 11 | * Options for the fetchWithTimeout utility. 12 | * Extends standard RequestInit but omits 'signal' as it's handled internally. 13 | */ 14 | export type FetchWithTimeoutOptions = Omit; 15 | 16 | /** 17 | * Fetches a resource with a specified timeout. 18 | * 19 | * @param url - The URL to fetch. 20 | * @param timeoutMs - The timeout duration in milliseconds. 21 | * @param context - The request context for logging. 22 | * @param options - Optional fetch options (RequestInit), excluding 'signal'. 23 | * @returns A promise that resolves to the Response object. 24 | * @throws {McpError} If the request times out or another fetch-related error occurs. 25 | */ 26 | export async function fetchWithTimeout( 27 | url: string | URL, 28 | timeoutMs: number, 29 | context: RequestContext, 30 | options?: FetchWithTimeoutOptions, 31 | ): Promise { 32 | const controller = new AbortController(); 33 | const timeoutId = setTimeout(() => controller.abort(), timeoutMs); 34 | 35 | const urlString = url.toString(); 36 | const operationDescription = `fetch ${options?.method || "GET"} ${urlString}`; 37 | 38 | logger.debug( 39 | `Attempting ${operationDescription} with ${timeoutMs}ms timeout.`, 40 | context, 41 | ); 42 | 43 | try { 44 | const response = await fetch(url, { 45 | ...options, 46 | signal: controller.signal, 47 | }); 48 | clearTimeout(timeoutId); 49 | 50 | if (!response.ok) { 51 | const errorBody = await response 52 | .text() 53 | .catch(() => "Could not read response body"); 54 | logger.error( 55 | `Fetch failed for ${urlString} with status ${response.status}.`, 56 | { 57 | ...context, 58 | statusCode: response.status, 59 | statusText: response.statusText, 60 | responseBody: errorBody, 61 | errorSource: "FetchHttpError", 62 | }, 63 | ); 64 | throw new McpError( 65 | BaseErrorCode.SERVICE_UNAVAILABLE, 66 | `Fetch failed for ${urlString}. Status: ${response.status}`, 67 | { 68 | ...context, 69 | statusCode: response.status, 70 | statusText: response.statusText, 71 | responseBody: errorBody, 72 | }, 73 | ); 74 | } 75 | 76 | logger.debug( 77 | `Successfully fetched ${urlString}. Status: ${response.status}`, 78 | context, 79 | ); 80 | return response; 81 | } catch (error) { 82 | clearTimeout(timeoutId); 83 | if (error instanceof Error && error.name === "AbortError") { 84 | logger.error(`${operationDescription} timed out after ${timeoutMs}ms.`, { 85 | ...context, 86 | errorSource: "FetchTimeout", 87 | }); 88 | throw new McpError( 89 | BaseErrorCode.TIMEOUT, 90 | `${operationDescription} timed out.`, 91 | { ...context, errorSource: "FetchTimeout" }, 92 | ); 93 | } 94 | 95 | // Log and re-throw other errors as McpError 96 | const errorMessage = error instanceof Error ? error.message : String(error); 97 | logger.error( 98 | `Network error during ${operationDescription}: ${errorMessage}`, 99 | { 100 | ...context, 101 | originalErrorName: error instanceof Error ? error.name : "UnknownError", 102 | errorSource: "FetchNetworkError", 103 | }, 104 | ); 105 | 106 | if (error instanceof McpError) { 107 | // If it's already an McpError, re-throw it 108 | throw error; 109 | } 110 | 111 | throw new McpError( 112 | BaseErrorCode.SERVICE_UNAVAILABLE, // Generic error for network/service issues 113 | `Network error during ${operationDescription}: ${errorMessage}`, 114 | { 115 | ...context, 116 | originalErrorName: error instanceof Error ? error.name : "UnknownError", 117 | errorSource: "FetchNetworkErrorWrapper", 118 | }, 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedArticleConnections/registration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Registers the 'pubmed_article_connections' tool with the MCP server. 3 | * This tool finds articles related to a source PMID or retrieves citation formats. 4 | * @module src/mcp-server/tools/pubmedArticleConnections/registration 5 | */ 6 | 7 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 8 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 9 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 10 | import { 11 | ErrorHandler, 12 | logger, 13 | RequestContext, 14 | requestContextService, 15 | } from "../../../utils/index.js"; 16 | import { 17 | PubMedArticleConnectionsInput, 18 | PubMedArticleConnectionsInputSchema, 19 | handlePubMedArticleConnections, 20 | } from "./logic/index.js"; 21 | 22 | /** 23 | * Registers the 'pubmed_article_connections' tool with the given MCP server instance. 24 | * @param {McpServer} server - The MCP server instance. 25 | */ 26 | export async function registerPubMedArticleConnectionsTool( 27 | server: McpServer, 28 | ): Promise { 29 | const operation = "registerPubMedArticleConnectionsTool"; 30 | const toolName = "pubmed_article_connections"; 31 | const toolDescription = 32 | "Finds articles related to a source PubMed ID (PMID) or retrieves formatted citations for it. Supports finding similar articles, articles that cite the source, articles referenced by the source (via NCBI ELink), or fetching data to generate citations in various styles (RIS, BibTeX, APA, MLA via NCBI EFetch and server-side formatting). Returns a JSON object detailing the connections or formatted citations."; 33 | const context = requestContextService.createRequestContext({ operation }); 34 | 35 | await ErrorHandler.tryCatch( 36 | async () => { 37 | server.tool( 38 | toolName, 39 | toolDescription, 40 | PubMedArticleConnectionsInputSchema.shape, 41 | async ( 42 | input: PubMedArticleConnectionsInput, 43 | mcpProvidedContext: unknown, 44 | ): Promise => { 45 | const richContext: RequestContext = 46 | requestContextService.createRequestContext({ 47 | parentRequestId: context.requestId, 48 | operation: "pubMedArticleConnectionsToolHandler", 49 | mcpToolContext: mcpProvidedContext, 50 | input, 51 | }); 52 | 53 | try { 54 | const result = await handlePubMedArticleConnections( 55 | input, 56 | richContext, 57 | ); 58 | return { 59 | content: [ 60 | { type: "text", text: JSON.stringify(result, null, 2) }, 61 | ], 62 | isError: false, 63 | }; 64 | } catch (error) { 65 | const handledError = ErrorHandler.handleError(error, { 66 | operation: "pubMedArticleConnectionsToolHandler", 67 | context: richContext, 68 | input, 69 | rethrow: false, 70 | }); 71 | 72 | const mcpError = 73 | handledError instanceof McpError 74 | ? handledError 75 | : new McpError( 76 | BaseErrorCode.INTERNAL_ERROR, 77 | "An unexpected error occurred while getting PubMed article connections.", 78 | { 79 | originalErrorName: handledError.name, 80 | originalErrorMessage: handledError.message, 81 | }, 82 | ); 83 | 84 | return { 85 | content: [ 86 | { 87 | type: "text", 88 | text: JSON.stringify({ 89 | error: { 90 | code: mcpError.code, 91 | message: mcpError.message, 92 | details: mcpError.details, 93 | }, 94 | }), 95 | }, 96 | ], 97 | isError: true, 98 | }; 99 | } 100 | }, 101 | ); 102 | logger.notice(`Tool '${toolName}' registered.`, context); 103 | }, 104 | { 105 | operation, 106 | context, 107 | errorCode: BaseErrorCode.INITIALIZATION_FAILED, 108 | critical: true, 109 | }, 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedResearchAgent/registration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Registration for the pubmed_research_agent tool. 3 | * @module pubmedResearchAgent/registration 4 | */ 5 | 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 7 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 9 | import { 10 | ErrorHandler, 11 | logger, 12 | RequestContext, 13 | requestContextService, 14 | } from "../../../utils/index.js"; 15 | import { pubmedResearchAgentLogic } from "./logic.js"; 16 | import { 17 | PubMedResearchAgentInput, 18 | PubMedResearchAgentInputSchema, 19 | } from "./logic/index.js"; 20 | 21 | /** 22 | * Registers the pubmed_research_agent tool with the MCP server. 23 | * @param server - The McpServer instance. 24 | */ 25 | export async function registerPubMedResearchAgentTool( 26 | server: McpServer, 27 | ): Promise { 28 | const operation = "registerPubMedResearchAgentTool"; 29 | const toolName = "pubmed_research_agent"; 30 | const toolDescription = 31 | "Generates a standardized JSON research plan outline from component details you provide. It accepts granular inputs for all research phases (conception, data collection, analysis, dissemination, cross-cutting concerns). If `include_detailed_prompts_for_agent` is true, the output plan will embed instructive prompts and detailed guidance notes to aid the research agent. The tool's primary function is to organize and structure your rough ideas into a formal, machine-readable plan. This plan is intended for further processing; as the research agent, you should then utilize your full suite of tools (e.g., file manipulation, `get_pubmed_article_connections` for literature/data search via PMID) to execute the outlined research, tailored to the user's request."; 32 | const context = requestContextService.createRequestContext({ operation }); 33 | 34 | await ErrorHandler.tryCatch( 35 | async () => { 36 | server.tool( 37 | toolName, 38 | toolDescription, 39 | PubMedResearchAgentInputSchema.shape, 40 | async ( 41 | input: PubMedResearchAgentInput, 42 | mcpProvidedContext: unknown, 43 | ): Promise => { 44 | const richContext: RequestContext = 45 | requestContextService.createRequestContext({ 46 | parentRequestId: context.requestId, 47 | operation: "pubmedResearchAgentToolHandler", 48 | mcpToolContext: mcpProvidedContext, 49 | input, 50 | }); 51 | 52 | try { 53 | const result = await pubmedResearchAgentLogic(input, richContext); 54 | return { 55 | content: [ 56 | { type: "text", text: JSON.stringify(result, null, 2) }, 57 | ], 58 | isError: false, 59 | }; 60 | } catch (error) { 61 | const handledError = ErrorHandler.handleError(error, { 62 | operation: "pubmedResearchAgentToolHandler", 63 | context: richContext, 64 | input, 65 | rethrow: false, 66 | }); 67 | 68 | const mcpError = 69 | handledError instanceof McpError 70 | ? handledError 71 | : new McpError( 72 | BaseErrorCode.INTERNAL_ERROR, 73 | "An unexpected error occurred while generating the research plan.", 74 | { 75 | originalErrorName: handledError.name, 76 | originalErrorMessage: handledError.message, 77 | }, 78 | ); 79 | 80 | return { 81 | content: [ 82 | { 83 | type: "text", 84 | text: JSON.stringify({ 85 | error: { 86 | code: mcpError.code, 87 | message: mcpError.message, 88 | details: mcpError.details, 89 | }, 90 | }), 91 | }, 92 | ], 93 | isError: true, 94 | }; 95 | } 96 | }, 97 | ); 98 | logger.notice(`Tool '${toolName}' registered.`, context); 99 | }, 100 | { 101 | operation, 102 | context, 103 | errorCode: BaseErrorCode.INITIALIZATION_FAILED, 104 | critical: true, 105 | }, 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedResearchAgent/logic/outputTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines TypeScript types for the structured research plan outline 3 | * generated by the pubmed_research_agent tool. The tool primarily structures 4 | * client-provided inputs. 5 | * @module pubmedResearchAgent/logic/outputTypes 6 | */ 7 | 8 | // All string fields are optional as they depend on client input. 9 | // If include_detailed_prompts_for_agent is true, these strings might be prefixed 10 | // with a generic instruction by the planOrchestrator. 11 | 12 | export interface Phase1Step1_1_Content { 13 | primary_research_question?: string; 14 | knowledge_gap_statement?: string; 15 | primary_hypothesis?: string; 16 | pubmed_search_strategy?: string; 17 | guidance_notes?: string | string[]; 18 | } 19 | export interface Phase1Step1_2_Content { 20 | literature_review_scope?: string; 21 | key_databases_and_search_approach?: string; 22 | guidance_notes?: string | string[]; 23 | } 24 | export interface Phase1Step1_3_Content { 25 | experimental_paradigm?: string; 26 | data_acquisition_plan_existing_data?: string; 27 | data_acquisition_plan_new_data?: string; 28 | blast_utilization_plan?: string; 29 | controls_and_rigor_measures?: string; 30 | methodological_challenges_and_mitigation?: string; 31 | guidance_notes?: string | string[]; 32 | } 33 | export interface Phase1Output { 34 | title: "Phase 1: Conception and Planning"; 35 | step_1_1_research_question_and_hypothesis: Phase1Step1_1_Content; 36 | step_1_2_literature_review_strategy: Phase1Step1_2_Content; 37 | step_1_3_experimental_design_and_data_acquisition: Phase1Step1_3_Content; 38 | } 39 | 40 | export interface Phase2Step2_1_Content { 41 | data_collection_methods_wet_lab?: string; 42 | data_collection_methods_dry_lab?: string; 43 | guidance_notes?: string | string[]; 44 | } 45 | export interface Phase2Step2_2_Content { 46 | data_preprocessing_and_qc_plan?: string; 47 | guidance_notes?: string | string[]; 48 | } 49 | export interface Phase2Output { 50 | title: "Phase 2: Data Collection and Processing"; 51 | step_2_1_data_collection_retrieval: Phase2Step2_1_Content; 52 | step_2_2_data_preprocessing_and_qc: Phase2Step2_2_Content; 53 | } 54 | 55 | export interface Phase3Step3_1_Content { 56 | data_analysis_strategy?: string; 57 | bioinformatics_pipeline_summary?: string; 58 | guidance_notes?: string | string[]; 59 | } 60 | export interface Phase3Step3_2_Content { 61 | results_interpretation_framework?: string; 62 | comparison_with_literature_plan?: string; 63 | guidance_notes?: string | string[]; 64 | } 65 | export interface Phase3Output { 66 | title: "Phase 3: Analysis and Interpretation"; 67 | step_3_1_data_analysis_plan: Phase3Step3_1_Content; 68 | step_3_2_results_interpretation: Phase3Step3_2_Content; 69 | } 70 | 71 | export interface Phase4Step4_1_Content { 72 | dissemination_manuscript_plan?: string; 73 | dissemination_data_deposition_plan?: string; 74 | guidance_notes?: string | string[]; 75 | } 76 | export interface Phase4Step4_2_Content { 77 | peer_review_and_publication_approach?: string; 78 | guidance_notes?: string | string[]; 79 | } 80 | export interface Phase4Step4_3_Content { 81 | future_research_directions?: string; 82 | guidance_notes?: string | string[]; 83 | } 84 | export interface Phase4Output { 85 | title: "Phase 4: Dissemination and Iteration"; 86 | step_4_1_dissemination_strategy: Phase4Step4_1_Content; 87 | step_4_2_peer_review_and_publication: Phase4Step4_2_Content; 88 | step_4_3_further_research_and_iteration: Phase4Step4_3_Content; 89 | } 90 | 91 | export interface CrossCuttingContent { 92 | record_keeping_and_data_management?: string; 93 | collaboration_strategy?: string; 94 | ethical_considerations?: string; 95 | guidance_notes?: string | string[]; 96 | } 97 | export interface CrossCuttingOutput { 98 | title: "Cross-Cutting Considerations"; 99 | content: CrossCuttingContent; 100 | } 101 | 102 | export interface PubMedResearchPlanGeneratedOutput { 103 | plan_title: string; 104 | overall_instructions_for_research_agent?: string; 105 | input_summary: { 106 | keywords_received: string[]; 107 | primary_goal_stated_or_inferred: string; 108 | organism_focus?: string; // Ensured this is present 109 | included_detailed_prompts_for_agent: boolean; // Renamed from included_challenges_consideration for clarity 110 | }; 111 | phase_1_conception_and_planning: Phase1Output; 112 | phase_2_data_collection_and_processing: Phase2Output; 113 | phase_3_analysis_and_interpretation: Phase3Output; 114 | phase_4_dissemination_and_iteration: Phase4Output; 115 | cross_cutting_considerations: CrossCuttingOutput; 116 | } 117 | -------------------------------------------------------------------------------- /src/mcp-server/transports/http/mcpTransportMiddleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Hono middleware for handling MCP transport logic. 3 | * This middleware encapsulates the logic for processing MCP requests, 4 | * delegating to the appropriate transport manager, and preparing the 5 | * response for Hono to send. 6 | * @module src/mcp-server/transports/http/mcpTransportMiddleware 7 | */ 8 | 9 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; 10 | import { MiddlewareHandler } from "hono"; 11 | import { createMiddleware } from "hono/factory"; 12 | import { IncomingHttpHeaders } from "http"; 13 | import { config } from "../../../config/index.js"; 14 | import { RequestContext, requestContextService } from "../../../utils/index.js"; 15 | import { ServerInstanceInfo } from "../../server.js"; 16 | import { StatefulTransportManager } from "../core/statefulTransportManager.js"; 17 | import { StatelessTransportManager } from "../core/statelessTransportManager.js"; 18 | import { TransportManager, TransportResponse } from "../core/transportTypes.js"; 19 | import { HonoNodeBindings } from "./httpTypes.js"; 20 | 21 | /** 22 | * Converts a Fetch API Headers object to Node.js IncomingHttpHeaders. 23 | * @param headers - The Headers object to convert. 24 | * @returns An object compatible with IncomingHttpHeaders. 25 | */ 26 | function toIncomingHttpHeaders(headers: Headers): IncomingHttpHeaders { 27 | const result: IncomingHttpHeaders = {}; 28 | headers.forEach((value, key) => { 29 | result[key] = value; 30 | }); 31 | return result; 32 | } 33 | 34 | /** 35 | * Handles a stateless request by creating an ephemeral transport manager. 36 | * @param createServerInstanceFn - Function to create an McpServer instance. 37 | * @param headers - The request headers. 38 | * @param body - The request body. 39 | * @param context - The request context. 40 | * @returns A promise resolving with the transport response. 41 | */ 42 | async function handleStatelessRequest( 43 | createServerInstanceFn: () => Promise, 44 | headers: Headers, 45 | body: unknown, 46 | context: RequestContext, 47 | ): Promise { 48 | const getMcpServer = async () => (await createServerInstanceFn()).server; 49 | const statelessManager = new StatelessTransportManager(getMcpServer); 50 | return statelessManager.handleRequest( 51 | toIncomingHttpHeaders(headers), 52 | body, 53 | context, 54 | ); 55 | } 56 | 57 | /** 58 | * Creates a Hono middleware for handling MCP POST requests. 59 | * @param transportManager - The main transport manager (usually stateful). 60 | * @param createServerInstanceFn - Function to create an McpServer instance. 61 | * @returns A Hono middleware function. 62 | */ 63 | 64 | type McpMiddlewareEnv = { 65 | Variables: { 66 | mcpResponse: TransportResponse; 67 | }; 68 | }; 69 | 70 | export const mcpTransportMiddleware = ( 71 | transportManager: TransportManager, 72 | createServerInstanceFn: () => Promise, 73 | ): MiddlewareHandler => { 74 | return createMiddleware( 75 | async (c, next) => { 76 | const sessionId = c.req.header("mcp-session-id"); 77 | const context = requestContextService.createRequestContext({ 78 | operation: "mcpTransportMiddleware", 79 | sessionId, 80 | }); 81 | 82 | const body = await c.req.json(); 83 | let response: TransportResponse; 84 | 85 | if (isInitializeRequest(body)) { 86 | if (config.mcpSessionMode === "stateless") { 87 | response = await handleStatelessRequest( 88 | createServerInstanceFn, 89 | c.req.raw.headers, 90 | body, 91 | context, 92 | ); 93 | } else { 94 | response = await ( 95 | transportManager as StatefulTransportManager 96 | ).initializeAndHandle( 97 | toIncomingHttpHeaders(c.req.raw.headers), 98 | body, 99 | context, 100 | ); 101 | } 102 | } else { 103 | if (sessionId) { 104 | response = await transportManager.handleRequest( 105 | toIncomingHttpHeaders(c.req.raw.headers), 106 | body, 107 | context, 108 | sessionId, 109 | ); 110 | } else { 111 | response = await handleStatelessRequest( 112 | createServerInstanceFn, 113 | c.req.raw.headers, 114 | body, 115 | context, 116 | ); 117 | } 118 | } 119 | 120 | c.set("mcpResponse", response); 121 | await next(); 122 | }, 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/services/NCBI/core/ncbiRequestQueueManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Manages a queue for NCBI E-utility requests to ensure compliance with rate limits. 3 | * @module src/services/NCBI/core/ncbiRequestQueueManager 4 | */ 5 | 6 | import { config } from "../../../config/index.js"; 7 | import { 8 | logger, 9 | RequestContext, 10 | requestContextService, 11 | sanitizeInputForLogging, 12 | } from "../../../utils/index.js"; 13 | import { NcbiRequestParams } from "./ncbiConstants.js"; 14 | 15 | /** 16 | * Interface for a queued NCBI request. 17 | */ 18 | export interface QueuedRequest { 19 | resolve: (value: T | PromiseLike) => void; 20 | reject: (reason?: unknown) => void; 21 | task: () => Promise; // The actual function that makes the API call 22 | context: RequestContext; 23 | endpoint: string; // For logging purposes 24 | params: NcbiRequestParams; // For logging purposes 25 | } 26 | 27 | export class NcbiRequestQueueManager { 28 | private requestQueue: QueuedRequest[] = []; 29 | private isProcessingQueue = false; 30 | private lastRequestTime = 0; 31 | 32 | constructor() { 33 | // The constructor is kept for future initializations, if any. 34 | } 35 | 36 | /** 37 | * Processes the request queue, ensuring delays between requests to respect NCBI rate limits. 38 | */ 39 | private async processQueue(): Promise { 40 | if (this.isProcessingQueue || this.requestQueue.length === 0) { 41 | return; 42 | } 43 | this.isProcessingQueue = true; 44 | 45 | const requestItem = this.requestQueue.shift(); 46 | if (!requestItem) { 47 | this.isProcessingQueue = false; 48 | return; 49 | } 50 | 51 | const { resolve, reject, task, context, endpoint, params } = requestItem; 52 | 53 | try { 54 | const now = Date.now(); 55 | const timeSinceLastRequest = now - this.lastRequestTime; 56 | const delayNeeded = config.ncbiRequestDelayMs - timeSinceLastRequest; 57 | 58 | if (delayNeeded > 0) { 59 | logger.debug( 60 | `Delaying NCBI request by ${delayNeeded}ms to respect rate limit.`, 61 | requestContextService.createRequestContext({ 62 | ...context, 63 | operation: "NCBI_RateLimitDelay", 64 | delayNeeded, 65 | endpoint, 66 | }), 67 | ); 68 | await new Promise((r) => setTimeout(r, delayNeeded)); 69 | } 70 | 71 | this.lastRequestTime = Date.now(); 72 | logger.info( 73 | `Executing NCBI request via queue: ${endpoint}`, 74 | requestContextService.createRequestContext({ 75 | ...context, 76 | operation: "NCBI_ExecuteFromQueue", 77 | endpoint, 78 | params: sanitizeInputForLogging(params), 79 | }), 80 | ); 81 | const result = await task(); 82 | resolve(result); 83 | } catch (error: unknown) { 84 | const err = error as Error; 85 | logger.error( 86 | "Error processing NCBI request from queue", 87 | err, 88 | requestContextService.createRequestContext({ 89 | ...context, 90 | operation: "NCBI_QueueError", 91 | endpoint, 92 | params: sanitizeInputForLogging(params), 93 | errorMessage: err?.message, 94 | }), 95 | ); 96 | reject(err); 97 | } finally { 98 | this.isProcessingQueue = false; 99 | if (this.requestQueue.length > 0) { 100 | // Ensure processQueue is called without awaiting it here to prevent deep stacks 101 | Promise.resolve().then(() => this.processQueue()); 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * Enqueues a task (an NCBI API call) to be processed. 108 | * @param task A function that returns a Promise resolving to the API call result. 109 | * @param context The request context for logging and correlation. 110 | * @param endpoint The NCBI endpoint being called (e.g., "esearch", "efetch"). 111 | * @param params The parameters for the NCBI request. 112 | * @returns A Promise that resolves or rejects with the result of the task. 113 | */ 114 | public enqueueRequest( 115 | task: () => Promise, 116 | context: RequestContext, 117 | endpoint: string, 118 | params: NcbiRequestParams, 119 | ): Promise { 120 | return new Promise((resolve, reject) => { 121 | this.requestQueue.push({ 122 | resolve: (value: unknown) => resolve(value as T), 123 | reject: (reason?: unknown) => reject(reason), 124 | task, 125 | context, 126 | endpoint, 127 | params, 128 | }); 129 | if (!this.isProcessingQueue) { 130 | // Ensure processQueue is called without awaiting it here 131 | Promise.resolve().then(() => this.processQueue()); 132 | } 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/services/NCBI/core/ncbiService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Service for interacting with NCBI E-utilities. 3 | * This module centralizes all communication with NCBI's E-utility APIs, 4 | * handling request construction, API key management, rate limiting, 5 | * retries, and parsing of XML/JSON responses. It aims to provide a robust 6 | * and compliant interface for other parts of the pubmed-mcp-server to 7 | * access PubMed data. 8 | * @module src/services/NCBI/core/ncbiService 9 | */ 10 | 11 | import { 12 | ESearchResult, 13 | EFetchArticleSet, 14 | ESearchResponseContainer, 15 | } from "../../../types-global/pubmedXml.js"; 16 | import { 17 | logger, 18 | RequestContext, 19 | requestContextService, 20 | } from "../../../utils/index.js"; 21 | import { NcbiRequestParams, NcbiRequestOptions } from "./ncbiConstants.js"; 22 | import { NcbiCoreApiClient } from "./ncbiCoreApiClient.js"; 23 | import { NcbiRequestQueueManager } from "./ncbiRequestQueueManager.js"; 24 | import { NcbiResponseHandler } from "./ncbiResponseHandler.js"; 25 | 26 | export class NcbiService { 27 | private queueManager: NcbiRequestQueueManager; 28 | private apiClient: NcbiCoreApiClient; 29 | private responseHandler: NcbiResponseHandler; 30 | 31 | constructor() { 32 | this.queueManager = new NcbiRequestQueueManager(); 33 | this.apiClient = new NcbiCoreApiClient(); 34 | this.responseHandler = new NcbiResponseHandler(); 35 | } 36 | 37 | private async performNcbiRequest( 38 | endpoint: string, 39 | params: NcbiRequestParams, 40 | context: RequestContext, 41 | options: NcbiRequestOptions = {}, 42 | ): Promise { 43 | const task = async () => { 44 | const rawResponse = await this.apiClient.makeRequest( 45 | endpoint, 46 | params, 47 | context, 48 | options, 49 | ); 50 | return this.responseHandler.parseAndHandleResponse( 51 | rawResponse, 52 | endpoint, 53 | context, 54 | options, 55 | ); 56 | }; 57 | 58 | return this.queueManager.enqueueRequest(task, context, endpoint, params); 59 | } 60 | 61 | public async eSearch( 62 | params: NcbiRequestParams, 63 | context: RequestContext, 64 | ): Promise { 65 | const response = await this.performNcbiRequest( 66 | "esearch", 67 | params, 68 | context, 69 | { 70 | retmode: "xml", 71 | }, 72 | ); 73 | 74 | const esResult = response.eSearchResult; 75 | return { 76 | count: parseInt(esResult.Count, 10) || 0, 77 | retmax: parseInt(esResult.RetMax, 10) || 0, 78 | retstart: parseInt(esResult.RetStart, 10) || 0, 79 | queryKey: esResult.QueryKey, 80 | webEnv: esResult.WebEnv, 81 | idList: esResult.IdList?.Id || [], 82 | queryTranslation: esResult.QueryTranslation, 83 | errorList: esResult.ErrorList, 84 | warningList: esResult.WarningList, 85 | }; 86 | } 87 | 88 | public async eSummary( 89 | params: NcbiRequestParams, 90 | context: RequestContext, 91 | ): Promise { 92 | // Determine retmode based on params, default to xml 93 | const retmode = 94 | params.version === "2.0" && params.retmode === "json" ? "json" : "xml"; 95 | return this.performNcbiRequest("esummary", params, context, { retmode }); 96 | } 97 | 98 | public async eFetch( 99 | params: NcbiRequestParams, 100 | context: RequestContext, 101 | options: NcbiRequestOptions = { retmode: "xml" }, // Default retmode for eFetch 102 | ): Promise { 103 | // Determine if POST should be used based on number of IDs 104 | const usePost = 105 | typeof params.id === "string" && params.id.split(",").length > 200; 106 | const fetchOptions = { ...options, usePost }; 107 | 108 | return this.performNcbiRequest( 109 | "efetch", 110 | params, 111 | context, 112 | fetchOptions, 113 | ); 114 | } 115 | 116 | public async eLink( 117 | params: NcbiRequestParams, 118 | context: RequestContext, 119 | ): Promise { 120 | return this.performNcbiRequest("elink", params, context, { 121 | retmode: "xml", 122 | }); 123 | } 124 | 125 | public async eInfo( 126 | params: NcbiRequestParams, 127 | context: RequestContext, 128 | ): Promise { 129 | return this.performNcbiRequest("einfo", params, context, { 130 | retmode: "xml", 131 | }); 132 | } 133 | } 134 | 135 | let ncbiServiceInstance: NcbiService; 136 | 137 | export function getNcbiService(): NcbiService { 138 | if (!ncbiServiceInstance) { 139 | ncbiServiceInstance = new NcbiService(); 140 | logger.debug( 141 | "NcbiService lazily initialized.", 142 | requestContextService.createRequestContext({ 143 | service: "NcbiService", 144 | operation: "getNcbiServiceInstance", 145 | }), 146 | ); 147 | } 148 | return ncbiServiceInstance; 149 | } 150 | -------------------------------------------------------------------------------- /scripts/make-executable.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @fileoverview Utility script to make files executable (chmod +x) on Unix-like systems. 5 | * @module scripts/make-executable 6 | * On Windows, this script does nothing but exits successfully. 7 | * Useful for CLI applications where built output needs executable permissions. 8 | * Default target (if no args): dist/index.js. 9 | * Ensures output paths are within the project directory for security. 10 | * 11 | * @example 12 | * // Add to package.json build script: 13 | * // "build": "tsc && ts-node --esm scripts/make-executable.ts dist/index.js" 14 | * 15 | * @example 16 | * // Run directly with custom files: 17 | * // ts-node --esm scripts/make-executable.ts path/to/script1 path/to/script2 18 | */ 19 | 20 | import fs from "fs/promises"; 21 | import os from "os"; 22 | import path from "path"; 23 | 24 | const isUnix = os.platform() !== "win32"; 25 | const projectRoot = process.cwd(); 26 | const EXECUTABLE_MODE = 0o755; // rwxr-xr-x 27 | 28 | /** 29 | * Represents the result of an attempt to make a file executable. 30 | * @property file - The relative path of the file targeted. 31 | * @property status - The outcome of the operation ('success', 'error', or 'skipped'). 32 | * @property reason - If status is 'error' or 'skipped', an explanation. 33 | */ 34 | interface ExecutableResult { 35 | file: string; 36 | status: "success" | "error" | "skipped"; 37 | reason?: string; 38 | } 39 | 40 | /** 41 | * Main function to make specified files executable. 42 | * Skips operation on Windows. Processes command-line arguments for target files 43 | * or defaults to 'dist/index.js'. Reports status for each file. 44 | */ 45 | const makeExecutable = async (): Promise => { 46 | try { 47 | const targetFiles: string[] = 48 | process.argv.slice(2).length > 0 49 | ? process.argv.slice(2) 50 | : ["dist/index.js"]; 51 | 52 | if (!isUnix) { 53 | console.log( 54 | "Skipping chmod operation: Script is running on Windows (not applicable).", 55 | ); 56 | return; 57 | } 58 | 59 | console.log( 60 | `Attempting to make files executable: ${targetFiles.join(", ")}`, 61 | ); 62 | 63 | const results = await Promise.allSettled( 64 | targetFiles.map(async (targetFile): Promise => { 65 | const normalizedPath = path.resolve(projectRoot, targetFile); 66 | 67 | if ( 68 | !normalizedPath.startsWith(projectRoot + path.sep) && 69 | normalizedPath !== projectRoot 70 | ) { 71 | return { 72 | file: targetFile, 73 | status: "error", 74 | reason: `Path resolves outside project boundary: ${normalizedPath}`, 75 | }; 76 | } 77 | 78 | try { 79 | await fs.access(normalizedPath); // Check if file exists 80 | await fs.chmod(normalizedPath, EXECUTABLE_MODE); 81 | return { file: targetFile, status: "success" }; 82 | } catch (error) { 83 | const err = error as NodeJS.ErrnoException; 84 | if (err.code === "ENOENT") { 85 | return { 86 | file: targetFile, 87 | status: "error", 88 | reason: "File not found", 89 | }; 90 | } 91 | console.error( 92 | `Error setting executable permission for ${targetFile}: ${err.message}`, 93 | ); 94 | return { file: targetFile, status: "error", reason: err.message }; 95 | } 96 | }), 97 | ); 98 | 99 | let hasErrors = false; 100 | results.forEach((result) => { 101 | if (result.status === "fulfilled") { 102 | const { file, status, reason } = result.value; 103 | if (status === "success") { 104 | console.log(`Successfully made executable: ${file}`); 105 | } else if (status === "error") { 106 | console.error(`Error for ${file}: ${reason}`); 107 | hasErrors = true; 108 | } else if (status === "skipped") { 109 | // This status is not currently generated by the mapAsync logic but kept for future flexibility 110 | console.warn(`Skipped ${file}: ${reason}`); 111 | } 112 | } else { 113 | console.error( 114 | `Unexpected failure for one of the files: ${result.reason}`, 115 | ); 116 | hasErrors = true; 117 | } 118 | }); 119 | 120 | if (hasErrors) { 121 | console.error( 122 | "One or more files could not be made executable. Please check the errors above.", 123 | ); 124 | // process.exit(1); // Uncomment to exit with error if any file fails 125 | } else { 126 | console.log("All targeted files processed successfully."); 127 | } 128 | } catch (error) { 129 | console.error( 130 | "A fatal error occurred during the make-executable script:", 131 | error instanceof Error ? error.message : error, 132 | ); 133 | process.exit(1); 134 | } 135 | }; 136 | 137 | makeExecutable(); 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cyanheads/pubmed-mcp-server", 3 | "version": "1.4.4", 4 | "description": "Production-ready PubMed Model Context Protocol (MCP) server that empowers AI agents and research tools with comprehensive access to PubMed's article database. Enables advanced, automated LLM workflows for searching, retrieving, analyzing, and visualizing biomedical and scientific literature via NCBI E-utilities.", 5 | "mcpName": "io.github.cyanheads/pubmed-mcp-server", 6 | "main": "dist/index.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "bin": { 11 | "pubmed-mcp-server": "dist/index.js" 12 | }, 13 | "exports": "./dist/index.js", 14 | "type": "module", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/cyanheads/pubmed-mcp-server.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/cyanheads/pubmed-mcp-server/issues" 21 | }, 22 | "homepage": "https://github.com/cyanheads/pubmed-mcp-server#readme", 23 | "scripts": { 24 | "build": "tsc && node --loader ts-node/esm scripts/make-executable.ts dist/index.js", 25 | "start": "node dist/index.js", 26 | "start:stdio": "MCP_LOG_LEVEL=debug MCP_TRANSPORT_TYPE=stdio node dist/index.js", 27 | "start:http": "MCP_LOG_LEVEL=debug MCP_TRANSPORT_TYPE=http node dist/index.js", 28 | "rebuild": "ts-node --esm scripts/clean.ts && npm run build", 29 | "docs:generate": "typedoc --tsconfig ./tsconfig.typedoc.json", 30 | "lint": "eslint .", 31 | "lint:fix": "eslint . --fix", 32 | "tree": "ts-node --esm scripts/tree.ts", 33 | "fetch-spec": "ts-node --esm scripts/fetch-openapi-spec.ts", 34 | "format": "prettier --write \"**/*.{ts,js,json,md,html,css}\"", 35 | "inspector": "mcp-inspector --config mcp.json --server pubmed-mcp-server", 36 | "test": "vitest run", 37 | "test:watch": "vitest", 38 | "test:coverage": "vitest run --coverage" 39 | }, 40 | "dependencies": { 41 | "@hono/node-server": "^1.17.1", 42 | "@modelcontextprotocol/sdk": "^1.18.0", 43 | "@opentelemetry/api": "^1.9.0", 44 | "@opentelemetry/auto-instrumentations-node": "^0.64.1", 45 | "@opentelemetry/exporter-metrics-otlp-http": "^0.205.0", 46 | "@opentelemetry/exporter-trace-otlp-http": "^0.205.0", 47 | "@opentelemetry/instrumentation-winston": "^0.50.0", 48 | "@opentelemetry/resources": "^2.1.0", 49 | "@opentelemetry/sdk-metrics": "^2.1.0", 50 | "@opentelemetry/sdk-node": "^0.205.0", 51 | "@opentelemetry/sdk-trace-node": "^2.1.0", 52 | "@opentelemetry/semantic-conventions": "^1.37.0", 53 | "@types/node": "^24.4.0", 54 | "@types/sanitize-html": "^2.16.0", 55 | "@types/validator": "13.15.3", 56 | "axios": "^1.12.2", 57 | "chart.js": "^4.5.0", 58 | "chartjs-node-canvas": "^5.0.0", 59 | "chrono-node": "^2.8.3", 60 | "citation-js": "^0.7.20", 61 | "dotenv": "^16.6.1", 62 | "fast-xml-parser": "^5.2.5", 63 | "hono": "^4.8.4", 64 | "jose": "^6.1.0", 65 | "js-yaml": "^4.1.0", 66 | "node-cron": "^4.2.1", 67 | "openai": "^5.20.2", 68 | "partial-json": "^0.1.7", 69 | "patch-package": "^8.0.0", 70 | "sanitize-html": "^2.17.0", 71 | "tiktoken": "^1.0.22", 72 | "ts-node": "^10.9.2", 73 | "typescript": "^5.9.2", 74 | "typescript-eslint": "^8.43.0", 75 | "validator": "13.15.15", 76 | "winston": "^3.17.0", 77 | "winston-transport": "^4.9.0", 78 | "zod": "^3.25.74" 79 | }, 80 | "keywords": [ 81 | "mcp", 82 | "model-context-protocol", 83 | "ai-agent", 84 | "llm", 85 | "llm-integration", 86 | "pubmed", 87 | "pubmed-api", 88 | "ncbi", 89 | "e-utilities", 90 | "biomedical-research", 91 | "scientific-literature", 92 | "computational-biology", 93 | "typescript", 94 | "ai-tools", 95 | "bioinformatics", 96 | "health-tech", 97 | "research-automation", 98 | "literature-search", 99 | "citation-analysis", 100 | "data-visualization", 101 | "api-server", 102 | "typescript", 103 | "nodejs", 104 | "ai-tools", 105 | "research-agent", 106 | "generative-ai" 107 | ], 108 | "author": "Casey Hand @cyanheads", 109 | "email": "casey@caseyjhand.com", 110 | "license": "Apache-2.0", 111 | "funding": [ 112 | { 113 | "type": "github", 114 | "url": "https://github.com/sponsors/cyanheads" 115 | }, 116 | { 117 | "type": "buy_me_a_coffee", 118 | "url": "https://www.buymeacoffee.com/cyanheads" 119 | } 120 | ], 121 | "engines": { 122 | "node": ">=20.0.0" 123 | }, 124 | "devDependencies": { 125 | "@types/js-yaml": "^4.0.9", 126 | "@types/node-cron": "^3.0.11", 127 | "ajv": "^8.17.1", 128 | "ajv-formats": "^3.0.1", 129 | "eslint": "^9.35.0", 130 | "prettier": "^3.6.2", 131 | "typedoc": "^0.28.13", 132 | "vite-tsconfig-paths": "^5.1.4", 133 | "vitest": "^3.2.4" 134 | }, 135 | "overrides": { 136 | "patch-package": { 137 | "tmp": "0.2.5" 138 | } 139 | }, 140 | "publishConfig": { 141 | "access": "public" 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedArticleConnections/logic/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Main logic handler for the 'pubmed_article_connections' MCP tool. 3 | * Orchestrates calls to ELink or citation formatting handlers. 4 | * @module src/mcp-server/tools/pubmedArticleConnections/logic/index 5 | */ 6 | 7 | import { z } from "zod"; 8 | import { BaseErrorCode, McpError } from "../../../../types-global/errors.js"; 9 | import { 10 | logger, 11 | RequestContext, 12 | requestContextService, 13 | sanitizeInputForLogging, 14 | } from "../../../../utils/index.js"; 15 | import { handleCitationFormats } from "./citationFormatter.js"; 16 | import { handleELinkRelationships } from "./elinkHandler.js"; 17 | import type { ToolOutputData } from "./types.js"; 18 | 19 | /** 20 | * Zod schema for the input parameters of the 'pubmed_article_connections' tool. 21 | */ 22 | export const PubMedArticleConnectionsInputSchema = z.object({ 23 | sourcePmid: z 24 | .string() 25 | .regex(/^\d+$/) 26 | .describe( 27 | "The PubMed Unique Identifier (PMID) of the source article for which to find connections or format citations. This PMID must be a valid number string.", 28 | ), 29 | relationshipType: z 30 | .enum([ 31 | "pubmed_similar_articles", 32 | "pubmed_citedin", 33 | "pubmed_references", 34 | "citation_formats", 35 | ]) 36 | .default("pubmed_similar_articles") 37 | .describe( 38 | "Specifies the type of connection or action: 'pubmed_similar_articles' (finds similar articles), 'pubmed_citedin' (finds citing articles), 'pubmed_references' (finds referenced articles), or 'citation_formats' (retrieves formatted citations).", 39 | ), 40 | maxRelatedResults: z 41 | .number() 42 | .int() 43 | .positive() 44 | .max(50, "Maximum 50 related results can be requested.") 45 | .optional() 46 | .default(5) 47 | .describe( 48 | "Maximum number of related articles to retrieve for relationship-based searches. Default is 5, max is 50.", 49 | ), 50 | citationStyles: z 51 | .array(z.enum(["ris", "bibtex", "apa_string", "mla_string"])) 52 | .optional() 53 | .default(["ris"]) 54 | .describe( 55 | "An array of citation styles to format the source article into when 'relationshipType' is 'citation_formats'. Supported styles: 'ris', 'bibtex', 'apa_string', 'mla_string'. Default is ['ris'].", 56 | ), 57 | }); 58 | 59 | /** 60 | * Type alias for the validated input of the 'pubmed_article_connections' tool. 61 | */ 62 | export type PubMedArticleConnectionsInput = z.infer< 63 | typeof PubMedArticleConnectionsInputSchema 64 | >; 65 | 66 | /** 67 | * Main handler for the 'pubmed_article_connections' tool. 68 | * @param {PubMedArticleConnectionsInput} input - Validated input parameters. 69 | * @param {RequestContext} context - The request context for this tool invocation. 70 | * @returns {Promise} The result of the tool call. 71 | */ 72 | export async function handlePubMedArticleConnections( 73 | input: PubMedArticleConnectionsInput, 74 | context: RequestContext, 75 | ): Promise { 76 | const toolLogicContext = requestContextService.createRequestContext({ 77 | parentRequestId: context.requestId, 78 | operation: "handlePubMedArticleConnections", 79 | toolName: "pubmed_article_connections", 80 | input: sanitizeInputForLogging(input), 81 | }); 82 | 83 | logger.info("Executing pubmed_article_connections tool", toolLogicContext); 84 | 85 | const outputData: ToolOutputData = { 86 | sourcePmid: input.sourcePmid, 87 | relationshipType: input.relationshipType, 88 | relatedArticles: [], 89 | citations: {}, 90 | retrievedCount: 0, 91 | eUtilityUrl: undefined, 92 | message: undefined, 93 | }; 94 | 95 | switch (input.relationshipType) { 96 | case "pubmed_similar_articles": 97 | case "pubmed_citedin": 98 | case "pubmed_references": 99 | await handleELinkRelationships(input, outputData, toolLogicContext); 100 | break; 101 | case "citation_formats": 102 | await handleCitationFormats(input, outputData, toolLogicContext); 103 | break; 104 | default: 105 | throw new McpError( 106 | BaseErrorCode.VALIDATION_ERROR, 107 | `Unsupported relationshipType: ${input.relationshipType}`, 108 | { ...toolLogicContext, receivedType: input.relationshipType }, 109 | ); 110 | } 111 | 112 | if ( 113 | outputData.retrievedCount === 0 && 114 | !outputData.message && 115 | (input.relationshipType !== "citation_formats" || 116 | Object.keys(outputData.citations).length === 0) 117 | ) { 118 | outputData.message = "No results found for the given parameters."; 119 | } 120 | 121 | logger.notice("Successfully executed pubmed_article_connections tool.", { 122 | ...toolLogicContext, 123 | relationshipType: input.relationshipType, 124 | retrievedCount: outputData.retrievedCount, 125 | citationsGenerated: Object.keys(outputData.citations).length, 126 | }); 127 | 128 | return outputData; 129 | } 130 | -------------------------------------------------------------------------------- /src/utils/internal/requestContext.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Utilities for creating and managing request contexts. 3 | * A request context is an object carrying a unique ID, timestamp, and other 4 | * relevant data for logging, tracing, and processing. It also defines 5 | * configuration and operational context structures. 6 | * @module src/utils/internal/requestContext 7 | */ 8 | 9 | import { trace } from "@opentelemetry/api"; 10 | import { generateUUID } from "../index.js"; 11 | import { logger } from "./logger.js"; 12 | 13 | /** 14 | * Defines the core structure for context information associated with a request or operation. 15 | * This is fundamental for logging, tracing, and passing operational data. 16 | */ 17 | export interface RequestContext { 18 | /** 19 | * Unique ID for the context instance. 20 | * Used for log correlation and request tracing. 21 | */ 22 | requestId: string; 23 | 24 | /** 25 | * ISO 8601 timestamp indicating when the context was created. 26 | */ 27 | timestamp: string; 28 | 29 | /** 30 | * Allows arbitrary key-value pairs for specific context needs. 31 | * Using `unknown` promotes type-safe access. 32 | * Consumers must type-check/assert when accessing extended properties. 33 | */ 34 | [key: string]: unknown; 35 | } 36 | 37 | /** 38 | * Configuration for the {@link requestContextService}. 39 | * Allows for future extensibility of service-wide settings. 40 | */ 41 | export interface ContextConfig { 42 | /** Custom configuration properties. Allows for arbitrary key-value pairs. */ 43 | [key: string]: unknown; 44 | } 45 | 46 | /** 47 | * Represents a broader context for a specific operation or task. 48 | * It can optionally include a base {@link RequestContext} and other custom properties 49 | * relevant to the operation. 50 | */ 51 | export interface OperationContext { 52 | /** Optional base request context data, adhering to the `RequestContext` structure. */ 53 | requestContext?: RequestContext; 54 | 55 | /** Allows for additional, custom properties specific to the operation. */ 56 | [key: string]: unknown; 57 | } 58 | 59 | /** 60 | * Singleton-like service object for managing request context operations. 61 | * @private 62 | */ 63 | const requestContextServiceInstance = { 64 | /** 65 | * Internal configuration store for the service. 66 | */ 67 | config: {} as ContextConfig, 68 | 69 | /** 70 | * Configures the request context service with new settings. 71 | * Merges the provided partial configuration with existing settings. 72 | * 73 | * @param config - A partial `ContextConfig` object containing settings to update or add. 74 | * @returns A shallow copy of the newly updated configuration. 75 | */ 76 | configure(config: Partial): ContextConfig { 77 | this.config = { 78 | ...this.config, 79 | ...config, 80 | }; 81 | const logContext = this.createRequestContext({ 82 | operation: "RequestContextService.configure", 83 | newConfigState: { ...this.config }, 84 | }); 85 | logger.debug("RequestContextService configuration updated", logContext); 86 | return { ...this.config }; 87 | }, 88 | 89 | /** 90 | * Retrieves a shallow copy of the current service configuration. 91 | * This prevents direct mutation of the internal configuration state. 92 | * 93 | * @returns A shallow copy of the current `ContextConfig`. 94 | */ 95 | getConfig(): ContextConfig { 96 | return { ...this.config }; 97 | }, 98 | 99 | /** 100 | * Creates a new {@link RequestContext} instance. 101 | * Each context is assigned a unique `requestId` (UUID) and a current `timestamp` (ISO 8601). 102 | * Additional custom properties can be merged into the context. 103 | * 104 | * @param additionalContext - An optional record of key-value pairs to be 105 | * included in the created request context. 106 | * @returns A new `RequestContext` object. 107 | */ 108 | createRequestContext( 109 | additionalContext: Record = {}, 110 | ): RequestContext { 111 | const requestId = generateUUID(); 112 | const timestamp = new Date().toISOString(); 113 | 114 | const context: RequestContext = { 115 | requestId, 116 | timestamp, 117 | ...additionalContext, 118 | }; 119 | 120 | // --- OpenTelemetry Integration --- 121 | // Automatically inject active trace and span IDs into the context for correlation. 122 | const activeSpan = trace.getActiveSpan(); 123 | if (activeSpan) { 124 | const spanContext = activeSpan.spanContext(); 125 | context.traceId = spanContext.traceId; 126 | context.spanId = spanContext.spanId; 127 | } 128 | // --- End OpenTelemetry Integration --- 129 | 130 | return context; 131 | }, 132 | }; 133 | 134 | /** 135 | * Primary export for request context functionalities. 136 | * This service provides methods to create and manage {@link RequestContext} instances, 137 | * which are essential for logging, tracing, and correlating operations. 138 | */ 139 | export const requestContextService = requestContextServiceInstance; 140 | -------------------------------------------------------------------------------- /src/mcp-server/transports/core/transportTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines the core types and interfaces for the transport layer abstraction. 3 | * This module establishes the data contracts and abstract interfaces that decouple 4 | * the MCP server's core logic from specific transport implementations like HTTP or stdio. 5 | * @module src/mcp-server/transports/core/transportTypes 6 | */ 7 | 8 | import type { IncomingHttpHeaders } from "http"; 9 | import { RequestContext } from "../../../utils/index.js"; 10 | 11 | /** 12 | * Defines the set of valid HTTP status codes that the transport layer can return. 13 | * This ensures type safety and consistency in response handling. 14 | */ 15 | export type HttpStatusCode = 16 | | 200 // OK 17 | | 201 // Created 18 | | 400 // Bad Request 19 | | 401 // Unauthorized 20 | | 403 // Forbidden 21 | | 404 // Not Found 22 | | 409 // Conflict 23 | | 429 // Too Many Requests 24 | | 500 // Internal Server Error 25 | | 502 // Bad Gateway 26 | | 503; // Service Unavailable 27 | 28 | /** 29 | * A base interface for all transport responses, containing common properties. 30 | */ 31 | interface BaseTransportResponse { 32 | sessionId?: string; 33 | headers: Headers; 34 | statusCode: HttpStatusCode; 35 | } 36 | 37 | /** 38 | * Represents a transport response where the entire body is buffered in memory. 39 | * Suitable for small, non-streamed responses. 40 | */ 41 | export interface BufferedTransportResponse extends BaseTransportResponse { 42 | type: "buffered"; 43 | body: unknown; 44 | } 45 | 46 | /** 47 | * Represents a transport response that streams its body. 48 | * Essential for handling large or chunked responses efficiently without high memory usage. 49 | */ 50 | export interface StreamingTransportResponse extends BaseTransportResponse { 51 | type: "stream"; 52 | stream: ReadableStream; 53 | } 54 | 55 | /** 56 | * A discriminated union representing the possible types of a transport response. 57 | * Using a discriminated union on the `type` property allows for type-safe handling 58 | * of different response formats (buffered vs. streamed). 59 | */ 60 | export type TransportResponse = 61 | | BufferedTransportResponse 62 | | StreamingTransportResponse; 63 | 64 | /** 65 | * Represents the state of an active, persistent transport session. 66 | */ 67 | export interface TransportSession { 68 | id: string; 69 | createdAt: Date; 70 | lastAccessedAt: Date; 71 | /** 72 | * A counter for requests currently being processed for this session. 73 | * This is a critical mechanism to prevent race conditions where a session 74 | * might be garbage-collected while a long-running request is still in flight. 75 | * It is incremented when a request begins and decremented when it finishes. 76 | */ 77 | activeRequests: number; 78 | } 79 | 80 | /** 81 | * Defines the abstract interface for a transport manager. 82 | * This contract ensures that any transport manager, regardless of its statefulness, 83 | * provides a consistent way to handle requests and manage its lifecycle. 84 | */ 85 | export interface TransportManager { 86 | /** 87 | * Handles an incoming request. 88 | * @param headers The incoming request headers. 89 | * @param body The parsed body of the request. 90 | * @param context The request context for logging, tracing, and metadata. 91 | * @param sessionId An optional session identifier for stateful operations. 92 | * @returns A promise that resolves to a TransportResponse object. 93 | */ 94 | handleRequest( 95 | headers: IncomingHttpHeaders, 96 | body: unknown, 97 | context: RequestContext, 98 | sessionId?: string, 99 | ): Promise; 100 | 101 | /** 102 | * Gracefully shuts down the transport manager, cleaning up any resources. 103 | */ 104 | shutdown(): Promise; 105 | } 106 | 107 | /** 108 | * Extends the base TransportManager with operations specific to stateful sessions. 109 | */ 110 | export interface StatefulTransportManager extends TransportManager { 111 | /** 112 | * Initializes a new stateful session and handles the first request. 113 | * @param headers The incoming request headers. 114 | * @param body The parsed body of the request. 115 | * @param context The request context. 116 | * @returns A promise resolving to a TransportResponse, which will include a session ID. 117 | */ 118 | initializeAndHandle( 119 | headers: IncomingHttpHeaders, 120 | body: unknown, 121 | context: RequestContext, 122 | ): Promise; 123 | 124 | /** 125 | * Handles a request to explicitly delete a session. 126 | * @param sessionId The ID of the session to delete. 127 | * @param context The request context. 128 | * @returns A promise resolving to a TransportResponse confirming closure. 129 | */ 130 | handleDeleteRequest( 131 | sessionId: string, 132 | context: RequestContext, 133 | ): Promise; 134 | 135 | /** 136 | * Retrieves information about a specific session. 137 | * @param sessionId The ID of the session to retrieve. 138 | * @returns A TransportSession object if the session exists, otherwise undefined. 139 | */ 140 | getSession(sessionId: string): TransportSession | undefined; 141 | } 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # OPERATING SYSTEM FILES 3 | # ============================================================================= 4 | .DS_Store 5 | .DS_Store? 6 | ._* 7 | .Spotlight-V100 8 | .Trashes 9 | ehthumbs.db 10 | Thumbs.db 11 | 12 | # ============================================================================= 13 | # IDE AND EDITOR FILES 14 | # ============================================================================= 15 | .idea/ 16 | *.swp 17 | *.swo 18 | *~ 19 | *.sublime-workspace 20 | *.sublime-project 21 | .history/ 22 | 23 | # ============================================================================= 24 | # NODE.JS & PACKAGE MANAGERS 25 | # ============================================================================= 26 | node_modules/ 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | .npm 32 | .pnp.js 33 | .pnp.cjs 34 | .pnp.mjs 35 | .pnp.json 36 | .pnp.ts 37 | 38 | # ============================================================================= 39 | # TYPESCRIPT & JAVASCRIPT 40 | # ============================================================================= 41 | *.tsbuildinfo 42 | .tscache/ 43 | *.js.map 44 | *.mjs.map 45 | *.cjs.map 46 | *.d.ts.map 47 | *.d.ts 48 | !*.d.ts.template 49 | *.tgz 50 | .eslintcache 51 | .rollup.cache 52 | 53 | # ============================================================================= 54 | # PYTHON 55 | # ============================================================================= 56 | __pycache__/ 57 | *.py[cod] 58 | *$py.class 59 | *.so 60 | .Python 61 | develop-eggs/ 62 | eggs/ 63 | .eggs/ 64 | parts/ 65 | sdist/ 66 | var/ 67 | wheels/ 68 | *.egg-info/ 69 | .installed.cfg 70 | *.egg 71 | .pytest_cache/ 72 | .coverage 73 | htmlcov/ 74 | .tox/ 75 | .venv 76 | venv/ 77 | ENV/ 78 | 79 | # ============================================================================= 80 | # JAVA 81 | # ============================================================================= 82 | *.class 83 | *.jar 84 | *.war 85 | *.nar 86 | *.ear 87 | hs_err_pid* 88 | target/ 89 | .gradle/ 90 | 91 | # ============================================================================= 92 | # RUBY 93 | # ============================================================================= 94 | *.gem 95 | *.rbc 96 | /.config 97 | /coverage/ 98 | /InstalledFiles 99 | /pkg/ 100 | /spec/reports/ 101 | /test/tmp/ 102 | /test/version_tmp/ 103 | /tmp/ 104 | .byebug_history 105 | 106 | # ============================================================================= 107 | # BUILD & DISTRIBUTION 108 | # ============================================================================= 109 | build/ 110 | dist/ 111 | out/ 112 | 113 | # ============================================================================= 114 | # COMPILED FILES 115 | # ============================================================================= 116 | *.com 117 | *.dll 118 | *.exe 119 | *.o 120 | 121 | # ============================================================================= 122 | # PACKAGE & ARCHIVE FILES 123 | # ============================================================================= 124 | *.7z 125 | *.dmg 126 | *.gz 127 | *.iso 128 | *.rar 129 | *.tar 130 | *.tar.gz 131 | *.zip 132 | 133 | # ============================================================================= 134 | # LOGS & DATABASES 135 | # ============================================================================= 136 | *.log 137 | *.sql 138 | *.sqlite 139 | *.sqlite3 140 | logs/ 141 | 142 | # ============================================================================= 143 | # TESTING & COVERAGE 144 | # ============================================================================= 145 | coverage/ 146 | .nyc_output/ 147 | 148 | # ============================================================================= 149 | # CACHE & TEMPORARY FILES 150 | # ============================================================================= 151 | .cache/ 152 | .parcel-cache/ 153 | *.bak 154 | 155 | # ============================================================================= 156 | # ENVIRONMENT & CONFIGURATION 157 | # ============================================================================= 158 | .env 159 | .env.local 160 | .env.development.local 161 | .env.test.local 162 | .env.production.local 163 | .sample-env 164 | !sample.template.* 165 | mcp-servers.json 166 | mcp-config.json 167 | .wrangler 168 | worker-configuration.d.ts 169 | 170 | # ============================================================================= 171 | # DEMO & EXAMPLE DIRECTORIES 172 | # ============================================================================= 173 | 174 | # ============================================================================= 175 | # GENERATED DOCUMENTATION 176 | # ============================================================================= 177 | docs/api/ 178 | 179 | # ============================================================================= 180 | # APPLICATION SPECIFIC 181 | # ============================================================================= 182 | .storage/ 183 | repomix-output* 184 | duckdata/ 185 | .claude 186 | data/ 187 | docs/devdocs.md 188 | 189 | # ============================================================================= 190 | # MCP REGISTRY 191 | # ============================================================================= 192 | .mcpregistry_github_token 193 | .mcpregistry_registry_token 194 | -------------------------------------------------------------------------------- /docs/tree.md: -------------------------------------------------------------------------------- 1 | # pubmed-mcp-server - Directory Structure 2 | 3 | Generated on: 2025-08-08 19:58:30 4 | 5 | ``` 6 | pubmed-mcp-server 7 | ├── .clinerules 8 | │ └── clinerules.md 9 | ├── .github 10 | │ ├── workflows 11 | │ │ └── publish.yml 12 | │ └── FUNDING.yml 13 | ├── docs 14 | │ ├── api-references 15 | │ │ └── typedoc-reference.md 16 | │ ├── project-spec.md 17 | │ └── tree.md 18 | ├── examples 19 | │ ├── generate_pubmed_chart 20 | │ │ ├── bar_chart.png 21 | │ │ ├── doughnut_chart.png 22 | │ │ ├── line_chart.png 23 | │ │ ├── pie_chart.png 24 | │ │ ├── polar_chart.png 25 | │ │ ├── radar_chart.png 26 | │ │ └── scatter_plot.png 27 | │ ├── pubmed_article_connections_1.md 28 | │ ├── pubmed_article_connections_2.md 29 | │ ├── pubmed_fetch_contents_example.md 30 | │ ├── pubmed_research_agent_example.md 31 | │ └── pubmed_search_articles_example.md 32 | ├── scripts 33 | │ ├── clean.ts 34 | │ ├── fetch-openapi-spec.ts 35 | │ ├── make-executable.ts 36 | │ └── tree.ts 37 | ├── src 38 | │ ├── config 39 | │ │ └── index.ts 40 | │ ├── mcp-server 41 | │ │ ├── tools 42 | │ │ │ ├── pubmedArticleConnections 43 | │ │ │ │ ├── logic 44 | │ │ │ │ │ ├── citationFormatter.ts 45 | │ │ │ │ │ ├── elinkHandler.ts 46 | │ │ │ │ │ ├── index.ts 47 | │ │ │ │ │ └── types.ts 48 | │ │ │ │ ├── index.ts 49 | │ │ │ │ └── registration.ts 50 | │ │ │ ├── pubmedFetchContents 51 | │ │ │ │ ├── index.ts 52 | │ │ │ │ ├── logic.ts 53 | │ │ │ │ └── registration.ts 54 | │ │ │ ├── pubmedGenerateChart 55 | │ │ │ │ ├── index.ts 56 | │ │ │ │ ├── logic.ts 57 | │ │ │ │ └── registration.ts 58 | │ │ │ ├── pubmedResearchAgent 59 | │ │ │ │ ├── logic 60 | │ │ │ │ │ ├── index.ts 61 | │ │ │ │ │ ├── inputSchema.ts 62 | │ │ │ │ │ ├── outputTypes.ts 63 | │ │ │ │ │ └── planOrchestrator.ts 64 | │ │ │ │ ├── index.ts 65 | │ │ │ │ ├── logic.ts 66 | │ │ │ │ └── registration.ts 67 | │ │ │ └── pubmedSearchArticles 68 | │ │ │ ├── index.ts 69 | │ │ │ ├── logic.ts 70 | │ │ │ └── registration.ts 71 | │ │ ├── transports 72 | │ │ │ ├── auth 73 | │ │ │ │ ├── lib 74 | │ │ │ │ │ ├── authContext.ts 75 | │ │ │ │ │ ├── authTypes.ts 76 | │ │ │ │ │ └── authUtils.ts 77 | │ │ │ │ ├── strategies 78 | │ │ │ │ │ ├── authStrategy.ts 79 | │ │ │ │ │ ├── jwtStrategy.ts 80 | │ │ │ │ │ └── oauthStrategy.ts 81 | │ │ │ │ ├── authFactory.ts 82 | │ │ │ │ ├── authMiddleware.ts 83 | │ │ │ │ └── index.ts 84 | │ │ │ ├── core 85 | │ │ │ │ ├── baseTransportManager.ts 86 | │ │ │ │ ├── headerUtils.ts 87 | │ │ │ │ ├── honoNodeBridge.ts 88 | │ │ │ │ ├── statefulTransportManager.ts 89 | │ │ │ │ ├── statelessTransportManager.ts 90 | │ │ │ │ └── transportTypes.ts 91 | │ │ │ ├── http 92 | │ │ │ │ ├── httpErrorHandler.ts 93 | │ │ │ │ ├── httpTransport.ts 94 | │ │ │ │ ├── httpTypes.ts 95 | │ │ │ │ ├── index.ts 96 | │ │ │ │ └── mcpTransportMiddleware.ts 97 | │ │ │ └── stdio 98 | │ │ │ ├── index.ts 99 | │ │ │ └── stdioTransport.ts 100 | │ │ └── server.ts 101 | │ ├── services 102 | │ │ └── NCBI 103 | │ │ ├── core 104 | │ │ │ ├── ncbiConstants.ts 105 | │ │ │ ├── ncbiCoreApiClient.ts 106 | │ │ │ ├── ncbiRequestQueueManager.ts 107 | │ │ │ ├── ncbiResponseHandler.ts 108 | │ │ │ └── ncbiService.ts 109 | │ │ └── parsing 110 | │ │ ├── eSummaryResultParser.ts 111 | │ │ ├── index.ts 112 | │ │ ├── pubmedArticleStructureParser.ts 113 | │ │ └── xmlGenericHelpers.ts 114 | │ ├── types-global 115 | │ │ ├── declarations.d.ts 116 | │ │ ├── errors.ts 117 | │ │ └── pubmedXml.ts 118 | │ ├── utils 119 | │ │ ├── internal 120 | │ │ │ ├── errorHandler.ts 121 | │ │ │ ├── index.ts 122 | │ │ │ ├── logger.ts 123 | │ │ │ ├── performance.ts 124 | │ │ │ └── requestContext.ts 125 | │ │ ├── metrics 126 | │ │ │ ├── index.ts 127 | │ │ │ └── tokenCounter.ts 128 | │ │ ├── network 129 | │ │ │ ├── fetchWithTimeout.ts 130 | │ │ │ └── index.ts 131 | │ │ ├── parsing 132 | │ │ │ ├── dateParser.ts 133 | │ │ │ ├── index.ts 134 | │ │ │ └── jsonParser.ts 135 | │ │ ├── scheduling 136 | │ │ │ ├── index.ts 137 | │ │ │ └── scheduler.ts 138 | │ │ ├── security 139 | │ │ │ ├── idGenerator.ts 140 | │ │ │ ├── index.ts 141 | │ │ │ ├── rateLimiter.ts 142 | │ │ │ └── sanitization.ts 143 | │ │ ├── telemetry 144 | │ │ │ ├── instrumentation.ts 145 | │ │ │ └── semconv.ts 146 | │ │ └── index.ts 147 | │ └── index.ts 148 | ├── .dockerignore 149 | ├── .gitignore 150 | ├── .ncurc.json 151 | ├── CHANGELOG.md 152 | ├── Dockerfile 153 | ├── eslint.config.js 154 | ├── LICENSE 155 | ├── mcp.json 156 | ├── package-lock.json 157 | ├── package.json 158 | ├── README.md 159 | ├── repomix.config.json 160 | ├── smithery.yaml 161 | ├── tsconfig.json 162 | ├── tsconfig.typedoc.json 163 | ├── tsdoc.json 164 | └── typedoc.json 165 | ``` 166 | 167 | _Note: This tree excludes files and directories matched by .gitignore and default patterns._ 168 | -------------------------------------------------------------------------------- /src/utils/parsing/dateParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides utility functions for parsing natural language date strings 3 | * into Date objects or detailed parsing results using the `chrono-node` library. 4 | * @module src/utils/parsing/dateParser 5 | */ 6 | import * as chrono from "chrono-node"; 7 | import { BaseErrorCode } from "../../types-global/errors.js"; 8 | import { ErrorHandler, logger, RequestContext } from "../index.js"; 9 | 10 | /** 11 | * Parses a natural language date string into a JavaScript Date object. 12 | * Uses `chrono.parseDate` for lenient parsing of various date formats. 13 | * 14 | * @param text - The natural language date string to parse. 15 | * @param context - The request context for logging and error tracking. 16 | * @param refDate - Optional reference date for parsing relative dates. Defaults to current date/time. 17 | * @returns A promise resolving with a Date object or `null` if parsing fails. 18 | * @throws {McpError} If an unexpected error occurs during parsing. 19 | * @private 20 | */ 21 | export async function parseDateString( 22 | text: string, 23 | context: RequestContext, 24 | refDate?: Date, 25 | ): Promise { 26 | const operation = "parseDateString"; 27 | const logContext = { ...context, operation, inputText: text, refDate }; 28 | logger.debug(`Attempting to parse date string: "${text}"`, logContext); 29 | 30 | return await ErrorHandler.tryCatch( 31 | async () => { 32 | const parsedDate = chrono.parseDate(text, refDate, { forwardDate: true }); 33 | if (parsedDate) { 34 | logger.debug( 35 | `Successfully parsed "${text}" to ${parsedDate.toISOString()}`, 36 | logContext, 37 | ); 38 | return parsedDate; 39 | } else { 40 | logger.warning(`Failed to parse date string: "${text}"`, logContext); 41 | return null; 42 | } 43 | }, 44 | { 45 | operation, 46 | context: logContext, 47 | input: { text, refDate }, 48 | errorCode: BaseErrorCode.PARSING_ERROR, 49 | }, 50 | ); 51 | } 52 | 53 | /** 54 | * Parses a natural language date string and returns detailed parsing results. 55 | * Provides more information than just the Date object, including matched text and components. 56 | * 57 | * @param text - The natural language date string to parse. 58 | * @param context - The request context for logging and error tracking. 59 | * @param refDate - Optional reference date for parsing relative dates. Defaults to current date/time. 60 | * @returns A promise resolving with an array of `chrono.ParsedResult` objects. Empty if no dates found. 61 | * @throws {McpError} If an unexpected error occurs during parsing. 62 | * @private 63 | */ 64 | export async function parseDateStringDetailed( 65 | text: string, 66 | context: RequestContext, 67 | refDate?: Date, 68 | ): Promise { 69 | const operation = "parseDateStringDetailed"; 70 | const logContext = { ...context, operation, inputText: text, refDate }; 71 | logger.debug( 72 | `Attempting detailed parse of date string: "${text}"`, 73 | logContext, 74 | ); 75 | 76 | return await ErrorHandler.tryCatch( 77 | async () => { 78 | const results = chrono.parse(text, refDate, { forwardDate: true }); 79 | logger.debug( 80 | `Detailed parse of "${text}" resulted in ${results.length} result(s)`, 81 | logContext, 82 | ); 83 | return results; 84 | }, 85 | { 86 | operation, 87 | context: logContext, 88 | input: { text, refDate }, 89 | errorCode: BaseErrorCode.PARSING_ERROR, 90 | }, 91 | ); 92 | } 93 | 94 | /** 95 | * An object providing date parsing functionalities. 96 | * 97 | * @example 98 | * ```typescript 99 | * import { dateParser, requestContextService } from './utils'; // Assuming utils/index.js exports these 100 | * const context = requestContextService.createRequestContext({ operation: 'TestDateParsing' }); 101 | * 102 | * async function testParsing() { 103 | * const dateObj = await dateParser.parseDate("next Friday at 3pm", context); 104 | * if (dateObj) { 105 | * console.log("Parsed Date:", dateObj.toISOString()); 106 | * } 107 | * 108 | * const detailedResults = await dateParser.parse("Meeting on 2024-12-25 and another one tomorrow", context); 109 | * detailedResults.forEach(result => { 110 | * console.log("Detailed Result:", result.text, result.start.date()); 111 | * }); 112 | * } 113 | * testParsing(); 114 | * ``` 115 | */ 116 | export const dateParser = { 117 | /** 118 | * Parses a natural language date string and returns detailed parsing results 119 | * from `chrono-node`. 120 | * @param text - The natural language date string to parse. 121 | * @param context - The request context for logging and error tracking. 122 | * @param refDate - Optional reference date for parsing relative dates. 123 | * @returns A promise resolving with an array of `chrono.ParsedResult` objects. 124 | */ 125 | parse: parseDateStringDetailed, 126 | /** 127 | * Parses a natural language date string into a single JavaScript Date object. 128 | * @param text - The natural language date string to parse. 129 | * @param context - The request context for logging and error tracking. 130 | * @param refDate - Optional reference date for parsing relative dates. 131 | * @returns A promise resolving with a Date object or `null`. 132 | */ 133 | parseDate: parseDateString, 134 | }; 135 | -------------------------------------------------------------------------------- /src/utils/metrics/tokenCounter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides utility functions for counting tokens in text and chat messages 3 | * using the `tiktoken` library, specifically configured for 'gpt-4o' tokenization. 4 | * These functions are essential for managing token limits and estimating costs 5 | * when interacting with language models. 6 | * @module src/utils/metrics/tokenCounter 7 | */ 8 | import { ChatCompletionMessageParam } from "openai/resources/chat/completions"; 9 | import { encoding_for_model, Tiktoken, TiktokenModel } from "tiktoken"; 10 | import { BaseErrorCode } from "../../types-global/errors.js"; 11 | import { ErrorHandler, logger, RequestContext } from "../index.js"; 12 | 13 | /** 14 | * The specific Tiktoken model used for all tokenization operations in this module. 15 | * This ensures consistent token counting. 16 | * @private 17 | */ 18 | const TOKENIZATION_MODEL: TiktokenModel = "gpt-4o"; 19 | 20 | /** 21 | * Calculates the number of tokens for a given text string using the 22 | * tokenizer specified by `TOKENIZATION_MODEL`. 23 | * Wraps tokenization in `ErrorHandler.tryCatch` for robust error management. 24 | * 25 | * @param text - The input text to tokenize. 26 | * @param context - Optional request context for logging and error handling. 27 | * @returns A promise that resolves with the number of tokens in the text. 28 | * @throws {McpError} If tokenization fails. 29 | */ 30 | export async function countTokens( 31 | text: string, 32 | context?: RequestContext, 33 | ): Promise { 34 | return ErrorHandler.tryCatch( 35 | () => { 36 | let encoding: Tiktoken | null = null; 37 | try { 38 | encoding = encoding_for_model(TOKENIZATION_MODEL); 39 | const tokens = encoding.encode(text); 40 | return tokens.length; 41 | } finally { 42 | encoding?.free(); 43 | } 44 | }, 45 | { 46 | operation: "countTokens", 47 | context: context, 48 | input: { textSample: text.substring(0, 50) + "..." }, 49 | errorCode: BaseErrorCode.INTERNAL_ERROR, 50 | }, 51 | ); 52 | } 53 | 54 | /** 55 | * Calculates the estimated number of tokens for an array of chat messages. 56 | * Uses the tokenizer specified by `TOKENIZATION_MODEL` and accounts for 57 | * special tokens and message overhead according to OpenAI's guidelines. 58 | * 59 | * For multi-part content, only text parts are currently tokenized. 60 | * 61 | * Reference: {@link https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb} 62 | * 63 | * @param messages - An array of chat messages. 64 | * @param context - Optional request context for logging and error handling. 65 | * @returns A promise that resolves with the estimated total number of tokens. 66 | * @throws {McpError} If tokenization fails. 67 | */ 68 | export async function countChatTokens( 69 | messages: ReadonlyArray, 70 | context?: RequestContext, 71 | ): Promise { 72 | return ErrorHandler.tryCatch( 73 | () => { 74 | let encoding: Tiktoken | null = null; 75 | let num_tokens = 0; 76 | try { 77 | encoding = encoding_for_model(TOKENIZATION_MODEL); 78 | 79 | const tokens_per_message = 3; // For gpt-4o, gpt-4, gpt-3.5-turbo 80 | const tokens_per_name = 1; // For gpt-4o, gpt-4, gpt-3.5-turbo 81 | 82 | for (const message of messages) { 83 | num_tokens += tokens_per_message; 84 | num_tokens += encoding.encode(message.role).length; 85 | 86 | if (typeof message.content === "string") { 87 | num_tokens += encoding.encode(message.content).length; 88 | } else if (Array.isArray(message.content)) { 89 | for (const part of message.content) { 90 | if (part.type === "text") { 91 | num_tokens += encoding.encode(part.text).length; 92 | } else { 93 | logger.warning( 94 | `Non-text content part found (type: ${part.type}), token count contribution ignored.`, 95 | context, 96 | ); 97 | } 98 | } 99 | } 100 | 101 | if ("name" in message && message.name) { 102 | num_tokens += tokens_per_name; 103 | num_tokens += encoding.encode(message.name).length; 104 | } 105 | 106 | if ( 107 | message.role === "assistant" && 108 | "tool_calls" in message && 109 | message.tool_calls 110 | ) { 111 | for (const tool_call of message.tool_calls) { 112 | if (tool_call.type === "function" && tool_call.function.name) { 113 | num_tokens += encoding.encode(tool_call.function.name).length; 114 | if (tool_call.function.arguments) { 115 | num_tokens += encoding.encode( 116 | tool_call.function.arguments, 117 | ).length; 118 | } 119 | } 120 | } 121 | } 122 | 123 | if ( 124 | message.role === "tool" && 125 | "tool_call_id" in message && 126 | message.tool_call_id 127 | ) { 128 | num_tokens += encoding.encode(message.tool_call_id).length; 129 | } 130 | } 131 | num_tokens += 3; // Every reply is primed with <|start|>assistant<|message|> 132 | return num_tokens; 133 | } finally { 134 | encoding?.free(); 135 | } 136 | }, 137 | { 138 | operation: "countChatTokens", 139 | context: context, 140 | input: { messageCount: messages.length }, 141 | errorCode: BaseErrorCode.INTERNAL_ERROR, 142 | }, 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/strategies/oauthStrategy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Implements the OAuth 2.1 authentication strategy. 3 | * This module provides a concrete implementation of the AuthStrategy for validating 4 | * JWTs against a remote JSON Web Key Set (JWKS), as is common in OAuth 2.1 flows. 5 | * @module src/mcp-server/transports/auth/strategies/OauthStrategy 6 | */ 7 | import { createRemoteJWKSet, jwtVerify, JWTVerifyResult } from "jose"; 8 | import { config } from "../../../../config/index.js"; 9 | import { BaseErrorCode, McpError } from "../../../../types-global/errors.js"; 10 | import { 11 | ErrorHandler, 12 | logger, 13 | requestContextService, 14 | } from "../../../../utils/index.js"; 15 | import type { AuthInfo } from "../lib/authTypes.js"; 16 | import type { AuthStrategy } from "./authStrategy.js"; 17 | 18 | export class OauthStrategy implements AuthStrategy { 19 | private readonly jwks: ReturnType; 20 | 21 | constructor() { 22 | const context = requestContextService.createRequestContext({ 23 | operation: "OauthStrategy.constructor", 24 | }); 25 | logger.debug("Initializing OauthStrategy...", context); 26 | 27 | if (config.mcpAuthMode !== "oauth") { 28 | // This check is for internal consistency, so a standard Error is acceptable here. 29 | throw new Error("OauthStrategy instantiated for non-oauth auth mode."); 30 | } 31 | if (!config.oauthIssuerUrl || !config.oauthAudience) { 32 | logger.fatal( 33 | "CRITICAL: OAUTH_ISSUER_URL and OAUTH_AUDIENCE must be set for OAuth mode.", 34 | context, 35 | ); 36 | // This is a user-facing configuration error, so McpError is appropriate. 37 | throw new McpError( 38 | BaseErrorCode.CONFIGURATION_ERROR, 39 | "OAUTH_ISSUER_URL and OAUTH_AUDIENCE must be set for OAuth mode.", 40 | context, 41 | ); 42 | } 43 | 44 | try { 45 | const jwksUrl = new URL( 46 | config.oauthJwksUri || 47 | `${config.oauthIssuerUrl.replace(/\/$/, "")}/.well-known/jwks.json`, 48 | ); 49 | this.jwks = createRemoteJWKSet(jwksUrl, { 50 | cooldownDuration: 300000, // 5 minutes 51 | timeoutDuration: 5000, // 5 seconds 52 | }); 53 | logger.info(`JWKS client initialized for URL: ${jwksUrl.href}`, context); 54 | } catch (error) { 55 | logger.fatal("Failed to initialize JWKS client.", { 56 | ...context, 57 | error: error instanceof Error ? error.message : String(error), 58 | }); 59 | // This is a critical startup failure, so a specific McpError is warranted. 60 | throw new McpError( 61 | BaseErrorCode.SERVICE_UNAVAILABLE, 62 | "Could not initialize JWKS client for OAuth strategy.", 63 | { 64 | ...context, 65 | originalError: error instanceof Error ? error.message : "Unknown", 66 | }, 67 | ); 68 | } 69 | } 70 | 71 | async verify(token: string): Promise { 72 | const context = requestContextService.createRequestContext({ 73 | operation: "OauthStrategy.verify", 74 | }); 75 | logger.debug("Attempting to verify OAuth token via JWKS.", context); 76 | 77 | try { 78 | const { payload }: JWTVerifyResult = await jwtVerify(token, this.jwks, { 79 | issuer: config.oauthIssuerUrl!, 80 | audience: config.oauthAudience!, 81 | }); 82 | logger.debug("OAuth token signature verified successfully.", { 83 | ...context, 84 | claims: payload, 85 | }); 86 | 87 | const scopes = 88 | typeof payload.scope === "string" ? payload.scope.split(" ") : []; 89 | if (scopes.length === 0) { 90 | logger.warning( 91 | "Invalid token: missing or empty 'scope' claim.", 92 | context, 93 | ); 94 | throw new McpError( 95 | BaseErrorCode.UNAUTHORIZED, 96 | "Token must contain valid, non-empty scopes.", 97 | context, 98 | ); 99 | } 100 | 101 | const clientId = 102 | typeof payload.client_id === "string" ? payload.client_id : undefined; 103 | if (!clientId) { 104 | logger.warning("Invalid token: missing 'client_id' claim.", context); 105 | throw new McpError( 106 | BaseErrorCode.UNAUTHORIZED, 107 | "Token must contain a 'client_id' claim.", 108 | context, 109 | ); 110 | } 111 | 112 | const authInfo: AuthInfo = { 113 | token, 114 | clientId, 115 | scopes, 116 | subject: typeof payload.sub === "string" ? payload.sub : undefined, 117 | }; 118 | logger.info("OAuth token verification successful.", { 119 | ...context, 120 | clientId, 121 | scopes, 122 | }); 123 | return authInfo; 124 | } catch (error) { 125 | // If the error is already a structured McpError, re-throw it directly. 126 | if (error instanceof McpError) { 127 | throw error; 128 | } 129 | 130 | const message = 131 | error instanceof Error && error.name === "JWTExpired" 132 | ? "Token has expired." 133 | : "OAuth token verification failed."; 134 | 135 | logger.warning(`OAuth token verification failed: ${message}`, { 136 | ...context, 137 | errorName: error instanceof Error ? error.name : "Unknown", 138 | }); 139 | 140 | // For all other errors, use the ErrorHandler to wrap them. 141 | throw ErrorHandler.handleError(error, { 142 | operation: "OauthStrategy.verify", 143 | context, 144 | rethrow: true, 145 | errorCode: BaseErrorCode.UNAUTHORIZED, 146 | errorMapper: () => 147 | new McpError(BaseErrorCode.UNAUTHORIZED, message, context), 148 | }); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/mcp-server/transports/auth/strategies/jwtStrategy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Implements the JWT authentication strategy. 3 | * This module provides a concrete implementation of the AuthStrategy for validating 4 | * JSON Web Tokens (JWTs). It encapsulates all logic related to JWT verification, 5 | * including secret key management and payload validation. 6 | * @module src/mcp-server/transports/auth/strategies/JwtStrategy 7 | */ 8 | import { jwtVerify } from "jose"; 9 | import { config, environment } from "../../../../config/index.js"; 10 | import { BaseErrorCode, McpError } from "../../../../types-global/errors.js"; 11 | import { 12 | ErrorHandler, 13 | logger, 14 | requestContextService, 15 | } from "../../../../utils/index.js"; 16 | import type { AuthInfo } from "../lib/authTypes.js"; 17 | import type { AuthStrategy } from "./authStrategy.js"; 18 | 19 | export class JwtStrategy implements AuthStrategy { 20 | private readonly secretKey: Uint8Array | null; 21 | 22 | constructor() { 23 | const context = requestContextService.createRequestContext({ 24 | operation: "JwtStrategy.constructor", 25 | }); 26 | logger.debug("Initializing JwtStrategy...", context); 27 | 28 | if (config.mcpAuthMode === "jwt") { 29 | if (environment === "production" && !config.mcpAuthSecretKey) { 30 | logger.fatal( 31 | "CRITICAL: MCP_AUTH_SECRET_KEY is not set in production for JWT auth.", 32 | context, 33 | ); 34 | throw new McpError( 35 | BaseErrorCode.CONFIGURATION_ERROR, 36 | "MCP_AUTH_SECRET_KEY must be set for JWT auth in production.", 37 | context, 38 | ); 39 | } else if (!config.mcpAuthSecretKey) { 40 | logger.warning( 41 | "MCP_AUTH_SECRET_KEY is not set. JWT auth will be bypassed (DEV ONLY).", 42 | context, 43 | ); 44 | this.secretKey = null; 45 | } else { 46 | logger.info("JWT secret key loaded successfully.", context); 47 | this.secretKey = new TextEncoder().encode(config.mcpAuthSecretKey); 48 | } 49 | } else { 50 | this.secretKey = null; 51 | } 52 | } 53 | 54 | async verify(token: string): Promise { 55 | const context = requestContextService.createRequestContext({ 56 | operation: "JwtStrategy.verify", 57 | }); 58 | logger.debug("Attempting to verify JWT.", context); 59 | 60 | // Handle development mode bypass 61 | if (!this.secretKey) { 62 | if (environment !== "production") { 63 | logger.warning( 64 | "Bypassing JWT verification: No secret key (DEV ONLY).", 65 | context, 66 | ); 67 | return { 68 | token: "dev-mode-placeholder-token", 69 | clientId: config.devMcpClientId || "dev-client-id", 70 | scopes: config.devMcpScopes || ["dev-scope"], 71 | }; 72 | } 73 | // This path is defensive. The constructor should prevent this state in production. 74 | logger.crit("Auth secret key is missing in production.", context); 75 | throw new McpError( 76 | BaseErrorCode.CONFIGURATION_ERROR, 77 | "Auth secret key is missing in production. This indicates a server configuration error.", 78 | context, 79 | ); 80 | } 81 | 82 | try { 83 | const { payload: decoded } = await jwtVerify(token, this.secretKey); 84 | logger.debug("JWT signature verified successfully.", { 85 | ...context, 86 | claims: decoded, 87 | }); 88 | 89 | const clientId = 90 | typeof decoded.cid === "string" 91 | ? decoded.cid 92 | : typeof decoded.client_id === "string" 93 | ? decoded.client_id 94 | : undefined; 95 | 96 | if (!clientId) { 97 | logger.warning( 98 | "Invalid token: missing 'cid' or 'client_id' claim.", 99 | context, 100 | ); 101 | throw new McpError( 102 | BaseErrorCode.UNAUTHORIZED, 103 | "Invalid token: missing 'cid' or 'client_id' claim.", 104 | context, 105 | ); 106 | } 107 | 108 | let scopes: string[] = []; 109 | if ( 110 | Array.isArray(decoded.scp) && 111 | decoded.scp.every((s) => typeof s === "string") 112 | ) { 113 | scopes = decoded.scp as string[]; 114 | } else if (typeof decoded.scope === "string" && decoded.scope.trim()) { 115 | scopes = decoded.scope.split(" ").filter(Boolean); 116 | } 117 | 118 | if (scopes.length === 0) { 119 | logger.warning( 120 | "Invalid token: missing or empty 'scp' or 'scope' claim.", 121 | context, 122 | ); 123 | throw new McpError( 124 | BaseErrorCode.UNAUTHORIZED, 125 | "Token must contain valid, non-empty scopes.", 126 | context, 127 | ); 128 | } 129 | 130 | const authInfo: AuthInfo = { 131 | token, 132 | clientId, 133 | scopes, 134 | subject: decoded.sub, 135 | }; 136 | logger.info("JWT verification successful.", { 137 | ...context, 138 | clientId, 139 | scopes, 140 | }); 141 | return authInfo; 142 | } catch (error) { 143 | // If the error is already a structured McpError, re-throw it directly. 144 | if (error instanceof McpError) { 145 | throw error; 146 | } 147 | 148 | const message = 149 | error instanceof Error && error.name === "JWTExpired" 150 | ? "Token has expired." 151 | : "Token verification failed."; 152 | 153 | logger.warning(`JWT verification failed: ${message}`, { 154 | ...context, 155 | errorName: error instanceof Error ? error.name : "Unknown", 156 | }); 157 | 158 | throw ErrorHandler.handleError(error, { 159 | operation: "JwtStrategy.verify", 160 | context, 161 | rethrow: true, 162 | errorCode: BaseErrorCode.UNAUTHORIZED, 163 | errorMapper: () => 164 | new McpError(BaseErrorCode.UNAUTHORIZED, message, context), 165 | }); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/utils/scheduling/scheduler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a singleton service for scheduling and managing cron jobs. 3 | * This service wraps the 'node-cron' library to offer a unified interface for 4 | * defining, starting, stopping, and listing recurring tasks within the application. 5 | * @module src/utils/scheduling/scheduler 6 | */ 7 | 8 | import cron, { ScheduledTask, createTask } from "node-cron"; 9 | import { logger, RequestContext } from "../internal/index.js"; 10 | import { requestContextService } from "../internal/requestContext.js"; 11 | 12 | /** 13 | * Represents a scheduled job managed by the SchedulerService. 14 | */ 15 | export interface Job { 16 | /** A unique identifier for the job. */ 17 | id: string; 18 | /** The cron pattern defining the job's schedule. */ 19 | schedule: string; 20 | /** A description of what the job does. */ 21 | description: string; 22 | /** The underlying 'node-cron' task instance. */ 23 | task: ScheduledTask; 24 | /** Indicates whether the job is currently running. */ 25 | isRunning: boolean; 26 | } 27 | 28 | /** 29 | * A singleton service for scheduling and managing cron jobs. 30 | */ 31 | export class SchedulerService { 32 | private static instance: SchedulerService; 33 | private jobs: Map = new Map(); 34 | 35 | /** @private */ 36 | private constructor() { 37 | logger.info("SchedulerService initialized.", { 38 | requestId: "scheduler-init", 39 | timestamp: new Date().toISOString(), 40 | }); 41 | } 42 | 43 | /** 44 | * Gets the singleton instance of the SchedulerService. 45 | * @returns The singleton SchedulerService instance. 46 | */ 47 | public static getInstance(): SchedulerService { 48 | if (!SchedulerService.instance) { 49 | SchedulerService.instance = new SchedulerService(); 50 | } 51 | return SchedulerService.instance; 52 | } 53 | 54 | /** 55 | * Schedules a new job. 56 | * 57 | * @param id - A unique identifier for the job. 58 | * @param schedule - The cron pattern for the schedule (e.g., '* * * * *'). 59 | * @param taskFunction - The function to execute on schedule. It receives a RequestContext. 60 | * @param description - A description of the job. 61 | * @returns The newly created Job object. 62 | */ 63 | public schedule( 64 | id: string, 65 | schedule: string, 66 | taskFunction: (context: RequestContext) => void | Promise, 67 | description: string, 68 | ): Job { 69 | if (this.jobs.has(id)) { 70 | throw new Error(`Job with ID '${id}' already exists.`); 71 | } 72 | 73 | if (!cron.validate(schedule)) { 74 | throw new Error(`Invalid cron schedule: ${schedule}`); 75 | } 76 | 77 | const task = createTask(schedule, async () => { 78 | const job = this.jobs.get(id); 79 | if (job && job.isRunning) { 80 | logger.warning( 81 | `Job '${id}' is already running. Skipping this execution.`, 82 | { 83 | requestId: `job-skip-${id}`, 84 | timestamp: new Date().toISOString(), 85 | }, 86 | ); 87 | return; 88 | } 89 | 90 | if (job) { 91 | job.isRunning = true; 92 | } 93 | 94 | const context = requestContextService.createRequestContext({ 95 | jobId: id, 96 | schedule, 97 | }); 98 | 99 | logger.info(`Starting job '${id}'...`, context); 100 | try { 101 | await Promise.resolve(taskFunction(context)); 102 | logger.info(`Job '${id}' completed successfully.`, context); 103 | } catch (error) { 104 | logger.error(`Job '${id}' failed.`, error as Error, context); 105 | } finally { 106 | if (job) { 107 | job.isRunning = false; 108 | } 109 | } 110 | }); 111 | 112 | const newJob: Job = { 113 | id, 114 | schedule, 115 | description, 116 | task, 117 | isRunning: false, 118 | }; 119 | 120 | this.jobs.set(id, newJob); 121 | logger.info(`Job '${id}' scheduled: ${description}`, { 122 | requestId: `job-schedule-${id}`, 123 | timestamp: new Date().toISOString(), 124 | }); 125 | return newJob; 126 | } 127 | 128 | /** 129 | * Starts a scheduled job. 130 | * @param id - The ID of the job to start. 131 | */ 132 | public start(id: string): void { 133 | const job = this.jobs.get(id); 134 | if (!job) { 135 | throw new Error(`Job with ID '${id}' not found.`); 136 | } 137 | job.task.start(); 138 | logger.info(`Job '${id}' started.`, { 139 | requestId: `job-start-${id}`, 140 | timestamp: new Date().toISOString(), 141 | }); 142 | } 143 | 144 | /** 145 | * Stops a scheduled job. 146 | * @param id - The ID of the job to stop. 147 | */ 148 | public stop(id: string): void { 149 | const job = this.jobs.get(id); 150 | if (!job) { 151 | throw new Error(`Job with ID '${id}' not found.`); 152 | } 153 | job.task.stop(); 154 | logger.info(`Job '${id}' stopped.`, { 155 | requestId: `job-stop-${id}`, 156 | timestamp: new Date().toISOString(), 157 | }); 158 | } 159 | 160 | /** 161 | * Removes a job from the scheduler. The job is stopped before being removed. 162 | * @param id - The ID of the job to remove. 163 | */ 164 | public remove(id: string): void { 165 | const job = this.jobs.get(id); 166 | if (!job) { 167 | throw new Error(`Job with ID '${id}' not found.`); 168 | } 169 | job.task.stop(); 170 | this.jobs.delete(id); 171 | logger.info(`Job '${id}' removed.`, { 172 | requestId: `job-remove-${id}`, 173 | timestamp: new Date().toISOString(), 174 | }); 175 | } 176 | 177 | /** 178 | * Gets a list of all scheduled jobs. 179 | * @returns An array of all Job objects. 180 | */ 181 | public listJobs(): Job[] { 182 | return Array.from(this.jobs.values()); 183 | } 184 | } 185 | 186 | /** 187 | * The singleton instance of the SchedulerService. 188 | * Use this instance for all job scheduling operations. 189 | */ 190 | export const schedulerService = SchedulerService.getInstance(); 191 | -------------------------------------------------------------------------------- /src/mcp-server/tools/pubmedArticleConnections/logic/citationFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Handles citation formatting for the pubmedArticleConnections tool. 3 | * Fetches article details using EFetch and formats them into various citation styles. 4 | * @module src/mcp-server/tools/pubmedArticleConnections/logic/citationFormatter 5 | */ 6 | 7 | import Cite from "citation-js"; 8 | import { getNcbiService } from "../../../../services/NCBI/core/ncbiService.js"; 9 | import type { 10 | XmlPubmedArticle, 11 | XmlPubmedArticleSet, 12 | } from "../../../../types-global/pubmedXml.js"; 13 | import { 14 | logger, 15 | RequestContext, 16 | requestContextService, 17 | } from "../../../../utils/index.js"; 18 | import { 19 | extractAuthors, 20 | extractDoi, 21 | extractJournalInfo, 22 | extractPmid, 23 | getText, 24 | } from "../../../../services/NCBI/parsing/index.js"; 25 | import { ensureArray } from "../../../../services/NCBI/parsing/index.js"; 26 | import type { PubMedArticleConnectionsInput } from "./index.js"; 27 | import type { ToolOutputData } from "./types.js"; 28 | 29 | // Main handler for citation formats 30 | export async function handleCitationFormats( 31 | input: PubMedArticleConnectionsInput, 32 | outputData: ToolOutputData, 33 | context: RequestContext, 34 | ): Promise { 35 | const eFetchParams = { 36 | db: "pubmed", 37 | id: input.sourcePmid, 38 | retmode: "xml" as const, 39 | }; 40 | 41 | const eFetchBaseUrl = 42 | "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"; 43 | const searchParamsString = new URLSearchParams( 44 | eFetchParams as Record, 45 | ).toString(); 46 | outputData.eUtilityUrl = `${eFetchBaseUrl}?${searchParamsString}`; 47 | 48 | const ncbiService = getNcbiService(); 49 | const eFetchResult: { PubmedArticleSet?: XmlPubmedArticleSet } = 50 | (await ncbiService.eFetch(eFetchParams, context)) as { 51 | PubmedArticleSet?: XmlPubmedArticleSet; 52 | }; 53 | 54 | const pubmedArticles = ensureArray( 55 | eFetchResult?.PubmedArticleSet?.PubmedArticle as 56 | | XmlPubmedArticle 57 | | XmlPubmedArticle[] 58 | | undefined, 59 | ); 60 | 61 | if (pubmedArticles.length === 0) { 62 | outputData.message = 63 | "Could not retrieve article details for citation formatting."; 64 | logger.warning(outputData.message, context); 65 | return; 66 | } 67 | 68 | const article: XmlPubmedArticle = pubmedArticles[0]!; 69 | const csl = pubmedArticleToCsl(article, context); 70 | const cite = new Cite(csl); 71 | 72 | if (input.citationStyles?.includes("ris")) { 73 | outputData.citations.ris = cite.format("ris"); 74 | } 75 | if (input.citationStyles?.includes("bibtex")) { 76 | outputData.citations.bibtex = cite.format("bibtex"); 77 | } 78 | if (input.citationStyles?.includes("apa_string")) { 79 | outputData.citations.apa_string = cite.format("bibliography", { 80 | format: "text", 81 | template: "apa", 82 | }); 83 | } 84 | if (input.citationStyles?.includes("mla_string")) { 85 | outputData.citations.mla_string = cite.format("bibliography", { 86 | format: "text", 87 | template: "mla", 88 | }); 89 | } 90 | outputData.retrievedCount = 1; 91 | } 92 | 93 | /** 94 | * Converts an XML PubMed Article object to a CSL-JSON object. 95 | * @param article The PubMed article in XML format. 96 | * @param context The request context for logging. 97 | * @returns A CSL-JSON object compatible with citation-js. 98 | */ 99 | function pubmedArticleToCsl( 100 | article: XmlPubmedArticle, 101 | context: RequestContext, 102 | ): Record { 103 | const medlineCitation = article.MedlineCitation; 104 | const articleDetails = medlineCitation?.Article; 105 | const pmid = extractPmid(medlineCitation); 106 | 107 | const cslContext = requestContextService.createRequestContext({ 108 | ...context, 109 | pmid, 110 | }); 111 | logger.debug("Converting PubMed XML to CSL-JSON", cslContext); 112 | 113 | if (!articleDetails) { 114 | logger.warning("Article details not found for CSL conversion", cslContext); 115 | return { id: pmid || "unknown", title: "Article details not found" }; 116 | } 117 | 118 | const authors = extractAuthors(articleDetails.AuthorList); 119 | const journalInfo = extractJournalInfo( 120 | articleDetails.Journal, 121 | medlineCitation, 122 | ); 123 | const title = getText(articleDetails.ArticleTitle); 124 | const doi = extractDoi(articleDetails); 125 | 126 | const cslAuthors = authors.map((author) => 127 | author.collectiveName 128 | ? { literal: author.collectiveName } 129 | : { family: author.lastName, given: author.firstName }, 130 | ); 131 | 132 | const dateParts: (number | string)[] = []; 133 | if (journalInfo?.publicationDate?.year) { 134 | dateParts.push(parseInt(journalInfo.publicationDate.year, 10)); 135 | if (journalInfo.publicationDate.month) { 136 | // Convert month name/number to number 137 | const monthNumber = new Date( 138 | `${journalInfo.publicationDate.month} 1, 2000`, 139 | ).getMonth(); 140 | if (!isNaN(monthNumber)) { 141 | dateParts.push(monthNumber + 1); 142 | if (journalInfo.publicationDate.day) { 143 | dateParts.push(parseInt(journalInfo.publicationDate.day, 10)); 144 | } 145 | } 146 | } 147 | } 148 | 149 | const cslData: Record = { 150 | id: pmid, 151 | type: "article-journal", 152 | title: title, 153 | author: cslAuthors, 154 | issued: { 155 | "date-parts": [dateParts], 156 | }, 157 | "container-title": journalInfo?.title, 158 | volume: journalInfo?.volume, 159 | issue: journalInfo?.issue, 160 | page: journalInfo?.pages, 161 | DOI: doi, 162 | PMID: pmid, 163 | URL: pmid ? `https://pubmed.ncbi.nlm.nih.gov/${pmid}` : undefined, 164 | }; 165 | 166 | // Clean up any undefined/null properties 167 | for (const [key, value] of Object.entries(cslData)) { 168 | if (value === undefined || value === null) { 169 | delete (cslData as Record)[key]; 170 | } 171 | } 172 | 173 | return cslData; 174 | } 175 | -------------------------------------------------------------------------------- /src/mcp-server/transports/core/honoNodeBridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a high-fidelity bridge between the MCP SDK's Node.js-style 3 | * streamable HTTP transport and Hono's Web Standards-based streaming response. 4 | * This class is essential for adapting the Node.js `http.ServerResponse` API 5 | * to a format consumable by modern web frameworks. 6 | * @module src/mcp-server/transports/core/honoNodeBridge 7 | */ 8 | 9 | import { PassThrough } from "stream"; 10 | import type { OutgoingHttpHeaders } from "http"; 11 | 12 | /** 13 | * A mock `http.ServerResponse` that pipes all written data to a `PassThrough` stream. 14 | * 15 | * This class serves as a critical compatibility layer, emulating the behavior of a 16 | * Node.js `ServerResponse` to capture status codes, headers, and the response body. 17 | * The captured data can then be used to construct a Web-standard `Response` object, 18 | * for instance in a Hono application. It pays close attention to the timing of when 19 | * headers are considered "sent" to mimic Node.js behavior accurately. 20 | */ 21 | export class HonoStreamResponse extends PassThrough { 22 | public statusCode = 200; 23 | public headers: OutgoingHttpHeaders = {}; 24 | private _headersSent = false; 25 | 26 | constructor() { 27 | super(); 28 | } 29 | 30 | /** 31 | * A getter that reports whether the headers have been sent. 32 | * In this emulation, headers are considered sent the first time `write()` or `end()` is called. 33 | */ 34 | get headersSent(): boolean { 35 | return this._headersSent; 36 | } 37 | 38 | /** 39 | * Sets the status code and headers for the response, mimicking `http.ServerResponse.writeHead`. 40 | * 41 | * @param statusCode - The HTTP status code. 42 | * @param statusMessageOrHeaders - An optional status message (string) or headers object. 43 | * @param headers - An optional headers object, used if the second argument is a status message. 44 | * @returns The instance of the class for chaining. 45 | */ 46 | writeHead( 47 | statusCode: number, 48 | statusMessageOrHeaders?: string | OutgoingHttpHeaders, 49 | headers?: OutgoingHttpHeaders, 50 | ): this { 51 | if (this._headersSent) { 52 | // Per Node.js spec, do nothing if headers are already sent. 53 | return this; 54 | } 55 | this.statusCode = statusCode; 56 | 57 | const headersArg = 58 | typeof statusMessageOrHeaders === "string" 59 | ? headers 60 | : statusMessageOrHeaders; 61 | 62 | if (headersArg) { 63 | for (const [key, value] of Object.entries(headersArg)) { 64 | if (value !== undefined) { 65 | this.setHeader(key, value); 66 | } 67 | } 68 | } 69 | return this; 70 | } 71 | 72 | /** 73 | * Sets a single header value. 74 | * 75 | * @param name - The name of the header. 76 | * @param value - The value of the header. 77 | * @returns The instance of the class for chaining. 78 | */ 79 | setHeader(name: string, value: string | number | string[]): this { 80 | if (this._headersSent) { 81 | // This is a deviation from Node.js (which would throw), but provides a 82 | // more graceful warning for this emulation layer. 83 | console.warn( 84 | `[HonoBridge] Warning: Cannot set header "${name}" after headers are sent.`, 85 | ); 86 | return this; 87 | } 88 | this.headers[name.toLowerCase()] = value; 89 | return this; 90 | } 91 | 92 | /** 93 | * Gets a header that has been queued for the response. 94 | * @param name - The name of the header. 95 | * @returns The value of the header, or undefined if not set. 96 | */ 97 | getHeader(name: string): string | number | string[] | undefined { 98 | return this.headers[name.toLowerCase()]; 99 | } 100 | 101 | /** 102 | * Returns a copy of the current outgoing headers. 103 | */ 104 | getHeaders(): OutgoingHttpHeaders { 105 | return { ...this.headers }; 106 | } 107 | 108 | /** 109 | * Removes a header that has been queued for the response. 110 | * @param name - The name of the header to remove. 111 | */ 112 | removeHeader(name: string): void { 113 | if (this._headersSent) { 114 | console.warn( 115 | `[HonoBridge] Warning: Cannot remove header "${name}" after headers are sent.`, 116 | ); 117 | return; 118 | } 119 | delete this.headers[name.toLowerCase()]; 120 | } 121 | 122 | /** 123 | * A private helper to mark headers as sent. This is called implicitly 124 | * before any part of the body is written. 125 | */ 126 | private ensureHeadersSent(): void { 127 | if (!this._headersSent) { 128 | this._headersSent = true; 129 | } 130 | } 131 | 132 | /** 133 | * Writes a chunk of the response body, mimicking `http.ServerResponse.write`. 134 | * This is the first point where headers are implicitly flushed. 135 | */ 136 | write( 137 | chunk: unknown, 138 | encodingOrCallback?: 139 | | BufferEncoding 140 | | ((error: Error | null | undefined) => void), 141 | callback?: (error: Error | null | undefined) => void, 142 | ): boolean { 143 | this.ensureHeadersSent(); 144 | 145 | const encoding = 146 | typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; 147 | const cb = 148 | typeof encodingOrCallback === "function" ? encodingOrCallback : callback; 149 | 150 | if (encoding) { 151 | return super.write(chunk, encoding, cb); 152 | } 153 | return super.write(chunk, cb); 154 | } 155 | 156 | /** 157 | * Finishes sending the response, mimicking `http.ServerResponse.end`. 158 | * This also implicitly flushes headers if they haven't been sent yet. 159 | */ 160 | end( 161 | chunk?: unknown, 162 | encodingOrCallback?: BufferEncoding | (() => void), 163 | callback?: () => void, 164 | ): this { 165 | this.ensureHeadersSent(); 166 | 167 | const encoding = 168 | typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; 169 | const cb = 170 | typeof encodingOrCallback === "function" ? encodingOrCallback : callback; 171 | 172 | if (encoding) { 173 | super.end(chunk, encoding, cb); 174 | } else { 175 | super.end(chunk, cb); 176 | } 177 | return this; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/utils/parsing/jsonParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Provides a utility class for parsing potentially partial JSON strings. 3 | * It wraps the 'partial-json' npm library and includes functionality to handle 4 | * optional ... blocks often found at the beginning of LLM outputs. 5 | * @module src/utils/parsing/jsonParser 6 | */ 7 | import { 8 | parse as parsePartialJson, 9 | Allow as PartialJsonAllow, 10 | } from "partial-json"; 11 | import { BaseErrorCode, McpError } from "../../types-global/errors.js"; 12 | import { logger, RequestContext, requestContextService } from "../index.js"; 13 | 14 | /** 15 | * Enum mirroring `partial-json`'s `Allow` constants. These specify 16 | * what types of partial JSON structures are permissible during parsing. 17 | * They can be combined using bitwise OR (e.g., `Allow.STR | Allow.OBJ`). 18 | * 19 | * The available properties are: 20 | * - `STR`: Allow partial string. 21 | * - `NUM`: Allow partial number. 22 | * - `ARR`: Allow partial array. 23 | * - `OBJ`: Allow partial object. 24 | * - `NULL`: Allow partial null. 25 | * - `BOOL`: Allow partial boolean. 26 | * - `NAN`: Allow partial NaN. (Note: Standard JSON does not support NaN) 27 | * - `INFINITY`: Allow partial Infinity. (Note: Standard JSON does not support Infinity) 28 | * - `_INFINITY`: Allow partial -Infinity. (Note: Standard JSON does not support -Infinity) 29 | * - `INF`: Allow both partial Infinity and -Infinity. 30 | * - `SPECIAL`: Allow all special values (NaN, Infinity, -Infinity). 31 | * - `ATOM`: Allow all atomic values (strings, numbers, booleans, null, special values). 32 | * - `COLLECTION`: Allow all collection values (objects, arrays). 33 | * - `ALL`: Allow all value types to be partial (default for `partial-json`'s parse). 34 | * @see {@link https://github.com/promplate/partial-json-parser-js} for more details. 35 | */ 36 | export const Allow = PartialJsonAllow; 37 | 38 | /** 39 | * Regular expression to find a block at the start of a string. 40 | * Captures content within ... (Group 1) and the rest of the string (Group 2). 41 | * @private 42 | */ 43 | const thinkBlockRegex = /^([\s\S]*?)<\/think>\s*([\s\S]*)$/; 44 | 45 | /** 46 | * Utility class for parsing potentially partial JSON strings. 47 | * Wraps the 'partial-json' library for robust JSON parsing, handling 48 | * incomplete structures and optional blocks from LLMs. 49 | */ 50 | export class JsonParser { 51 | /** 52 | * Parses a JSON string, which may be partial or prefixed with a block. 53 | * If a block is present, its content is logged, and parsing proceeds on the 54 | * remainder. Uses 'partial-json' to handle incomplete JSON. 55 | * 56 | * @template T The expected type of the parsed JSON object. Defaults to `any`. 57 | * @param jsonString - The JSON string to parse. 58 | * @param allowPartial - Bitwise OR combination of `Allow` constants specifying permissible 59 | * partial JSON types. Defaults to `Allow.ALL`. 60 | * @param context - Optional `RequestContext` for logging and error correlation. 61 | * @returns The parsed JavaScript value. 62 | * @throws {McpError} If the string is empty after processing or if `partial-json` fails. 63 | */ 64 | parse( 65 | jsonString: string, 66 | allowPartial: number = Allow.ALL, 67 | context?: RequestContext, 68 | ): T { 69 | let stringToParse = jsonString; 70 | const match = jsonString.match(thinkBlockRegex); 71 | 72 | if (match) { 73 | const thinkContent = match[1]?.trim() ?? ""; 74 | const restOfString = match[2] ?? ""; 75 | 76 | const logContext = 77 | context || 78 | requestContextService.createRequestContext({ 79 | operation: "JsonParser.thinkBlock", 80 | }); 81 | if (thinkContent) { 82 | logger.debug("LLM block detected and logged.", { 83 | ...logContext, 84 | thinkContent, 85 | }); 86 | } else { 87 | logger.debug("Empty LLM block detected.", logContext); 88 | } 89 | stringToParse = restOfString; 90 | } 91 | 92 | stringToParse = stringToParse.trim(); 93 | 94 | if (!stringToParse) { 95 | throw new McpError( 96 | BaseErrorCode.VALIDATION_ERROR, 97 | "JSON string is empty after removing block and trimming.", 98 | context, 99 | ); 100 | } 101 | 102 | try { 103 | return parsePartialJson(stringToParse, allowPartial) as T; 104 | } catch (e: unknown) { 105 | const error = e as Error; 106 | const errorLogContext = 107 | context || 108 | requestContextService.createRequestContext({ 109 | operation: "JsonParser.parseError", 110 | }); 111 | logger.error("Failed to parse JSON content.", { 112 | ...errorLogContext, 113 | errorDetails: error.message, 114 | contentAttempted: stringToParse.substring(0, 200), 115 | }); 116 | 117 | throw new McpError( 118 | BaseErrorCode.VALIDATION_ERROR, 119 | `Failed to parse JSON: ${error.message}`, 120 | { 121 | ...context, 122 | originalContentSample: 123 | stringToParse.substring(0, 200) + 124 | (stringToParse.length > 200 ? "..." : ""), 125 | rawError: error instanceof Error ? error.stack : String(error), 126 | }, 127 | ); 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Singleton instance of the `JsonParser`. 134 | * Use this instance to parse JSON strings, with support for partial JSON and blocks. 135 | * @example 136 | * ```typescript 137 | * import { jsonParser, Allow, requestContextService } from './utils'; 138 | * const context = requestContextService.createRequestContext({ operation: 'TestJsonParsing' }); 139 | * 140 | * const fullJson = '{"key": "value"}'; 141 | * const parsedFull = jsonParser.parse(fullJson, Allow.ALL, context); 142 | * console.log(parsedFull); // Output: { key: 'value' } 143 | * 144 | * const partialObject = 'This is a thought.{"key": "value", "arr": [1,'; 145 | * try { 146 | * const parsedPartial = jsonParser.parse(partialObject, undefined, context); 147 | * console.log(parsedPartial); 148 | * } catch (e) { 149 | * console.error("Parsing partial object failed:", e); 150 | * } 151 | * ``` 152 | */ 153 | export const jsonParser = new JsonParser(); 154 | -------------------------------------------------------------------------------- /src/services/NCBI/core/ncbiCoreApiClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Core client for making HTTP requests to NCBI E-utilities. 3 | * Handles request construction, API key injection, retries, and basic error handling. 4 | * @module src/services/NCBI/core/ncbiCoreApiClient 5 | */ 6 | 7 | import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; 8 | import { config } from "../../../config/index.js"; 9 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; 10 | import { 11 | logger, 12 | RequestContext, 13 | requestContextService, 14 | sanitizeInputForLogging, 15 | } from "../../../utils/index.js"; 16 | import { 17 | NCBI_EUTILS_BASE_URL, 18 | NcbiRequestParams, 19 | NcbiRequestOptions, 20 | } from "./ncbiConstants.js"; 21 | 22 | export class NcbiCoreApiClient { 23 | private axiosInstance: AxiosInstance; 24 | 25 | constructor() { 26 | this.axiosInstance = axios.create({ 27 | timeout: 30000, // 30 seconds timeout for NCBI requests 28 | }); 29 | } 30 | 31 | /** 32 | * Makes an HTTP request to the specified NCBI E-utility endpoint. 33 | * Handles parameter assembly, API key injection, GET/POST selection, and retries. 34 | * @param endpoint The E-utility endpoint (e.g., "esearch", "efetch"). 35 | * @param params The parameters for the E-utility. 36 | * @param context The request context for logging. 37 | * @param options Options for the request, like retmode and whether to use POST. 38 | * @param retries The current retry attempt number. 39 | * @returns A Promise resolving to the raw AxiosResponse. 40 | * @throws {McpError} If the request fails after all retries or an unexpected error occurs. 41 | */ 42 | public async makeRequest( 43 | endpoint: string, 44 | params: NcbiRequestParams, 45 | context: RequestContext, 46 | options: NcbiRequestOptions = {}, 47 | ): Promise { 48 | const rawParams: Record = { 49 | tool: config.ncbiToolIdentifier, 50 | email: config.ncbiAdminEmail, 51 | api_key: config.ncbiApiKey, 52 | ...params, 53 | }; 54 | 55 | const finalParams: Record = {}; 56 | for (const key in rawParams) { 57 | if (Object.prototype.hasOwnProperty.call(rawParams, key)) { 58 | const value = rawParams[key]; 59 | if (value !== undefined && value !== null) { 60 | finalParams[key] = String(value); 61 | } 62 | } 63 | } 64 | 65 | const requestConfig: AxiosRequestConfig = { 66 | method: options.usePost ? "POST" : "GET", 67 | url: `${NCBI_EUTILS_BASE_URL}/${endpoint}.fcgi`, 68 | }; 69 | 70 | if (options.usePost) { 71 | requestConfig.data = new URLSearchParams(finalParams).toString(); 72 | requestConfig.headers = { 73 | "Content-Type": "application/x-www-form-urlencoded", 74 | }; 75 | } else { 76 | requestConfig.params = finalParams; 77 | } 78 | 79 | for (let attempt = 0; attempt <= config.ncbiMaxRetries; attempt++) { 80 | try { 81 | logger.debug( 82 | `Making NCBI HTTP request: ${requestConfig.method} ${requestConfig.url}`, 83 | requestContextService.createRequestContext({ 84 | ...context, 85 | operation: "NCBI_HttpRequest", 86 | endpoint, 87 | method: requestConfig.method, 88 | requestParams: sanitizeInputForLogging(finalParams), 89 | attempt: attempt + 1, 90 | }), 91 | ); 92 | const response: AxiosResponse = await this.axiosInstance(requestConfig); 93 | return response; 94 | } catch (error: unknown) { 95 | const err = error as Error; 96 | if (attempt < config.ncbiMaxRetries) { 97 | const retryDelay = Math.pow(2, attempt) * 200; 98 | logger.warning( 99 | `NCBI request to ${endpoint} failed. Retrying (${attempt + 1}/${config.ncbiMaxRetries}) in ${retryDelay}ms...`, 100 | requestContextService.createRequestContext({ 101 | ...context, 102 | operation: "NCBI_HttpRequestRetry", 103 | endpoint, 104 | error: err.message, 105 | retryCount: attempt + 1, 106 | maxRetries: config.ncbiMaxRetries, 107 | delay: retryDelay, 108 | }), 109 | ); 110 | await new Promise((r) => setTimeout(r, retryDelay)); 111 | continue; // Continue to the next iteration of the loop 112 | } 113 | 114 | // If all retries are exhausted, handle the final error 115 | if (axios.isAxiosError(error)) { 116 | logger.error( 117 | `Axios error during NCBI request to ${endpoint} after ${attempt} retries`, 118 | error, 119 | requestContextService.createRequestContext({ 120 | ...context, 121 | operation: "NCBI_AxiosError", 122 | endpoint, 123 | status: error.response?.status, 124 | responseData: sanitizeInputForLogging(error.response?.data), 125 | }), 126 | ); 127 | throw new McpError( 128 | BaseErrorCode.NCBI_SERVICE_UNAVAILABLE, 129 | `NCBI request failed: ${error.message}`, 130 | { 131 | endpoint, 132 | status: error.response?.status, 133 | details: error.response?.data 134 | ? String(error.response.data).substring(0, 500) 135 | : undefined, 136 | }, 137 | ); 138 | } 139 | if (error instanceof McpError) throw error; 140 | 141 | logger.error( 142 | `Unexpected error during NCBI request to ${endpoint} after ${attempt} retries`, 143 | err, 144 | requestContextService.createRequestContext({ 145 | ...context, 146 | operation: "NCBI_UnexpectedError", 147 | endpoint, 148 | errorMessage: err.message, 149 | }), 150 | ); 151 | throw new McpError( 152 | BaseErrorCode.INTERNAL_ERROR, 153 | `Unexpected error communicating with NCBI: ${err.message}`, 154 | { endpoint }, 155 | ); 156 | } 157 | } 158 | // This line should theoretically be unreachable, but it satisfies TypeScript's need 159 | // for a return path if the loop completes without returning or throwing. 160 | throw new McpError( 161 | BaseErrorCode.INTERNAL_ERROR, 162 | "Request failed after all retries.", 163 | { endpoint }, 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/mcp-server/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Main entry point for the MCP (Model Context Protocol) server. 3 | * This file orchestrates the server's lifecycle: 4 | * 1. Initializes the core `McpServer` instance (from `@modelcontextprotocol/sdk`) with its identity and capabilities. 5 | * 2. Registers available resources and tools, making them discoverable and usable by clients. 6 | * 3. Selects and starts the appropriate communication transport (stdio or Streamable HTTP) 7 | * based on configuration. 8 | * 4. Handles top-level error management during startup. 9 | * 10 | * @module src/mcp-server/server 11 | */ 12 | 13 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 14 | import http from "http"; 15 | import { config, environment } from "../config/index.js"; 16 | import { ErrorHandler, logger, requestContextService } from "../utils/index.js"; 17 | import { registerPubMedArticleConnectionsTool } from "./tools/pubmedArticleConnections/index.js"; 18 | import { registerPubMedFetchContentsTool } from "./tools/pubmedFetchContents/index.js"; 19 | import { registerPubMedGenerateChartTool } from "./tools/pubmedGenerateChart/index.js"; 20 | import { registerPubMedResearchAgentTool } from "./tools/pubmedResearchAgent/index.js"; 21 | import { registerPubMedSearchArticlesTool } from "./tools/pubmedSearchArticles/index.js"; 22 | import { startHttpTransport } from "./transports/http/index.js"; 23 | import { startStdioTransport } from "./transports/stdio/index.js"; 24 | 25 | type SdkToolSpec = Parameters[1]; 26 | type ServerIdentity = ConstructorParameters[0]; 27 | type McpServerOptions = NonNullable[1]>; 28 | 29 | export interface DescribedTool extends SdkToolSpec { 30 | title: string; 31 | } 32 | 33 | export interface ServerInstanceInfo { 34 | server: McpServer; 35 | tools: DescribedTool[]; 36 | identity: ServerIdentity; 37 | options: McpServerOptions; 38 | } 39 | 40 | /** 41 | * Creates and configures a new instance of the `McpServer`. 42 | * 43 | * @returns A promise resolving with the configured `McpServer` instance and its tool metadata. 44 | * @throws {McpError} If any resource or tool registration fails. 45 | * @private 46 | */ 47 | async function createMcpServerInstance(): Promise { 48 | const context = requestContextService.createRequestContext({ 49 | operation: "createMcpServerInstance", 50 | }); 51 | logger.info("Initializing MCP server instance", context); 52 | 53 | requestContextService.configure({ 54 | appName: config.mcpServerName, 55 | appVersion: config.mcpServerVersion, 56 | environment, 57 | }); 58 | 59 | const identity: ServerIdentity = { 60 | name: config.mcpServerName, 61 | version: config.mcpServerVersion, 62 | description: config.mcpServerDescription, 63 | }; 64 | 65 | const options: McpServerOptions = { 66 | capabilities: { 67 | logging: {}, 68 | resources: { listChanged: true }, 69 | tools: { listChanged: true }, 70 | }, 71 | }; 72 | 73 | const server = new McpServer(identity, options); 74 | 75 | const registeredTools: DescribedTool[] = []; 76 | const originalRegisterTool = server.registerTool.bind(server); 77 | server.registerTool = (name, spec, implementation) => { 78 | registeredTools.push({ 79 | title: name, 80 | description: spec.description, 81 | inputSchema: spec.inputSchema, 82 | outputSchema: spec.outputSchema, 83 | annotations: spec.annotations, 84 | }); 85 | return originalRegisterTool(name, spec, implementation); 86 | }; 87 | 88 | try { 89 | logger.debug("Registering resources and tools...", context); 90 | // IMPORTANT: Keep tool registrations in alphabetical order. 91 | await registerPubMedArticleConnectionsTool(server); 92 | await registerPubMedFetchContentsTool(server); 93 | await registerPubMedGenerateChartTool(server); 94 | await registerPubMedResearchAgentTool(server); 95 | await registerPubMedSearchArticlesTool(server); 96 | logger.info("Resources and tools registered successfully", context); 97 | } catch (err) { 98 | logger.error("Failed to register resources/tools", { 99 | ...context, 100 | error: err instanceof Error ? err.message : String(err), 101 | stack: err instanceof Error ? err.stack : undefined, 102 | }); 103 | throw err; 104 | } 105 | 106 | return { server, tools: registeredTools, identity, options }; 107 | } 108 | 109 | /** 110 | * Selects, sets up, and starts the appropriate MCP transport layer based on configuration. 111 | * 112 | * @returns Resolves with `McpServer` for 'stdio' or `http.Server` for 'http'. 113 | * @throws {Error} If transport type is unsupported or setup fails. 114 | * @private 115 | */ 116 | async function startTransport(): Promise { 117 | const transportType = config.mcpTransportType; 118 | const context = requestContextService.createRequestContext({ 119 | operation: "startTransport", 120 | transport: transportType, 121 | }); 122 | logger.info(`Starting transport: ${transportType}`, context); 123 | 124 | if (transportType === "http") { 125 | const { server } = await startHttpTransport( 126 | createMcpServerInstance, 127 | context, 128 | ); 129 | return server as http.Server; 130 | } 131 | 132 | if (transportType === "stdio") { 133 | const { server } = await createMcpServerInstance(); 134 | await startStdioTransport(server, context); 135 | return server; 136 | } 137 | 138 | logger.fatal( 139 | `Unsupported transport type configured: ${transportType}`, 140 | context, 141 | ); 142 | throw new Error( 143 | `Unsupported transport type: ${transportType}. Must be 'stdio' or 'http'.`, 144 | ); 145 | } 146 | 147 | /** 148 | * Main application entry point. Initializes and starts the MCP server. 149 | */ 150 | export async function initializeAndStartServer(): Promise< 151 | McpServer | http.Server 152 | > { 153 | const context = requestContextService.createRequestContext({ 154 | operation: "initializeAndStartServer", 155 | }); 156 | logger.info("MCP Server initialization sequence started.", context); 157 | try { 158 | const result = await startTransport(); 159 | logger.info( 160 | "MCP Server initialization sequence completed successfully.", 161 | context, 162 | ); 163 | return result; 164 | } catch (err) { 165 | logger.fatal("Critical error during MCP server initialization.", { 166 | ...context, 167 | error: err instanceof Error ? err.message : String(err), 168 | stack: err instanceof Error ? err.stack : undefined, 169 | }); 170 | ErrorHandler.handleError(err, { 171 | ...context, 172 | operation: "initializeAndStartServer_Catch", 173 | critical: true, 174 | }); 175 | logger.info( 176 | "Exiting process due to critical initialization error.", 177 | context, 178 | ); 179 | process.exit(1); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/types-global/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Defines standardized error codes, a custom error class, and related schemas 3 | * for handling errors within the Model Context Protocol (MCP) server and its components. 4 | * This module provides a structured way to represent and communicate errors, ensuring 5 | * consistency and clarity for both server-side operations and client-side error handling. 6 | * @module src/types-global/errors 7 | */ 8 | 9 | import { z } from "zod"; 10 | 11 | /** 12 | * Defines a comprehensive set of standardized error codes for common issues encountered 13 | * within MCP servers, tools, or related operations. These codes are designed to help 14 | * clients and developers programmatically understand the nature of an error, facilitating 15 | * more precise error handling and debugging. 16 | */ 17 | export enum BaseErrorCode { 18 | /** Access denied due to invalid credentials or lack of authentication. */ 19 | UNAUTHORIZED = "UNAUTHORIZED", 20 | /** Access denied despite valid authentication, due to insufficient permissions. */ 21 | FORBIDDEN = "FORBIDDEN", 22 | /** The requested resource or entity could not be found. */ 23 | NOT_FOUND = "NOT_FOUND", 24 | /** The request could not be completed due to a conflict with the current state of the resource. */ 25 | CONFLICT = "CONFLICT", 26 | /** The request failed due to invalid input parameters or data. */ 27 | VALIDATION_ERROR = "VALIDATION_ERROR", 28 | /** The provided input is invalid, but not necessarily a schema validation failure. */ 29 | INVALID_INPUT = "INVALID_INPUT", 30 | /** An error occurred while parsing input data (e.g., date string, JSON). */ 31 | PARSING_ERROR = "PARSING_ERROR", 32 | /** The request was rejected because the client has exceeded rate limits. */ 33 | RATE_LIMITED = "RATE_LIMITED", 34 | /** The request timed out before a response could be generated. */ 35 | TIMEOUT = "TIMEOUT", 36 | /** The service is temporarily unavailable, possibly due to maintenance or overload. */ 37 | SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE", 38 | /** An unexpected error occurred on the server side. */ 39 | INTERNAL_ERROR = "INTERNAL_ERROR", 40 | /** An error occurred, but the specific cause is unknown or cannot be categorized. */ 41 | UNKNOWN_ERROR = "UNKNOWN_ERROR", 42 | /** An error occurred during the loading or validation of configuration data. */ 43 | CONFIGURATION_ERROR = "CONFIGURATION_ERROR", 44 | /** An error occurred during the initialization phase of a service or module. */ 45 | INITIALIZATION_FAILED = "INITIALIZATION_FAILED", 46 | 47 | // NCBI Specific Errors 48 | /** An error was returned by the NCBI E-utilities API. */ 49 | NCBI_API_ERROR = "NCBI_API_ERROR", 50 | /** An error occurred while parsing a response from NCBI (e.g., XML, JSON). */ 51 | NCBI_PARSING_ERROR = "NCBI_PARSING_ERROR", 52 | /** A warning or notice related to NCBI rate limits. */ 53 | NCBI_RATE_LIMIT_WARNING = "NCBI_RATE_LIMIT_WARNING", 54 | /** An error related to the construction or validity of an NCBI E-utility query. */ 55 | NCBI_QUERY_ERROR = "NCBI_QUERY_ERROR", 56 | /** NCBI service temporarily unavailable or returned a server-side error. */ 57 | NCBI_SERVICE_UNAVAILABLE = "NCBI_SERVICE_UNAVAILABLE", 58 | } 59 | 60 | /** 61 | * Custom error class for MCP-specific errors, extending the built-in `Error` class. 62 | * It standardizes error reporting by encapsulating a `BaseErrorCode`, a descriptive 63 | * human-readable message, and optional structured details for more context. 64 | * 65 | * This class is central to error handling within the MCP framework, allowing for 66 | * consistent error creation and propagation. 67 | */ 68 | export class McpError extends Error { 69 | /** 70 | * The standardized error code from {@link BaseErrorCode}. 71 | */ 72 | public readonly code: BaseErrorCode; 73 | 74 | /** 75 | * Optional additional details or context about the error. 76 | * This can be any structured data that helps in understanding or debugging the error. 77 | */ 78 | public readonly details?: Record; 79 | 80 | /** 81 | * Creates an instance of McpError. 82 | * 83 | * @param code - The standardized error code that categorizes the error. 84 | * @param message - A human-readable description of the error. 85 | * @param details - Optional. A record containing additional structured details about the error. 86 | */ 87 | constructor( 88 | code: BaseErrorCode, 89 | message: string, 90 | details?: Record, 91 | ) { 92 | super(message); 93 | 94 | this.code = code; 95 | this.details = details; 96 | this.name = "McpError"; 97 | 98 | // Maintain a proper prototype chain. 99 | Object.setPrototypeOf(this, McpError.prototype); 100 | 101 | // Capture the stack trace, excluding the constructor call from it, if available. 102 | if (Error.captureStackTrace) { 103 | Error.captureStackTrace(this, McpError); 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Zod schema for validating error objects. This schema can be used for: 110 | * - Validating error structures when parsing error responses from external services. 111 | * - Ensuring consistency when creating or handling error objects internally. 112 | * - Generating TypeScript types for error objects. 113 | * 114 | * The schema enforces the presence of a `code` (from {@link BaseErrorCode}) and a `message`, 115 | * and allows for optional `details`. 116 | */ 117 | export const ErrorSchema = z 118 | .object({ 119 | /** 120 | * The error code, corresponding to one of the {@link BaseErrorCode} enum values. 121 | * This field is required and helps in programmatically identifying the error type. 122 | */ 123 | code: z 124 | .nativeEnum(BaseErrorCode) 125 | .describe("Standardized error code from BaseErrorCode enum"), 126 | /** 127 | * A human-readable, descriptive message explaining the error. 128 | * This field is required and provides context to developers or users. 129 | */ 130 | message: z 131 | .string() 132 | .min(1, "Error message cannot be empty.") 133 | .describe("Detailed human-readable error message"), 134 | /** 135 | * Optional. A record containing additional structured details or context about the error. 136 | * This can include things like invalid field names, specific values that caused issues, or other relevant data. 137 | */ 138 | details: z 139 | .record(z.unknown()) 140 | .optional() 141 | .describe( 142 | "Optional structured details providing more context about the error", 143 | ), 144 | }) 145 | .describe( 146 | "Schema for validating structured error objects, ensuring consistency in error reporting.", 147 | ); 148 | 149 | /** 150 | * TypeScript type inferred from the {@link ErrorSchema}. 151 | * This type represents the structure of a validated error object, commonly used 152 | * for error responses or when passing error information within the application. 153 | */ 154 | export type ErrorResponse = z.infer; 155 | --------------------------------------------------------------------------------