@cyanheads/openalex-mcp-server 0.10.0 → 0.11.0

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.
Files changed (64) hide show
  1. package/AGENTS.md +30 -15
  2. package/CLAUDE.md +30 -15
  3. package/Dockerfile +6 -0
  4. package/README.md +55 -116
  5. package/changelog/0.10.x/0.10.1.md +28 -0
  6. package/changelog/0.11.x/0.11.0.md +41 -0
  7. package/dist/config/server-config.d.ts.map +1 -1
  8. package/dist/config/server-config.js +1 -1
  9. package/dist/config/server-config.js.map +1 -1
  10. package/dist/index.js +2 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  13. package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
  14. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +1 -0
  15. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +10 -5
  17. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +6 -9
  19. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +43 -33
  21. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts +1 -0
  23. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +25 -1
  25. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +44 -16
  28. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +22 -18
  30. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-entities.tool.js +194 -85
  32. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/escape-markdown.d.ts +26 -11
  34. package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/escape-markdown.js +95 -26
  36. package/dist/mcp-server/tools/escape-markdown.js.map +1 -1
  37. package/dist/mcp-server/tools/page-size.d.ts +38 -7
  38. package/dist/mcp-server/tools/page-size.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/page-size.js +37 -11
  40. package/dist/mcp-server/tools/page-size.js.map +1 -1
  41. package/dist/mcp-server/tools/record-page.d.ts +174 -0
  42. package/dist/mcp-server/tools/record-page.d.ts.map +1 -0
  43. package/dist/mcp-server/tools/record-page.js +392 -0
  44. package/dist/mcp-server/tools/record-page.js.map +1 -0
  45. package/dist/mcp-server/tools/render-entity-record.d.ts +3 -1
  46. package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/render-entity-record.js +7 -2
  48. package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
  49. package/dist/services/openalex/field-catalog.json +107 -0
  50. package/dist/services/openalex/openalex-service.d.ts +18 -7
  51. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  52. package/dist/services/openalex/openalex-service.js +84 -63
  53. package/dist/services/openalex/openalex-service.js.map +1 -1
  54. package/dist/services/openalex/types.d.ts +13 -2
  55. package/dist/services/openalex/types.d.ts.map +1 -1
  56. package/dist/services/openalex/types.js +28 -1
  57. package/dist/services/openalex/types.js.map +1 -1
  58. package/package.json +7 -6
  59. package/server.json +5 -5
  60. package/skills/openalex-guide/SKILL.md +77 -0
  61. package/dist/mcp-server/tools/response-budget.d.ts +0 -159
  62. package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
  63. package/dist/mcp-server/tools/response-budget.js +0 -509
  64. package/dist/mcp-server/tools/response-budget.js.map +0 -1
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.10.0
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
4
+ **Version:** 0.11.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
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.
@@ -15,7 +15,7 @@
15
15
 
16
16
  [OpenAlex](https://openalex.org) is a fully open catalog of the global research system — 270M+ works, 90M+ authors, 100K+ sources. CC0 data, no API key required (email address optional for polite pool access).
17
17
 
18
- **Entity types:** works, authors, sources, institutions, topics, keywords, publishers, funders. All share a uniform API (list/filter, search, get-by-ID, group-by, autocomplete).
18
+ **Entity types:** works, authors, sources, institutions, topics, keywords, publishers, funders, awards. All share a uniform API (list/filter, search, get-by-ID, group-by); all but awards (grants, `G…` IDs) also have autocomplete, so `openalex_resolve_name` takes the eight `AUTOCOMPLETE_ENTITY_TYPES` and answers an award ID with a notice naming the `openalex_search_entities` call.
19
19
 
20
20
  **Critical workflow:** Names are ambiguous, IDs are not. Always resolve names to IDs first via `openalex_resolve_name` before using them in filters.
21
21
 
@@ -32,8 +32,9 @@
32
32
  | Tool | `openalex_describe_fields` | List valid filter/group_by/select field names per entity type |
33
33
  | Prompt | `openalex_literature_review` | Guided systematic literature search workflow |
34
34
  | Prompt | `openalex_research_landscape` | Quantitative research landscape analysis |
35
+ | Skill | `openalex-guide` | Published Agent Skill (root `skills/`): which filter key answers the question, how to read `group_by` counts, ID sources; served at `skill://openalex-guide/SKILL.md` |
35
36
 
36
- No resources — entity lookups need `select` for payload control, which fits tools better than URI templates.
37
+ No hand-written resources — entity lookups need `select` for payload control, which fits tools better than URI templates.
37
38
 
38
39
  ### Config
39
40
 
@@ -43,7 +44,7 @@ No resources — entity lookups need `select` for payload control, which fits to
43
44
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
45
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
46
  | `OPENALEX_FILTER_STRING_INPUT` | No | Default: `true`; accept unambiguous native filter strings on `openalex_search_entities`; `false` restores record-only input. Strict boolean parsing rejects invalid values. |
46
- | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` to 100 for search/citation and 200 for analysis, with a notice, log, and counter. `false` restores rejecting advertised maxima. Strict boolean parsing; semantic cap 50 and sample maxima still reject. |
47
+ | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` (100 search/citation, 50 semantic, 200 analysis), `slice.limit` (5,000), and `sample` (10,000) to their caps, with a notice, log, and `openalex.count_input.clamps` counter. `false` rejects each over its cap. Strict boolean parsing. |
47
48
 
48
49
  **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.
49
50
 
@@ -87,13 +88,13 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
87
88
  ```ts
88
89
  import { tool, z } from '@cyanheads/mcp-ts-core';
89
90
  import { getOpenAlexService } from '@/services/openalex/openalex-service.js';
90
- import { ENTITY_TYPES } from '@/services/openalex/types.js';
91
+ import { AUTOCOMPLETE_ENTITY_TYPES } from '@/services/openalex/types.js';
91
92
 
92
93
  export const resolveNameTool = tool('openalex_resolve_name', {
93
94
  description: 'Resolve a name or partial name to an OpenAlex ID. Returns up to 10 matches with disambiguation hints.',
94
95
  annotations: { readOnlyHint: true, openWorldHint: true },
95
96
  input: z.object({
96
- entity_type: z.enum(ENTITY_TYPES).optional().describe('Entity type to search. Omit for cross-entity search.'),
97
+ entity_type: z.enum(AUTOCOMPLETE_ENTITY_TYPES).optional().describe('Entity type to search. Omit for cross-entity search.'),
97
98
  query: z.string().describe('Name or partial name to resolve.'),
98
99
  }),
99
100
  output: z.object({
@@ -193,7 +194,7 @@ async handler(input, ctx) {
193
194
 
194
195
  Native-filter parser failures raised during schema preprocessing carry explicit recovery guidance: the handler boundary has not run yet, so handler-contract hint filling cannot supply it.
195
196
 
196
- The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_with_page`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, and `slice_field_not_array` on `openalex_search_entities`; `reserved_filter_key` and the seed-lookup `entity_not_found` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
197
+ The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_page_without_seed`, `sample_page_too_deep`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, `slice_field_not_array`, and `response_too_large` on `openalex_search_entities`; `reserved_filter_key`, the seed-lookup `entity_not_found`, and `response_too_large` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
197
198
 
198
199
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
199
200
 
@@ -232,8 +233,8 @@ src/
232
233
  escape-markdown.ts # Markdown escaping for provider text in content[]
233
234
  render-entity-record.ts # Entity records as content[] markdown
234
235
  render-budget.ts # The budget trailer line
235
- response-budget.ts # 64,000-byte response budget and its disclosures
236
- page-size.ts # Configurable page-size validation and disclosed clamping
236
+ record-page.ts # Record pages: author-cap and slice windows, the 8,000,000-byte response_too_large ceiling
237
+ page-size.ts # Count-input validation (per_page, sample, slice.limit) and disclosed clamping
237
238
  tools/definitions/
238
239
  search-entities.tool.ts # openalex_search_entities
239
240
  analyze-trends.tool.ts # openalex_analyze_trends
@@ -243,6 +244,8 @@ src/
243
244
  prompts/definitions/
244
245
  literature-review.prompt.ts # openalex_literature_review
245
246
  research-landscape.prompt.ts # openalex_research_landscape
247
+ skills/
248
+ openalex-guide/SKILL.md # Published Agent Skill, served via createApp({ skills: 'skills' })
246
249
  ```
247
250
 
248
251
  ---
@@ -260,9 +263,18 @@ src/
260
263
 
261
264
  ## Skills
262
265
 
263
- 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. Keep `skills/` free for skills meant for those agents.
266
+ Two skill trees live at the project root, written for different readers:
264
267
 
265
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
268
+ | Tree | For | Written by |
269
+ |:-----|:----|:-----------|
270
+ | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
271
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Ships `openalex-guide` |
272
+
273
+ Development skills are modular instructions. 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. Their 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 them to every agent that installs it.
274
+
275
+ The published skill in root `skills/` (`openalex-guide`) reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients through `createApp({ skills: 'skills' })` in `src/index.ts`, served at `skill://openalex-guide/SKILL.md`. `lint:packaging` validates the tree.
276
+
277
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
266
278
 
267
279
  Available skills:
268
280
 
@@ -274,6 +286,7 @@ Available skills:
274
286
  | `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
275
287
  | `add-resource` | Scaffold a new resource definition |
276
288
  | `add-prompt` | Scaffold a new prompt definition |
289
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
277
290
  | `add-service` | Scaffold a new service integration |
278
291
  | `add-test` | Scaffold test file for a tool, resource, or service |
279
292
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
@@ -322,7 +335,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
322
335
  | `bun run format` | Auto-fix formatting (safe fixes only) |
323
336
  | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
324
337
  | `bun run lint:mcp` | Validate MCP tool/prompt definitions (rule catalog: `api-linter` skill) |
325
- | `bun run lint:packaging` | Packaging surface checks — env-var parity, plugin metadata, README version badge (run by devcheck) |
338
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, README version badge (run by devcheck) |
326
339
  | `bun run list-skills` | List available skills |
327
340
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
328
341
  | `bun run start:stdio` | Production mode (stdio, after `rebuild`) |
@@ -338,7 +351,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
338
351
 
339
352
  ## Bundling
340
353
 
341
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. MCPB is stdio-only — HTTP deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
354
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line, and a root path of the server's own (`/claude-plans/`) is added by hand. MCPB is stdio-only — HTTP deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
342
355
 
343
356
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match.
344
357
  Every bundle option must be wired as `${user_config.<option>}`; optional strings need a default. Claude plugins use the same options under `userConfig`, while Codex forwards user variables through `env_vars`. Never overwrite a user's exported value with an empty plugin `env` entry.
@@ -402,4 +415,6 @@ import { getOpenAlexService } from '@/services/openalex/openalex-service.js';
402
415
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
403
416
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
404
417
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
418
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name; `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y <full package name>`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
419
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
405
420
  - [ ] `bun run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.10.0
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
4
+ **Version:** 0.11.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
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.
@@ -15,7 +15,7 @@
15
15
 
16
16
  [OpenAlex](https://openalex.org) is a fully open catalog of the global research system — 270M+ works, 90M+ authors, 100K+ sources. CC0 data, no API key required (email address optional for polite pool access).
17
17
 
18
- **Entity types:** works, authors, sources, institutions, topics, keywords, publishers, funders. All share a uniform API (list/filter, search, get-by-ID, group-by, autocomplete).
18
+ **Entity types:** works, authors, sources, institutions, topics, keywords, publishers, funders, awards. All share a uniform API (list/filter, search, get-by-ID, group-by); all but awards (grants, `G…` IDs) also have autocomplete, so `openalex_resolve_name` takes the eight `AUTOCOMPLETE_ENTITY_TYPES` and answers an award ID with a notice naming the `openalex_search_entities` call.
19
19
 
20
20
  **Critical workflow:** Names are ambiguous, IDs are not. Always resolve names to IDs first via `openalex_resolve_name` before using them in filters.
21
21
 
@@ -32,8 +32,9 @@
32
32
  | Tool | `openalex_describe_fields` | List valid filter/group_by/select field names per entity type |
33
33
  | Prompt | `openalex_literature_review` | Guided systematic literature search workflow |
34
34
  | Prompt | `openalex_research_landscape` | Quantitative research landscape analysis |
35
+ | Skill | `openalex-guide` | Published Agent Skill (root `skills/`): which filter key answers the question, how to read `group_by` counts, ID sources; served at `skill://openalex-guide/SKILL.md` |
35
36
 
36
- No resources — entity lookups need `select` for payload control, which fits tools better than URI templates.
37
+ No hand-written resources — entity lookups need `select` for payload control, which fits tools better than URI templates.
37
38
 
38
39
  ### Config
39
40
 
@@ -43,7 +44,7 @@ No resources — entity lookups need `select` for payload control, which fits to
43
44
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
45
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
46
  | `OPENALEX_FILTER_STRING_INPUT` | No | Default: `true`; accept unambiguous native filter strings on `openalex_search_entities`; `false` restores record-only input. Strict boolean parsing rejects invalid values. |
46
- | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` to 100 for search/citation and 200 for analysis, with a notice, log, and counter. `false` restores rejecting advertised maxima. Strict boolean parsing; semantic cap 50 and sample maxima still reject. |
47
+ | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` (100 search/citation, 50 semantic, 200 analysis), `slice.limit` (5,000), and `sample` (10,000) to their caps, with a notice, log, and `openalex.count_input.clamps` counter. `false` rejects each over its cap. Strict boolean parsing. |
47
48
 
48
49
  **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.
49
50
 
@@ -87,13 +88,13 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
87
88
  ```ts
88
89
  import { tool, z } from '@cyanheads/mcp-ts-core';
89
90
  import { getOpenAlexService } from '@/services/openalex/openalex-service.js';
90
- import { ENTITY_TYPES } from '@/services/openalex/types.js';
91
+ import { AUTOCOMPLETE_ENTITY_TYPES } from '@/services/openalex/types.js';
91
92
 
92
93
  export const resolveNameTool = tool('openalex_resolve_name', {
93
94
  description: 'Resolve a name or partial name to an OpenAlex ID. Returns up to 10 matches with disambiguation hints.',
94
95
  annotations: { readOnlyHint: true, openWorldHint: true },
95
96
  input: z.object({
96
- entity_type: z.enum(ENTITY_TYPES).optional().describe('Entity type to search. Omit for cross-entity search.'),
97
+ entity_type: z.enum(AUTOCOMPLETE_ENTITY_TYPES).optional().describe('Entity type to search. Omit for cross-entity search.'),
97
98
  query: z.string().describe('Name or partial name to resolve.'),
98
99
  }),
99
100
  output: z.object({
@@ -193,7 +194,7 @@ async handler(input, ctx) {
193
194
 
194
195
  Native-filter parser failures raised during schema preprocessing carry explicit recovery guidance: the handler boundary has not run yet, so handler-contract hint filling cannot supply it.
195
196
 
196
- The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_with_page`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, and `slice_field_not_array` on `openalex_search_entities`; `reserved_filter_key` and the seed-lookup `entity_not_found` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
197
+ The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_page_without_seed`, `sample_page_too_deep`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, `slice_field_not_array`, and `response_too_large` on `openalex_search_entities`; `reserved_filter_key`, the seed-lookup `entity_not_found`, and `response_too_large` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
197
198
 
198
199
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
199
200
 
@@ -232,8 +233,8 @@ src/
232
233
  escape-markdown.ts # Markdown escaping for provider text in content[]
233
234
  render-entity-record.ts # Entity records as content[] markdown
234
235
  render-budget.ts # The budget trailer line
235
- response-budget.ts # 64,000-byte response budget and its disclosures
236
- page-size.ts # Configurable page-size validation and disclosed clamping
236
+ record-page.ts # Record pages: author-cap and slice windows, the 8,000,000-byte response_too_large ceiling
237
+ page-size.ts # Count-input validation (per_page, sample, slice.limit) and disclosed clamping
237
238
  tools/definitions/
238
239
  search-entities.tool.ts # openalex_search_entities
239
240
  analyze-trends.tool.ts # openalex_analyze_trends
@@ -243,6 +244,8 @@ src/
243
244
  prompts/definitions/
244
245
  literature-review.prompt.ts # openalex_literature_review
245
246
  research-landscape.prompt.ts # openalex_research_landscape
247
+ skills/
248
+ openalex-guide/SKILL.md # Published Agent Skill, served via createApp({ skills: 'skills' })
246
249
  ```
247
250
 
248
251
  ---
@@ -260,9 +263,18 @@ src/
260
263
 
261
264
  ## Skills
262
265
 
263
- 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. Keep `skills/` free for skills meant for those agents.
266
+ Two skill trees live at the project root, written for different readers:
264
267
 
265
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
268
+ | Tree | For | Written by |
269
+ |:-----|:----|:-----------|
270
+ | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
271
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Ships `openalex-guide` |
272
+
273
+ Development skills are modular instructions. 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. Their 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 them to every agent that installs it.
274
+
275
+ The published skill in root `skills/` (`openalex-guide`) reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients through `createApp({ skills: 'skills' })` in `src/index.ts`, served at `skill://openalex-guide/SKILL.md`. `lint:packaging` validates the tree.
276
+
277
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
266
278
 
267
279
  Available skills:
268
280
 
@@ -274,6 +286,7 @@ Available skills:
274
286
  | `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
275
287
  | `add-resource` | Scaffold a new resource definition |
276
288
  | `add-prompt` | Scaffold a new prompt definition |
289
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
277
290
  | `add-service` | Scaffold a new service integration |
278
291
  | `add-test` | Scaffold test file for a tool, resource, or service |
279
292
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
@@ -322,7 +335,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
322
335
  | `bun run format` | Auto-fix formatting (safe fixes only) |
323
336
  | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
324
337
  | `bun run lint:mcp` | Validate MCP tool/prompt definitions (rule catalog: `api-linter` skill) |
325
- | `bun run lint:packaging` | Packaging surface checks — env-var parity, plugin metadata, README version badge (run by devcheck) |
338
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, README version badge (run by devcheck) |
326
339
  | `bun run list-skills` | List available skills |
327
340
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
328
341
  | `bun run start:stdio` | Production mode (stdio, after `rebuild`) |
@@ -338,7 +351,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
338
351
 
339
352
  ## Bundling
340
353
 
341
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. MCPB is stdio-only — HTTP deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
354
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line, and a root path of the server's own (`/claude-plans/`) is added by hand. MCPB is stdio-only — HTTP deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
342
355
 
343
356
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match.
344
357
  Every bundle option must be wired as `${user_config.<option>}`; optional strings need a default. Claude plugins use the same options under `userConfig`, while Codex forwards user variables through `env_vars`. Never overwrite a user's exported value with an empty plugin `env` entry.
@@ -402,4 +415,6 @@ import { getOpenAlexService } from '@/services/openalex/openalex-service.js';
402
415
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
403
416
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
404
417
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
418
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name; `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y <full package name>`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
419
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
405
420
  - [ ] `bun run devcheck` passes
package/Dockerfile CHANGED
@@ -29,6 +29,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
29
29
  # Copy the rest of the source code
30
30
  COPY . .
31
31
 
32
+ # The skills a server publishes (`createApp({ skills: 'skills' })`) are read at
33
+ # runtime from the application root. Created when the project has none, so the
34
+ # production stage's copy below holds with or without one.
35
+ RUN mkdir -p skills
36
+
32
37
  # Build the application
33
38
  RUN bun run build
34
39
 
@@ -136,6 +141,7 @@ COPY --from=deps /usr/src/app/node_modules ./node_modules
136
141
 
137
142
  # Copy the compiled application code from the build stage
138
143
  COPY --from=build /usr/src/app/dist ./dist
144
+ COPY --from=build /usr/src/app/skills ./skills
139
145
 
140
146
  # The 'oven/bun' image already provides a non-root user named 'bun'.
141
147
  # We will use this existing user for enhanced security.