@cyanheads/pubmed-mcp-server
Version:
Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP.
345 lines (269 loc) • 17.1 kB
Markdown
# Agent Protocol
**Server:** @cyanheads/pubmed-mcp-server
**Version:** 2.6.12
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.9.1`
**Engines:** Bun ≥1.3.2, Node ≥24.0.0
> **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
---
## What's Next?
When the user asks what to do next, what's left, or needs direction, suggest relevant options based on the current project state:
1. **Re-run the `setup` skill** — ensures CLAUDE.md, skills, structure, and metadata are populated and up to date with the current codebase
2. **Run the `design-mcp-server` skill** — if the tool/resource surface hasn't been mapped yet, work through domain design
3. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills
4. **Add services** — scaffold domain service integrations using the `add-service` skill
5. **Add tests** — scaffold tests for existing definitions using the `add-test` skill
6. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points
7. **Run `devcheck`** — lint, format, typecheck, and security audit
8. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation
9. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping
10. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest`
Tailor suggestions to what's actually missing or stale — don't recite the full list every time.
---
## Core Rules
- **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
- **Use `ctx.log`** for request-scoped logging. No `console` calls.
- **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
- **Check `ctx.elicit` / `ctx.sample`** for presence before calling.
- **Secrets in env vars only** — never hardcoded.
---
## Patterns
### Tool
```ts
import { tool, z } from '@cyanheads/mcp-ts-core';
import { getNcbiService } from '@/services/ncbi/ncbi-service.js';
export const spellCheckTool = tool('pubmed_spell_check', {
description: "Spell-check a query and get NCBI's suggested correction.",
annotations: { readOnlyHint: true, openWorldHint: true },
input: z.object({
query: z.string().min(2).describe('PubMed search query to spell-check'),
}),
output: z.object({
original: z.string().describe('Original query'),
corrected: z.string().describe('Corrected query (same as original if no suggestion)'),
hasSuggestion: z.boolean().describe('Whether NCBI suggested a correction'),
}),
async handler(input, ctx) {
ctx.log.info('Executing pubmed_spell_check tool', { query: input.query });
const result = await getNcbiService().eSpell({ db: 'pubmed', term: input.query });
return { original: result.original, corrected: result.corrected, hasSuggestion: result.hasSuggestion };
},
format: (result) => {
if (result.hasSuggestion) {
return [{ type: 'text', text: `**Suggested correction:** "${result.corrected}" (original: "${result.original}")` }];
}
return [{ type: 'text', text: `No spelling corrections suggested for: "${result.original}"` }];
},
});
```
### Resource
```ts
import { resource, z } from '@cyanheads/mcp-ts-core';
import { getNcbiService } from '@/services/ncbi/ncbi-service.js';
export const databaseInfoResource = resource('pubmed://database/info', {
name: 'database-info',
title: 'PubMed Database Info',
description: 'PubMed database metadata including field list, last update date, and record count.',
mimeType: 'application/json',
params: z.object({}),
output: OutputSchema,
async handler(_params, ctx) {
ctx.log.info('Fetching PubMed database info');
const raw = await getNcbiService().eInfo({ db: 'pubmed' });
// ... parse XML response ...
return { dbName, description, count, lastUpdate, fields };
},
list: () => ({
resources: [{ uri: 'pubmed://database/info', name: 'PubMed Database Info' }],
}),
});
```
### Server config
```ts
// src/config/server-config.ts — lazy-parsed, separate from framework config
import { z } from '@cyanheads/mcp-ts-core';
import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
const emptyAsUndefined = (v: unknown) => (v === '' ? undefined : v);
const ServerConfigSchema = z.object({
apiKey: z.preprocess(emptyAsUndefined, z.string().optional()).describe('NCBI API key'),
toolIdentifier: z.string().default('pubmed-mcp-server').describe('NCBI tool identifier'),
adminEmail: z.preprocess(emptyAsUndefined, z.email().optional()).describe('Admin contact email'),
requestDelayMs: z.coerce.number().min(50).max(5000).default(334).describe('Request delay in ms'),
maxRetries: z.coerce.number().min(0).max(10).default(6).describe('Max retry attempts'),
timeoutMs: z.coerce.number().min(1000).max(120000).default(30000).describe('Request timeout in ms'),
});
let _config: z.infer<typeof ServerConfigSchema> | undefined;
export function getServerConfig(): z.infer<typeof ServerConfigSchema> {
_config ??= parseEnvConfig(ServerConfigSchema, {
apiKey: 'NCBI_API_KEY',
toolIdentifier: 'NCBI_TOOL_IDENTIFIER',
adminEmail: 'NCBI_ADMIN_EMAIL',
requestDelayMs: 'NCBI_REQUEST_DELAY_MS',
maxRetries: 'NCBI_MAX_RETRIES',
timeoutMs: 'NCBI_TIMEOUT_MS',
});
return _config;
}
```
`parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`NCBI_REQUEST_DELAY_MS` must be a number) rather than the internal path (`requestDelayMs: expected number`).
---
## Context
Handlers receive a unified `ctx` object. Key properties:
| Property | Description |
|:---------|:------------|
| `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
| `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
| `ctx.recoveryFor(reason)` | Typed lookup of the contract `recovery` for a declared reason. Returns `{ recovery: { hint } }` for known reasons, `{}` otherwise. Spread into `ctx.fail` data to mirror the contract hint into `content[]`. |
| `ctx.signal` | `AbortSignal` for cancellation. |
| `ctx.requestId` | Unique request ID. |
| `ctx.tenantId` | Tenant ID from JWT, `'default'` for stdio or HTTP+`MCP_AUTH_MODE=none`. |
---
## Errors
Handlers throw — the framework catches, classifies, and formats.
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint. Spread `ctx.recoveryFor('reason')` into `data` to mirror the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text); pass an explicit `recovery: { hint: '...' }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
```ts
errors: [
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound,
when: 'No requested PMID returned data',
recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
],
async handler(input, ctx) {
const articles = await ncbi.fetch(input.pmids);
if (articles.length === 0) {
// Static contract recovery
throw ctx.fail('no_match', `None of ${input.pmids.length} PMIDs returned data`, {
...ctx.recoveryFor('no_match'),
});
}
return { articles };
}
```
**Fallback (no contract entry fits):** error factories or plain `Error`.
```ts
// Error factories — explicit code, concise
import { notFound, serializationError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
throw notFound('Item not found', { itemId });
throw serviceUnavailable('API unavailable', { url }, { cause: err });
throw serializationError('Invalid XML response', { endpoint });
// Plain Error — framework auto-classifies from message patterns
throw new Error('Item not found'); // → NotFound
throw new Error('Invalid query format'); // → ValidationError
// McpError — full control when the code is dynamic
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
```
For HTTP responses, prefer `httpErrorFromResponse(response, { service, data })` from `/utils` over hand-rolled status ladders — covers the full 4xx/5xx → `JsonRpcErrorCode` table and captures body + `Retry-After`.
**Service-layer:** services don't have `ctx.fail`. To carry a contract `reason` from a service throw, pass `data: { reason: 'X' }` to the factory — the auto-classifier preserves `data` on the wire so clients see the same `error.data.reason` they'd see from `ctx.fail`.
See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all factories, and the contract reference.
---
## Structure
```text
src/
index.ts # createApp() entry point
config/
server-config.ts # NCBI-specific env vars (Zod schema)
services/
ncbi/
ncbi-service.ts # NCBI E-utilities service (init/accessor)
api-client.ts # HTTP client for NCBI API
request-queue.ts # Rate-limited request queue
response-handler.ts # XML response parsing
types.ts # NCBI/PubMed domain types
parsing/ # XML parsers (article, esummary, PMC)
formatting/ # Citation formatter (APA, MLA, BibTeX, RIS)
mcp-server/
tools/definitions/ # 9 tool definitions (*.tool.ts)
resources/definitions/ # database-info.resource.ts
prompts/definitions/ # research-plan.prompt.ts
```
---
## Naming
| What | Convention | Example |
|:-----|:-----------|:--------|
| Files | kebab-case with suffix | `search-articles.tool.ts` |
| Tool/resource/prompt names | snake_case | `pubmed_search_articles` |
| Directories | kebab-case | `src/services/ncbi/` |
| Descriptions | Single string or template literal, no `+` concatenation | `'Search PubMed with full query syntax.'` |
---
## Skills
Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool.
**Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). This makes skills available as context without needing to reference `skills/` paths manually. After framework updates, run the `maintenance` skill — it re-syncs the agent directory automatically (Phase B).
Available skills:
| Skill | Purpose |
|:------|:--------|
| `setup` | Post-init project orientation |
| `design-mcp-server` | Design tool surface, resources, and services for a new server |
| `add-tool` | Scaffold a new tool definition |
| `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
| `add-resource` | Scaffold a new resource definition |
| `add-prompt` | Scaffold a new prompt definition |
| `add-service` | Scaffold a new service integration |
| `add-test` | Scaffold test file for a tool, resource, or service |
| `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
| `tool-defs-analysis` | Read-only audit of definition language: voice, leaks, defaults, recovery hints, examples |
| `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
| `devcheck` | Lint, format, typecheck, audit |
| `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
| `maintenance` | Investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest` |
| `release-and-publish` | Ship a release: verification gate, push commits+tags, publish to npm / MCP Registry / GHCR |
| `api-auth` | Auth modes, scopes, JWT/OAuth |
| `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
| `api-config` | AppConfig, parseConfig, env vars |
| `api-context` | Context interface, logger, state, progress |
| `api-errors` | McpError, JsonRpcErrorCode, error patterns |
| `api-linter` | Definition lint rules reference (`format-parity`, `schema-*`, `server-json-*`, …) |
| `api-services` | LLM, Speech, Graph services |
| `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
| `api-testing` | createMockContext, test patterns |
| `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers |
| `api-workers` | Cloudflare Workers runtime |
| `report-issue-framework` | File bug/feature request against @cyanheads/mcp-ts-core |
| `report-issue-local` | File bug/feature request against this server's repo |
When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
---
## Commands
| Command | Purpose |
|:--------|:--------|
| `bun run build` | Compile TypeScript |
| `bun run rebuild` | Clean + build |
| `bun run clean` | Remove build artifacts |
| `bun run devcheck` | Lint + format + typecheck + security |
| `bun run tree` | Generate directory structure doc |
| `bun run format` | Auto-fix formatting |
| `bun run test` | Run tests |
| `bun run lint:mcp` | Validate MCP definitions against spec |
| `bun run start:stdio` | Production mode (stdio) |
| `bun run start:http` | Production mode (HTTP) |
---
## Publishing
Run the `release-and-publish` skill after git wrapup — it runs the verification gate (`devcheck`, `rebuild`, `test`), pushes commits and tags, and publishes to npm, the MCP Registry, and GHCR, halting on the first failure. For reference, the underlying commands are:
```bash
bun publish --access public
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/cyanheads/pubmed-mcp-server:<version> \
-t ghcr.io/cyanheads/pubmed-mcp-server:latest \
--push .
mcp-publisher publish
```
---
## Imports
```ts
// Framework — z is re-exported, no separate zod import needed
import { tool, z } from '@cyanheads/mcp-ts-core';
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
// Server's own code — via path alias
import { getNcbiService } from '@/services/ncbi/ncbi-service.js';
```
---
## Checklist
- [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`)
- [ ] Optional nested objects: handler guards for empty inner values from form-based clients (`if (input.obj?.field && ...)`, not just `if (input.obj)`). When regex/length constraints matter, use `z.union([z.literal(''), z.string().regex(...).describe(...)])` — literal variants are exempt from `describe-on-fields`.
- [ ] JSDoc `@fileoverview` + `@module` on every file
- [ ] `ctx.log` for logging, `ctx.state` for storage
- [ ] Handlers throw on failure — error factories or plain `Error`, no try/catch
- [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data
- [ ] NCBI wrapping: raw/domain/output schemas reviewed against real upstream sparsity/nullability before finalizing required vs optional fields
- [ ] NCBI wrapping: normalization and `format()` preserve uncertainty; do not fabricate facts from missing upstream data
- [ ] NCBI wrapping: tests include at least one sparse payload case with omitted upstream fields
- [ ] Registered in `createApp()` arrays (directly or via barrel exports)
- [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
- [ ] `bun run devcheck` passes