# Developer Protocol **Server:** openalex-mcp-server **Version:** 0.11.1 **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22` **Engines:** Bun ≥1.4.4, Node ≥24.0.0 **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 **Zod:** ^4.6.7 > **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. --- ## Domain [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). **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. **Critical workflow:** Names are ambiguous, IDs are not. Always resolve names to IDs first via `openalex_resolve_name` before using them in filters. **Reference docs:** `README.md` covers the current tool and prompt surface; `docs/tree.md` shows the current repository layout. ### MCP Surface | Type | Name | Purpose | |:-----|:-----|:--------| | Tool | `openalex_search_entities` | Search, filter, sort, or retrieve by ID | | Tool | `openalex_analyze_trends` | Group-by aggregation for trends/distributions | | Tool | `openalex_resolve_name` | Name-to-ID resolution via autocomplete | | Tool | `openalex_get_citation_graph` | One-hop citation graph traversal (cites/cited_by/related_to) | | Tool | `openalex_describe_fields` | List valid filter/group_by/select field names per entity type | | Prompt | `openalex_literature_review` | Guided systematic literature search workflow | | Prompt | `openalex_research_landscape` | Quantitative research landscape analysis | | 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` | No hand-written resources — entity lookups need `select` for payload control, which fits tools better than URI templates. ### Config | Env Var | Required | Description | |:--------|:---------|:------------| | `OPENALEX_API_KEY` | No | OpenAlex account API key, sent as `api_key=` (free from https://openalex.org/settings/api); omit for anonymous access | | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key | | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` | | `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. | | `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. | | `OPENALEX_FILTER_RECORD_REPAIR` | No | Default: `true`; accept boolean and number values, nested objects, and quoted keys in the `filters` record of every filter-taking tool, normalized to string values under dotted keys and disclosed in `notice`; `false` requires string values. Strict boolean parsing. | | `OPENALEX_SELECT_STRING_INPUT` | No | Default: `true`; accept a comma-separated `select` string on `openalex_search_entities` and `openalex_get_citation_graph`, disclosed in `notice`; `false` requires an array. Strict boolean parsing. | **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. --- ## What's Next? When the user asks what's next or needs direction, suggest options based on the current project state. Common next steps: 1. **Re-run the `setup` skill** — ensures CLAUDE.md, skills, structure, and metadata are populated and up to date with the current codebase 2. **Run the `design-mcp-server` skill** — if the tool/resource surface hasn't been mapped yet, work through domain design 3. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills 4. **Add services** — scaffold domain service integrations using the `add-service` skill 5. **Add tests** — scaffold tests for existing definitions using the `add-test` skill 6. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points 7. **Run `devcheck`** — lint, format, typecheck, and security audit 8. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation 9. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping 10. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest` Tailor suggestions to what's actually missing or stale — don't recite the full list every time. --- ## Core Rules - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters. - **Use `ctx.log`** for request-scoped logging — it also reaches the client as `notifications/message`. Per-response upstream cost goes through the module `logger` with `withExtra(ctx, …)`, the process log only. No `console` calls. - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly. - **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. - **Secrets in env vars only** — never hardcoded. - **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. - **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. --- ## Patterns ### Tool ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { getOpenAlexService } from '@/services/openalex/openalex-service.js'; import { AUTOCOMPLETE_ENTITY_TYPES } from '@/services/openalex/types.js'; export const resolveNameTool = tool('openalex_resolve_name', { description: 'Resolve a name or partial name to an OpenAlex ID. Returns up to 10 matches with disambiguation hints.', annotations: { readOnlyHint: true, openWorldHint: true }, input: z.object({ entity_type: z.enum(AUTOCOMPLETE_ENTITY_TYPES).optional().describe('Entity type to search. Omit for cross-entity search.'), query: z.string().describe('Name or partial name to resolve.'), }), output: z.object({ results: z.array(z.object({ id: z.string().describe('OpenAlex ID.'), display_name: z.string().describe('Human-readable name.'), entity_type: z.string().describe('Entity type.'), })).describe('Autocomplete matches, up to 10.'), }), async handler(input, ctx) { const service = getOpenAlexService(); const result = await service.autocomplete({ entityType: input.entity_type, query: input.query }, ctx); ctx.log.info('Name resolved', { query: input.query, matchCount: result.results.length }); return result; }, // format() populates content[] — the markdown twin of structuredContent. // Different clients forward different surfaces (Claude Code → structuredContent, // Claude Desktop → content[]); both must carry the same data. // Enforced at lint time: every terminal field in `output` must appear in format()'s // rendered text via sentinel injection. The linter is locale-aware — digit-group // separators (commas, periods, spaces) are stripped before matching, so // `.toLocaleString()` is fine on linted numbers. Compact/scientific forms still fail. format: (result) => { if (result.results.length === 0) { return [{ type: 'text', text: 'No matches found.' }]; } const lines = result.results.map((r) => `**${r.display_name}** (${r.entity_type}) — ${r.id}`); return [{ type: 'text', text: lines.join('\n') }]; }, }); ``` ### Prompt ```ts import { prompt, z } from '@cyanheads/mcp-ts-core'; export const researchLandscapePrompt = prompt('openalex_research_landscape', { description: 'Analyzes the research landscape for a topic: volume trends, top authors/institutions, open access rates.', args: z.object({ topic: z.string().describe('Research area to analyze.'), }), generate: (args) => [ { role: 'user', content: { type: 'text', text: `Analyze the research landscape for: "${args.topic}"\n\nUse the OpenAlex tools to build a quantitative profile...` } }, ], }); ``` --- ## Context Handlers receive a unified `ctx` object. Key properties: | Property | Description | |:---------|:------------| | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. Operator-only records (`OpenAlex response metrics`) use the module `logger` with `withExtra(ctx, …)` instead. | | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). | | `ctx.requestInput` | Suspend and ask the caller for more input with `return ctx.requestInput(...)`; the handler is re-entered with answers. | | `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). | | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. | | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). | | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. | | `ctx.signal` | `AbortSignal` for cancellation. Passed to `withRetry` in the OpenAlex service, whose per-attempt signal reaches `fetch()`. | | `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. | | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. | | `ctx.tenantId` | `'default'` for stdio and `MCP_AUTH_MODE=none` over HTTP; JWT `tid` claim for `MCP_AUTH_MODE=jwt`/`oauth`. | --- ## Errors Handlers throw — the framework catches, classifies, and formats. **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request )`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring. ```ts import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; errors: [ { reason: 'semantic_per_page_cap', code: JsonRpcErrorCode.ValidationError, when: 'per_page exceeds the semantic-search cap', recovery: 'Reduce per_page to 50 or less, or switch search_mode to keyword.' }, { reason: 'upstream_timeout', code: JsonRpcErrorCode.Timeout, when: 'OpenAlex did not respond within the request deadline', recovery: 'Retry after a short delay, or narrow the request with tighter filters.', thrownBy: 'service' }, ], async handler(input, ctx) { if (overCap) throw ctx.fail('semantic_per_page_cap', message); } ``` **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. 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. 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: `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); `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; and `entity_not_found` for an arXiv ID whose landing-page lookup found several works (`data.candidates`) or was refused or failed upstream (`data.fallbackFailedReason`), whose hint names the retry that fits. 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. The upstream error body `fetchWithTimeout` captures (`data.body` and `data.responseBody`, capped at 500 bytes) stays on the server: the service parses it and the failure log records it, but no client envelope carries it. OpenAlex's `error` and `message` fields reach the caller as `data.upstreamError` and `data.upstreamMessage`, and as the error message wherever the service does not write its own, so no call sets `exposeErrorBody`. **Fallback for ad-hoc throws:** error factories or plain `Error`. ```ts import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; throw notFound('Item not found', { itemId }); throw serviceUnavailable('API unavailable', { url }, { cause: err }); throw new Error('Item not found'); // → auto-classified to NotFound ``` See framework CLAUDE.md for the full auto-classification table and all available factories. --- ## Structure ```text src/ index.ts # createApp() entry point config/ env-file.ts # Loads ./.env before the definition modules read the server config (imported first by index.ts) server-config.ts # OpenAlex API access and input compatibility switches services/ openalex/ openalex-service.ts # API client (init/accessor pattern) types.ts # Domain types filter-values.ts # Native filter-string parsing and filter values: country/language codes, numeric ranges, quoted phrases, `+` as AND groupable-fields.ts # Filter fields OpenAlex accepts as group_by targets (describe-fields view, rejected-group_by suggestions) arxiv-id.ts # arXiv ID grammar, its 10.48550 DOI and landing-page URLs, and the resolution notice iso-codes.json # ISO 3166-1 / 639-2 lookups (generated by scripts/generate-iso-codes.ts) provider-text.ts # Cleanup of provider text: entities, markup, MathType blobs html-entities.json # WHATWG named-entity table provider-text.ts decodes against field-catalog.json # Valid filter/select fields per entity type (generated by scripts/generate-field-catalog.ts) field-ranker.ts # Similarity ranking for field suggestions budget.ts # Per-call OpenAlex budget from the X-RateLimit-* headers url-redaction.ts # Strips api_key/mailto from URLs in error text mcp-server/ tools/ escape-markdown.ts # Markdown escaping for provider text in content[] render-entity-record.ts # Entity records as content[] markdown render-budget.ts # The budget trailer line record-page.ts # Record pages: author-cap and slice windows, the 8,000,000-byte response_too_large ceiling page-size.ts # Count-input validation (per_page, sample, slice.limit) and disclosed clamping filters-input.ts # The shared filters input: canonical record schema, native strings on search, record normalization and its notice select-input.ts # The select input seam: field arrays or comma-joined strings, and the split/widening notices tools/definitions/ search-entities.tool.ts # openalex_search_entities analyze-trends.tool.ts # openalex_analyze_trends resolve-name.tool.ts # openalex_resolve_name citation-graph.tool.ts # openalex_get_citation_graph describe-fields.tool.ts # openalex_describe_fields prompts/definitions/ literature-review.prompt.ts # openalex_literature_review research-landscape.prompt.ts # openalex_research_landscape skills/ openalex-guide/SKILL.md # Published Agent Skill, served via createApp({ skills: 'skills' }) ``` --- ## Naming | What | Convention | Example | |:-----|:-----------|:--------| | Files | kebab-case with suffix | `search-entities.tool.ts` | | Tool/prompt names | snake_case with `openalex_` prefix | `openalex_search_entities` | | Directories | kebab-case | `src/services/openalex/` | | Descriptions | Single string or template literal, no `+` concatenation | `'Search items by query and filter.'` | --- ## Skills Two skill trees live at the project root, written for different readers: | Tree | For | Written by | |:-----|:----|:-----------| | `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) | | `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` | 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. 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. **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. Available skills: | Skill | Purpose | |:------|:--------| | `setup` | Post-init project orientation | | `design-mcp-server` | Design tool surface, resources, and services for a new server | | `add-tool` | Scaffold a new tool definition | | `add-app-tool` | Scaffold an MCP App tool + paired UI resource | | `add-resource` | Scaffold a new resource definition | | `add-prompt` | Scaffold a new prompt definition | | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery | | `add-service` | Scaffold a new service integration | | `add-test` | Scaffold test file for a tool, resource, or service | | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues | | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions | | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation | | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase | | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping | | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode | | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only | | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` | | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs | | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents | | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI | | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI | | `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns | | `api-auth` | Auth modes, scopes, JWT/OAuth | | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in | | `api-config` | AppConfig, parseConfig, env vars | | `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input | | `api-errors` | McpError, JsonRpcErrorCode, error patterns | | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` | | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in | | `api-services` | LLM, Speech, Graph services | | `api-testing` | createMockContext, test patterns | | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers | | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules | | `api-workers` | Cloudflare Workers runtime | **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase. When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`). --- ## Commands | Command | Purpose | |:--------|:--------| | `bun run build` | Compile TypeScript | | `bun run rebuild` | Clean + build | | `bun run clean` | Remove build artifacts | | `bun run devcheck` | Lint + format + typecheck + security + changelog sync | | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update `, then `bun dedupe` | | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update `, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` | | `bun run tree` | Generate directory structure doc | | `bun run format` | Auto-fix formatting (safe fixes only) | | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior | | `bun run lint:mcp` | Validate MCP tool/prompt definitions (rule catalog: `api-linter` skill) | | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, README version badge, the published `skills/` tree (run by devcheck) | | `bun run list-skills` | List available skills | | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) | | `bun run start:stdio` | Production mode (stdio, after `rebuild`) | | `bun run start:http` | Production mode (HTTP, after `rebuild`) | | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` | | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) | | `bun run release:github` | Create GitHub Release from the latest annotated tag (title `v: `, optional `.mcpb` attach) | | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install | **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. --- ## Bundling `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. **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. Every bundle option must be wired as `${user_config.