# Developer Protocol **Server:** @cyanheads/datacite-mcp-server **Version:** 0.2.1 **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18` **Engines:** Bun ≥1.4.0, 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. --- ## 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. 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 Abbreviated from `src/mcp-server/tools/definitions/get-work.tool.ts`: ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { getDataCiteService } from '@/services/datacite/datacite-service.js'; import { mapWork } from '@/services/datacite/mappers.js'; import { normalizeDoi } from '@/services/datacite/normalize.js'; import { getRegistrationAgencyService } from '@/services/doi-ra/doi-ra-service.js'; import { missFields, missGuidance } from './_miss.js'; import { doiString } from './_schemas.js'; export const getWorkTool = tool('datacite_get_work', { title: 'Get DataCite work', description: 'Fetch the full deposited DataCite metadata for one DOI: …', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, errors: [ { reason: 'invalid_doi', code: JsonRpcErrorCode.ValidationError, when: 'The doi input is not a DOI after normalization.', severity: 'notice', recovery: 'Pass a DOI such as 10.5061/dryad.234 (bare, doi:, or a doi.org URL); to find one by title, call datacite_search_works with text.', }, // rate_limited — thrownBy: 'service' ], input: z.object({ doi: doiString().describe('The DOI, e.g. 10.5061/dryad.234: …'), }), output: z.object({ found: z.boolean().describe('True when DataCite holds a public record for the DOI.'), doi: z.string().describe('The DOI, normalized to lowercase bare form.'), // … found-arm fields … ...missFields, }), async handler(input, ctx) { const doi = normalizeDoi(input.doi); if (!doi) throw ctx.fail('invalid_doi', 'The doi input is not a DOI after normalization; …'); const hit = await getDataCiteService().getWork(doi, ctx); if (!hit) { // A miss is a typed answer, not an error: name the agency that holds the DOI. const miss = await getRegistrationAgencyService().classifyMiss(doi, ctx); return { found: false, doi, ...miss, guidance: missGuidance(doi, miss, 'record') }; } const { work, truncatedLists } = mapWork(hit.record, hit.client); if (truncatedLists.length > 0) ctx.enrich({ truncatedLists }); return { found: true, doi, ...work }; }, // format() populates content[] — the markdown twin of structuredContent. Different clients // read different surfaces (Claude Code → structuredContent, Claude Desktop → content[]); both // must carry the same data, enforced at lint time: every field in `output` must appear in the // rendered text. Depositor text goes through the _text.ts helpers: flattenInline for inline // values, descriptionQuote for deposited descriptions, blockquote for other prose, urlLink // for URLs, and fenced for machine documents. Escape only at the rendering boundary. format: (result) => [{ type: 'text', text: /* … */ '' }], }); ``` Every DataCite-calling tool declares `rate_limited` with `thrownBy: 'service'`: `src/services/http/upstream-client.ts` raises it when the shared request budget is spent. Shared input shapes (`doiString`, `enumish`, `optionalArray`, `blankAsUnset`) live in `_schemas.ts`; reuse them rather than re-deriving a DOI or enum pattern. This server registers no explicit resources or prompts. Its root `skills/` tree is served through the Skills extension by `createApp({ skills: 'skills' })`. Scaffold an explicit resource or prompt with the `add-resource` or `add-prompt` skill. ### Server config ```ts // src/config/server-config.ts — lazy-parsed, separate from framework config import { z } from '@cyanheads/mcp-ts-core'; import { config, parseEnvConfig } from '@cyanheads/mcp-ts-core/config'; const ServerConfigSchema = z.object({ clampLimits: z.stringbool().default(true).describe('Clamp oversized read counts; false advertises and enforces strict upstream caps.'), contactEmail: z .email() .optional() .describe("Contact email appended to the User-Agent as mailto: (header only). Moves requests to DataCite's identified tier."), maxRequestsPer5Min: z.coerce .number() .int() .min(50) .max(1000) .optional() .describe('Pacer budget per 5-minute window. Default 800 with a contact email, 400 without.'), }); let _config: ServerConfig | undefined; export function getServerConfig(): ServerConfig { void config.pkg; // Load the framework config and Node .env before tool schemas read the switch. _config ??= parseEnvConfig(ServerConfigSchema, { clampLimits: 'DATACITE_CLAMP_LIMITS', contactEmail: 'DATACITE_CONTACT_EMAIL', maxRequestsPer5Min: 'DATACITE_MAX_REQUESTS_PER_5MIN', }); return _config; } ``` `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`DATACITE_CONTACT_EMAIL`) not the path (`contactEmail`), and throws `ConfigurationError` for malformed values. Count schemas read the config during tool-definition construction; the framework config accessor loads Node's `.env` first. `DATACITE_CLAMP_LIMITS` defaults to `true`: search counts apply at most 1,000 and graph counts at most 100, with requested/applied notices, logs, and a counter. `false` advertises and enforces those caps as strict maxima. The contact email rides the `User-Agent` only — never a URL parameter, cache key, or log line. For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables. ### Server identity and instructions `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`): ```ts await createApp({ name: 'datacite-mcp-server', title: 'datacite-mcp-server', // must match the unscoped package name — enforced by lint:packaging tools: [searchWorksTool, getWorkTool, traceRelationsTool, searchRepositoriesTool, getCitationTool, listReferenceTool], instructions: 'DataCite DOI metadata for datasets, software, samples, workflows, …', }); ``` `websiteUrl` and `icons` are also accepted. `description` is not set here: `package.json` is the canonical source, and the framework serves it from there. `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. The instructions in `src/index.ts` name the tool chain (search → get → cite, trace, reference), the per-DOI citation-count caveat, the depositor-text trust boundary, and the CC0 metadata / work-license split; keep them in step with the tool descriptions when either changes. Client adoption is uneven, but there's no downside when set. ### Session posture and shutdown Two more `createApp()` options shape how the server runs rather than how it presents itself: ```ts await createApp({ sessionMode: 'stateless', setup() { initDataCiteServices(); }, teardown() { shutdownDataCiteServices(); }, }); ``` `sessionMode` declares the HTTP session posture in `src/` instead of leaving it to a deployment's `MCP_SESSION_MODE`, which still wins whenever it carries a meaningful value (an empty string and an unsubstituted `${…}` placeholder read as unset and fall through to the option). This server declares `'stateless'` — no tool calls `ctx.requestInput` — and `.env.example`, the `Dockerfile`, and the README env table say the same; keep all four in agreement. Add `require: 'stateful'` if a tool ever asks the caller for input mid-handler: startup then fails with a `ConfigurationError` rather than serving a mode in which a 2025-era client can never answer the prompt. Stdio is never refused. `teardown(core)` is the `setup()` counterpart — release a watcher, socket, or non-`unref()`'d timer there. It runs after the transport stops and before the logger closes, on every shutdown path, and a signal-triggered shutdown then exits the process explicitly (0, or 1 if a step never settles within the framework's 10 s ceiling). Here `setup()` builds the DataCite, doi.org and ROR services over one shared response cache, and `teardown()` disposes all three request pacers. --- ## 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. | | `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 — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. This server uses none (stateless). | | `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). The search tools echo `effectiveQuery` and `appliedFilters` here; `datacite_trace_relations` reports a bound `max_nodes` through `.truncated()`. | | `ctx.recoveryFor(reason)` | Typed lookup of the contract `recovery` for a declared reason. Returns `{ recovery: { hint } }` for known reasons, `{}` otherwise. Not needed to put a declared hint on the wire — the framework fills it. Use it only when the hint must ride the thrown error itself (a test of the handler's own throw) or a site deliberately sends another entry's guidance. | | `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. | | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. | | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. | --- ## 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: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query', recovery: 'Broaden the query or check the spelling and try again.' }, ], async handler(input, ctx) { const item = await db.find(input.id); if (!item) throw ctx.fail('no_match', `No item ${input.id}`); return item; } ``` **Declare contracts inline on each tool.** The contract is part of the tool's public surface — one file should give the full picture. Don't extract a shared `errors[]` constant; per-tool repetition is the intended cost of locality. **Fallback (no contract entry fits):** throw via factories or plain `Error`. ```ts // Error factories — explicit code import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; throw notFound('Item not found', { itemId }); throw serviceUnavailable('API unavailable', { url }, { cause: err }); // Plain Error — framework auto-classifies from message patterns throw new Error('Item not found'); // → NotFound throw new Error('Invalid query format'); // → ValidationError // McpError — when no factory exists for the code import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; throw new McpError(JsonRpcErrorCode.InitializationFailed, 'Connection failed', { pool: 'primary' }); ``` See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference. --- ## Structure ```text src/ index.ts # createApp() entry point — six tools, instructions, stateless session mode config/ server-config.ts # Contact email, request budget, and read-count clamping (Zod schema) services/ datacite/ datacite-service.ts # DataCite REST client (init/accessor): /dois, /repositories, /events, content negotiation query-builder.ts # Filters → query params and escaped query clauses normalize.ts # DOI, ORCID iD, ROR ID, Crossref Funder ID, language normalizers mappers.ts # Raw DataCite records → output shapes relation-graph.ts # datacite_trace_relations graph builder citation.ts # Content negotiation + CSL style verdict jats-notice.ts # Detect empty names and inconsistent years without rewriting JATS cursor.ts # Work-search cursor envelope html-text.ts # Formatted-citation HTML → plain text types.ts # Raw DataCite response types doi-ra/ doi-ra-service.ts # doi.org registration-agency lookup (DOIs DataCite doesn't hold) ror/ ror-service.ts # Exact FundRef-to-ROR mapping; bounded literal-search fallback http/ upstream-client.ts # Cache → pacer → fetch pipeline, retry, deadline, 429 / pacer-shed handling ttl-cache.ts # Shared in-process LRU with per-entry TTLs reference/ # Static vocabularies behind datacite_list_reference and the input validators mcp-server/ tools/definitions/ *.tool.ts # The six tool definitions _schemas.ts # Shared input-schema helpers (blank-as-unset, DOI and id patterns, enumish) _counts.ts # Count schemas, applied caps, and clamp telemetry _miss.ts # found: false guidance shared by datacite_get_work and datacite_get_citation _text.ts # format() helpers for depositor-supplied text (flatten, blockquote, fence) ``` --- ## Naming | What | Convention | Example | |:-----|:-----------|:--------| | Files | kebab-case with suffix | `trace-relations.tool.ts` | | Tool/resource/prompt names | snake_case, `datacite_` prefix | `datacite_trace_relations` | | Directories | kebab-case | `src/services/doi-ra/` | | Descriptions | Single string or template literal, no `+` concatenation | `'Look up the controlled vocabularies and identifier forms …'` | --- ## 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 server-specific development skills | `init` and `maintenance` for framework skills; the author for local skills | | `skills/` | Agents installing the server and calling its tools — tool chains, identifier sources, gates | The author, using `add-skill` | Read development skills 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. Claude Code and Codex auto-load a plugin's root `skills/`, so development skills stay in `framework-skills/`. Published root skills ship through plugins, npm, Docker, the `.mcpb` bundle, and the MCP Skills extension; `lint:packaging` validates that tree. **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. 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 — 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 **Runtime:** Scripts use Bun's native TypeScript execution — `bun run ` is the standard invocation. `npm run ` also works (npm delegates to bun). | 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 + packaging alignment + 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 lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) | | `bun run lint:packaging` | Packaging checks — env-var parity, `.mcpbignore` coverage, plugin manifests, published `skills/` (run by devcheck) | | `bun run list-skills` | Print the skill registry | | `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 test` | Run tests (Vitest — use `bun run test`, not `bun test`) | | `bun run test:coverage` | Run tests with Istanbul coverage | | `bun run start` | Run the built server (`node dist/index.js`, transport from the environment) | | `bun run start:stdio` | Production mode (stdio) | | `bun run start:http` | Production mode (HTTP) | | `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 bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install | | `bun run release:github` | Create the GitHub Release from an annotated tag and attach the `.mcpb` bundle | | `bun run publish-mcp` | Log in to the MCP Registry with the GitHub PAT from the macOS Keychain item `mcp-publisher-github-pat` and publish `server.json` | **CI is one file.** `.github/workflows/codeql.yml` (scaffolded) 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 `dist/datacite-mcp-server.mcpb` for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`; root development paths need root-anchored exclusions, while root `skills/` ships. `scripts/clean-mcpb.ts` prunes dev dependencies and strips dependency agent docs, platform-specific native bindings, node-gyp generator output containing build-host paths, and build-only package files. Compiled `*.node` addons stay. MCPB is stdio-only. `release-and-publish` attaches the bundle to the GitHub Release at the stable `releases/latest/download/datacite-mcp-server.mcpb` URL used by the README badge. **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, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`. A user-supplied variable also goes into the plugin manifests — `.claude-plugin/plugin.json` `userConfig` + `env`, `.codex-plugin/mcp.json` `env_vars` (see Checklist) — plus `.env.example` and the README Configuration table. **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `framework-skills/polish-docs-meta/references/readme.md`. --- ## Changelog Directory-based, grouped by minor series via the `.x` semver-wildcard convention. Source of truth: `changelog/.x/.md` (e.g. `changelog/0.1.x/0.1.0.md`) — one file per release, shipped in the npm package. At release, author the per-version file with a concrete version and date, then run `npm run changelog:build` to regenerate the rollup. `changelog/template.md` is a **pristine format reference** — never edited or moved; read it for the frontmatter + section layout when scaffolding. `CHANGELOG.md` is a **navigation index** (header + link + summary per version), regenerated by `npm run changelog:build` — devcheck hard-fails on drift; never hand-edit it. Each per-version file opens with YAML frontmatter: ```markdown --- summary: "One-line headline, ≤350 chars" # required — powers the rollup index breaking: false # optional — true flags breaking changes security: false # optional — true ONLY for a source-code security fix, never a dependency CVE bump --- # 0.1.0 — YYYY-MM-DD ... ``` `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section — set it only for a security fix in this server's *own source code*, never for a routine dependency or transitive CVE bump (record those under `## Dependencies`). When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`. `agent-notes` is an optional free-form field for maintenance agents processing the release downstream. Content here won't appear in the rendered CHANGELOG — it's consumed by agents running the `maintenance` skill. Use it for adoption instructions that don't fit the human-facing sections: new files to create, fields to populate, one-time migration steps. Omit entirely when there's nothing to say. **Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries — don't ship empty headers. **Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject omits the version number (GitHub prepends it). See `changelog/template.md` for the full format reference. --- ## Publishing **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. --- ## Imports ```ts // Framework — z is re-exported, no separate zod import needed import { tool, z } from '@cyanheads/mcp-ts-core'; import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; // Server's own code — via path alias import { getDataCiteService } from '@/services/datacite/datacite-service.js'; ``` --- ## Checklist - [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`) - [ ] Optional nested objects: handler guards for empty inner values from form-based clients (`if (input.obj?.field && ...)`, not just `if (input.obj)`). When regex/length constraints matter, use `z.union([z.literal(''), z.string().regex(...).describe(...)])` — literal variants are exempt from `describe-on-fields`. - [ ] JSDoc `@fileoverview` + `@module` on every file - [ ] `ctx.log` for logging, `ctx.state` for storage - [ ] Handlers throw on failure — error factories or plain `Error`, no try/catch - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data - [ ] DataCite wrapping: raw/domain/output schemas reviewed against real upstream sparsity/nullability before finalizing required vs optional fields - [ ] DataCite wrapping: normalization and `format()` preserve uncertainty; do not fabricate facts from missing upstream data - [ ] DataCite wrapping: tests include at least one sparse payload case with omitted upstream fields, against recorded fixtures under `tests/fixtures/` - [ ] Every upstream request goes through `UpstreamClient` (`src/services/http/upstream-client.ts`) so it is cached, paced, and counted against the shared budget - [ ] Depositor-supplied text in `format()` goes through the `_text.ts` helpers; caller input echoed back is flattened the same way - [ ] Registered in `createApp()` arrays (directly or via barrel exports) - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing` - [ ] `.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 - [ ] Root `plugin.json` + `mcp.json` populated from `package.json`; the root MCP entry carries fixed env values only. User variables stay in the plugin overlays, and `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"` so Codex forwards `env_vars`. - [ ] `.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 - [ ] `.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.