@cyanheads/pubmed-mcp-server 1.1.1 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -16
- package/dist/config/index.d.ts +4 -2
- package/dist/config/index.js +52 -7
- package/dist/mcp-server/tools/fetchPubMedContent/logic.js +59 -160
- package/dist/mcp-server/tools/fetchPubMedContent/registration.d.ts +1 -1
- package/dist/mcp-server/tools/fetchPubMedContent/registration.js +11 -15
- package/dist/mcp-server/tools/generatePubMedChart/logic.js +21 -22
- package/dist/mcp-server/tools/generatePubMedChart/registration.d.ts +1 -1
- package/dist/mcp-server/tools/generatePubMedChart/registration.js +10 -19
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +3 -3
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.js +9 -6
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.d.ts +1 -2
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.js +16 -26
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/planOrchestrator.js +1 -3
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.d.ts +1 -2
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.js +7 -6
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.d.ts +1 -1
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.js +9 -14
- package/dist/mcp-server/tools/searchPubMedArticles/logic.js +9 -4
- package/dist/mcp-server/tools/searchPubMedArticles/registration.d.ts +1 -1
- package/dist/mcp-server/tools/searchPubMedArticles/registration.js +13 -20
- package/dist/mcp-server/transports/httpTransport.js +9 -0
- package/dist/utils/internal/logger.js +49 -51
- package/dist/utils/security/rateLimiter.d.ts +4 -0
- package/dist/utils/security/rateLimiter.js +4 -0
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://modelcontextprotocol.io/)
|
|
5
|
-
[](./CHANGELOG.md)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/pubmed-mcp-server/issues)
|
|
8
8
|
[](https://github.com/cyanheads/pubmed-mcp-server)
|
|
@@ -17,13 +17,13 @@ Built on the [`cyanheads/mcp-ts-template`](https://github.com/cyanheads/mcp-ts-t
|
|
|
17
17
|
|
|
18
18
|
This server equips your AI with specialized tools to interact with PubMed:
|
|
19
19
|
|
|
20
|
-
| Tool Name | Description
|
|
21
|
-
| :-------------------------------------------------------------------------------------- |
|
|
22
|
-
| [`search_pubmed_articles`](./src/mcp-server/tools/searchPubMedArticles/) | Searches PubMed for articles based on your query. (See [Example](./examples/search_pubmed_articles_example.md))
|
|
23
|
-
| [`fetch_pubmed_content`](./src/mcp-server/tools/fetchPubMedContent/) | Retrieves detailed information for PubMed articles. Can use a list of PMIDs or ESearch history (queryKey/webEnv) with pagination. (See [Example](./examples/fetch_pubmed_content_example.md))
|
|
24
|
-
| [`get_pubmed_article_connections`](./src/mcp-server/tools/getPubMedArticleConnections/) | Finds related articles (cited by, similar, references) or formats citations for a PMID. (See [Ex.1](./examples/get_pubmed_article_connections_1.md), [Ex.2](./examples/get_pubmed_article_connections_2.md))
|
|
25
|
-
| [`pubmed_research_agent`](./src/mcp-server/tools/pubmedResearchAgent/) | Generates a standardized JSON research plan outline from component details. (See [Example](./examples/pubmed_research_agent_example.md))
|
|
26
|
-
| [`generate_pubmed_chart`](./src/mcp-server/tools/generatePubMedChart/) | Generates a chart image (PNG) from given input data. (See [
|
|
20
|
+
| Tool Name | Description | Key Features |
|
|
21
|
+
| :-------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| [`search_pubmed_articles`](./src/mcp-server/tools/searchPubMedArticles/) | Searches PubMed for articles based on your query. (See [Example](./examples/search_pubmed_articles_example.md)) | - Filter by max results, sort order, date range, publication types.<br/>- Uses NCBI ESearch for PMIDs.<br/>- Optionally fetches brief summaries (title, authors, source, dates) via ESummary. |
|
|
23
|
+
| [`fetch_pubmed_content`](./src/mcp-server/tools/fetchPubMedContent/) | Retrieves detailed information for PubMed articles. Can use a list of PMIDs or ESearch history (queryKey/webEnv) with pagination. (See [Example](./examples/fetch_pubmed_content_example.md)) | - Flexible `detailLevel`: `abstract_plus` (parsed details, optional MeSH/grant), `full_xml` (JSON representation of the PubMedArticle XML structure), `medline_text` (MEDLINE format), `citation_data` (minimal for citations).<br/>- Supports direct PMID list or `queryKey`/`webEnv` from ESearch history.<br/>- Supports `retstart`/`retmax` for pagination with history.<br/>- Uses NCBI EFetch. |
|
|
24
|
+
| [`get_pubmed_article_connections`](./src/mcp-server/tools/getPubMedArticleConnections/) | Finds related articles (cited by, similar, references) or formats citations for a PMID. (See [Ex.1](./examples/get_pubmed_article_connections_1.md), [Ex.2](./examples/get_pubmed_article_connections_2.md)) | - Uses NCBI ELink for relationships.<br/>- Uses NCBI EFetch for citation data (RIS, BibTeX, APA, MLA).<br/>- Filter by max related results. |
|
|
25
|
+
| [`pubmed_research_agent`](./src/mcp-server/tools/pubmedResearchAgent/) | Generates a standardized JSON research plan outline from component details. (See [Example](./examples/pubmed_research_agent_example.md)) | - Accepts granular inputs for all research phases.<br/>- Optionally embeds instructive prompts for agent execution.<br/>- Structures rough ideas into a formal, machine-readable plan for further processing. |
|
|
26
|
+
| [`generate_pubmed_chart`](./src/mcp-server/tools/generatePubMedChart/) | Generates a chart image (PNG) from given input data. (See [Examples](./examples/)) | - Supports 'bar', 'line', and 'scatter' chart types.<br/>- Takes data values and field specifications for axes and encoding.<br/>- Constructs a Vega-Lite specification internally and renders it as a PNG using a canvas renderer. |
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
@@ -56,14 +56,15 @@ Built on the robust `mcp-ts-template`, this server provides a standardized, secu
|
|
|
56
56
|
|
|
57
57
|
Leverages the robust utilities provided by the `mcp-ts-template`:
|
|
58
58
|
|
|
59
|
-
- **Logging**: Structured, configurable logging (file rotation,
|
|
59
|
+
- **Logging**: Structured, configurable logging (file rotation, stdout JSON, MCP notifications) with sensitive data redaction.
|
|
60
60
|
- **Error Handling**: Centralized error processing, standardized error types (`McpError`), and automatic logging.
|
|
61
61
|
- **Configuration**: Environment variable loading (`dotenv`) with comprehensive validation.
|
|
62
62
|
- **Input Validation/Sanitization**: Uses `zod` for schema validation and custom sanitization logic.
|
|
63
|
-
- **Request Context**: Tracking and correlation of operations via unique request IDs
|
|
63
|
+
- **Request Context**: Tracking and correlation of operations via unique request IDs using `AsyncLocalStorage`.
|
|
64
64
|
- **Type Safety**: Strong typing enforced by TypeScript and Zod schemas.
|
|
65
|
-
- **HTTP Transport
|
|
66
|
-
- **
|
|
65
|
+
- **HTTP Transport**: High-performance HTTP server using **Hono**, featuring session management with garbage collection, CORS, and IP-based rate limiting.
|
|
66
|
+
- **Authentication**: Robust authentication layer supporting JWT and OAuth 2.1, with fine-grained scope enforcement.
|
|
67
|
+
- **Deployment**: Multi-stage `Dockerfile` for creating small, secure production images with native dependency support.
|
|
67
68
|
|
|
68
69
|
### PubMed Integration
|
|
69
70
|
|
|
@@ -82,6 +83,7 @@ Leverages the robust utilities provided by the `mcp-ts-template`:
|
|
|
82
83
|
|
|
83
84
|
- [Node.js (>=18.0.0)](https://nodejs.org/)
|
|
84
85
|
- [npm](https://www.npmjs.com/) (comes with Node.js)
|
|
86
|
+
- [Docker](https://www.docker.com/) (optional, for containerized deployment)
|
|
85
87
|
- **NCBI API Key** (recommended for higher rate limits) - [Get one here](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/)
|
|
86
88
|
|
|
87
89
|
### Install via npm (recommended)
|
|
@@ -124,10 +126,12 @@ Configure the server using environment variables. These environmental variables
|
|
|
124
126
|
| `MCP_HTTP_HOST` | Host address for the HTTP server (if `MCP_TRANSPORT_TYPE=http`). | `127.0.0.1` |
|
|
125
127
|
| `MCP_ALLOWED_ORIGINS` | Comma-separated list of allowed origins for CORS (if `MCP_TRANSPORT_TYPE=http`). | (none) |
|
|
126
128
|
| `MCP_LOG_LEVEL` | Logging level (`debug`, `info`, `notice`, `warning`, `error`, `crit`, `alert`, `emerg`). | `debug` |
|
|
127
|
-
| `
|
|
129
|
+
| `LOG_OUTPUT_MODE` | Logging output mode: `file` or `stdout`. | `file` |
|
|
130
|
+
| `MCP_AUTH_MODE` | Authentication mode for HTTP: `jwt` or `oauth`. | `jwt` |
|
|
131
|
+
| `MCP_AUTH_SECRET_KEY` | **Required for `jwt` auth.** Minimum 32-character secret key for JWT authentication. | (none) |
|
|
128
132
|
| `NCBI_API_KEY` | **Recommended.** Your NCBI API Key for higher rate limits and reliable access. | (none) |
|
|
129
133
|
| `NCBI_TOOL_IDENTIFIER` | Tool identifier for NCBI E-utility requests. | `@cyanheads/pubmed-mcp-server` |
|
|
130
|
-
| `LOGS_DIR` | Directory for log file storage.
|
|
134
|
+
| `LOGS_DIR` | Directory for log file storage (if `LOG_OUTPUT_MODE=file`). | `logs/` |
|
|
131
135
|
|
|
132
136
|
### MCP Client Settings
|
|
133
137
|
|
|
@@ -166,8 +170,7 @@ src/
|
|
|
166
170
|
│ ├── resources/ # MCP Resource implementations
|
|
167
171
|
│ └── tools/ # MCP Tool implementations (subdirs per tool)
|
|
168
172
|
├── services/ # External service integrations
|
|
169
|
-
│
|
|
170
|
-
│ └── llm-providers/ # LLM provider integrations (optional)
|
|
173
|
+
│ └── NCBI/ # NCBI E-utilities API client and parsing
|
|
171
174
|
├── types-global/ # Shared TypeScript type definitions
|
|
172
175
|
└── utils/ # Common utility functions (logger, error handler, etc.)
|
|
173
176
|
```
|
package/dist/config/index.d.ts
CHANGED
|
@@ -25,8 +25,10 @@ export declare const config: {
|
|
|
25
25
|
mcpServerVersion: string;
|
|
26
26
|
/** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
|
|
27
27
|
logLevel: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/** Defines the logging output mode ('file' or 'stdout'). From `LOG_OUTPUT_MODE`. */
|
|
29
|
+
logOutputMode: "file" | "stdout";
|
|
30
|
+
/** Absolute path to the logs directory (if logOutputMode is 'file'). From `LOGS_DIR`. */
|
|
31
|
+
logsPath: string | null;
|
|
30
32
|
/** Runtime environment. From `NODE_ENV` env var. Default: "development". */
|
|
31
33
|
environment: string;
|
|
32
34
|
/** MCP transport type ('stdio' or 'http'). From `MCP_TRANSPORT_TYPE` env var. Default: "stdio". */
|
package/dist/config/index.js
CHANGED
|
@@ -71,7 +71,8 @@ catch (error) {
|
|
|
71
71
|
* Provides type safety, validation, defaults, and clear error messages.
|
|
72
72
|
* @private
|
|
73
73
|
*/
|
|
74
|
-
const EnvSchema = z
|
|
74
|
+
const EnvSchema = z
|
|
75
|
+
.object({
|
|
75
76
|
/** Optional. The desired name for the MCP server. Defaults to `package.json` name. */
|
|
76
77
|
MCP_SERVER_NAME: z.string().optional(),
|
|
77
78
|
/** Optional. The version of the MCP server. Defaults to `package.json` version. */
|
|
@@ -80,6 +81,8 @@ const EnvSchema = z.object({
|
|
|
80
81
|
MCP_LOG_LEVEL: z.string().default("debug"),
|
|
81
82
|
/** Directory for log files. Defaults to "logs" in project root. */
|
|
82
83
|
LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
|
|
84
|
+
/** Defines the logging output mode. "file" for logs in LOGS_DIR, "stdout" for console logging. */
|
|
85
|
+
LOG_OUTPUT_MODE: z.enum(["file", "stdout"]).default("file"),
|
|
83
86
|
/** Runtime environment (e.g., "development", "production"). Default: "development". */
|
|
84
87
|
NODE_ENV: z.string().default("development"),
|
|
85
88
|
/** MCP communication transport ("stdio" or "http"). Default: "stdio". */
|
|
@@ -144,6 +147,43 @@ const EnvSchema = z.object({
|
|
|
144
147
|
NCBI_REQUEST_DELAY_MS: z.coerce.number().int().positive().optional(), // Default will be set conditionally
|
|
145
148
|
/** Maximum number of retries for failed NCBI requests. Default: 3. */
|
|
146
149
|
NCBI_MAX_RETRIES: z.coerce.number().int().nonnegative().default(3),
|
|
150
|
+
})
|
|
151
|
+
.superRefine((data, ctx) => {
|
|
152
|
+
// Rule 1: MCP_AUTH_SECRET_KEY is required for http transport in production with jwt auth
|
|
153
|
+
if (data.NODE_ENV === "production" &&
|
|
154
|
+
data.MCP_TRANSPORT_TYPE === "http" &&
|
|
155
|
+
data.MCP_AUTH_MODE === "jwt" &&
|
|
156
|
+
!data.MCP_AUTH_SECRET_KEY) {
|
|
157
|
+
ctx.addIssue({
|
|
158
|
+
code: z.ZodIssueCode.custom,
|
|
159
|
+
path: ["MCP_AUTH_SECRET_KEY"],
|
|
160
|
+
message: "MCP_AUTH_SECRET_KEY is required for 'jwt' auth with 'http' transport in a 'production' environment.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Rule 2: Core OAuth variables are required when MCP_AUTH_MODE is 'oauth'
|
|
164
|
+
if (data.MCP_AUTH_MODE === "oauth") {
|
|
165
|
+
if (!data.OAUTH_ISSUER_URL) {
|
|
166
|
+
ctx.addIssue({
|
|
167
|
+
code: z.ZodIssueCode.custom,
|
|
168
|
+
path: ["OAUTH_ISSUER_URL"],
|
|
169
|
+
message: "OAUTH_ISSUER_URL is required when MCP_AUTH_MODE is 'oauth'.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (!data.OAUTH_AUDIENCE) {
|
|
173
|
+
ctx.addIssue({
|
|
174
|
+
code: z.ZodIssueCode.custom,
|
|
175
|
+
path: ["OAUTH_AUDIENCE"],
|
|
176
|
+
message: "OAUTH_AUDIENCE is required when MCP_AUTH_MODE is 'oauth'.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (!data.OAUTH_JWKS_URI) {
|
|
180
|
+
ctx.addIssue({
|
|
181
|
+
code: z.ZodIssueCode.custom,
|
|
182
|
+
path: ["OAUTH_JWKS_URI"],
|
|
183
|
+
message: "OAUTH_JWKS_URI is required when MCP_AUTH_MODE is 'oauth'.",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
147
187
|
});
|
|
148
188
|
const parsedEnv = EnvSchema.safeParse(process.env);
|
|
149
189
|
if (!parsedEnv.success) {
|
|
@@ -209,12 +249,15 @@ const ensureDirectory = (dirPath, rootDir, dirName) => {
|
|
|
209
249
|
};
|
|
210
250
|
// --- End Directory Ensurance Function ---
|
|
211
251
|
// --- Logs Directory Handling ---
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
252
|
+
let validatedLogsPath = null;
|
|
253
|
+
if (env.LOG_OUTPUT_MODE === "file") {
|
|
254
|
+
validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
|
|
255
|
+
if (!validatedLogsPath) {
|
|
256
|
+
if (process.stdout.isTTY) {
|
|
257
|
+
console.error("FATAL: Log mode is 'file' but logs directory is invalid or could not be created. Please check LOGS_DIR, permissions, and path. Exiting.");
|
|
258
|
+
}
|
|
259
|
+
process.exit(1); // Exit if file logging is configured but directory is not usable
|
|
216
260
|
}
|
|
217
|
-
process.exit(1); // Exit if logs directory is not usable
|
|
218
261
|
}
|
|
219
262
|
// --- End Logs Directory Handling ---
|
|
220
263
|
/**
|
|
@@ -228,7 +271,9 @@ export const config = {
|
|
|
228
271
|
mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
|
|
229
272
|
/** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
|
|
230
273
|
logLevel: env.MCP_LOG_LEVEL,
|
|
231
|
-
/**
|
|
274
|
+
/** Defines the logging output mode ('file' or 'stdout'). From `LOG_OUTPUT_MODE`. */
|
|
275
|
+
logOutputMode: env.LOG_OUTPUT_MODE,
|
|
276
|
+
/** Absolute path to the logs directory (if logOutputMode is 'file'). From `LOGS_DIR`. */
|
|
232
277
|
logsPath: validatedLogsPath,
|
|
233
278
|
/** Runtime environment. From `NODE_ENV` env var. Default: "development". */
|
|
234
279
|
environment: env.NODE_ENV,
|
|
@@ -97,14 +97,12 @@ export const FetchPubMedContentInputSchema = z
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
-
function parsePubMedArticleSet(xmlData,
|
|
101
|
-
input, parentContext) {
|
|
100
|
+
function parsePubMedArticleSet(xmlData, input, parentContext) {
|
|
102
101
|
const articles = [];
|
|
103
102
|
const operationContext = requestContextService.createRequestContext({
|
|
104
103
|
parentRequestId: parentContext.requestId,
|
|
105
104
|
operation: "parsePubMedArticleSet",
|
|
106
105
|
});
|
|
107
|
-
// Type guard for xmlData
|
|
108
106
|
if (!xmlData ||
|
|
109
107
|
typeof xmlData !== "object" ||
|
|
110
108
|
!("PubmedArticleSet" in xmlData)) {
|
|
@@ -115,30 +113,17 @@ input, parentContext) {
|
|
|
115
113
|
});
|
|
116
114
|
return articles;
|
|
117
115
|
}
|
|
118
|
-
const typedXmlData = xmlData;
|
|
116
|
+
const typedXmlData = xmlData;
|
|
119
117
|
const articleSet = typedXmlData.PubmedArticleSet;
|
|
120
118
|
if (!articleSet || !articleSet.PubmedArticle) {
|
|
121
|
-
logger.warning("PubmedArticleSet or PubmedArticle array not found in EFetch XML response.",
|
|
119
|
+
logger.warning("PubmedArticleSet or PubmedArticle array not found in EFetch XML response.", {
|
|
122
120
|
...operationContext,
|
|
123
121
|
xmlDataPreview: sanitizeInputForLogging(JSON.stringify(typedXmlData).substring(0, 200)),
|
|
124
|
-
})
|
|
122
|
+
});
|
|
125
123
|
return articles;
|
|
126
124
|
}
|
|
127
125
|
const pubmedArticlesXml = ensureArray(articleSet.PubmedArticle);
|
|
128
|
-
|
|
129
|
-
...operationContext,
|
|
130
|
-
pubmedArticlesXmlPreview: sanitizeInputForLogging(JSON.stringify(pubmedArticlesXml).substring(0, 500)),
|
|
131
|
-
isPubmedArticlesXmlArray: Array.isArray(pubmedArticlesXml),
|
|
132
|
-
pubmedArticlesXmlLength: Array.isArray(pubmedArticlesXml)
|
|
133
|
-
? pubmedArticlesXml.length
|
|
134
|
-
: undefined,
|
|
135
|
-
});
|
|
136
|
-
if (Array.isArray(pubmedArticlesXml) && pubmedArticlesXml.length > 0) {
|
|
137
|
-
logger.debug("First item of pubmedArticlesXml:", {
|
|
138
|
-
...operationContext,
|
|
139
|
-
firstItemPreview: sanitizeInputForLogging(JSON.stringify(pubmedArticlesXml[0]).substring(0, 500)),
|
|
140
|
-
});
|
|
141
|
-
}
|
|
126
|
+
const totalArticlesInXml = pubmedArticlesXml.length;
|
|
142
127
|
for (const articleXml of pubmedArticlesXml) {
|
|
143
128
|
if (!articleXml || typeof articleXml !== "object") {
|
|
144
129
|
logger.warning("Skipping invalid articleXml item in pubmedArticlesXml array", {
|
|
@@ -156,13 +141,13 @@ input, parentContext) {
|
|
|
156
141
|
continue;
|
|
157
142
|
}
|
|
158
143
|
const pmid = extractPmid(medlineCitation);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (!pmid)
|
|
144
|
+
if (!pmid) {
|
|
145
|
+
logger.warning("Could not extract PMID from MedlineCitation, skipping.", {
|
|
146
|
+
...operationContext,
|
|
147
|
+
medlineCitationPreview: sanitizeInputForLogging(JSON.stringify(medlineCitation).substring(0, 200)),
|
|
148
|
+
});
|
|
165
149
|
continue;
|
|
150
|
+
}
|
|
166
151
|
const articleNode = medlineCitation.Article;
|
|
167
152
|
const parsedArticle = {
|
|
168
153
|
pmid: pmid,
|
|
@@ -201,82 +186,24 @@ input, parentContext) {
|
|
|
201
186
|
}
|
|
202
187
|
articles.push(parsedArticle);
|
|
203
188
|
}
|
|
189
|
+
logger.debug(`Successfully parsed ${articles.length} of ${totalArticlesInXml} articles from XML.`, {
|
|
190
|
+
...operationContext,
|
|
191
|
+
parsedCount: articles.length,
|
|
192
|
+
totalInXml: totalArticlesInXml,
|
|
193
|
+
});
|
|
204
194
|
return articles;
|
|
205
195
|
}
|
|
206
196
|
export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
message: "webEnv is required if queryKey is provided.",
|
|
218
|
-
},
|
|
219
|
-
}),
|
|
220
|
-
},
|
|
221
|
-
],
|
|
222
|
-
isError: true,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
if (!input.queryKey && input.webEnv) {
|
|
226
|
-
return {
|
|
227
|
-
content: [
|
|
228
|
-
{
|
|
229
|
-
type: "text",
|
|
230
|
-
text: JSON.stringify({
|
|
231
|
-
error: {
|
|
232
|
-
code: BaseErrorCode.VALIDATION_ERROR,
|
|
233
|
-
message: "queryKey is required if webEnv is provided.",
|
|
234
|
-
},
|
|
235
|
-
}),
|
|
236
|
-
},
|
|
237
|
-
],
|
|
238
|
-
isError: true,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
if (input.pmids &&
|
|
242
|
-
input.pmids.length > 0 &&
|
|
243
|
-
(input.queryKey || input.webEnv)) {
|
|
244
|
-
return {
|
|
245
|
-
content: [
|
|
246
|
-
{
|
|
247
|
-
type: "text",
|
|
248
|
-
text: JSON.stringify({
|
|
249
|
-
error: {
|
|
250
|
-
code: BaseErrorCode.VALIDATION_ERROR,
|
|
251
|
-
message: "Cannot use pmids and queryKey/webEnv simultaneously. Please choose one method.",
|
|
252
|
-
},
|
|
253
|
-
}),
|
|
254
|
-
},
|
|
255
|
-
],
|
|
256
|
-
isError: true,
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
if ((input.retstart !== undefined || input.retmax !== undefined) &&
|
|
260
|
-
!(input.queryKey && input.webEnv)) {
|
|
261
|
-
return {
|
|
262
|
-
content: [
|
|
263
|
-
{
|
|
264
|
-
type: "text",
|
|
265
|
-
text: JSON.stringify({
|
|
266
|
-
error: {
|
|
267
|
-
code: BaseErrorCode.VALIDATION_ERROR,
|
|
268
|
-
message: "retstart/retmax can only be used with queryKey and webEnv.",
|
|
269
|
-
},
|
|
270
|
-
}),
|
|
271
|
-
},
|
|
272
|
-
],
|
|
273
|
-
isError: true,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
// SuperRefine also checks: if ((!data.pmids || data.pmids.length === 0) && !(data.queryKey && data.webEnv))
|
|
277
|
-
// This should ideally be caught before handler, but as a safeguard:
|
|
278
|
-
if ((!input.pmids || input.pmids.length === 0) &&
|
|
279
|
-
!(input.queryKey && input.webEnv)) {
|
|
197
|
+
const toolLogicContext = requestContextService.createRequestContext({
|
|
198
|
+
parentRequestId: parentRequestContext.requestId,
|
|
199
|
+
operation: "fetchPubMedContentLogic",
|
|
200
|
+
input: sanitizeInputForLogging(input),
|
|
201
|
+
});
|
|
202
|
+
// Manual validation safeguard
|
|
203
|
+
const validationResult = FetchPubMedContentInputSchema.safeParse(input);
|
|
204
|
+
if (!validationResult.success) {
|
|
205
|
+
const errorMessage = validationResult.error.errors[0]?.message || "Invalid input";
|
|
206
|
+
logger.warning(`Input validation failed pre-check: ${errorMessage}`, toolLogicContext);
|
|
280
207
|
return {
|
|
281
208
|
content: [
|
|
282
209
|
{
|
|
@@ -284,7 +211,8 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
284
211
|
text: JSON.stringify({
|
|
285
212
|
error: {
|
|
286
213
|
code: BaseErrorCode.VALIDATION_ERROR,
|
|
287
|
-
message:
|
|
214
|
+
message: errorMessage,
|
|
215
|
+
details: validationResult.error.flatten(),
|
|
288
216
|
},
|
|
289
217
|
}),
|
|
290
218
|
},
|
|
@@ -293,18 +221,13 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
293
221
|
};
|
|
294
222
|
}
|
|
295
223
|
const ncbiService = getNcbiService();
|
|
296
|
-
const toolLogicContext = requestContextService.createRequestContext({
|
|
297
|
-
parentRequestId: parentRequestContext.requestId,
|
|
298
|
-
operation: "fetchPubMedContentLogic",
|
|
299
|
-
input: sanitizeInputForLogging(input),
|
|
300
|
-
});
|
|
301
224
|
logger.info("Executing fetch_pubmed_content tool", toolLogicContext);
|
|
302
225
|
const eFetchParams = { db: "pubmed" };
|
|
303
226
|
let usingHistory = false;
|
|
304
227
|
if (input.queryKey && input.webEnv) {
|
|
305
228
|
usingHistory = true;
|
|
306
229
|
eFetchParams.query_key = input.queryKey;
|
|
307
|
-
eFetchParams.WebEnv = input.webEnv;
|
|
230
|
+
eFetchParams.WebEnv = input.webEnv;
|
|
308
231
|
if (input.retstart !== undefined) {
|
|
309
232
|
eFetchParams.retstart = String(input.retstart);
|
|
310
233
|
}
|
|
@@ -315,10 +238,8 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
315
238
|
else if (input.pmids && input.pmids.length > 0) {
|
|
316
239
|
eFetchParams.id = input.pmids.join(",");
|
|
317
240
|
}
|
|
318
|
-
|
|
319
|
-
let serviceRetmode = "xml"; // Renamed to avoid conflict with local retmode variable if any
|
|
241
|
+
let serviceRetmode = "xml";
|
|
320
242
|
let rettype;
|
|
321
|
-
// responseContentType is determined by input.outputFormat at the end
|
|
322
243
|
switch (input.detailLevel) {
|
|
323
244
|
case "full_xml":
|
|
324
245
|
serviceRetmode = "xml";
|
|
@@ -329,7 +250,7 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
329
250
|
break;
|
|
330
251
|
case "abstract_plus":
|
|
331
252
|
case "citation_data":
|
|
332
|
-
serviceRetmode = "xml";
|
|
253
|
+
serviceRetmode = "xml";
|
|
333
254
|
break;
|
|
334
255
|
}
|
|
335
256
|
eFetchParams.retmode = serviceRetmode;
|
|
@@ -340,11 +261,11 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
340
261
|
const eFetchBase = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi";
|
|
341
262
|
const eFetchQueryString = new URLSearchParams(eFetchParams).toString();
|
|
342
263
|
eFetchUrl = `${eFetchBase}?${eFetchQueryString}`;
|
|
343
|
-
// Determine if raw XML should be fetched
|
|
344
264
|
const shouldReturnRawXml = input.detailLevel === "full_xml" && input.outputFormat === "raw_text";
|
|
345
265
|
const eFetchResponseData = await ncbiService.eFetch(eFetchParams, toolLogicContext, { retmode: serviceRetmode, rettype, returnRawXml: shouldReturnRawXml });
|
|
346
266
|
let finalOutputText;
|
|
347
|
-
let structuredResponseData;
|
|
267
|
+
let structuredResponseData;
|
|
268
|
+
let articlesCount = 0;
|
|
348
269
|
if (input.detailLevel === "medline_text") {
|
|
349
270
|
const medlineText = String(eFetchResponseData);
|
|
350
271
|
const foundPmidsInMedline = new Set();
|
|
@@ -353,6 +274,7 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
353
274
|
while ((match = pmidRegex.exec(medlineText)) !== null) {
|
|
354
275
|
foundPmidsInMedline.add(match[1]);
|
|
355
276
|
}
|
|
277
|
+
articlesCount = foundPmidsInMedline.size;
|
|
356
278
|
let notFoundPmids = [];
|
|
357
279
|
if (input.pmids && input.pmids.length > 0) {
|
|
358
280
|
notFoundPmids = input.pmids.filter((pmid) => !foundPmidsInMedline.has(pmid));
|
|
@@ -375,48 +297,30 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
375
297
|
: "GET",
|
|
376
298
|
},
|
|
377
299
|
};
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
finalOutputText = JSON.stringify(structuredResponseData);
|
|
383
|
-
}
|
|
300
|
+
finalOutputText =
|
|
301
|
+
input.outputFormat === "raw_text"
|
|
302
|
+
? medlineText
|
|
303
|
+
: JSON.stringify(structuredResponseData);
|
|
384
304
|
}
|
|
385
305
|
else if (input.detailLevel === "full_xml") {
|
|
386
306
|
if (input.outputFormat === "raw_text") {
|
|
387
|
-
// eFetchResponseData is already the raw XML string due to returnRawXml: true
|
|
388
307
|
finalOutputText = String(eFetchResponseData);
|
|
389
|
-
|
|
390
|
-
// For now, returning the direct XML string as per user expectation for "raw_text"
|
|
308
|
+
articlesCount = (finalOutputText.match(/<PubmedArticle>/g) || []).length;
|
|
391
309
|
}
|
|
392
310
|
else {
|
|
393
|
-
// outputFormat is 'json', so eFetchResponseData is the parsed XML object
|
|
394
311
|
const articlesXml = ensureArray(eFetchResponseData?.PubmedArticleSet?.PubmedArticle || []);
|
|
312
|
+
articlesCount = articlesXml.length;
|
|
395
313
|
const articlesPayload = [];
|
|
396
314
|
const foundPmidsInXml = new Set();
|
|
397
315
|
for (const articleXml of articlesXml) {
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
const extracted = extractPmid(articleXml.MedlineCitation);
|
|
401
|
-
if (extracted) {
|
|
402
|
-
pmid = extracted;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if (pmid !== "unknown_pmid") {
|
|
316
|
+
const pmid = extractPmid(articleXml.MedlineCitation) || "unknown_pmid";
|
|
317
|
+
if (pmid !== "unknown_pmid")
|
|
406
318
|
foundPmidsInXml.add(pmid);
|
|
407
|
-
}
|
|
408
|
-
articlesPayload.push({
|
|
409
|
-
pmid: pmid,
|
|
410
|
-
fullXmlContent: articleXml,
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
let notFoundPmids = [];
|
|
414
|
-
if (input.pmids && input.pmids.length > 0) {
|
|
415
|
-
notFoundPmids = input.pmids.filter((pmid) => !foundPmidsInXml.has(pmid));
|
|
416
|
-
}
|
|
417
|
-
else if (usingHistory) {
|
|
418
|
-
notFoundPmids = "N/A (used history query)";
|
|
319
|
+
articlesPayload.push({ pmid, fullXmlContent: articleXml });
|
|
419
320
|
}
|
|
321
|
+
const notFoundPmids = input.pmids && input.pmids.length > 0
|
|
322
|
+
? input.pmids.filter((pmid) => !foundPmidsInXml.has(pmid))
|
|
323
|
+
: "N/A (used history query)";
|
|
420
324
|
structuredResponseData = {
|
|
421
325
|
requestedPmids: input.pmids || "N/A (used history query)",
|
|
422
326
|
articles: articlesPayload,
|
|
@@ -432,17 +336,13 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
432
336
|
}
|
|
433
337
|
}
|
|
434
338
|
else {
|
|
435
|
-
// abstract_plus or citation_data
|
|
436
|
-
// eFetchResponseData is the parsed XML object
|
|
339
|
+
// abstract_plus or citation_data
|
|
437
340
|
const parsedArticles = parsePubMedArticleSet(eFetchResponseData, input, toolLogicContext);
|
|
341
|
+
articlesCount = parsedArticles.length;
|
|
438
342
|
const foundPmids = new Set(parsedArticles.map((p) => p.pmid));
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
else if (usingHistory) {
|
|
444
|
-
notFoundPmids = "N/A (used history query)";
|
|
445
|
-
}
|
|
343
|
+
const notFoundPmids = input.pmids && input.pmids.length > 0
|
|
344
|
+
? input.pmids.filter((pmid) => !foundPmids.has(pmid))
|
|
345
|
+
: "N/A (used history query)";
|
|
446
346
|
structuredResponseData = {
|
|
447
347
|
requestedPmids: input.pmids || "N/A (used history query)",
|
|
448
348
|
articles: parsedArticles,
|
|
@@ -471,21 +371,20 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
471
371
|
year: article.journalInfo?.publicationDate?.year,
|
|
472
372
|
},
|
|
473
373
|
doi: article.doi,
|
|
474
|
-
// Conditionally include meshTerms if the input flag was set (it defaults to true)
|
|
475
374
|
...(input.includeMeshTerms && { meshTerms: article.meshTerms }),
|
|
476
375
|
}));
|
|
477
376
|
}
|
|
478
|
-
// For abstract_plus and citation_data, outputFormat 'raw_text' doesn't make sense,
|
|
479
|
-
// as the data is inherently structured. So, always output JSON.
|
|
480
377
|
finalOutputText = JSON.stringify(structuredResponseData);
|
|
481
378
|
}
|
|
379
|
+
logger.notice("Successfully executed fetch_pubmed_content tool.", {
|
|
380
|
+
...toolLogicContext,
|
|
381
|
+
detailLevel: input.detailLevel,
|
|
382
|
+
outputFormat: input.outputFormat,
|
|
383
|
+
articlesReturned: articlesCount,
|
|
384
|
+
usingHistory,
|
|
385
|
+
});
|
|
482
386
|
return {
|
|
483
|
-
content: [
|
|
484
|
-
{
|
|
485
|
-
type: "text",
|
|
486
|
-
text: finalOutputText,
|
|
487
|
-
},
|
|
488
|
-
],
|
|
387
|
+
content: [{ type: "text", text: finalOutputText }],
|
|
489
388
|
isError: false,
|
|
490
389
|
};
|
|
491
390
|
}
|
|
@@ -7,4 +7,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
7
7
|
* Registers the fetch_pubmed_content tool with the MCP server.
|
|
8
8
|
* @param server - The McpServer instance.
|
|
9
9
|
*/
|
|
10
|
-
export declare function registerFetchPubMedContentTool(server: McpServer): void
|
|
10
|
+
export declare function registerFetchPubMedContentTool(server: McpServer): Promise<void>;
|
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
* @fileoverview Registration for the fetch_pubmed_content MCP tool.
|
|
3
3
|
* @module src/mcp-server/tools/fetchPubMedContent/registration
|
|
4
4
|
*/
|
|
5
|
-
import { BaseErrorCode
|
|
6
|
-
import { ErrorHandler, requestContextService } from "../../../utils/index.js";
|
|
5
|
+
import { BaseErrorCode } from "../../../types-global/errors.js";
|
|
6
|
+
import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
|
|
7
7
|
import { FetchPubMedContentInputSchema, fetchPubMedContentLogic, } from "./logic.js";
|
|
8
8
|
/**
|
|
9
9
|
* Registers the fetch_pubmed_content tool with the MCP server.
|
|
10
10
|
* @param server - The McpServer instance.
|
|
11
11
|
*/
|
|
12
|
-
export function registerFetchPubMedContentTool(server) {
|
|
12
|
+
export async function registerFetchPubMedContentTool(server) {
|
|
13
13
|
const operation = "registerFetchPubMedContentTool";
|
|
14
14
|
const context = requestContextService.createRequestContext({ operation });
|
|
15
|
-
|
|
15
|
+
await ErrorHandler.tryCatch(() => {
|
|
16
16
|
server.tool("fetch_pubmed_content", "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 title, abstract, authors, journal, keywords, DOI, optional MeSH/grant info), 'full_xml' (JSON representation of the PubMedArticle XML structure), 'medline_text' (MEDLINE format), or 'citation_data' (minimal data for citations). Returns a JSON object containing results, any PMIDs not found (if applicable), and EFetch details.", FetchPubMedContentInputSchema._def.schema.shape, // Access .shape from the ZodObject before superRefine
|
|
17
17
|
async (input, toolContext) => {
|
|
18
18
|
// Added 'any' type for toolContext
|
|
@@ -23,15 +23,11 @@ export function registerFetchPubMedContentTool(server) {
|
|
|
23
23
|
});
|
|
24
24
|
return fetchPubMedContentLogic(input, richContext);
|
|
25
25
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
errorCode: BaseErrorCode.INITIALIZATION_FAILED,
|
|
34
|
-
critical: true,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
26
|
+
logger.notice("Tool 'fetch_pubmed_content' registered.", context);
|
|
27
|
+
}, {
|
|
28
|
+
operation,
|
|
29
|
+
context,
|
|
30
|
+
errorCode: BaseErrorCode.INITIALIZATION_FAILED,
|
|
31
|
+
critical: true,
|
|
32
|
+
});
|
|
37
33
|
}
|