@cyanheads/openalex-mcp-server 0.7.13 → 0.7.14

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/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.7.13
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.0`
4
+ **Version:** 0.7.14
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.5`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.1
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **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.
11
11
 
@@ -43,6 +43,8 @@ No resources — entity lookups need `select` for payload control, which fits to
43
43
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
44
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
45
 
46
+ **Session mode:** `createApp({ sessionMode: 'stateless' })` in `src/index.ts` — no tool gates on `ctx.requestInput`, so every call completes in one round trip. An explicit `MCP_SESSION_MODE` still overrides it; `.env.example` and the `Dockerfile` set the same value. No service holds a watcher, socket, or ref'd timer, so there is no `teardown` hook.
47
+
46
48
  ---
47
49
 
48
50
  ## What's Next?
@@ -71,6 +73,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
71
73
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
72
74
  - **Need input the caller didn't supply?** `return ctx.requestInput(...)` and read `ctx.inputs` when the handler is re-entered. Never `await` for user input mid-handler.
73
75
  - **Secrets in env vars only** — never hardcoded.
76
+ - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
74
77
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
75
78
 
76
79
  ---
@@ -147,13 +150,13 @@ Handlers receive a unified `ctx` object. Key properties:
147
150
 
148
151
  | Property | Description |
149
152
  |:---------|:------------|
150
- | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino and client-visible `notifications/message`. |
153
+ | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
151
154
  | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
152
155
  | `ctx.requestInput` | Suspend and ask the caller for more input with `return ctx.requestInput(...)`; the handler is re-entered with answers. |
153
156
  | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. |
154
157
  | `ctx.enrich` | Success-path agent context for notices, query echoes, totals, and truncation disclosure. Reaches both client response surfaces. |
155
158
  | `ctx.content` | Non-text content blocks appended to `content[]`; never enters `structuredContent`. |
156
- | `ctx.signal` | `AbortSignal` for cancellation. Passed to `fetch()` in the OpenAlex service. |
159
+ | `ctx.signal` | `AbortSignal` for cancellation. Passed to `withRetry` in the OpenAlex service, whose per-attempt signal reaches `fetch()`. |
157
160
  | `ctx.fail` | Typed throw keyed by a declared `errors[]` contract — `ctx.fail(reason, msg?, data?)`. Auto-populates `data.reason` and resolves `code` from the contract. |
158
161
  | `ctx.recoveryFor` | Opt-in resolver returning `{ recovery: { hint } }` for a declared reason. Spread into `data` at throw site to surface contract recovery on the wire. |
159
162
  | `ctx.requestId` | Unique request ID. |
@@ -182,7 +185,7 @@ async handler(input, ctx) {
182
185
 
183
186
  **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture. Don't extract a shared `errors[]` constant; per-tool repetition is the intended cost of locality.
184
187
 
185
- The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes; those bubble through and are auto-classified. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely without needing to be declared on a contract.
188
+ The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and the recovery via `ctx.recoveryFor(reason)`; those bubble through and are auto-classified. Because most declared reasons are produced in the service, handler-local precondition throws (`semantic_per_page_cap`, `reserved_filter_key`, …) live in module-level helpers (`assertListQueryConstraints`, `assertNoReservedFilterKey`, `resolveSeedToWorkId`) rather than inline: `error-contract-unthrown` only scans handlers holding a literal `ctx.fail(`, and an inline one would flag every service-produced reason as dead. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely without needing to be declared on a contract.
186
189
 
187
190
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
188
191
 
@@ -234,7 +237,7 @@ src/
234
237
 
235
238
  ## Skills
236
239
 
237
- Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool.
240
+ Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it.
238
241
 
239
242
  **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 `framework-skills/` paths manually. After framework updates, run the `maintenance` skill — it re-syncs the agent directory automatically (Phase B).
240
243
 
@@ -293,8 +296,10 @@ When you complete a skill's checklist, check the boxes and add a completion time
293
296
  | `bun run audit:fix` | Run `bun audit fix` to upgrade vulnerable packages within existing ranges. |
294
297
  | `bun run audit:refresh` | Delete `bun.lock` and reinstall; last resort after `audit:fix`, targeted updates, and `bun dedupe`. |
295
298
  | `bun run tree` | Generate directory structure doc |
296
- | `bun run format` | Auto-fix formatting |
297
- | `bun run lint:mcp` | Validate MCP tool/prompt definitions |
299
+ | `bun run format` | Auto-fix formatting (safe fixes only) |
300
+ | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
301
+ | `bun run lint:mcp` | Validate MCP tool/prompt definitions (rule catalog: `api-linter` skill) |
302
+ | `bun run lint:packaging` | Packaging surface checks — env-var parity, plugin metadata, README version badge (run by devcheck) |
298
303
  | `bun run list-skills` | List available skills |
299
304
  | `bun run test` | Run tests |
300
305
  | `bun run start:stdio` | Production mode (stdio, after `rebuild`) |
@@ -304,6 +309,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
304
309
  | `bun run release:github` | Create GitHub Release from the latest annotated tag (title `v<VERSION>: <subject>`, optional `.mcpb` attach) |
305
310
  | `bun run bundle` | Build and pack as `.mcpb` for one-click Claude Desktop install |
306
311
 
312
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
313
+
307
314
  ---
308
315
 
309
316
  ## Bundling
@@ -319,12 +326,16 @@ Every bundle option must be wired as `${user_config.<option>}`; optional strings
319
326
 
320
327
  Directory-based, grouped by minor series via the `.x` semver-wildcard convention. Source of truth: `changelog/<major.minor>.x/<version>.md` (e.g. `changelog/0.6.x/0.6.12.md`) — one file per release, shipped in the npm package. At release, author the per-version file with a concrete version and date, then run `bun run changelog:build` to regenerate the rollup. `changelog/template.md` is a **pristine format reference** — never edited or moved. `CHANGELOG.md` is a **navigation index** regenerated by `bun run changelog:build` — devcheck hard-fails on drift; never hand-edit it.
321
328
 
329
+ Each per-version file opens with YAML frontmatter: `summary` (required, ≤350 chars — powers the rollup index), `breaking` (true when consumers must change code on upgrade), `security` (true only for a security fix in this server's own source, never a dependency CVE bump — those go under `## Dependencies`), and optional `agent-notes` for downstream maintenance agents. See `changelog/template.md` for the full layout.
330
+
322
331
  **Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries — don't ship empty headers.
323
332
 
324
333
  ---
325
334
 
326
335
  ## Publishing
327
336
 
337
+ **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.
338
+
328
339
  Run the `release-and-publish` skill — it runs the verification gate (`devcheck`, `rebuild`, `test`), pushes commits and tags, then publishes to every applicable destination. The full reference:
329
340
 
330
341
  ```bash
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.7.13
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.0`
4
+ **Version:** 0.7.14
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.5`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.1
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **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.
11
11
 
@@ -43,6 +43,8 @@ No resources — entity lookups need `select` for payload control, which fits to
43
43
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
44
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
45
 
46
+ **Session mode:** `createApp({ sessionMode: 'stateless' })` in `src/index.ts` — no tool gates on `ctx.requestInput`, so every call completes in one round trip. An explicit `MCP_SESSION_MODE` still overrides it; `.env.example` and the `Dockerfile` set the same value. No service holds a watcher, socket, or ref'd timer, so there is no `teardown` hook.
47
+
46
48
  ---
47
49
 
48
50
  ## What's Next?
@@ -71,6 +73,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
71
73
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
72
74
  - **Need input the caller didn't supply?** `return ctx.requestInput(...)` and read `ctx.inputs` when the handler is re-entered. Never `await` for user input mid-handler.
73
75
  - **Secrets in env vars only** — never hardcoded.
76
+ - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
74
77
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
75
78
 
76
79
  ---
@@ -147,13 +150,13 @@ Handlers receive a unified `ctx` object. Key properties:
147
150
 
148
151
  | Property | Description |
149
152
  |:---------|:------------|
150
- | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino and client-visible `notifications/message`. |
153
+ | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
151
154
  | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
152
155
  | `ctx.requestInput` | Suspend and ask the caller for more input with `return ctx.requestInput(...)`; the handler is re-entered with answers. |
153
156
  | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. |
154
157
  | `ctx.enrich` | Success-path agent context for notices, query echoes, totals, and truncation disclosure. Reaches both client response surfaces. |
155
158
  | `ctx.content` | Non-text content blocks appended to `content[]`; never enters `structuredContent`. |
156
- | `ctx.signal` | `AbortSignal` for cancellation. Passed to `fetch()` in the OpenAlex service. |
159
+ | `ctx.signal` | `AbortSignal` for cancellation. Passed to `withRetry` in the OpenAlex service, whose per-attempt signal reaches `fetch()`. |
157
160
  | `ctx.fail` | Typed throw keyed by a declared `errors[]` contract — `ctx.fail(reason, msg?, data?)`. Auto-populates `data.reason` and resolves `code` from the contract. |
158
161
  | `ctx.recoveryFor` | Opt-in resolver returning `{ recovery: { hint } }` for a declared reason. Spread into `data` at throw site to surface contract recovery on the wire. |
159
162
  | `ctx.requestId` | Unique request ID. |
@@ -182,7 +185,7 @@ async handler(input, ctx) {
182
185
 
183
186
  **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture. Don't extract a shared `errors[]` constant; per-tool repetition is the intended cost of locality.
184
187
 
185
- The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes; those bubble through and are auto-classified. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely without needing to be declared on a contract.
188
+ The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and the recovery via `ctx.recoveryFor(reason)`; those bubble through and are auto-classified. Because most declared reasons are produced in the service, handler-local precondition throws (`semantic_per_page_cap`, `reserved_filter_key`, …) live in module-level helpers (`assertListQueryConstraints`, `assertNoReservedFilterKey`, `resolveSeedToWorkId`) rather than inline: `error-contract-unthrown` only scans handlers holding a literal `ctx.fail(`, and an inline one would flag every service-produced reason as dead. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely without needing to be declared on a contract.
186
189
 
187
190
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
188
191
 
@@ -234,7 +237,7 @@ src/
234
237
 
235
238
  ## Skills
236
239
 
237
- Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool.
240
+ Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it.
238
241
 
239
242
  **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 `framework-skills/` paths manually. After framework updates, run the `maintenance` skill — it re-syncs the agent directory automatically (Phase B).
240
243
 
@@ -293,8 +296,10 @@ When you complete a skill's checklist, check the boxes and add a completion time
293
296
  | `bun run audit:fix` | Run `bun audit fix` to upgrade vulnerable packages within existing ranges. |
294
297
  | `bun run audit:refresh` | Delete `bun.lock` and reinstall; last resort after `audit:fix`, targeted updates, and `bun dedupe`. |
295
298
  | `bun run tree` | Generate directory structure doc |
296
- | `bun run format` | Auto-fix formatting |
297
- | `bun run lint:mcp` | Validate MCP tool/prompt definitions |
299
+ | `bun run format` | Auto-fix formatting (safe fixes only) |
300
+ | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
301
+ | `bun run lint:mcp` | Validate MCP tool/prompt definitions (rule catalog: `api-linter` skill) |
302
+ | `bun run lint:packaging` | Packaging surface checks — env-var parity, plugin metadata, README version badge (run by devcheck) |
298
303
  | `bun run list-skills` | List available skills |
299
304
  | `bun run test` | Run tests |
300
305
  | `bun run start:stdio` | Production mode (stdio, after `rebuild`) |
@@ -304,6 +309,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
304
309
  | `bun run release:github` | Create GitHub Release from the latest annotated tag (title `v<VERSION>: <subject>`, optional `.mcpb` attach) |
305
310
  | `bun run bundle` | Build and pack as `.mcpb` for one-click Claude Desktop install |
306
311
 
312
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
313
+
307
314
  ---
308
315
 
309
316
  ## Bundling
@@ -319,12 +326,16 @@ Every bundle option must be wired as `${user_config.<option>}`; optional strings
319
326
 
320
327
  Directory-based, grouped by minor series via the `.x` semver-wildcard convention. Source of truth: `changelog/<major.minor>.x/<version>.md` (e.g. `changelog/0.6.x/0.6.12.md`) — one file per release, shipped in the npm package. At release, author the per-version file with a concrete version and date, then run `bun run changelog:build` to regenerate the rollup. `changelog/template.md` is a **pristine format reference** — never edited or moved. `CHANGELOG.md` is a **navigation index** regenerated by `bun run changelog:build` — devcheck hard-fails on drift; never hand-edit it.
321
328
 
329
+ Each per-version file opens with YAML frontmatter: `summary` (required, ≤350 chars — powers the rollup index), `breaking` (true when consumers must change code on upgrade), `security` (true only for a security fix in this server's own source, never a dependency CVE bump — those go under `## Dependencies`), and optional `agent-notes` for downstream maintenance agents. See `changelog/template.md` for the full layout.
330
+
322
331
  **Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries — don't ship empty headers.
323
332
 
324
333
  ---
325
334
 
326
335
  ## Publishing
327
336
 
337
+ **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.
338
+
328
339
  Run the `release-and-publish` skill — it runs the verification gate (`devcheck`, `rebuild`, `test`), pushes commits and tags, then publishes to every applicable destination. The full reference:
329
340
 
330
341
  ```bash
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.7.13-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/openalex-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/openalex-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openalex-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.7.14-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/openalex-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/openalex-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openalex-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -27,105 +27,111 @@
27
27
 
28
28
  ---
29
29
 
30
- ## Tools
30
+ ## Overview
31
31
 
32
- Five tools for querying the [OpenAlex](https://openalex.org) academic research catalog:
32
+ Scholarly catalog data from [OpenAlex](https://openalex.org) — 270M+ works, 90M+ authors, 100K+ sources, plus institutions, topics, keywords, publishers, and funders. Search, filter, and aggregate across all eight entity types, resolve ambiguous names to canonical IDs, and walk the citation graph one hop at a time. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
33
33
 
34
- | Tool Name | Description |
35
- |:----------|:------------|
36
- | `openalex_search_entities` | Search, filter, sort, or retrieve by ID across all 8 entity types. |
37
- | `openalex_analyze_trends` | Group-by aggregation for trend and distribution analysis. |
38
- | `openalex_resolve_name` | Resolve a name or an identifier (DOI, ORCID, ROR, PMID, ISSN, OpenAlex ID) to an OpenAlex ID. |
39
- | `openalex_get_citation_graph` | Walk the citation graph one hop from a seed work: cites, cited_by, or related_to. |
40
- | `openalex_describe_fields` | List valid filter, group_by, and select field names for an entity type — call before building a query to avoid invalid-field errors. |
34
+ ### Tools
41
35
 
42
- ### `openalex_search_entities`
36
+ | Tool | Description |
37
+ |:---|:---|
38
+ | `openalex_search_entities` | Search, filter, sort, or retrieve by ID across all 8 entity types |
39
+ | `openalex_analyze_trends` | Group-by aggregation for trend and distribution analysis |
40
+ | `openalex_resolve_name` | Resolve a name or an identifier (DOI, ORCID, ROR, PMID, ISSN, OpenAlex ID) to an OpenAlex ID |
41
+ | `openalex_get_citation_graph` | Walk the citation graph one hop from a seed work: `cites`, `cited_by`, or `related_to` |
42
+ | `openalex_describe_fields` | List valid filter, group_by, and select field names for an entity type |
43
43
 
44
- Primary discovery and lookup tool. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders).
44
+ ### Prompts
45
45
 
46
- - Retrieve a single entity by ID (OpenAlex ID, DOI, ORCID, ROR, PMID, ISSN), bare or in URL form, with the scheme prefix accepted in any case. A PMCID is recognized as well, bare or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work's PMID or DOI instead. `id` takes precedence: search criteria passed alongside it are not applied, and the response says which ones were dropped rather than echoing them back as though they ran. The search-only validations (semantic page cap, `sample` with `cursor`, `seed` without `sample`) are skipped too — a lookup is never rejected over parameters it ignores
47
- - Keyword search with boolean operators, quoted phrases, wildcards, and fuzzy matching
48
- - Exact and AI semantic search modes
49
- - Rich filter syntax: AND across fields, OR within fields (`us|gb`), NOT (`!us`), ranges (`2020-2024`), comparisons (`>100`)
50
- - Sensible default field selection per entity type, applied to both searches and ID lookups — prevents oversized responses; pass `select` to choose fields, or `["*"]` for the full record
51
- - Invalid `select` field names produce an error listing the valid fields for that entity type
52
- - Formatted MCP output is a generic markdown renderer — every returned field is surfaced without per-entity-type hard-coding
53
- - Cursor pagination and up to 100 results per page; `sort` takes a single key or a comma-separated list, with the `-` descending prefix applied per key
54
- - `display_name` is nullable — OpenAlex holds no title for paratext and other untitled records, which pass through instead of failing the whole page
55
-
56
- ---
46
+ | Prompt | Description |
47
+ |:-------|:------------|
48
+ | `openalex_literature_review` | Guides a systematic literature search: formulate query, search, filter, analyze citation network, synthesize findings |
49
+ | `openalex_research_landscape` | Analyzes the research landscape for a topic: volume trends, top authors/institutions, open access rates, funding sources |
57
50
 
58
- ### `openalex_analyze_trends`
51
+ ## Capability reference
59
52
 
60
- Aggregate entities into groups and count them for trend, distribution, and comparative analysis.
53
+ ### `openalex_search_entities` <sub>tool</sub>
61
54
 
62
- - Group by any supported field (publication year, OA status, institution, country, topic, etc.)
63
- - Combine with filters to scope the population before aggregation
64
- - Up to 200 groups per page with cursor pagination
65
- - Supports `include_unknown` to show entities with no value for the grouped field
55
+ - Retrieve a single entity by ID — OpenAlex ID, DOI, ORCID, ROR, PMID, ISSN, or PMCID (bare or URL form). `id` takes precedence: search parameters passed alongside it are dropped, and the response names which ones. A PMCID resolves nothing (OpenAlex indexes none) — use the work's PMID or DOI instead
56
+ - Keyword search (boolean operators, quoted phrases, wildcards, fuzzy match) plus `exact` and `semantic` search modes — semantic caps at 50 results per page and ~1 req/sec
57
+ - Rich filter syntax: AND across fields, OR within a field (`|`), NOT (`!`), ranges, comparisons; a comma inside a filter value is rejected (use `|`, or a `.search` filter for free text)
58
+ - `select` returns a curated per-entity-type default unless overridden, or `["*"]` for the full record; invalid field names error with the valid set
59
+ - Cursor pagination, up to 100 results per page (default 25); `sample` (up to 100, single page only, no `cursor`) plus a deterministic `seed` for reproducible random sampling
60
+ - `display_name` is nullable for untitled records; every call reports OpenAlex daily-budget cost and remaining balance
66
61
 
67
62
  ---
68
63
 
69
- ### `openalex_resolve_name`
64
+ ### `openalex_analyze_trends` <sub>tool</sub>
70
65
 
71
- The front door for turning anything you have into an OpenAlex ID. **Always use this before filtering by entity** — names are ambiguous, IDs are not.
66
+ - Group any supported field for trend, distribution, or comparative analysis; combine with `filters` to scope the population before aggregation
67
+ - Up to 200 groups per page (default). `order: "count"` (default) returns the top-N by count with no further pages; `order: "key"` enumerates all distinct values key-ascending with cursor pagination
68
+ - `include_unknown` (default `false`) adds a group for entities with no value for the grouped field
69
+ - Not every field is groupable — raw date fields, `.search` operators, and `from_*`/`to_*` range modifiers are rejected; check with `openalex_describe_fields(entity_type, "group_by")`
70
+ - Reports OpenAlex daily-budget cost and remaining balance — aggregation is priced far below paging the same entities
72
71
 
73
- - A name or partial name runs an autocomplete search: up to 10 matches with disambiguation hints, ~200ms
74
- - An identifier resolves deterministically to the single record it addresses — OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN, bare or in URL form, with the scheme prefix accepted in any case. No `entity_type` needed: the identifier determines its own
75
- - A PMCID is recognized, bare or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — the empty result says so and points at converting it to a PMID or DOI
76
- - An identifier that matches nothing returns an empty result naming the scheme, not name-search advice
77
- - Optional entity type filter and field-level filters, applied to name queries
72
+ ---
73
+
74
+ ### `openalex_resolve_name` <sub>tool</sub>
75
+
76
+ - A name or partial name runs an autocomplete search: up to 10 matches with disambiguation hints (last institution, host organization, place, etc.)
77
+ - An identifier — OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN, bare or in URL form — resolves directly to the one record it addresses; no `entity_type` needed, since the identifier determines its own. A PMCID is recognized but resolves nothing — OpenAlex indexes none
78
+ - `filters` narrows autocomplete only; on an identifier lookup they're ignored and named in a notice
79
+ - Reports OpenAlex daily-budget cost and remaining balance
78
80
 
79
81
  ---
80
82
 
81
- ### `openalex_get_citation_graph`
83
+ ### `openalex_get_citation_graph` <sub>tool</sub>
82
84
 
83
- One-hop citation graph traversal from a seed work. Wraps the OpenAlex `cites`/`cited_by`/`related_to` filters behind an explicit `direction` argument so callers do not have to know the filter names.
85
+ - `direction` sets the edge: `cites` (works citing the seed), `cited_by` (the seed's own reference list), `related_to` (OpenAlex's algorithmic related works, ~8-30 typical, may be empty)
86
+ - `seed_id` accepts an OpenAlex ID, DOI, or PMID (PMCID recognized but resolves nothing); validated against a live lookup first, so a non-existent seed fails as `NotFound` rather than returning an empty graph
87
+ - Stacks with `filters`/`sort`/`select` to narrow the graph; `filters` cannot set `cites`/`cited_by`/`related_to` directly — those are reserved for `direction`
88
+ - Cursor pagination, up to 100 results per page (default 25)
89
+ - Reports OpenAlex daily-budget cost, covering both the seed-validation lookup and the graph page, plus remaining balance
84
90
 
85
- - `cites`: works that cite the seed (incoming citations)
86
- - `cited_by`: works the seed cites (its reference list)
87
- - `related_to`: OpenAlex algorithmic "related works" (~8-30 typical, may be empty for less-cited seeds)
88
- - Accepts OpenAlex IDs, DOIs, and PMIDs as `seed_id`, bare or in URL form; validates the seed via a singleton `/works/{id}` lookup before walking, so non-existent seeds surface as `NotFound`. A PMCID seed is recognized but OpenAlex indexes no PMCIDs, so it resolves nothing — the `NotFound` says so and points at converting it to a PMID or DOI
89
- - Stacks with `filters`/`sort`/`select` to narrow the graph (e.g., `publication_year=">2020"`, `is_oa="true"`)
91
+ ---
92
+
93
+ ### `openalex_describe_fields` <sub>tool</sub>
94
+
95
+ - Lists every valid field name for an entity type + context (`filter`, `group_by`, `select`) — the complete pool, never truncated
96
+ - `group_by` is the filter set minus raw date fields, `.search`/`.search.exact` operators, and `from_*`/`to_*` range modifiers, which OpenAlex rejects as aggregation keys
97
+ - Optional `query` reorders results by name similarity without dropping any field — a nested value's parent object stays reachable further down the list
98
+ - Backed by a generated field catalog — no live API calls
90
99
 
91
100
  ---
92
101
 
93
- ### `openalex_describe_fields`
102
+ ### `openalex_literature_review` <sub>prompt</sub>
94
103
 
95
- Discover valid field names before constructing a query — avoids invalid-field 400 errors. Backed by a catalog generated from OpenAlex's own field validation.
104
+ - Arguments: `topic` required; `scope` (`narrow` / `broad`) optional, defaults to `narrow`
105
+ - Returns one user message walking a 6-step workflow: resolve entities, search literature, identify key papers, trace citations, analyze the landscape, synthesize findings
106
+ - `scope` changes the search step: `narrow` favors exact search with tight topic filters; `broad` adds semantic search across multiple related topic IDs
96
107
 
97
- - List valid fields for any entity type and context (`filter`, `group_by`, or `select`)
98
- - `group_by` returns the subset of the `filter` set OpenAlex can aggregate — raw date fields, `*.search` operators, and `from_*`/`to_*` range modifiers are excluded
99
- - Pass `query` (a partial or guessed name) to rank results by name similarity — surfaces the right field when you only know roughly what you want. Ranking reorders the list without shortening it, so a nested value's parent object is still reachable further down
100
- - Complements the ranked "did you mean" suggestions now appended to invalid-field errors on the search, trends, and citation-graph tools
108
+ ---
101
109
 
102
- ## Prompts
110
+ ### `openalex_research_landscape` <sub>prompt</sub>
103
111
 
104
- | Prompt | Description |
105
- |:-------|:------------|
106
- | `openalex_literature_review` | Guides a systematic literature search: formulate query, search, filter, analyze citation network, synthesize findings. |
107
- | `openalex_research_landscape` | Analyzes the research landscape for a topic: volume trends, top authors/institutions, open access rates, funding sources. |
112
+ - Arguments: `topic` required
113
+ - Returns one user message walking a 7-step quantitative workflow: resolve the topic ID, volume trends, top contributors (institutions/countries/journals), open access rate, funding sources, most-cited works, emerging fronts
114
+ - The funding step groups by `awards.funder_id` (resolve names via `openalex_resolve_name`) or `awards.funder_display_name` for readable labels in a single hop
108
115
 
109
116
  ## Features
110
117
 
111
- Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core):
112
-
113
- - Declarative tool definitions — single file per tool, framework handles registration and validation
114
- - Unified error handling across all tools
115
- - Pluggable auth (`none`, `jwt`, `oauth`)
116
- - Swappable storage backends via the framework (not currently used by this server)
117
- - Structured logging with optional OpenTelemetry tracing
118
- - Runs locally (stdio/HTTP) or in Docker from the same codebase
118
+ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): stdio and Streamable HTTP transports, pluggable auth (`none` / `jwt` / `oauth`), swappable storage (`in-memory`, `filesystem`, `Supabase`, `Cloudflare KV/R2/D1`), structured logging with optional OpenTelemetry tracing.
119
119
 
120
120
  OpenAlex-specific:
121
121
 
122
- - Typed API client with automatic ID normalization (DOI, ORCID, ROR, PMID, PMCID, ISSN, OpenAlex and PubMed/PubMed Central URLs). A PMCID normalizes but resolves nothing — OpenAlex indexes none — so use the work's PMID or DOI
123
- - Abstract reconstruction from inverted indices — plaintext instead of OpenAlex's position-keyed encoding
124
- - HTTP status codes mapped to specific MCP error classes (400 → InvalidParams, 422 → ValidationError, 429 → RateLimited, etc.) with upstream messages surfaced
125
- - Every API-calling tool reports what the call spent against the OpenAlex daily budget and what is left of it, so a paginated sweep can be priced before it runs instead of ending in a 429. An account holding prepaid balance sees that too, since it keeps serving once the day's allowance is gone
122
+ - Typed API client with automatic ID normalization (DOI, ORCID, ROR, PMID, PMCID, ISSN, OpenAlex and PubMed/PubMed Central URLs); a PMCID normalizes but resolves nothing since OpenAlex indexes none
123
+ - Keyless by default — an optional API key raises rate and daily-budget limits, and an optional `mailto` identifies the caller to OpenAlex's polite pool
124
+ - HTTP status codes mapped to specific MCP error classes (400 → InvalidParams, 422 → ValidationError, 429 → RateLimited) with upstream messages surfaced
126
125
  - Timeout-aware request retries and cancellation support via `AbortSignal`
127
126
 
128
- ## Getting Started
127
+ Agent-friendly output:
128
+
129
+ - Provenance — every API-calling tool reports OpenAlex daily-budget cost, remaining balance, and reset time (`budget.costUsd`, `remainingUsd`, `resetsInSeconds`)
130
+ - Effective-query echo — search, trends, and citation-graph responses echo the criteria that actually ran, so an empty result is diagnosable without re-reading the request
131
+ - Discriminated output contracts — typed error reasons (`entity_not_found`, `upstream_budget_exhausted`, `semantic_per_page_cap`, `reserved_filter_key`, and more) each carrying an explicit recovery hint
132
+ - Response shaping — abstracts are reconstructed from OpenAlex's inverted-index encoding into plaintext, and `display_name` stays `null` for untitled or paratext records instead of being backfilled
133
+
134
+ ## Getting started
129
135
 
130
136
  ### Public Hosted Instance
131
137
 
@@ -144,7 +150,26 @@ A public instance is available at `https://openalex.caseyjhand.com/mcp` — no i
144
150
 
145
151
  ### Self-Hosted / Local
146
152
 
147
- Add to your MCP client config (e.g., `claude_desktop_config.json`):
153
+ Add the following to your MCP client configuration file.
154
+
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "openalex-mcp-server": {
159
+ "type": "stdio",
160
+ "command": "bunx",
161
+ "args": ["@cyanheads/openalex-mcp-server@latest"],
162
+ "env": {
163
+ "MCP_TRANSPORT_TYPE": "stdio",
164
+ "MCP_LOG_LEVEL": "info",
165
+ "OPENALEX_API_KEY": "your-api-key"
166
+ }
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ Or with npx (no Bun required):
148
173
 
149
174
  ```json
150
175
  {
@@ -152,94 +177,147 @@ Add to your MCP client config (e.g., `claude_desktop_config.json`):
152
177
  "openalex-mcp-server": {
153
178
  "type": "stdio",
154
179
  "command": "npx",
155
- "args": ["-y", "@cyanheads/openalex-mcp-server"],
180
+ "args": ["-y", "@cyanheads/openalex-mcp-server@latest"],
156
181
  "env": {
157
- "OPENALEX_API_KEY": "your-openalex-api-key"
182
+ "MCP_TRANSPORT_TYPE": "stdio",
183
+ "MCP_LOG_LEVEL": "info",
184
+ "OPENALEX_API_KEY": "your-api-key"
158
185
  }
159
186
  }
160
187
  }
161
188
  }
162
189
  ```
163
190
 
191
+ Or with Docker:
192
+
193
+ ```json
194
+ {
195
+ "mcpServers": {
196
+ "openalex-mcp-server": {
197
+ "type": "stdio",
198
+ "command": "docker",
199
+ "args": [
200
+ "run", "-i", "--rm",
201
+ "-e", "MCP_TRANSPORT_TYPE=stdio",
202
+ "-e", "OPENALEX_API_KEY=your-api-key",
203
+ "ghcr.io/cyanheads/openalex-mcp-server:latest"
204
+ ]
205
+ }
206
+ }
207
+ }
208
+ ```
209
+
210
+ For Streamable HTTP, set the transport and start the server:
211
+
212
+ ```sh
213
+ MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 OPENALEX_API_KEY=... bun run start:http
214
+ # Server listens at http://localhost:3010/mcp
215
+ ```
216
+
164
217
  `OPENALEX_API_KEY` is optional — set it to a free [OpenAlex account key](https://openalex.org/settings/api) for keyed rate limits and budget under OpenAlex's usage-based pricing, or omit it for anonymous access. Set `OPENALEX_MAILTO` to an email if you want to identify yourself to OpenAlex (the [polite pool](https://developers.openalex.org/guides/authentication)).
165
218
 
166
219
  ### Prerequisites
167
220
 
168
- - [Bun v1.4.0](https://bun.sh/) or higher (for development)
221
+ - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
222
+ - Optional: an [OpenAlex account API key](https://openalex.org/settings/api) for keyed rate limits and budget — omit for anonymous access.
169
223
 
170
224
  ### Installation
171
225
 
172
226
  1. **Clone the repository:**
227
+
173
228
  ```sh
174
229
  git clone https://github.com/cyanheads/openalex-mcp-server.git
175
230
  ```
176
231
 
177
232
  2. **Navigate into the directory:**
233
+
178
234
  ```sh
179
235
  cd openalex-mcp-server
180
236
  ```
181
237
 
182
238
  3. **Install dependencies:**
239
+
183
240
  ```sh
184
241
  bun install
185
242
  ```
186
243
 
244
+ 4. **Configure environment:**
245
+
246
+ ```sh
247
+ cp .env.example .env
248
+ # edit .env and set required vars
249
+ ```
250
+
187
251
  ## Configuration
188
252
 
189
253
  | Variable | Description | Default |
190
254
  |:---------|:------------|:--------|
191
- | `OPENALEX_API_KEY` | **Optional.** OpenAlex account API key, sent upstream as `api_key=` (free from [openalex.org/settings/api](https://openalex.org/settings/api)). Without it, anonymous rate limits apply. | — |
192
- | `OPENALEX_MAILTO` | **Optional.** Email sent upstream as `mailto=` to identify yourself to OpenAlex (the [polite pool](https://developers.openalex.org/guides/authentication)). A courtesy identifier, separate from the API key. | — |
193
- | `OPENALEX_BASE_URL` | OpenAlex API base URL. | `https://api.openalex.org` |
194
255
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
195
256
  | `MCP_HTTP_PORT` | Port for HTTP server. | `3010` |
257
+ | `MCP_SESSION_MODE` | HTTP session mode: `stateless`, `stateful`, or `auto` (resolves to `stateful`). The server declares `stateless` in code; an explicit value overrides it. | `stateless` |
196
258
  | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |
197
- | `MCP_SESSION_MODE` | HTTP session handling: `auto`, `stateful`, or `stateless`. This server ships `stateless` — set by `Dockerfile` and by `.env.example`. | `auto` (resolves to `stateful`) |
198
259
  | `MCP_ALLOWED_ORIGINS` | Comma-separated allow-list of browser `Origin` headers for HTTP transport. Unset = loopback-only; set to `*` to disable. | _loopback only_ |
199
260
  | `MCP_LOG_LEVEL` | Log level (RFC 5424). | `debug` |
200
261
  | `LOGS_DIR` | Directory for log files (Node.js only). | `<project-root>/logs` |
262
+ | `STORAGE_PROVIDER_TYPE` | Storage backend. | `in-memory` |
263
+ | `OPENALEX_API_KEY` | OpenAlex account API key, sent upstream as `api_key=` (free from [openalex.org/settings/api](https://openalex.org/settings/api)). Without it, anonymous rate limits apply. | — |
264
+ | `OPENALEX_MAILTO` | Email sent upstream as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key. | — |
265
+ | `OPENALEX_BASE_URL` | OpenAlex API base URL. | `https://api.openalex.org` |
201
266
  | `OTEL_ENABLED` | Enable [OpenTelemetry instrumentation](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry) (spans, metrics, completion logs). | `false` |
202
267
 
203
- ## Running the Server
268
+ See [`.env.example`](./.env.example) for the full list of optional overrides.
269
+
270
+ ## Running the server
271
+
272
+ ### Local development
204
273
 
205
- ### Local Development
274
+ - **Build and run:**
206
275
 
207
- - **Build and run the production version:**
208
276
  ```sh
209
- bun run build
210
- bun run start:http # or start:stdio
277
+ # One-time build
278
+ bun run rebuild
279
+
280
+ # Run the built server
281
+ bun run start:stdio
282
+ # or
283
+ bun run start:http
211
284
  ```
212
285
 
213
286
  - **Run checks and tests:**
287
+
214
288
  ```sh
215
- bun run devcheck # Lints, formats, type-checks
216
- bun run test # Runs test suite
289
+ bun run devcheck # Lints, formats, type-checks
290
+ bun run test # Runs the test suite
217
291
  ```
218
292
 
219
293
  ### Docker
220
294
 
221
295
  ```sh
222
296
  docker build -t openalex-mcp-server .
223
- docker run -e OPENALEX_API_KEY=your-key -p 3010:3010 openalex-mcp-server
297
+ docker run --rm -e OPENALEX_API_KEY=your-key -p 3010:3010 openalex-mcp-server
224
298
  ```
225
299
 
226
- ## Project Structure
300
+ The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/openalex-mcp-server`. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
301
+
302
+ ## Project structure
227
303
 
228
304
  | Directory | Purpose |
229
305
  |:----------|:--------|
306
+ | `src/index.ts` | `createApp()` entry point — registers tools and prompts. |
307
+ | `src/config/` | Server-specific environment variable parsing and validation with Zod. |
230
308
  | `src/mcp-server/tools/definitions/` | Tool definitions (`*.tool.ts`). |
231
309
  | `src/mcp-server/prompts/definitions/` | Prompt definitions (`*.prompt.ts`). |
232
- | `src/services/openalex/` | OpenAlex API client service and domain types. |
233
- | `src/config/` | Environment variable parsing and validation with Zod. |
310
+ | `src/services/openalex/` | OpenAlex API client, field catalog, and domain types. |
234
311
  | `tests/` | Unit and integration tests, mirroring the `src/` structure. |
235
312
 
236
- ## Development Guide
313
+ ## Development guide
237
314
 
238
315
  See [`CLAUDE.md`](./CLAUDE.md) for development guidelines and architectural rules. The short version:
239
316
 
240
317
  - Handlers throw, framework catches — no `try/catch` in tool logic
241
318
  - Use `ctx.log` for logging, `ctx.state` for storage
242
- - Always resolve names to IDs via `openalex_resolve_name` before using them in filters
319
+ - Wrap OpenAlex responses: validate the raw payload → normalize to a domain type → return the output schema; never fabricate missing fields
320
+ - Always resolve names to IDs via `openalex_resolve_name` before filtering by entity
243
321
 
244
322
  ## Contributing
245
323