@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.
- package/AGENTS.md +30 -15
- package/CLAUDE.md +30 -15
- package/Dockerfile +6 -0
- package/README.md +55 -116
- package/changelog/0.10.x/0.10.1.md +28 -0
- package/changelog/0.11.x/0.11.0.md +41 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +1 -1
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +10 -5
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +6 -9
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +43 -33
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +25 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +44 -16
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +22 -18
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +194 -85
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.d.ts +26 -11
- package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.js +95 -26
- package/dist/mcp-server/tools/escape-markdown.js.map +1 -1
- package/dist/mcp-server/tools/page-size.d.ts +38 -7
- package/dist/mcp-server/tools/page-size.d.ts.map +1 -1
- package/dist/mcp-server/tools/page-size.js +37 -11
- package/dist/mcp-server/tools/page-size.js.map +1 -1
- package/dist/mcp-server/tools/record-page.d.ts +174 -0
- package/dist/mcp-server/tools/record-page.d.ts.map +1 -0
- package/dist/mcp-server/tools/record-page.js +392 -0
- package/dist/mcp-server/tools/record-page.js.map +1 -0
- package/dist/mcp-server/tools/render-entity-record.d.ts +3 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +7 -2
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/services/openalex/field-catalog.json +107 -0
- package/dist/services/openalex/openalex-service.d.ts +18 -7
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +84 -63
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/types.d.ts +13 -2
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +28 -1
- package/dist/services/openalex/types.js.map +1 -1
- package/package.json +7 -6
- package/server.json +5 -5
- package/skills/openalex-guide/SKILL.md +77 -0
- package/dist/mcp-server/tools/response-budget.d.ts +0 -159
- package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
- package/dist/mcp-server/tools/response-budget.js +0 -509
- 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.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.
|
|
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.
|
|
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`
|
|
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 {
|
|
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(
|
|
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`, `
|
|
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
|
-
|
|
236
|
-
page-size.ts #
|
|
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
|
-
|
|
266
|
+
Two skill trees live at the project root, written for different readers:
|
|
264
267
|
|
|
265
|
-
|
|
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
|
|
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.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.
|
|
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.
|
|
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`
|
|
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 {
|
|
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(
|
|
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`, `
|
|
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
|
-
|
|
236
|
-
page-size.ts #
|
|
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
|
-
|
|
266
|
+
Two skill trees live at the project root, written for different readers:
|
|
264
267
|
|
|
265
|
-
|
|
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
|
|
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.
|